Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 589fbf26aa fix(explorer): close a folder's window when the folder is deleted, however it was opened
Windows opened by path, such as with Ctrl-click on a breadcrumb or sidebar
entry, never received the marker used to find a deleted folder's windows,
so they stayed open on a folder that no longer existed.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 8d5eed1cb7 fix(explorer): open an item's context menu where it was requested
The menu is built after permission and app lookups, and it opened wherever
the pointer had moved to by the time they finished.
2026-11-10 11:19:06 -07:00
Nariman Jelveh a80c748c15 fix(explorer): refuse uploads, copies and shortcuts into the Shared view
Dropping files on Shared in the sidebar, or Ctrl/Alt-dropping items onto a
window showing it, sent requests against a path that is not a directory and
ended in an error or a stuck progress window.
2026-11-10 11:19:06 -07:00
Nariman Jelveh d60526fa72 fix(explorer): remove the stray tooltip from the loading spinner
The spinner's SVG carried an internal title that showed as a tooltip and
was read out by screen readers.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 704649f0c3 fix(explorer): give the layout button a tooltip and a pointer cursor
It was the only navbar control without either, so it did not read as
clickable.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 87c1013986 fix(explorer): align the details header with its rows and mark it clickable
The header ended 10px before the rows and the selection highlight, and its
sortable column titles kept the default cursor.
2026-11-10 11:19:06 -07:00
Nariman Jelveh c0abf69aad fix(explorer): grey out the whole selected row in an unfocused details view
Only the name lost its highlight when the window lost focus, leaving a grey
pill inside a blue row.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 2c819cc158 fix(explorer): keep the current folder visible in a long breadcrumb
A path too long for the bar was cut at its end, hiding the folder being
viewed and its nearest parents. It is now cut at the root end.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 96f3c7d058 fix(explorer): show the slow-load message only for a load that is slow
Once any folder took over a second to load, the spinner text switched to
the slow-load message three seconds later even after the load finished, and
every later load in that window opened with it. The text is also translated
now.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 3809f38e5f fix(explorer): enable the Open dialog's button only for a selected file
The button stayed enabled after navigating to another folder, and a
rubber-band selection of folders alone enabled it, although clicking it then
did nothing.
2026-11-10 11:19:06 -07:00
Nariman Jelveh 89124bb27d fix(explorer): keep sidebar labels translated and titled after reordering
A reordered sidebar rendered the labels saved at the time of the drag, so
they stayed in the old language, and every item's tooltip was empty.
2026-11-10 11:19:06 -07:00
Nariman Jelveh eaff5c5929 fix(explorer): label the root and Shared in the Back/Forward menus
The long-press history menus showed an empty row for the root folder and a
lowercase path segment for Shared, and the Forward menu's Home entry was not
translated.
2026-11-10 11:19:05 -07:00
Nariman Jelveh f452d8d8fa fix(explorer): empty Trash from its window in one request
The Trash window's own Empty Trash sent one delete request per item and
reported Trash as empty before any of them finished. It now runs the same
single operation as the taskbar and desktop.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 23e56f2ce2 fix(explorer): keep desktop rearranging to the desktop itself
Dropping an item from a window showing the Desktop folder onto the desktop
background positioned the window's row absolutely and saved that position.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 095c18fdb0 fix(explorer): don't highlight a window as a drop target for its own items
The check meant to skip the folder an item is already in compared the wrong
path, so dragging inside a window outlined it although dropping there does
nothing.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 8c2b834fd3 fix(explorer): handle a drop onto a folder that is part of the dragged selection
Dragging a selection onto one of its own selected folders also tried to move
that folder into itself. On the desktop the drop also cleared the saved
position of the target folder instead of the items that left the desktop.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 2217513589 fix(explorer): make New > JPEG Image create an actual JPEG
The blank image was encoded with the canvas default, so the .jpg file held
transparent PNG data.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 6379260042 fix(explorer): give a folder named *.app a folder icon
The app-shortcut branch ran before the directory branch, so such a folder
was read as a file on every listing, failed, and showed the app icon.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 9fb31abad4 fix(explorer): offer Cut only where the item can be moved
The item menu on the desktop and in folder windows offered Cut on trashed
items and on shared items the user may not move, unlike the same menu built
for the dashboard.
2026-11-10 11:19:05 -07:00
Nariman Jelveh c369b55841 fix(explorer): use an item's current path and name in its context menu
Cut, Copy, Download, Create Shortcut and the rename editor read the path and
name captured when the row was created. After a rename made from another
window or tab they acted on the old path, and closing the editor repainted
the old name.
2026-11-10 11:19:05 -07:00
Nariman Jelveh 01da03437a fix(explorer): offer a download when no app can open a file
The prompt called a translation import that is not a function, so opening
a file with no suitable app threw and did nothing.
2026-11-10 11:19:05 -07:00
Nariman Jelveh f67a04f67e fix(explorer): let Delete work on a file after its rename was cancelled
Cancelling a rename with Escape, or leaving the editor without a change,
left the item marked as being renamed, and the Delete shortcut skips such
items.
2026-11-10 11:05:01 -07:00
Nariman Jelveh 908dcbbd22 fix(explorer): release each row's document click handler when the row is removed
Every file row bound a click handler on document that was never unbound, so
browsing folders grew the number of handlers run on every click and kept the
removed rows alive.
2026-11-10 11:03:38 -07:00
Nariman Jelveh 1002f628a7 fix(explorer): accept drops into a window that covers a desktop folder
A desktop folder hidden under a window still reacted to the drag and
disabled the window as a drop target, so the drop was ignored. A drop that
bailed out early also left every folder view disabled as a target until the
next hover; targets are now restored when the drag ends.
2026-11-10 11:01:58 -07:00
Nariman Jelveh 01135fcee4 fix(explorer): don't flash the empty-folder message while a folder refreshes
Clearing the rows for a refresh showed the message until the listing came
back, even for a folder full of items.
2026-11-10 10:59:49 -07:00
Nariman Jelveh e7f2f55b79 fix(explorer): show file names once-escaped in move alerts
The name was escaped before being passed to an alert that escapes its whole
message, so names with &, quotes or angle brackets showed entity text.
2026-11-10 10:59:49 -07:00
Nariman Jelveh d98f16f191 fix(explorer): keep one progress window per copy or move
The progress timer is re-armed after every conflict dialog, and it opened a
second window when one was already showing, leaving the first on screen for
good.
2026-11-10 10:58:48 -07:00
Nariman Jelveh bdfcbebd31 fix(explorer): follow renamed and moved folders in Back/Forward and on undo
A window's navigation history kept the old path after a folder was renamed
or moved, so Back led to a load error. Undoing a folder rename also left
windows and items inside it on the renamed path, because the undo retargeted
from the path it was restoring.
2026-11-10 10:58:48 -07:00
Nariman Jelveh af01727f0f fix(explorer): end the extract operation when an archive is corrupt or has no files
A corrupt zip threw before its error could be shown, and an archive with
nothing to write or a failed upload never closed the progress window. A
progress update arriving before the window existed also threw.
2026-11-10 10:57:32 -07:00
Nariman Jelveh a0b1f82b85 fix(explorer): allow moving into folders whose name starts with Trash
The deleted-folder check matched any sibling of Trash sharing its prefix,
so moving into a folder such as Trash-old was refused.
2026-11-10 10:56:40 -07:00
Nariman Jelveh 225eeba6ec fix(explorer): download files whose names contain &, #, % or +
The path went into the download URL unencoded, so the server received a
truncated path and the download failed.
2026-11-10 10:56:39 -07:00
Nariman Jelveh fcf8bc53d1 fix(explorer): refresh size and date when a file is overwritten elsewhere
An overwrite arrives as an update event, which only applied the name, icon
and path, so the size and modified columns and sorting kept old values.
2026-11-10 10:55:59 -07:00
Nariman Jelveh 8b3746cdca fix(explorer): remove rows deleted elsewhere instead of hiding them
A deletion from another tab or an app only faded the row out, so it still
counted in the footer, in Select All and in keyboard actions. The row is now
removed, and windows showing the deleted folder close even when its own row
is not displayed anywhere.
2026-11-10 10:55:59 -07:00
Nariman Jelveh 737fdd2148 fix(explorer): keep the Trash window's states when Trash is emptied elsewhere
Emptying Trash from another tab cleared the whole container, including its
empty, error and loading elements, and left the item count stale.
2026-11-10 10:55:59 -07:00
Nariman Jelveh fb6571e914 fix(explorer): sort names the way the dashboard does, with a stable tie-break
Folder windows and the desktop compared raw lowercase strings, so file10
came before file2 and accented names landed after z, and equal sizes, dates
or types kept whatever order they arrived in. They now use the dashboard's
comparator, so the same folder reads the same in both.
2026-11-10 10:55:59 -07:00
Nariman Jelveh 3512093143 fix(explorer): show a moved item once in its destination
The tab that moves an item renders the row and also receives the move
event, so the destination showed the item twice whenever the event arrived
first. Both paths now replace any row the other already rendered.
2026-11-10 10:52:48 -07:00
Nariman Jelveh 0b48fff418 fix(explorer): paste cut items after their folder is no longer displayed
Paste resolved the cut paths to rows on screen, so cutting a file and then
navigating the same window to the destination moved nothing and silently
emptied the clipboard. Items without a row are now looked up and moved the
same way.
2026-11-10 10:52:48 -07:00
Nariman Jelveh 8d1656dff8 fix(explorer): count only visible items in the footer and drop hidden files from the selection
The item count included dotfiles that were not shown, and hiding hidden
files left them selected, so a later Delete or Cut acted on files the user
could not see.
2026-11-10 10:48:34 -07:00
Nariman Jelveh ed21d14385 fix(explorer): keep selection shortcuts working after navigating, and out of text fields
Select All, Copy, Cut and Delete looked up the folder from the element last
pressed, which is a removed item or a sidebar entry after navigating, so the
shortcuts did nothing. Select All also skips hidden files, and Delete keys
typed in a text field are left to the field.
2026-11-10 10:47:35 -07:00
Nariman Jelveh b2b9db0205 fix(explorer): paste once per Ctrl+V press
The shortcut ran on both keydown and keyup, so releasing V before Ctrl
pasted the clipboard twice.
2026-11-10 10:45:54 -07:00
Nariman Jelveh 804e9c35e9 fix(explorer): make the keyboard permanent-delete confirmation work in every language
The confirmation compared the dialog's answer with the English button label,
so in any other language confirming did nothing.
2026-11-10 10:45:54 -07:00
Nariman Jelveh f14fa619bf fix(explorer): ignore non-character keys in type-to-select
Shift, arrows and Enter were appended to the search term by name, so typing
a capital letter or typing right after navigating matched nothing. Hidden
files are no longer matched, and the footer count follows the container the
selection changed in.
2026-11-10 10:45:54 -07:00
Nariman Jelveh d689861fa2 fix(explorer): keep arrow-key navigation on visible items and a valid anchor
Arrow keys could land on a hidden dotfile and then stop moving, threw when
the remembered anchor belonged to another window, and left the footer's
selected count stale.
2026-11-10 10:45:53 -07:00
Nariman Jelveh 3984ec724c fix(explorer): anchor a Ctrl/Cmd rubber-band selection where the drag started
The rectangle's origin was only recorded for unmodified drags, so adding to
a selection drew it from the previous drag's start point or the top-left
corner.
2026-11-10 10:44:09 -07:00
Nariman Jelveh 95634784a0 fix(explorer): release a window's selection listeners and history when it closes
Each folder window left its rubber-band selection instance, a document
mouseup handler and its navigation history behind after closing, so every
later mouse press ran once per window ever opened. Windows that are not
folders no longer bind the handler at all.
2026-11-10 10:42:49 -07:00
Nariman Jelveh a028f2ce08 fix(explorer): drop the previous folder's identity when a folder fails to load
A rejected stat was never handled, so the window kept the previous folder's
uid, title and item count while showing the load error, and layout or sort
changes were saved to the wrong folder. The window now clears them, and the
failed request no longer surfaces as an unhandled rejection.
2026-11-10 10:42:03 -07:00
Nariman Jelveh dceda8aa2a fix(explorer): keep the address-bar input in step with the Shared view and the root
The path input was only updated after a successful stat, so in views that
are never statted it kept the previous folder's path and Enter navigated
back there.
2026-11-10 10:42:03 -07:00
Nariman Jelveh 63f6ed7c38 fix(explorer): record typed address-bar paths in navigation history and canonicalize them
Pressing Enter in the path input now pushes the destination onto the
window's Back/Forward history like every other navigation, and resolves
the entry first (trim, ~ and home-relative expansion, trailing or doubled
slashes) so the window's path matches the form the rest of the GUI compares
against.
2026-11-10 10:40:48 -07:00
Nariman Jelveh 8a15f09f48 Send one request, not four, after the account pick in the sign-in popup (#4564)
* Send one request, not four, after the account pick in the sign-in popup

A sign-in popup exchanges for the opener's app token as soon as the user
picks an account. postAuthActions now hands over that result when the
opener origin and session token still match, and exchanges again otherwise.

The relationship check is skipped when an account was picked, since the
pick already decides both the second picker and consent. Signup in the
popup is not a pick and still runs it.

The /whoami read before the picker is reused only when the same user and
session token are still in place, the exchange just accepted that session,
no verification gate is pending, and the read is under a minute old.

* Test picking a different account in the sign-in popup

The popup has to read the picked account once and hand over a token for
it, not for the account it opened with. The second-account helper moves
to the shared e2e helpers so both specs that need one use the same copy.
2026-11-10 08:31:12 -07:00
Daniel Salazar 2084587885 fix(apps): list only the caller's own apps (#4562)
The `puter-apps` `select` method returned every app that was not
protected unless the caller sent `predicate: ['user-can-edit']`. It now
always scopes to the caller's own apps. The predicate is still accepted
and no longer changes the result.

Apps owned by someone else stay reachable by name or uid through `read`
and `/query/app`.

`AppStore.count` drops `visibleToUserId`, which only the unscoped
listing used.
2026-11-09 17:43:28 -07:00
Daniel Salazar 222ffcfe39 fix(dev-center): accept only a hostname for puter.domain and digits for puter.port; escape the saved app link (#4561) 2026-11-09 17:27:42 -07:00
Nariman Jelveh 7494aa1cbc Reload a tab when another tab switches to a different account (#4560)
* Reload a tab when another tab switches to a different account

Every tab shares one stored session token. A tab that sees a new token
appear in storage adopted it whoever it was issued to, so after an account
switch in one tab the others kept showing the previous account while
sending requests as the new one.

A tab now reads the user out of the stored token. A token for the account
it already shows is adopted as before. A token for a different account
reloads the tab, which comes back up as that account. A tab with no
signed-in user, or a token whose user can't be read, behaves as before.

* Store a new account's token only after its session cookie is set

A tab that sees another account's token in storage reloads and signs in
from the session cookie. update_auth_data() stored the token before it
synced the cookie, so a tab that reloaded faster than the sync came back
up as the previous account and stored that account's token again, which
the switching tab then adopted under the new account.

The token is now stored after the cookie sync, in the same task as the
stored user.

* Keep the new account's token when another tab stores one during the cookie sync

update_auth_data() sets window.auth_token, then waits for the session cookie sync. A token another tab stored during that wait was adopted over it, so the tab finished the switch holding the previous account's token and saved it under the new account in logged_in_users. The token is set again once the sync has settled.

* Create one visitor account for the account switch e2e tests

Each test signed up its own visitor, and sign-ups are limited to 10 per 15 minutes, so a few runs in a row failed in setup.
2026-11-09 16:49:50 -07:00
Nariman Jelveh 1adca8a794 Show a shared item once, with its strongest access, when it has more than one share (#4559)
* Show a shared item once, with its strongest access, when it has more than one share

The Shared listing returns one row per grant, so an item shared by two
people, or directly and through a team, appeared twice and took its
actions from whichever grant was listed last. The GUI now merges the
rows for an item and keeps the strongest mode.

* Keep a shared item under each person who shared it in the people filter

A merged row carried only the strongest grant's issuer, so filtering the
Shared view to someone whose grant was the weaker one hid an item they
shared, and their chip left it out of its count. A merged row now lists
every issuer. The filter and its counts match any of them, Everyone
counts the item once, and the Shared by tooltip names them all.

* Keep an item listed after Remove from Shared when a team still shares it

Leaving an item drops the grants made to the user directly. One a team
also holds stays shared, but the desktop took its icon away until the
next refresh and the dashboard removed the row before re-listing it. A
merged row now records that a team holds the item too. The desktop
re-lists the Shared folder so the item shows as the team share it has
become, and the dashboard leaves the row in place for its refresh.

* Show an item as shared by the person the Shared view is filtered to

An item several people shared showed the strongest grant's issuer in
the Shared by column even when the listing was filtered to one of the
others. A filtered row now shows the person filtered to, which also
keeps the Shared by sort in step with what the column displays.
2026-11-09 14:55:24 -07:00
Nariman Jelveh 07b19888a4 Match desktop items and windows by their path value, not by a selector (#4558)
* Match desktop items and windows by their path value, not by a selector

Path lookups on the desktop compare data-path values through one helper, so
a name holding &, ' or " is found like any other. Explorer windows keep the
raw path in data-path after navigating, the same as when they open.

* Read data-path straight from the element in path lookups

atPath() and underPath() built two jQuery objects for every element they
looked at. On a desktop with 5,000 items a lookup took about 1.4 ms,
against 0.08 ms for the selector it replaced. Reading the attribute from
the element brings it to about 0.3 ms.

* Match no element when a path lookup has no path

atPath() with a missing path matched every element whose attribute is
empty, which is every item that is not a shortcut and every app window.
An item.moved event without old_path would point all of those items at
the moved file. underPath() with an empty path matched every path.

* Use atPath() to find the items a share badge belongs to

mark_item_shared() had its own copy of the same lookup.

* Keep the raw folder name in an explorer window's data-name

update_window_path() and refresh_item_container() wrote the HTML-encoded
name there, so after navigating into "R&D" the tab title and the publish
dialog showed "R&D". A newly opened window already held the raw name.
2026-11-09 14:24:52 -07:00
48 changed files with 1775 additions and 428 deletions

No files matched your search

+14 -31
View File
@@ -330,14 +330,11 @@ export class AppDriver extends PuterDriver {
}
/**
* Paged app listing. Unlike `/query/app`, which answers only for apps named
* by the caller and so must not confirm ones it may not see, this is the
* listing surface a launcher reads: `protected` apps are filtered by the
* permission check below, and everything else is listable by design. Narrow
* it with `predicate: ['user-can-edit']` to the caller's own.
* Paged listing of the caller's own apps. Apps owned by anyone else are
* reachable only by name or uid, through `read` and `/query/app`.
*/
async select(args = {}) {
const { predicate, params = {} } = args;
const { params = {} } = args;
const actor = requireContextActor();
this.#requireUserOrAppActor(actor);
@@ -353,16 +350,12 @@ export class AppDriver extends PuterDriver {
const includeTotal = args.includeTotal === true;
const paginated = hasCursor || offset !== undefined || includeTotal;
const filters = {};
// predicate: ['user-can-edit'] → scope to owner
const ownerScoped =
Array.isArray(predicate) && predicate[0] === 'user-can-edit';
if (ownerScoped) {
filters.ownerUserId = actor.user.id;
}
// Always the caller's own. `predicate: ['user-can-edit']` asks for the
// same thing and is accepted for older clients.
const ownerUserId = actor.user.id;
let apps = await this.appStore.list({
...filters,
ownerUserId,
limit: paginated ? limit + 1 : limit,
offset,
afterId: payload?.id !== undefined ? Number(payload.id) : undefined,
@@ -371,23 +364,18 @@ export class AppDriver extends PuterDriver {
let cursor;
if (paginated && apps.length > limit) {
apps = apps.slice(0, limit);
// The cursor tracks the last fetched row, not the last visible
// one, so rows hidden by the permission filter below aren't
// re-scanned on the next page.
cursor = sealCursor(
{ id: Number(apps[apps.length - 1].id) },
this.config.jwt_secret_v2,
);
}
// Protected apps are filtered by one batched grant check; stats and
// the shared views are each one batched lookup across the page.
const visible = await this.services.app.filterReadable(apps, actor);
// Stats and the shared views are each one batched lookup per page.
const [statsByUid, views] = await Promise.all([
this.appStore.getAppsStats(visible.map((a) => a.uid)),
this.services.app.views(visible, actor),
this.appStore.getAppsStats(apps.map((a) => a.uid)),
this.services.app.views(apps, actor),
]);
const items = visible.map((app, i) =>
const items = apps.map((app, i) =>
toAppReadView(app, views[i], {
viewer: actor.user,
stats: statsByUid.get(app.uid),
@@ -396,14 +384,9 @@ export class AppDriver extends PuterDriver {
);
if (!paginated) return items;
let total;
if (includeTotal) {
total = ownerScoped
? await this.appStore.count({ ownerUserId: actor.user.id })
: await this.appStore.count({
visibleToUserId: actor.user?.id ?? null,
});
}
const total = includeTotal
? await this.appStore.count({ ownerUserId })
: undefined;
return {
items,
+32 -41
View File
@@ -509,7 +509,7 @@ describe('AppDriver.read', () => {
// ── select ──────────────────────────────────────────────────────────
describe('AppDriver.select', () => {
it('returns visible apps including those owned by other users', async () => {
it("returns only the caller's apps when no predicate is sent", async () => {
const a = await makeUser();
const b = await makeUser();
const aName = uniqueName('a');
@@ -538,7 +538,7 @@ describe('AppDriver.select', () => {
)) as Array<Record<string, unknown>>;
const names = result.map((r) => r.name);
expect(names).toContain(aName);
expect(names).toContain(bName);
expect(names).not.toContain(bName);
});
it('predicate `user-can-edit` filters to actor-owned apps only', async () => {
@@ -1670,11 +1670,10 @@ describe('AppDriver.select pagination', () => {
expect(page.total).toBe(names.length);
});
it("hides other users' protected apps from paginated catalog listings", async () => {
it("pages through only the caller's apps, protected ones included", async () => {
const a = await makeApps(3);
const [visible1, hidden, visible2] = a.names;
const created = await withActor(a.actor, () =>
driver.read({ id: { name: hidden } }),
driver.read({ id: { name: a.names[1] } }),
);
const row = await server.stores.app.getByUid(
(created as Record<string, unknown>).uid as string,
@@ -1685,20 +1684,29 @@ describe('AppDriver.select pagination', () => {
);
await server.stores.app.invalidateByUid(row!.uid as string);
const b = await makeUser();
const seen: string[] = [];
let cursor: string | null | undefined = null;
do {
const page = (await withActor(b.actor, () =>
driver.select({ limit: 50, cursor }),
)) as { items: Array<{ name: string }>; cursor?: string };
seen.push(...page.items.map((r) => r.name));
cursor = page.cursor;
} while (cursor);
const listAll = async (actor: Actor) => {
const seen: string[] = [];
let total: number | undefined;
let cursor: string | null | undefined = null;
do {
const page = (await withActor(actor, () =>
driver.select({ limit: 2, cursor, includeTotal: true }),
)) as {
items: Array<{ name: string }>;
cursor?: string;
total?: number;
};
seen.push(...page.items.map((r) => r.name));
total ??= page.total;
cursor = page.cursor;
} while (cursor);
return { seen, total };
};
expect(seen).toContain(visible1);
expect(seen).toContain(visible2);
expect(seen).not.toContain(hidden);
expect(await listAll(a.actor)).toEqual({ seen: a.names, total: 3 });
const b = await makeUser();
expect(await listAll(b.actor)).toEqual({ seen: [], total: 0 });
});
});
@@ -2517,22 +2525,6 @@ describe('AppDriver.select query-count regression', () => {
// -- select / read parity --
describe('AppDriver.select / read parity', () => {
const findViaBroadSelect = async (
actor: Actor,
uid: string,
): Promise<Record<string, unknown> | undefined> => {
let cursor: string | null | undefined = null;
do {
const page = (await withActor(actor, () =>
driver.select({ limit: 50, cursor }),
)) as { items: Array<Record<string, unknown>>; cursor?: string };
const found = page.items.find((r) => r.uid === uid);
if (found) return found;
cursor = page.cursor;
} while (cursor);
return undefined;
};
it('every select item (minus stats) deep-equals the corresponding read', async () => {
const { actor } = await makeUser();
const names: string[] = [];
@@ -2640,7 +2632,7 @@ describe('AppDriver.select / read parity', () => {
).toBe(false);
});
it('dangling hosted app via select: owner keeps index_url, others do not', async () => {
it('dangling hosted app: owner keeps index_url in select, others do not in read', async () => {
const owner = await makeUser();
const other = await makeUser();
const sub = uniqueName('qcdangsel');
@@ -2672,13 +2664,12 @@ describe('AppDriver.select / read parity', () => {
?.reason,
).toBe('hosted_backing_unavailable');
const otherItem = await findViaBroadSelect(
other.actor,
created.uid as string,
);
expect(otherItem?.index_url).toBeUndefined();
const otherItem = (await withActor(other.actor, () =>
driver.read({ uid: created.uid }),
)) as Record<string, unknown>;
expect(otherItem.index_url).toBeUndefined();
expect(
(otherItem?.privateAccess as { hasAccess?: boolean } | undefined)
(otherItem.privateAccess as { hasAccess?: boolean } | undefined)
?.hasAccess,
).toBe(false);
});
@@ -20,6 +20,7 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { describe, expect, it } from 'vitest';
import { isTokenForAnotherUser } from '../../../gui/src/helpers/tokenUser.js';
import { TokenService } from './TokenService.js';
const V2_SECRET = 'test-v2-secret';
@@ -384,3 +385,21 @@ describe('TokenService payload key handling', () => {
},
);
});
describe('session token read by the desktop', () => {
// The desktop reads the user out of a token another tab stored, to tell
// an account switch from a renewed token for the same account.
it('names its user in the form the desktop decodes', () => {
const svc = createTokenService();
const userUid = '5d4adce0-a381-4982-9c02-6e2540026238';
const otherUid = 'a1b2c3d4-0000-4000-8000-00000000beef';
const token = svc.sign('auth', {
type: 'gui',
version: '2',
user_uid: userUid,
});
expect(isTokenForAnotherUser(token, { uuid: userUid })).toBe(false);
expect(isTokenForAnotherUser(token, { uuid: otherUid })).toBe(true);
});
});
+1 -12
View File
@@ -686,12 +686,7 @@ export class AppStore extends PuterStore {
return apps;
}
/**
* Count apps matching the same equality filters as `list`.
* `visibleToUserId` counts the public catalog for one caller —
* non-protected apps plus their own; apps visible only via explicit
* permission grants are not included.
*/
/** Count apps matching the same equality filters as `list`. */
async count(filters = {}) {
const where = [];
const params = [];
@@ -704,12 +699,6 @@ export class AppStore extends PuterStore {
where.push('`app_owner` = ?');
params.push(filters.appOwner);
}
if (filters.visibleToUserId !== undefined) {
where.push(
'(`protected` IS NULL OR `protected` = false OR `owner_user_id` = ?)',
);
params.push(filters.visibleToUserId);
}
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const rows = await this.clients.db.read(
+2 -19
View File
@@ -1292,31 +1292,14 @@ describe('AppStore CRUD and cache invalidation', () => {
).toBe('Bumped');
});
it('counts apps by owner, creator and per-caller visibility', async () => {
it('counts apps by owner and creator', async () => {
const countOwner = 4600;
const viewer = 4601;
const open = await createApp({}, { ownerUserId: countOwner });
await createApp({}, { ownerUserId: countOwner, appOwner: open.id });
const hidden = await createApp({}, { ownerUserId: countOwner });
await db.write('UPDATE `apps` SET `protected` = 1 WHERE `id` = ?', [
hidden.id,
]);
await createApp({}, { ownerUserId: countOwner });
expect(await appStore.count({ ownerUserId: countOwner })).toBe(3);
expect(await appStore.count({ appOwner: open.id })).toBe(1);
// The protected app is invisible to a stranger but visible to its
// owner.
const strangerView = await appStore.count({
ownerUserId: countOwner,
visibleToUserId: viewer,
});
expect(strangerView).toBe(2);
const ownerView = await appStore.count({
ownerUserId: countOwner,
visibleToUserId: countOwner,
});
expect(ownerView).toBe(3);
});
// -- filetype association batch + recent opens ----------------------
+1 -1
View File
@@ -1339,7 +1339,7 @@ async function saveEditedApp (mode) {
// Update title
$(`.app-title[data-uid="${uid}"]`).html(html_encode(app.title));
// Update app link
$(`.app-url[data-uid="${uid}"]`).html(applink(app));
$(`.app-url[data-uid="${uid}"]`).html(html_encode(applink(app)));
$(`.app-url[data-uid="${uid}"]`).attr('href', applink(app));
// Update icons
$(`.app-icon[data-uid="${uid}"]`).attr('src', html_encode(app.icon ? app.icon : './img/app.svg'));
+15 -16
View File
@@ -38,22 +38,27 @@ window.user = null;
}
})();
// domain and APIOrigin
if ( window.url_params.has('puter.domain') ) {
window.domain = window.url_params.get('puter.domain');
// domain and port are built into links and deploy URLs, so anything other
// than a bare hostname and a numeric port is ignored
const domainParam = window.url_params.get('puter.domain');
if ( domainParam && /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(domainParam) ) {
window.domain = domainParam;
}
const portParam = window.url_params.get('puter.port');
window.port = portParam && /^\d{1,5}$/.test(portParam) ? portParam : '';
// static hosting domain
window.static_hosting_domain = 'puter.site';
if ( window.domain === 'puter.localhost' ) {
window.static_hosting_domain = 'site.puter.localhost';
} else if ( window.domain && window.domain !== 'puter.com' ) {
window.static_hosting_domain = `site.${window.domain}`;
}
if ( window.domain === 'puter.localhost' ) {
window.static_hosting_domain = 'site.puter.localhost';
} else if ( window.domain && window.domain !== 'puter.com' ) {
window.static_hosting_domain = `site.${window.domain}`;
}
// add port to static_hosting_domain if provided
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
window.static_hosting_domain = `${window.static_hosting_domain }:${ html_encode(window.url_params.get('puter.port'))}`;
if ( window.port ) {
window.static_hosting_domain = `${window.static_hosting_domain}:${window.port}`;
}
// protocol
@@ -63,12 +68,6 @@ if ( window.url_params.has('puter.protocol') && window.url_params.get('puter.pro
window.protocol = 'http';
}
// port
window.port = '';
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
window.port = html_encode(window.url_params.get('puter.port'));
}
// source_path
if ( window.url_params.has('source_path') ) {
window.source_path = window.url_params.get('source_path');
+4 -2
View File
@@ -50,7 +50,7 @@ import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
import { is_owned_by_me } from '../../helpers/pathOwner.js';
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, sharerOf, summarizeSharers } from './sharedBy.js';
import { paintAvatars } from '../../helpers/profilePictures.js';
const { html_encode, SelectionArea } = window;
@@ -2983,7 +2983,7 @@ const TabFiles = {
// Rebuilding the chips drops focus and scroll; carry both across.
const focused = $bar.find('.sharer-chip:focus').attr('data-sharer');
const scrollLeft = bar.scrollLeft;
const html = sharerFilterHtml(sharers, this.sharerFilter);
const html = sharerFilterHtml(sharers, this.sharerFilter, rows.filter((row) => sharerOf(row)).length);
$bar.html(html).prop('hidden', ! html);
paintAvatars(bar);
bar.scrollLeft = scrollLeft;
@@ -6011,8 +6011,10 @@ const TabFiles = {
shared_with_me: true,
share_mode: share.mode,
shared_by: share.issuer,
shared_by_all: share.issuers,
owner: share.owner,
holder_team: share.holderTeam,
also_team_share: share.alsoHeldByTeam,
}));
const linked = this.pendingSharedLinks ?? [];
+28
View File
@@ -1667,6 +1667,34 @@ describe('TabFiles listSharedView', () => {
});
expect(rows[1].holder_team).toEqual(team);
});
it('merges the grants on one item into a single row', async () => {
const team = { uid: 't-1', name: 'Acme', handle: 'acme' };
const grant = { entryUid: 'u-1', name: 'a.txt', path: '/bob/u-1/a.txt', isDir: false, owner: 'bob' };
window.puter = {
fs: {
listShared: vi.fn(async () => ({
items: [
{ ...grant, mode: 'read', issuer: 'bob', holderTeam: team },
{ ...grant, mode: 'manage', issuer: 'bob' },
{ ...grant, mode: 'read', issuer: 'carol' },
],
})),
},
};
const rows = await TabFiles.listSharedView();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
uid: 'u-1',
share_mode: 'manage',
shared_by: 'bob',
shared_by_all: ['bob', 'carol'],
also_team_share: true,
});
expect(rows[0].holder_team).toBeUndefined();
});
});
describe('TabFiles path field', () => {
+43 -11
View File
@@ -22,9 +22,10 @@
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
import { avatarUserAttr } from '../../helpers/profilePictures.js';
import { intlLocale } from '../../i18n/intlLocale.js';
/**
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
* @typedef {{ shared_by?: string | null, shared_by_all?: string[], owner?: string | null }} SharedRow
* @typedef {{ name: string, count: number }} Sharer
*/
@@ -37,6 +38,19 @@ import { avatarUserAttr } from '../../helpers/profilePictures.js';
*/
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
/**
* Everyone who shared a row with the user, `sharerOf` first. More than one
* when the item was granted by several people.
*
* @param {SharedRow} [row]
* @returns {string[]} usernames
*/
export const sharersOf = (row) => {
const first = sharerOf(row);
if ( ! row?.shared_by_all?.length ) return first ? [first] : [];
return [...new Set([first, ...row.shared_by_all])].filter(Boolean);
};
/**
* Everyone who shared something in `rows`, with how many items each, most
* items first and ties by name.
@@ -47,8 +61,7 @@ export const sharerOf = (row) => row?.shared_by || row?.owner || '';
export const summarizeSharers = (rows) => {
const counts = new Map();
for ( const row of rows ) {
const name = sharerOf(row);
if ( name ) counts.set(name, (counts.get(name) ?? 0) + 1);
for ( const name of sharersOf(row) ) counts.set(name, (counts.get(name) ?? 0) + 1);
}
return [...counts]
.map(([name, count]) => ({ name, count }))
@@ -56,7 +69,8 @@ export const summarizeSharers = (rows) => {
};
/**
* The rows `sharer` shared; every row when `sharer` is empty.
* The rows `sharer` shared, each shown as shared by them; every row when
* `sharer` is empty.
*
* @template {SharedRow} T
* @param {T[]} rows
@@ -64,7 +78,9 @@ export const summarizeSharers = (rows) => {
* @returns {T[]}
*/
export const filterBySharer = (rows, sharer) => (sharer
? rows.filter((row) => sharerOf(row) === sharer)
? rows
.filter((row) => sharersOf(row).includes(sharer))
.map((row) => (sharerOf(row) === sharer ? row : { ...row, shared_by: sharer }))
: rows);
/**
@@ -80,6 +96,21 @@ export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
// one per call, which made sorting a large Shared listing over 20x slower.
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
/**
* Names joined the way the user's language lists them ("ann and bob").
*
* @param {string[]} names
* @returns {string}
*/
const listNames = (names) => {
if ( names.length === 1 ) return names[0];
try {
return new Intl.ListFormat(intlLocale(window.locale ?? 'en'), { type: 'conjunction' }).format(names);
} catch {
return names.join(', ');
}
};
/**
* The "Shared by" column's width beside an auto-width name column: `width`,
* held to 40% of the `room` the two split so the name keeps the larger part,
@@ -102,15 +133,16 @@ export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Ma
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}"${avatarUserAttr(name)} aria-hidden="true">${window.html_encode(avatarInitial(name))}</span>`;
/**
* Tooltip for a row's sharer, naming the owner too when someone else re-shared it.
* Tooltip for a row's sharers, naming the owner too when none of them owns it.
*
* @param {SharedRow} row
* @returns {string} plain text
*/
export const sharedByTitle = (row) => {
const username = sharerOf(row);
if ( ! username ) return '';
return row.owner && row.owner !== username
const sharers = sharersOf(row);
if ( sharers.length === 0 ) return '';
const username = listNames(sharers);
return row.owner && ! sharers.includes(row.owner)
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
: i18n('shared_by_user', { username }, false);
};
@@ -137,11 +169,11 @@ export const sharedByCellHtml = (row) => {
*
* @param {Sharer[]} sharers - From summarizeSharers
* @param {string | null} active - The sharer filtered to, or null for everyone
* @param {number} total - Items anyone shared; an item counts once however many shared it
* @returns {string} markup
*/
export const sharerFilterHtml = (sharers, active) => {
export const sharerFilterHtml = (sharers, active, total) => {
if ( sharers.length < 2 ) return '';
const total = sharers.reduce((sum, sharer) => sum + sharer.count, 0);
const chip = (value, inner, count, title) => {
const pressed = (active ?? '') === value;
return `<button type="button" class="sharer-chip${pressed ? ' active' : ''}" data-sharer="${window.html_encode(value)}" aria-pressed="${pressed}"${title ? ` title="${window.html_encode(title)}"` : ''}>`
+39 -1
View File
@@ -40,6 +40,13 @@ const {
} = await import('./sharedBy.js');
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
// An item granted by more than one person: `shared_by` holds the strongest grant's.
const multiRow = (shared_by_all, owner = shared_by_all[0], name = 'f') => ({
shared_by: shared_by_all[0],
shared_by_all,
owner,
name,
});
describe('sharerOf', () => {
it('names whoever granted the share', () => {
@@ -74,6 +81,13 @@ describe('summarizeSharers', () => {
it('skips rows that name nobody', () => {
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
});
it('counts an item for everyone who shared it', () => {
expect(summarizeSharers([multiRow(['bob', 'ann']), row('ann')])).toEqual([
{ name: 'ann', count: 2 },
{ name: 'bob', count: 1 },
]);
});
});
describe('filterBySharer', () => {
@@ -83,6 +97,20 @@ describe('filterBySharer', () => {
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
});
it('keeps an item for each person who shared it', () => {
const shared = [multiRow(['bob', 'ann'], 'ann', 'both'), row('cy', 'cy', 'c')];
expect(filterBySharer(shared, 'ann').map((r) => r.name)).toEqual(['both']);
expect(filterBySharer(shared, 'bob').map((r) => r.name)).toEqual(['both']);
});
it('shows a kept item as shared by the person filtered to', () => {
const [both, own] = filterBySharer([multiRow(['bob', 'ann'], 'ann', 'both'), row('ann', 'ann', 'own')], 'ann');
expect(sharerOf(both)).toBe('ann');
expect(sharedByCellHtml(both)).toContain('<span class="item-shared-by-name">ann</span>');
expect(sharedByTitle(both)).toBe('shared_by_user:ann and bob');
expect(sharerOf(own)).toBe('ann');
});
it('keeps everything without a filter', () => {
expect(filterBySharer(rows, null)).toBe(rows);
expect(filterBySharer(rows, '')).toBe(rows);
@@ -123,6 +151,11 @@ describe('sharedByTitle', () => {
it('names the owner too when someone else re-shared it', () => {
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
});
it('names everyone who shared the item', () => {
expect(sharedByTitle(multiRow(['bob', 'ann'], 'ann'))).toBe('shared_by_user:bob and ann');
expect(sharedByTitle(multiRow(['bob', 'cy'], 'ann'))).toBe('shared_by_user_owned_by:bob and cy,ann');
});
});
describe('sharedByCellHtml', () => {
@@ -155,7 +188,7 @@ describe('sharerFilterHtml', () => {
});
it('leads with Everyone, counting every item', () => {
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null, 3);
const chips = h.match(/<button[^>]*>/g);
expect(chips).toHaveLength(3);
expect(chips[0]).toContain('data-sharer=""');
@@ -165,6 +198,11 @@ describe('sharerFilterHtml', () => {
expect(h).toContain('data-avatar-user="bob"');
});
it('counts an item shared by two people once under Everyone', () => {
const h = sharerFilterHtml([{ name: 'ann', count: 2 }, { name: 'bob', count: 1 }], null, 2);
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">2</span>');
});
it('presses the active sharer only', () => {
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], 'ann');
const pressed = h.match(/<button[^>]*aria-pressed="true"[^>]*>/g);
+32 -10
View File
@@ -34,7 +34,7 @@ import UIWindowProgress from './UIWindowProgress.js';
import UITaskbar from './UITaskbar.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath, retargetPaths, underPath } from '../helpers/pathElements.js';
import { atPath, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
import changeLanguage from '../i18n/i18nChangeLanguage.js';
import UIWindowTaskManager from './UIWindowTaskManager.js';
import truncate_filename from '../helpers/truncateFilename.js';
@@ -233,7 +233,12 @@ async function UIDesktop (options) {
// empty trash windows if needed
if ( msg.is_empty )
{
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.item-container').empty();
// only the rows: the container also holds its empty, error and loading states
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).each(function () {
$(this).find('.item-container .item').removeItems();
window.update_explorer_footer_item_count(this);
window.update_explorer_footer_selected_items_count(this);
});
}
});
@@ -416,14 +421,17 @@ async function UIDesktop (options) {
return;
}
// hide all UIItems with matching uids
// remove all UIItems at the removed path
atPath('.item', item.path).fadeOut(150, function () {
// close all windows with matching uids
// $('.window-' + item.uid).close();
// close all windows that belong to a descendant of this item
// todo this has to be case-insensitive
underPath('.window', item.path).close();
const $parent_window = $(this).closest('.window');
$(this).removeItems();
window.update_explorer_footer_item_count($parent_window);
window.update_explorer_footer_selected_items_count($parent_window);
});
// close the windows showing the removed folder or anything inside it,
// whether or not its own row is displayed anywhere
// todo this has to be case-insensitive
atPath('.window[data-app="explorer"]', item.path).add(underPath('.window', item.path)).close();
});
window.socket.on('item.updated', async (item) => {
@@ -455,6 +463,14 @@ async function UIDesktop (options) {
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
// An overwrite arrives as an update, with a new size and date
if ( item.size !== undefined && item.modified !== undefined ) {
const $updated_items = $(`.item[data-uid='${item.uid}']`);
$updated_items.attr({ 'data-size': item.size ?? '', 'data-modified': item.modified ?? '' });
$updated_items.find('.item-attr--size > span').text(item.size ? window.byte_format(item.size) : '-');
$updated_items.find('.item-attr--modified > span').text(item.modified === 0 ? '-' : timeago.format(item.modified * 1000));
}
// Set new data-path
const new_path = item.path;
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
@@ -523,6 +539,7 @@ async function UIDesktop (options) {
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
});
retargetNavHistory(resp.old_path, fsentry.path);
}
if ( dest_path === window.trash_path ) {
@@ -546,13 +563,18 @@ async function UIDesktop (options) {
// Trashing renames the entry to its uid; the real name is in metadata.
fsentry.name = metadata.original_name ?? fsentry.name;
const moved_item_icon = await item_icon(fsentry);
const $dest_containers = atPath('.item-container', dest_path);
// the tab that made this move renders the row itself, in either order
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
// create new item on matching containers
UIItem({
appendTo: atPath('.item-container', dest_path),
appendTo: $dest_containers,
immutable: fsentry.immutable || fsentry.writable === false,
uid: fsentry.uid,
path: fsentry.path,
icon: await item_icon(fsentry),
icon: moved_item_icon,
name: fsentry.name,
is_dir: fsentry.is_dir,
size: fsentry.size,
+48 -17
View File
@@ -35,6 +35,7 @@ import { is_owned_by_me } from '../helpers/pathOwner.js';
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
import { is_default_app } from '../helpers/defaultApp.js';
import openWithAI from '../helpers/openWithAI.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
async function UIItem (options) {
const matching_appendto_count = $(options.appendTo).length;
@@ -64,6 +65,7 @@ async function UIItem (options) {
options.share_mode = options.share_mode ?? '';
options.shared_by = options.shared_by ?? '';
options.is_team_share = options.is_team_share ?? false;
options.is_also_team_share = options.is_also_team_share ?? false;
options.owner = options.owner ?? '';
options.metadata = options.metadata ?? '';
options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false;
@@ -102,6 +104,7 @@ async function UIItem (options) {
data-share_mode="${html_encode(options.share_mode)}"
data-shared_by="${html_encode(options.shared_by)}"
data-is_team_share="${options.is_team_share ? 1 : 0}"
data-is_also_team_share="${options.is_also_team_share ? 1 : 0}"
data-owner="${html_encode(options.owner)}"
data-has_website="${show_website_badge ? 1 : 0 }"
data-website_url = "${website_url ? html_encode(website_url) : ''}"
@@ -393,7 +396,7 @@ async function UIItem (options) {
// do not highlight if the target is the same as the item being moved
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') &&
// do not highlight if item is being moved to where it already is
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') ) {
path.dirname($(el_item).attr('data-path')) !== $(window.mouseover_item_container).attr('data-path') ) {
// highlight item container
$(window.mouseover_item_container).addClass('item-container-active');
@@ -413,7 +416,8 @@ async function UIItem (options) {
},
stop: function (event, ui) {
// Allow rearranging only if item is on desktop, not trash container, auto arrange is disabled and item is not dropped into another item
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path &&
// the desktop itself, not a window showing the Desktop folder
if ( $(el_item).closest('.item-container').hasClass('desktop') &&
!window.is_auto_arrange_enabled && $(el_item).attr('data-path') !== window.trash_path && !ui.helper.data('dropped') &&
// Item must be dropped on the Desktop and not on the taskbar
window.mouseover_window === undefined && ui.position.top <= window.desktop_height - window.taskbar_height - 15 ) {
@@ -431,7 +435,9 @@ async function UIItem (options) {
// re-enable all droppable UIItems that are not a dir
$('.item[data-is_dir=\'0\']:not(.item-selected)').droppable('enable');
// remove active item-container border highlights
$('.item-container').removeClass('item-container-active');
$('.item-container').removeClass('item-container-active item-container-transparent-border');
// a drop that bailed out early never re-enables the containers an item's hover disabled
$('.item-container.ui-droppable').droppable('enable');
// reset longer hover timeout and last window dragged over
clearTimeout(longer_hover_timeout);
last_window_dragged_over = null;
@@ -545,11 +551,14 @@ async function UIItem (options) {
}
// Otherwise, move items
else if ( options.is_dir ) {
// a selected folder can be the drop target of its own selection; it stays where it is
const items_to_drop = items_to_move.filter(item => $(item).attr('data-uid') !== $(el_item).attr('data-uid'));
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
delete window.desktop_item_positions[$(el_item).attr('data-uid')];
// the items leaving the desktop lose their saved positions, not the folder they land in
items_to_drop.forEach(item => delete window.desktop_item_positions[$(item).attr('data-uid')]);
window.save_desktop_item_positions();
}
window.move_items(items_to_move, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
window.move_items(items_to_drop, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
}
}
@@ -560,8 +569,9 @@ async function UIItem (options) {
},
over: function (event, ui) {
// Check hovering over an item that is VISIBILE
// A desktop item has no window, so it only counts while the pointer is over the desktop itself
const $event_parent_win = $(event.target).closest('.window');
if ( $event_parent_win.length > 0 && $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
if ( $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
{
return;
}
@@ -719,7 +729,7 @@ async function UIItem (options) {
}
});
$(document).on('click', function (e) {
$(document).on(`click.item-${item_id}`, function (e) {
if ( !$(e.target).hasClass('item') && !$(e.target).hasClass('item-name') && !$(e.target).hasClass('item-icon') ) {
skip_a_rename_click = true;
}
@@ -728,6 +738,10 @@ async function UIItem (options) {
skip_a_rename_click = true;
}
});
// a document handler outlives the row unless it is released with it
$(el_item).on('remove', function () {
$(document).off(`click.item-${item_id}`);
});
// --------------------------------------------------------
// Rename
@@ -735,6 +749,7 @@ async function UIItem (options) {
function rename () {
if ( rename_cancelled ) {
rename_cancelled = false;
$(el_item_name_editor).removeClass('item-name-editor-active');
return;
}
@@ -754,10 +769,12 @@ async function UIItem (options) {
UIAlert('The name ".." is not allowed, because it is a reserved name. Please choose another name.');
}
$(el_item_name).html(html_encode(truncate_filename(options.name)));
$(el_item_name).html(html_encode(truncate_filename($(el_item).attr('data-name'))));
$(el_item_name).show();
$(el_item_name_editor).val($(el_item).attr('data-name'));
$(el_item_name_editor).hide();
// the Delete shortcut skips an item whose editor is marked active
$(el_item_name_editor).removeClass('item-name-editor-active');
return;
}
// deactivate item name editable
@@ -803,8 +820,9 @@ async function UIItem (options) {
e.stopPropagation();
e.preventDefault();
rename_cancelled = true;
$(el_item_name_editor).removeClass('item-name-editor-active');
$(el_item_name_editor).hide();
$(el_item_name_editor).val(options.name);
$(el_item_name_editor).val($(el_item).attr('data-name'));
$(el_item_name).show();
}
});
@@ -863,6 +881,11 @@ async function UIItem (options) {
return;
}
// The menu is built after lookups that take time, so it opens where the pointer was when it was asked for.
const menu_position = (isMobile.phone || isMobile.tablet)
? { left: window.last_touch_x, top: window.last_touch_y }
: { left: window.mouseX, top: window.mouseY };
// if editing item name, preserve native context menu
if ( event.target === el_item_name_editor )
{
@@ -1134,7 +1157,7 @@ async function UIItem (options) {
// try to find suitable apps
const suitable_apps = await window.suggest_apps_for_fsentry({
uid: options.uid,
path: options.path,
path: $(el_item).attr('data-path'),
});
if ( suitable_apps && suitable_apps.length > 0 ) {
options.suggested_apps = suitable_apps;
@@ -1272,7 +1295,7 @@ async function UIItem (options) {
file_path: $(el_item).attr('data-path'),
file_uid: $(el_item).attr('data-uid'),
params: {
source_path: options.path,
source_path: $(el_item).attr('data-path'),
},
});
},
@@ -1319,7 +1342,7 @@ async function UIItem (options) {
}
else
{
window.trigger_download([options.path]);
window.trigger_download([$(el_item).attr('data-path')]);
}
},
});
@@ -1434,12 +1457,13 @@ async function UIItem (options) {
// -------------------------------------------
// Cut
// -------------------------------------------
if ( $(el_item).attr('data-immutable') === '0' ) {
// Trashed items leave the Trash through Restore; moved any other way, they'd keep their uid as their name.
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
menu_items.push({
html: i18n('cut'),
onClick: function () {
window.clipboard_op = 'move';
window.clipboard = [options.path];
window.clipboard = [$(el_item).attr('data-path')];
},
});
}
@@ -1451,7 +1475,7 @@ async function UIItem (options) {
html: i18n('copy'),
onClick: function () {
window.clipboard_op = 'copy';
window.clipboard = [{ path: options.path }];
window.clipboard = [{ path: $(el_item).attr('data-path') }];
},
});
}
@@ -1499,7 +1523,7 @@ async function UIItem (options) {
base_dir,
options.appendTo,
options.shortcut_to === '' ? options.uid : options.shortcut_to,
options.shortcut_to_path === '' ? options.path : options.shortcut_to_path,
options.shortcut_to_path === '' ? $(el_item).attr('data-path') : options.shortcut_to_path,
);
},
});
@@ -1529,7 +1553,13 @@ async function UIItem (options) {
// Or mode lookups keep answering for a share we
// just walked away from.
invalidate_shared_roots();
$(el_item).removeItems();
// A team that still shares it keeps it listed,
// as the team's share.
if ( $(el_item).attr('data-is_also_team_share') === '1' ) {
refresh_item_container($(el_item).closest('.item-container'));
} else {
$(el_item).removeItems();
}
} catch (e) {
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
}
@@ -1622,6 +1652,7 @@ async function UIItem (options) {
// Create ContextMenu
UIContextMenu({
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
position: menu_position,
items: menu_items,
});
+30 -1
View File
@@ -33,6 +33,7 @@ vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/refreshItemContainer.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
@@ -67,6 +68,8 @@ Object.assign(window, {
const { default: UIItem } = await import('./UIItem.js');
const { default: UIContextMenu } = await import('./UIContextMenu.js');
const { default: refresh_item_container } =
await import('../helpers/refreshItemContainer.js');
const menuLabels = async (options) => {
document.body.innerHTML =
@@ -90,14 +93,25 @@ const menuLabels = async (options) => {
return UIContextMenu.mock.calls[0][0].items.map((item) => item?.html);
};
const removeFromShared = async (options) => {
await menuLabels(options);
await UIContextMenu.mock.calls[0][0].items
.find((item) => item?.html === 'share_remove_from_shared')
.onClick();
};
describe('UIItem Remove from Shared', () => {
beforeEach(() => {
window.user = { username: 'alice' };
window.trash_path = '/alice/Trash';
window.puter = {
fs: { listShared: vi.fn(async () => ({ items: [] })) },
fs: {
listShared: vi.fn(async () => ({ items: [] })),
unshare: vi.fn(async () => ({ revoked: 1 })),
},
};
globalThis.puter = window.puter;
refresh_item_container.mockClear();
});
it('is offered on an item shared with the user directly', async () => {
@@ -109,4 +123,19 @@ describe('UIItem Remove from Shared', () => {
expect($('.item').attr('data-is_team_share')).toBe('1');
expect(labels).not.toContain('share_remove_from_shared');
});
it('takes the item away once the user has left it', async () => {
await removeFromShared({});
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
expect($('.item')).toHaveLength(0);
expect(refresh_item_container).not.toHaveBeenCalled();
});
it('lists the item again when a team still shares it', async () => {
await removeFromShared({ is_also_team_share: true });
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
expect($('.item')).toHaveLength(1);
expect(refresh_item_container).toHaveBeenCalledTimes(1);
expect(refresh_item_container.mock.calls[0][0][0]).toBe($('.item-container')[0]);
});
});
+139 -122
View File
@@ -26,6 +26,7 @@ import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath } from '../helpers/pathElements.js';
import { resolve_typed_path } from '../helpers/expandHomePath.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
import launch_app from '../helpers/launchApp.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
@@ -347,7 +348,18 @@ async function UIWindow (options) {
if ( ! items.some(item => item.path === window.home_path) ) {
items.unshift({ path: window.home_path, name: i18n('home') });
}
// A saved order stores the label it was rendered with; system folders follow the current language instead.
const system_folder_labels = {
[window.home_path]: i18n('home'),
[window.docs_path]: i18n('documents'),
[window.public_path]: i18n('public'),
[window.pictures_path]: i18n('pictures'),
[window.desktop_path]: i18n('desktop'),
[window.videos_path]: i18n('videos'),
[window.shared_path]: i18n('shared'),
};
for ( let item of items ) {
const label = system_folder_labels[item.path] ?? item.name;
let icon;
if ( item.path === window.home_path )
{
@@ -381,7 +393,7 @@ async function UIWindow (options) {
{
icon = window.icons['sidebar-folder.svg'];
}
h += `<div title="${html_encode(item.label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(item.name)}</div>`;
h += `<div title="${html_encode(label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(label)}</div>`;
}
}
h += '</div>';
@@ -406,7 +418,7 @@ async function UIWindow (options) {
// Path editor
h += `<input class="window-navbar-path-input" data-path="${html_encode(options.path)}" value="${html_encode(options.path)}" spellcheck="false"/>`;
// Layout settings
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false">`;
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false" title="${i18n('window_click_to_change_layout')}">`;
h += '</div>';
}
@@ -466,7 +478,7 @@ async function UIWindow (options) {
// Loading spinner
h += '<div class="explorer-loading-spinner">';
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
h += `<p class="explorer-loading-spinner-msg">${i18n('loading')}...</p>`;
h += '</div>';
}
@@ -1114,7 +1126,7 @@ async function UIWindow (options) {
// build item for context menu
items.push({
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
val: index,
onClick: async function (e) {
let history_index = e.value;
@@ -1187,7 +1199,7 @@ async function UIWindow (options) {
// build item for context menu
items.push({
html: `<span>${history_item === window.home_path ? 'Home' : html_encode(path.basename(history_item))}</span>`,
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
val: index,
onClick: async function (e) {
let history_index = e.value;
@@ -1333,6 +1345,8 @@ async function UIWindow (options) {
$(el_window).on('remove', function (e) {
// if on_close callback is set, call it
options.on_close?.();
delete window.window_nav_history[win_id];
delete window.window_nav_history_current_position[win_id];
});
// --------------------------------------------------------
@@ -1426,18 +1440,18 @@ async function UIWindow (options) {
.on('beforedrag', evt => {
})
.on('start', ({ store, event }) => {
if ( !event.ctrlKey && !event.metaKey ) {
// Get the scroll position of the window body
const scrollLeft = $(el_window_body).scrollLeft();
const scrollTop = $(el_window_body).scrollTop();
const scrollLeft = $(el_window_body).scrollLeft();
const scrollTop = $(el_window_body).scrollTop();
// Get the window body's bounding rect relative to the viewport
const windowBodyRect = el_window_body.getBoundingClientRect();
// Get the window body's bounding rect relative to the viewport
const windowBodyRect = el_window_body.getBoundingClientRect();
// Calculate position relative to the window body (accounting for scroll)
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
// Calculate position relative to the window body (accounting for scroll)
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
if ( !event.ctrlKey && !event.metaKey ) {
for ( const el of store.stored ) {
el.classList.remove('item-selected');
}
@@ -1512,7 +1526,7 @@ async function UIWindow (options) {
window.update_explorer_footer_selected_items_count(el_window);
// If this is openFileDialog, enable/disable the Open button accordingly
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
{
$(el_openfiledialog_open_btn).removeClass('disabled');
}
@@ -1523,7 +1537,7 @@ async function UIWindow (options) {
})
.on('stop', ({ store, event }) => {
// If this is openFileDialog, enable/disable the Open button accordingly
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
{
$(el_openfiledialog_open_btn).removeClass('disabled');
}
@@ -1532,6 +1546,20 @@ async function UIWindow (options) {
$(el_openfiledialog_open_btn).addClass('disabled');
}
});
$(document).on(`mouseup.window-selection-${win_id}`, function () {
if ( selection_area ) {
$(selection_area).hide();
$(selection_area).remove();
selection_area = null;
}
});
// viselect and the handler above listen on document, so they outlive the window unless released
$(el_window).on('remove', function () {
$(document).off(`mouseup.window-selection-${win_id}`);
selection.destroy();
});
}
// --------------------------------------------------------
@@ -2679,37 +2707,8 @@ async function UIWindow (options) {
{
html: i18n('empty_trash'),
disabled: false,
onClick: async function () {
// TODO: Merge this with window.empty_trash()
const alert_resp = await UIAlert({
message: i18n('empty_trash_confirmation'),
buttons: [
{
label: i18n('yes'),
value: 'yes',
type: 'primary',
},
{
label: i18n('no'),
value: 'no',
},
],
});
if ( alert_resp === 'no' )
{
return;
}
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.item[data-path^="${html_encode(window.trash_path)}/"]`).each(function () {
window.delete_item(this);
});
// update other clients
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: true });
}
// use the 'empty trash' icon
window.update_trash_icons(true);
onClick: function () {
window.empty_trash();
},
},
],
@@ -2845,14 +2844,6 @@ async function UIWindow (options) {
});
}
$(document).on('mouseup', function (e) {
if ( selection_area ) {
$(selection_area).hide();
$(selection_area).remove();
selection_area = null;
}
});
//set styles
$(el_window_body).css(options.body_css);
@@ -3072,7 +3063,21 @@ $(document).on('blur', '.window-navbar-path-input', function (e) {
$(document).on('keyup', '.window-navbar-path-input', function (e) {
if ( e.key === 'Enter' || e.keyCode === 13 ) {
window.update_window_path($(e.target).closest('.window'), $(e.target).val());
const $el_window = $(e.target).closest('.window');
const win_id = $el_window.attr('data-id');
const current_path = $el_window.attr('data-path');
// Other code matches containers by exact path, so navigate to the canonical form.
const target_path = resolve_typed_path($(e.target).val(), {
current_path,
home_path: window.home_path,
shared_path: window.shared_path,
});
if ( target_path !== current_path && window.window_nav_history[win_id] ) {
window.window_nav_history[win_id] = window.window_nav_history[win_id].slice(0, window.window_nav_history_current_position[win_id] + 1);
window.window_nav_history[win_id].push(target_path);
window.window_nav_history_current_position[win_id] = window.window_nav_history[win_id].length - 1;
}
window.update_window_path($el_window, target_path);
$(e.target).hide();
$(e.target).siblings('.window-navbar-path').show().select();
}
@@ -3336,6 +3341,14 @@ window.navbar_path = (abs_path) => {
return str;
};
// The root and the Shared view have no basename to show in the Back/Forward menus.
const nav_history_label = (history_path) => {
if ( history_path === window.home_path ) return i18n('home');
if ( history_path === '/' ) return window.root_dirname;
if ( history_path === window.shared_path ) return i18n('shared');
return path.basename(history_path);
};
window.update_window_path = async function (el_window, target_path) {
const win_id = $(el_window).attr('data-id');
const el_window_navbar_forward_btn = $(el_window).find('.window-navbar-btn-forward');
@@ -3393,6 +3406,8 @@ window.update_window_path = async function (el_window, target_path) {
// empty body to be filled with the results of /readdir
$(el_window_body).find('.item').removeItems();
// nothing is selected in the folder being opened
$(el_window).find('.openfiledialog-open-btn').addClass('disabled');
// add the 'Detail View' table header
if ( $(el_window).find('.explore-table-headers').length === 0 )
@@ -3435,6 +3450,8 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).attr('data-path', target_path);
$(el_window).attr('data-name', path.basename(target_path));
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// /stat
if ( target_path === window.shared_path ) {
@@ -3445,72 +3462,72 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).find('.window-head-icon').attr('src', window.icons['shared.svg']);
}
else if ( target_path !== '/' ) {
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
$(el_window_item_container).attr('data-uid', fsentry.id);
// title - use i18n for system directories
if ( target_path === window.home_path )
{
$(el_window).find('.window-head-title').text(i18n('home'));
}
else if ( target_path === window.desktop_path )
{
$(el_window).find('.window-head-title').text(i18n('desktop'));
}
else if ( target_path === window.docs_path || target_path === window.documents_path )
{
$(el_window).find('.window-head-title').text(i18n('documents'));
}
else if ( target_path === window.pictures_path )
{
$(el_window).find('.window-head-title').text(i18n('pictures'));
}
else if ( target_path === window.videos_path )
{
$(el_window).find('.window-head-title').text(i18n('videos'));
}
else if ( target_path === window.public_path )
{
$(el_window).find('.window-head-title').text(i18n('public'));
}
else if ( target_path === window.trash_path )
{
$(el_window).find('.window-head-title').text(i18n('trash'));
}
else
{
$(el_window).find('.window-head-title').text(fsentry.name);
}
// data-name
$(el_window).attr('data-name', fsentry.name);
// data-path
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
window.update_window_layout(el_window, fsentry.layout);
// update explore header if in details view
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
});
} catch ( err ) {
UIAlert(err.responseText);
// todo optim: this is dumb because updating the window should only happen if this /readdir request is successful,
// in that case there is no need for using update_window_path on error!!
window.update_window_path(el_window, old_path);
}
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
$(el_window_item_container).attr('data-uid', fsentry.id);
// title - use i18n for system directories
if ( target_path === window.home_path )
{
$(el_window).find('.window-head-title').text(i18n('home'));
}
else if ( target_path === window.desktop_path )
{
$(el_window).find('.window-head-title').text(i18n('desktop'));
}
else if ( target_path === window.docs_path || target_path === window.documents_path )
{
$(el_window).find('.window-head-title').text(i18n('documents'));
}
else if ( target_path === window.pictures_path )
{
$(el_window).find('.window-head-title').text(i18n('pictures'));
}
else if ( target_path === window.videos_path )
{
$(el_window).find('.window-head-title').text(i18n('videos'));
}
else if ( target_path === window.public_path )
{
$(el_window).find('.window-head-title').text(i18n('public'));
}
else if ( target_path === window.trash_path )
{
$(el_window).find('.window-head-title').text(i18n('trash'));
}
else
{
$(el_window).find('.window-head-title').text(fsentry.name);
}
// data-name
$(el_window).attr('data-name', fsentry.name);
// data-path
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
window.update_window_layout(el_window, fsentry.layout);
// update explore header if in details view
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
}).catch(() => {
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
// The folder couldn't be read, so the window must stop identifying as the previous one.
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).attr('data-uid', 'null');
$(el_window).find('.window-head-title').text(path.basename(target_path));
});
}
// path is '/' (global root)
else {
@@ -3866,7 +3883,7 @@ window.scale_window = (el_window) => {
window.update_explorer_footer_item_count = function (el_window) {
//update dir count in explorer footer
let item_count = $(el_window).find('.item').length;
let item_count = $(el_window).find('.item:not(.item-hidden)').length;
$(el_window).find('.explorer-footer .explorer-footer-item-count').html(`${item_count } ${i18n('item')}${ item_count == 0 || item_count > 1 ? `${i18n('plural_suffix')}` : ''}`);
};
+29 -2
View File
@@ -500,8 +500,9 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel
.explore-table-headers {
display: none;
width: 100%;
min-width: 795px;
/* the negative margin below shifts it left by the body's padding */
width: calc(100% + 10px);
min-width: 805px;
height: 25px;
border-bottom: 1px solid rgb(226, 226, 226);
background-color: #fff;
@@ -534,6 +535,7 @@ span.header-sort-icon img {
margin-left: 15px;
color: #555c61;
display: inline-block;
cursor: pointer;
}
.explore-table-headers-th-active {
@@ -842,6 +844,14 @@ span.header-sort-icon img {
color: white;
}
.device-desktop .item-container-details .item-selected.item-blurred {
background-color: #dbdada;
}
.device-desktop .item-container-details .item-selected.item-blurred .item-attr {
color: rgb(73, 73, 73);
}
.item-container-details .item-attr--modified {
left: 350px;
}
@@ -1199,6 +1209,22 @@ span.header-sort-icon img {
cursor: default;
}
/* A path too long for the bar is cut at the root end, so the current folder stays visible. */
.window-navbar-path {
display: flex;
justify-content: flex-end;
align-items: baseline;
min-height: 37px;
}
.window-navbar-path > * {
flex: none;
}
.window-navbar-path > :last-child {
margin-right: auto;
}
.device-phone .window-navbar-path {
display: none;
}
@@ -1208,6 +1234,7 @@ span.header-sort-icon img {
height: 30px;
margin-left: 10px;
margin-top: 3px;
cursor: pointer;
}
.device-phone .window-navbar-layout-settings {
+119 -76
View File
@@ -23,7 +23,7 @@ import { archiveEntryPath } from './helpers/archiveEntryPath.js';
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
import globToRegExp from './helpers/globToRegExp.js';
import item_icon from './helpers/itemIcon.js';
import { atPath, retargetPaths, underPath } from './helpers/pathElements.js';
import { atPath, retargetNavHistory, retargetPaths, underPath } from './helpers/pathElements.js';
import { is_owned_by_me, trash_path_for } from './helpers/pathOwner.js';
import { invalidate_shared_roots } from './helpers/sharedAccess.js';
import truncate_filename from './helpers/truncateFilename.js';
@@ -32,11 +32,13 @@ import update_username_in_gui from './helpers/updateUsernameInGui.js';
import { notifyItemWatchers } from './helpers/watchItems.js';
import { select_uploaded_items } from './helpers/uploadSelection.js';
import { templateMenuItem } from './helpers/templateMenuItem.js';
import { isTokenForAnotherUser } from './helpers/tokenUser.js';
import { uuidv4 } from './helpers/uuidv4.js';
import mime from './lib/mime.js';
import path from './lib/path.js';
import UIAlert from './UI/UIAlert.js';
import UIItem from './UI/UIItem.js';
import { compareEntries, compareNames } from './UI/Dashboard/sortEntries.js';
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
import UIWindowProgress from './UI/UIWindowProgress.js';
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
@@ -625,10 +627,6 @@ window.refresh_user_data = async (auth_token) => {
window.update_auth_data = async (auth_token, user) => {
window.auth_token = auth_token;
// Write the v2 key going forward and clear any lingering v1 key so
// a single localStorage source-of-truth is used.
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
// Set http-only session cookie when user is changing.
// This ensures user-protected endpoints, which only refer to the http-only cookie,
@@ -654,6 +652,13 @@ window.update_auth_data = async (auth_token, user) => {
}
}
// Stored only after the cookie sync: a peer tab reloads when it sees
// another account's token, and its boot signs in from the cookie.
// A token adopted from a peer tab during the sync must not outlive it.
window.auth_token = auth_token;
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
// Has username changed?
if ( window.user?.username !== user.username )
{
@@ -858,6 +863,12 @@ if ( typeof window !== 'undefined' && window.addEventListener ) {
window.addEventListener('storage', (event) => {
if ( event.key !== window.AUTH_TOKEN_KEY_V2 ) return;
if ( event.newValue ) {
// A peer tab changed accounts. Reload as that account instead of
// sending its token from a desktop still showing this one.
if ( isTokenForAnotherUser(event.newValue, window.user) ) {
window.location.reload();
return;
}
// A peer tab just (re)authed. Pick up the fresh token so
// subsequent requests stop bouncing on reauth_required.
window.auth_token = event.newValue;
@@ -1110,48 +1121,19 @@ window.sort_items = (item_container, sort_by, sort_order) => {
sort_order = 'asc';
}
const direction = sort_order === 'asc' ? 1 : -1;
$(item_container).find('.item[data-sortable="true"]').detach().sort(function (a, b) {
// Name
if ( !sort_by || sort_by === 'name' ) {
if ( a.dataset.name.toLowerCase() < b.dataset.name.toLowerCase() ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( a.dataset.name.toLowerCase() > b.dataset.name.toLowerCase() ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
const name_a = a.dataset.name;
const name_b = b.dataset.name;
if ( sort_by === 'type' ) {
const by_extension = compareNames(path.extname(name_a).toLowerCase(), path.extname(name_b).toLowerCase());
return direction * (by_extension || compareNames(name_a, name_b));
}
// Size
else if ( sort_by === 'size' ) {
if ( parseInt(a.dataset.size) < parseInt(b.dataset.size) ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( parseInt(a.dataset.size) > parseInt(b.dataset.size) ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
// Modified
else if ( sort_by === 'modified' ) {
if ( parseInt(a.dataset.modified) < parseInt(b.dataset.modified) ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( parseInt(a.dataset.modified) > parseInt(b.dataset.modified) ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
// Type
else if ( sort_by === 'type' ) {
if ( path.extname(a.dataset.name.toLowerCase()) < path.extname(b.dataset.name.toLowerCase()) ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( path.extname(a.dataset.name.toLowerCase()) > path.extname(b.dataset.name.toLowerCase()) ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
return direction * compareEntries(
{ name: name_a, size: parseInt(a.dataset.size), modified: parseInt(a.dataset.modified) },
{ name: name_b, size: parseInt(b.dataset.size), modified: parseInt(b.dataset.modified) },
sort_by,
);
}).appendTo(item_container);
};
@@ -1159,10 +1141,18 @@ window.show_or_hide_files = (item_containers) => {
const show_hidden_files = window.user_preferences.show_hidden_files;
const class_to_add = show_hidden_files ? 'item-revealed' : 'item-hidden';
const class_to_remove = show_hidden_files ? 'item-hidden' : 'item-revealed';
$(item_containers)
const $dotfiles = $(item_containers)
.find('.item')
.filter((_, item) => item.dataset.name.startsWith('.'))
.removeClass(class_to_remove).addClass(class_to_add);
// an item the user can no longer see must not stay in the selection
if ( ! show_hidden_files ) {
$dotfiles.removeClass('item-selected');
}
$(item_containers).closest('.window').each(function () {
window.update_explorer_footer_item_count(this);
window.update_explorer_footer_selected_items_count(this);
});
};
window.create_folder = async (basedir, appendto_element) => {
@@ -1265,6 +1255,9 @@ window.available_templates = () => {
};
window.create_shortcut = async (filename, is_dir, basedir, appendto_element, shortcut_to, shortcut_to_path) => {
// The Shared view is a query, not a directory — nothing can be created in it.
if ( basedir === window.shared_path ) return;
const extname = path.extname(filename);
const basename = `${path.basename(filename, extname) } - Shortcut`;
filename = basename + extname;
@@ -1299,6 +1292,8 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
let progwin;
let latest_status;
const arm_progwin = () => setTimeout(async () => {
// re-armed after a dialog while the window from before it is still open
if ( progwin ) return;
progwin = await UIWindowProgress({
operation_id: copy_op_id,
on_cancel: () => {
@@ -1432,6 +1427,9 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
* @param {string} dest_path - Destination path to copy items to
*/
window.copy_items = function (el_items, dest_path) {
// The Shared view is a query, not a directory — nothing can be copied into it.
if ( dest_path === window.shared_path ) return;
let copy_op_id = window.operation_id++;
let overwrite_all = false;
(async () => {
@@ -1441,6 +1439,8 @@ window.copy_items = function (el_items, dest_path) {
let progwin;
let latest_status;
const arm_progwin = () => setTimeout(async () => {
// re-armed after a dialog while the window from before it is still open
if ( progwin ) return;
progwin = await UIWindowProgress({
operation_id: copy_op_id,
on_cancel: () => {
@@ -1615,20 +1615,37 @@ window.delete_item = async function (el_item, descendants_only = false) {
}
};
window.move_clipboard_items = function (el_target_container, target_path) {
window.move_clipboard_items = async function (el_target_container, target_path) {
let dest_path = target_path === undefined ? $(el_target_container).attr('data-path') : target_path;
let el_items = [];
if ( window.clipboard.length > 0 ) {
for ( let i = 0; i < window.clipboard.length; i++ ) {
el_items.push(atPath('.item', window.clipboard[i]));
}
if ( el_items.length > 0 )
{
window.move_items(el_items, dest_path);
}
}
const cut_paths = window.clipboard;
window.clipboard = [];
// A cut item's row is gone once its folder is no longer displayed, so
// move_items gets a stand-in carrying what it reads off a row.
const el_items = (await Promise.all(cut_paths.map(async (cut_path) => {
const $el_item = atPath('.item', cut_path);
if ( $el_item.length > 0 ) {
return $el_item;
}
try {
const fsentry = await puter.fs.stat({ path: cut_path, consistency: 'eventual' });
return $('<div>').attr({
'data-uid': fsentry.uid,
'data-path': cut_path,
'data-name': fsentry.name,
'data-is_dir': fsentry.is_dir ? '1' : '0',
'data-immutable': (fsentry.immutable || fsentry.writable === false) ? '1' : '0',
'data-metadata': fsentry.metadata ?? '',
});
} catch ( e ) {
// the item no longer exists, so there is nothing to move
return null;
}
}))).filter(Boolean);
if ( el_items.length > 0 ) {
window.move_items(el_items, dest_path);
}
};
function downloadFile (url, postData = {}) {
@@ -1686,7 +1703,7 @@ window.trigger_download = (paths) => {
let urls = [];
for ( let index = 0; index < paths.length; index++ ) {
urls.push({
download: `${window.origin }/down?path=${ paths[index]}`,
download: `${window.origin }/down?path=${ encodeURIComponent(paths[index])}`,
filename: path.basename(paths[index]),
});
}
@@ -1792,6 +1809,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
let progwin;
let latest_status;
const arm_progwin = () => setTimeout(async () => {
// re-armed after a dialog while the window from before it is still open
if ( progwin ) return;
progwin = await UIWindowProgress({
operation_id: move_op_id,
on_cancel: () => {
@@ -1839,7 +1858,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
if ( path.dirname($(el_item).attr('data-path')) === item_dest_path ) {
// pause the progress-window timer while waiting for the user
clearTimeout(progwin_timeout);
await UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>Cannot move item to its current location.`);
// UIAlert escapes the message itself
await UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>Cannot move item to its current location.`);
progwin_timeout = arm_progwin();
continue;
@@ -1913,7 +1933,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
}
// moving an item into a trashed directory? deny.
else if ( dest_path.startsWith(window.trash_path) ) {
else if ( dest_path === window.trash_path || dest_path.startsWith(`${window.trash_path}/`) ) {
// the pending timer would otherwise open an orphan
// progress window after the operation already bailed
clearTimeout(progwin_timeout);
@@ -1998,6 +2018,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
underPath('.window', moved_from_path).add(atPath('.window', moved_from_path)).each(function () {
window.update_window_path(this, path.join(item_dest_path, fsentry.name) + $(this).attr('data-path').slice(moved_from_path.length));
});
retargetNavHistory(moved_from_path, fsentry.path);
}
// The dashboard's Back/Forward follows a moved folder, and
@@ -2025,14 +2046,19 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// if this is trash, get original name from item metadata
fsentry.name = metadata?.original_name || fsentry.name;
const moved_item_icon = await item_icon(fsentry);
const $dest_containers = atPath('.item-container', item_dest_path);
// the item.moved echo of this move can render the row first
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
// create new item on matching containers
const options = {
appendTo: atPath('.item-container', item_dest_path),
appendTo: $dest_containers,
immutable: fsentry.immutable || (fsentry.writable === false),
associated_app_name: fsentry.associated_app?.name,
uid: fsentry.uid,
path: fsentry.path,
icon: await item_icon(fsentry),
icon: moved_item_icon,
name: is_trashing ? $(el_item).attr('data-name') : fsentry.name,
is_dir: fsentry.is_dir,
size: fsentry.size,
@@ -2127,7 +2153,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
item_with_same_name_already_exists = false;
// error message after source item has reappeared
$(el_item).show(0, function () {
UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>${err.message ?? ''}`);
UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>${err.message ?? ''}`);
});
break;
@@ -2390,6 +2416,9 @@ window.upload_items = async function (items, dest_path) {
let upload_progress_window;
let opid;
// The Shared view is a query, not a directory — nothing can be uploaded into it.
if ( dest_path === window.shared_path ) return;
if ( dest_path == window.trash_path ) {
UIAlert('Uploading to trash is not allowed!');
return;
@@ -2869,13 +2898,15 @@ window.unzipItem = async function (itemPath) {
terminateOp = fflate.unzip(file, async (err, unzipped) => {
currentProgress += window.zippingProgressConfig.ZIPPING;
progwin?.set_progress(currentProgress.toPrecision(2));
if ( err ) {
UIAlert(e.message);
// close progress window
const close_progwin = (hide_delay) => {
clearTimeout(progwin_timeout);
setTimeout(() => {
progwin?.close();
}, Math.max(0, window.copy_progress_hide_delay - (Date.now() - start_ts)));
}, Math.max(0, hide_delay - (Date.now() - start_ts)));
};
if ( err ) {
UIAlert(err.message);
close_progwin(window.copy_progress_hide_delay);
} else {
const rootdir = await puter.fs.mkdir(`${path.dirname(filePath) }/${ path.basename(filePath, '.zip')}`, { dedupeName: true });
let perItemProgress = window.zippingProgressConfig.WRITING / Object.keys(unzipped).length;
@@ -2893,6 +2924,10 @@ window.unzipItem = async function (itemPath) {
UIAlert(e.message);
}
});
// an archive with no files leaves nothing to upload, so nothing else would end the operation
if ( queuedFileWrites.length === 0 ) {
close_progwin(window.unzip_progress_hide_delay);
}
queuedFileWrites.length && puter.fs.upload(
// what to upload
queuedFileWrites,
@@ -2904,7 +2939,7 @@ window.unzipItem = async function (itemPath) {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
progress: async function (operation_id, op_progress) {
progwin.set_progress(op_progress);
progwin?.set_progress(op_progress);
// update title if window is not visible
if ( document.visibilityState !== 'visible' ) {
update_title_based_on_uploads();
@@ -2912,11 +2947,10 @@ window.unzipItem = async function (itemPath) {
},
success: async function (items) {
progwin?.set_progress(window.zippingProgressConfig.TOTAL.toPrecision(2));
// close progress window
clearTimeout(progwin_timeout);
setTimeout(() => {
progwin?.close();
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
close_progwin(window.unzip_progress_hide_delay);
},
error: function () {
close_progwin(window.unzip_progress_hide_delay);
},
},
);
@@ -3148,6 +3182,13 @@ window.untarItem = async function (itemPath) {
}
}
// an archive with no files leaves nothing to upload, so nothing else would end the operation
if ( queuedFileWrites.length === 0 ) {
clearTimeout(progwin_timeout);
setTimeout(() => {
progwin?.close();
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
}
queuedFileWrites.length && puter.fs.upload(
queuedFileWrites,
`${rootdir.path }/`,
@@ -3156,7 +3197,7 @@ window.untarItem = async function (itemPath) {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
progress: async function (operation_id, op_progress) {
progwin.set_progress(op_progress);
progwin?.set_progress(op_progress);
if ( document.visibilityState !== 'visible' ) {
update_title_based_on_uploads();
}
@@ -3375,7 +3416,9 @@ window.undo_last_action = async () => {
delete: () => window.undo_delete(data),
rename: () => {
const { options, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, onDone } = data;
window.rename_file(options, old_name, new_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
// the item now sits at its renamed path, which is what this rename moves away from
const renamed_path = path.join(path.dirname(old_path), new_name);
window.rename_file(options, old_name, new_name, renamed_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
},
};
+23 -1
View File
@@ -37,7 +37,7 @@ globalThis.html_encode = value => jQuery('<div>').text(value).html();
await import('./helpers.js');
const { default: apply_item_added_to_containers } = await import('./helpers/applyItemAddedToContainers.js');
const { default: UIItem } = await import('./UI/UIItem.js');
const { atPath, retargetPaths, underPath } = await import('./helpers/pathElements.js');
const { atPath, retargetNavHistory, retargetPaths, underPath } = await import('./helpers/pathElements.js');
// A plain name, then names holding characters a selector or HTML treats specially.
const NAMES = ['Reports 2024', 'R&D', "Dan's files", 'the "big" one', 'a<b>c', 'back\\slash'];
@@ -146,3 +146,25 @@ describe.each([undefined, null, ''])('a lookup for the path %s', (missing) => {
expect(underPath('.window', missing)).toHaveLength(0);
});
});
describe('retargetNavHistory', () => {
it('rewrites entries at or inside the old path and leaves the rest', () => {
window.window_nav_history = {
1: ['/alice', '/alice/A', '/alice/A/sub', '/alice/AB', 'puter://shared'],
2: ['/alice/Documents'],
};
retargetNavHistory('/alice/A', '/alice/B');
expect(window.window_nav_history[1]).toEqual(['/alice', '/alice/B', '/alice/B/sub', '/alice/AB', 'puter://shared']);
expect(window.window_nav_history[2]).toEqual(['/alice/Documents']);
});
it('is applied by retargetPaths', () => {
window.window_nav_history = { 1: ['/alice/A/sub'] };
retargetPaths('/alice/A', '/alice/B', 'B');
expect(window.window_nav_history[1]).toEqual(['/alice/B/sub']);
});
});
+19
View File
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import path from '../lib/path.js';
/**
* Expands a user-facing path into the absolute path the filesystem API takes:
* `~` and `~/x` resolve under the user's home, a bare relative path is read as
@@ -39,3 +41,20 @@ export const expand_home_path = (fspath, home_path) => {
if ( fspath.startsWith('/') ) return fspath;
return `${home}/${fspath}`;
};
/**
* Turns what a user typed into an address bar into the canonical absolute
* path: trimmed, home-expanded, with `.`/`..`, doubled and trailing slashes
* resolved. An empty entry keeps the current path.
*
* @param {string} typed_path - the raw text the user entered
* @param {{ current_path: string, home_path: string, shared_path: string }} context
* @returns {string} the path to navigate to
*/
export const resolve_typed_path = (typed_path, { current_path, home_path, shared_path }) => {
const trimmed = typeof typed_path === 'string' ? typed_path.trim() : '';
if ( trimmed === '' ) return current_path;
// The Shared view is a pseudo-path, not a filesystem one.
if ( trimmed === shared_path ) return shared_path;
return path.resolve('/', expand_home_path(trimmed, home_path));
};
+26 -1
View File
@@ -18,7 +18,7 @@
*/
import { describe, it, expect } from 'vitest';
import { expand_home_path } from './expandHomePath.js';
import { expand_home_path, resolve_typed_path } from './expandHomePath.js';
describe('expand_home_path', () => {
it('resolves a tilde path under the home path', () => {
@@ -57,3 +57,28 @@ describe('expand_home_path', () => {
expect(expand_home_path(null, '/alice')).toBe(null);
});
});
describe('resolve_typed_path', () => {
const context = { current_path: '/alice/Videos', home_path: '/alice', shared_path: 'puter://shared' };
it('canonicalizes trailing, doubled and dot segments', () => {
expect(resolve_typed_path('/alice/Documents/', context)).toBe('/alice/Documents');
expect(resolve_typed_path('/alice//Documents', context)).toBe('/alice/Documents');
expect(resolve_typed_path('/alice/Documents/../Pictures', context)).toBe('/alice/Pictures');
expect(resolve_typed_path('/', context)).toBe('/');
});
it('trims whitespace and expands home-relative entries', () => {
expect(resolve_typed_path(' ~/Documents ', context)).toBe('/alice/Documents');
expect(resolve_typed_path('Documents', context)).toBe('/alice/Documents');
});
it('keeps the current path for an empty entry', () => {
expect(resolve_typed_path('', context)).toBe('/alice/Videos');
expect(resolve_typed_path(' ', context)).toBe('/alice/Videos');
});
it('passes the Shared view through untouched', () => {
expect(resolve_typed_path('puter://shared', context)).toBe('puter://shared');
});
});
@@ -50,7 +50,7 @@ import { is_default_app } from './defaultApp.js';
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has dropped the user's own access (used by Dashboard)
* @returns {Promise<Array>} Array of context menu items
*/
const generate_file_context_menu = async function (options) {
@@ -389,7 +389,9 @@ const generate_file_context_menu = async function (options) {
// Or mode lookups keep answering for a share we just
// walked away from.
invalidate_shared_roots();
$(el_item).remove();
// A team that still shares it keeps it listed, as the
// team's share; the refreshed listing shows it that way.
if ( fsentry.also_team_share !== true ) $(el_item).remove();
options.onRemovedFromShared?.();
} catch (e) {
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
@@ -89,6 +89,38 @@ it('does not offer it on a share held through a team', async () => {
);
});
describe('Remove from Shared', () => {
const remove = async (fsentry) => {
const row = sharedRow();
document.body.append(row);
const onRemovedFromShared = vi.fn();
window.user = { username: 'me' };
globalThis.puter = { fs: { unshare: vi.fn(async () => ({ revoked: 1 })) } };
const items = await generate_file_context_menu({
element: row,
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt', ...fsentry },
associated_app_name: 'editor',
onRemovedFromShared,
});
await items.find((item) => item.html === 'share_remove_from_shared').onClick();
expect(puter.fs.unshare).toHaveBeenCalledWith('/ann/uid-1/notes.txt', 'me');
expect(onRemovedFromShared).toHaveBeenCalledTimes(1);
return row;
};
afterEach(() => {
document.body.innerHTML = '';
});
it('takes the row away once the user has left it', async () => {
expect((await remove({})).isConnected).toBe(false);
});
it('keeps the row for the listing to update when a team still shares it', async () => {
expect((await remove({ also_team_share: true })).isConnected).toBe(true);
});
});
describe('Open With', () => {
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
+2 -8
View File
@@ -18,6 +18,7 @@
*/
import { isAttestedOrigin } from './attestedOrigin.js';
import { jwtPayload } from './jwtPayload.js';
/**
* Godmode apps that run on their own full-access token instead of the
@@ -73,14 +74,7 @@ export const shouldRenew = (expiresAt, nowMs) =>
!Number.isFinite(expiresAt) || expiresAt * 1000 - nowMs < RENEW_WHEN_LEFT_MS;
/** The token row a JWT belongs to; renewals of a live row keep it. */
const tokenRowOf = (token) => {
try {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(part))?.token_uid ?? null;
} catch {
return null;
}
};
const tokenRowOf = (token) => jwtPayload(token)?.token_uid ?? null;
/**
* Whether two tokens are the same row, so the app already holds a working one.
+1 -1
View File
@@ -100,7 +100,7 @@ const item_icon = async (fsentry) => {
// --------------------------------------------------
// .app files
// --------------------------------------------------
else if ( fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
else if ( !fsentry.is_dir && fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
try {
const content = await puter.fs.read({ path: fsentry.path });
const text = typeof content === 'string' ? content : await content.text();
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The claims of a JWT, read without checking its signature, so they say what
* the token claims and prove nothing.
*
* @param {string} token
* @returns {Record<string, unknown> | null} null when the token isn't a readable JWT
*/
export const jwtPayload = (token) => {
try {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(part));
return payload && typeof payload === 'object' ? payload : null;
} catch {
return null;
}
};
+41 -4
View File
@@ -17,13 +17,50 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { strongerMode } from './shareModes.js';
// Largest page the backend will serve (ShareStore.MAX_HOLDER_PAGE_SIZE).
const PAGE_SIZE = 200;
/**
* Every share the current user holds, across all pages. A page can be short
* once unreachable items are filtered out, so it pages on `cursor` rather than
* on the item count.
* One row per shared item. The API lists a row per grant and grants add up, so
* an item granted twice keeps its strongest row. It stays a team share only
* when no grant names the user directly, since a direct grant is theirs to leave.
* A merged row lists everyone who granted it in `issuers`, strongest grant first,
* and sets `alsoHeldByTeam` when leaving the direct grant leaves a team's in place.
*
* @param {Array<Object>} shares
* @returns {Array<Object>}
*/
const issuersOf = (share) => share.issuers ?? [share.issuer];
const heldByTeam = (share) => Boolean(share.holderTeam) || share.alsoHeldByTeam === true;
export const mergeGrants = (shares) => {
const merged = new Map();
for ( const share of shares ) {
const key = share.entryUid ?? share.path;
const held = merged.get(key);
if ( ! held ) {
merged.set(key, share);
continue;
}
const [best, other] = strongerMode(held.mode, share.mode) === held.mode ? [held, share] : [share, held];
const teamOnly = held.holderTeam && share.holderTeam;
const issuers = [...issuersOf(best), ...issuersOf(other)].filter(Boolean);
merged.set(key, {
...best,
holderTeam: teamOnly ? best.holderTeam : undefined,
alsoHeldByTeam: ! teamOnly && (heldByTeam(held) || heldByTeam(share)),
issuers: [...new Set(issuers)],
});
}
return [...merged.values()];
};
/**
* Every item shared with the current user, across all pages. A page can be
* short once unreachable items are filtered out, so it pages on `cursor`
* rather than on the item count.
*
* @returns {Promise<Array<Object>>}
*/
@@ -40,7 +77,7 @@ const list_all_shared = async () => {
cursor = page.cursor;
} while ( cursor );
return shares;
return mergeGrants(shares);
};
export default list_all_shared;
+127
View File
@@ -0,0 +1,127 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import list_all_shared, { mergeGrants } from './listAllShared.js';
const BUDGET = '33333333-3333-3333-3333-333333333333';
const REPORT = '44444444-4444-4444-4444-444444444444';
const TEAM = { uid: 'team-1', name: 'Finance', handle: 'finance' };
const grant = (entryUid, name, extra) => ({
entryUid,
path: `/jf/${entryUid}/${name}`,
name,
...extra,
});
describe('mergeGrants', () => {
it('lists an item once, with its strongest grant', () => {
const merged = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'manage', issuer: 'jf' }),
grant(REPORT, 'report.pdf', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
grant(REPORT, 'report.pdf', { mode: 'write', issuer: 'delegate' }),
]);
expect(merged.map((share) => [share.entryUid, share.mode, share.issuer])).toEqual([
[BUDGET, 'manage', 'jf'],
[REPORT, 'write', 'delegate'],
]);
});
it('names everyone who granted the item, strongest grant first', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf', holderTeam: TEAM }),
]);
expect(share.issuer).toBe('delegate');
expect(share.issuers).toEqual(['delegate', 'jf']);
});
it('keeps the first of two equal grants', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
]);
expect(share.issuer).toBe('jf');
});
it('is not a team share when one grant names the user directly', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'jf', holderTeam: TEAM }),
]);
expect(share.mode).toBe('write');
expect(share.holderTeam).toBeUndefined();
expect(share.alsoHeldByTeam).toBe(true);
});
it('marks only an item held both ways as also held by a team', () => {
const [direct, team] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
grant(REPORT, 'report.pdf', { mode: 'read', holderTeam: TEAM }),
grant(REPORT, 'report.pdf', { mode: 'write', holderTeam: TEAM }),
]);
expect(direct.alsoHeldByTeam).toBe(false);
expect(team.alsoHeldByTeam).toBe(false);
});
it('still marks it when a later grant is direct again', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: TEAM }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
]);
expect(share.holderTeam).toBeUndefined();
expect(share.alsoHeldByTeam).toBe(true);
});
it('stays a team share when every grant is to a team', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: { ...TEAM, uid: 'team-2' } }),
grant(BUDGET, 'Budget', { mode: 'write', holderTeam: TEAM }),
]);
expect(share.holderTeam).toEqual(TEAM);
});
});
describe('list_all_shared', () => {
beforeEach(() => {
globalThis.window = { puter: { fs: { listShared: vi.fn() } } };
});
it('merges grants for one item that arrive on different pages', async () => {
window.puter.fs.listShared
.mockResolvedValueOnce({
items: [grant(BUDGET, 'Budget', { mode: 'manage' })],
cursor: 'next',
})
.mockResolvedValueOnce({
items: [grant(BUDGET, 'Budget', { mode: 'read' })],
});
const shares = await list_all_shared();
expect(shares).toHaveLength(1);
expect(shares[0].mode).toBe('manage');
expect(window.puter.fs.listShared).toHaveBeenLastCalledWith({ limit: 200, cursor: 'next' });
});
});
+6 -1
View File
@@ -145,9 +145,14 @@ const new_context_menu_item = function (dirname, append_to_element) {
canvas.width = 800;
canvas.height = 600;
// JPEG has no transparency, so the blank image needs a background
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
window.create_file({ dirname: dirname, append_to_element: append_to_element, name: 'New Image.jpg', content: blob });
});
}, 'image/jpeg');
},
},
// Worker
+2 -1
View File
@@ -19,8 +19,9 @@
import UIWindow from '../UI/UIWindow.js';
import UIAlert from '../UI/UIAlert.js';
import i18n from '../i18n/i18n.js';
import launch_app from './launchApp.js';
// installs the global i18n()
import '../i18n/i18n.js';
import path from '../lib/path.js';
import item_icon from './itemIcon.js';
import { resolve_default_app } from './defaultApp.js';
+15 -1
View File
@@ -23,7 +23,6 @@ import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
vi.mock('../i18n/i18n.js', () => ({ default: key => key }));
vi.mock('./launchApp.js', () => ({ default: vi.fn(async () => ({ launchResult: { launched: true } })) }));
vi.mock('./itemIcon.js', () => ({ default: vi.fn() }));
vi.mock('./defaultApp.js', () => ({ resolve_default_app: vi.fn(async () => null) }));
@@ -113,3 +112,18 @@ describe('open_item with a new tab', () => {
expect(tab.close).not.toHaveBeenCalled();
});
});
describe('open_item with no app for the file', () => {
it('offers to download it', async () => {
window.is_dashboard_mode = false;
window.trigger_download = vi.fn();
UIAlert.mockResolvedValueOnce('download_file');
await open_item({ item: row({ name: 'data.bin', path: '/user/data.bin' }) });
expect(UIAlert).toHaveBeenCalledTimes(1);
expect(UIAlert.mock.calls[0][1].map(button => button.value ?? button.label)).toEqual(['download_file', 'Cancel']);
expect(window.trigger_download).toHaveBeenCalledWith(['/user/data.bin']);
});
});
+19
View File
@@ -70,4 +70,23 @@ export const retargetPaths = (oldPath, newPath, newName) => {
$inside.each(function () {
$(this).attr('data-path', newPath + $(this).attr('data-path').slice(oldPath.length));
});
retargetNavHistory(oldPath, newPath);
};
/**
* Points every window's Back/Forward entries at `oldPath`, or inside it, to
* `newPath`, so they still lead somewhere after a rename or move.
*
* @param {string} oldPath
* @param {string} newPath
*/
export const retargetNavHistory = (oldPath, newPath) => {
if ( ! oldPath ) return;
const prefix = `${oldPath}/`;
for ( const history of Object.values(window.window_nav_history ?? {}) ) {
history.forEach((entry, index) => {
if ( entry === oldPath ) history[index] = newPath;
else if ( entry.startsWith(prefix) ) history[index] = newPath + entry.slice(oldPath.length);
});
}
};
+18 -1
View File
@@ -72,9 +72,13 @@ const refresh_item_container = function (el_item_container, options) {
}
// show the loading spinner
$(loading_spinner).find('.explorer-loading-spinner-msg').text(`${i18n('loading')}...`);
$(loading_spinner).show();
setTimeout(function () {
$(loading_spinner).find('.explorer-loading-spinner-msg').html('Taking a little longer than usual. Please wait...');
// only a load that is still running has taken long
if ( is_current() && $(loading_spinner).is(':visible') ) {
$(loading_spinner).find('.explorer-loading-spinner-msg').text(i18n('taking_longer_than_usual'));
}
}, 3000);
}, 1000);
@@ -98,6 +102,8 @@ const refresh_item_container = function (el_item_container, options) {
return;
}
if ( el_window ) {
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
@@ -120,6 +126,8 @@ const refresh_item_container = function (el_item_container, options) {
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
}).catch(() => {
// The listing request below reports the failure to the user.
});
// is_directoryPicker
@@ -135,6 +143,8 @@ const refresh_item_container = function (el_item_container, options) {
// remove all existing items
$(el_item_container).find('.item').removeItems();
// removing the last row shows the empty message, which is wrong while the folder is loading
$(empty_message).hide();
// get items with subdomains/workers included to avoid per-item stat calls
const entries_promise = is_shared_view
@@ -159,6 +169,7 @@ const refresh_item_container = function (el_item_container, options) {
shared_by: share.issuer,
owner: share.owner,
holder_team: share.holderTeam,
also_team_share: share.alsoHeldByTeam,
metadata: '',
})))
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
@@ -264,6 +275,7 @@ const refresh_item_container = function (el_item_container, options) {
shared_by: fsentry.shared_by,
is_shared: fsentry.is_shared,
is_team_share: !!fsentry.holder_team,
is_also_team_share: fsentry.also_team_share === true,
owner: fsentry.owner?.username ?? fsentry.owner,
});
}
@@ -350,6 +362,11 @@ const refresh_item_container = function (el_item_container, options) {
$(error_message).html(`Failed to load directory${ html_encode((e && e.message ? `: ${ e.message}` : ''))}`);
$(error_message).show();
// the previous folder's items are gone, so its count must not linger
if ( el_window ) {
window.update_explorer_footer_item_count(el_window);
}
// Call onComplete callback even in error case, since the "loading" is technically complete
if ( options.onComplete && typeof options.onComplete === 'function' ) {
options.onComplete();
@@ -107,6 +107,31 @@ describe('refresh_item_container in the Shared view', () => {
is_team_share: true,
});
});
it('flags a direct share the team holds too', async () => {
list_all_shared.mockResolvedValue([
{
entryUid: 'u-1',
name: 'a.txt',
path: '/bob/u-1/a.txt',
isDir: false,
mode: 'read',
issuer: 'bob',
owner: 'bob',
alsoHeldByTeam: true,
},
]);
document.body.innerHTML =
'<div class="window"><div class="item-container" data-path="/alice/Shared"></div></div>';
refresh_item_container($('.item-container'));
await vi.waitFor(() => expect(UIItem).toHaveBeenCalledTimes(1));
expect(UIItem.mock.calls[0][0]).toMatchObject({
is_team_share: false,
is_also_team_share: true,
});
});
});
const deferred = () => {
+14
View File
@@ -23,6 +23,20 @@
// place in these dialogs; a row already set to one is shown as-is.
export const MODES = ['read', 'write', 'manage'];
// Every mode the API grants, weakest first. Each one implies those before it.
const STRENGTH = ['see', 'list', 'read', 'write', 'manage'];
/**
* The stronger of two access modes. `held` wins a tie, and a mode outside the
* API's five never beats a known one.
*
* @param {string} held
* @param {string} other
* @returns {string}
*/
export const strongerMode = (held, other) =>
STRENGTH.indexOf(other) > STRENGTH.indexOf(held) ? other : held;
/**
* Human-readable label for an access mode, ready to drop into HTML.
*
+17 -2
View File
@@ -6,12 +6,12 @@ import { decode, encode } from 'html-entities';
globalThis.window = globalThis.window ?? {};
globalThis.html_encode = (str) => encode(str);
let MODES, mode_label, options_for;
let MODES, mode_label, options_for, strongerMode;
beforeAll(async () => {
await import('../i18n/i18n.js'); // installs window.i18n
globalThis.i18n = window.i18n;
({ MODES, mode_label, options_for } = await import('./shareModes.js'));
({ MODES, mode_label, options_for, strongerMode } = await import('./shareModes.js'));
});
// What the browser renders for a fragment of HTML-safe markup.
@@ -41,6 +41,21 @@ describe('mode_label', () => {
});
});
describe('strongerMode', () => {
it('ranks all five API modes', () => {
const modes = ['see', 'list', 'read', 'write', 'manage'];
for ( let i = 1; i < modes.length; i++ ) {
expect(strongerMode(modes[i - 1], modes[i])).toBe(modes[i]);
expect(strongerMode(modes[i], modes[i - 1])).toBe(modes[i]);
}
});
it('never lets an unrecognized mode beat a known one', () => {
expect(strongerMode('see', 'owner')).toBe('see');
expect(strongerMode('owner', 'see')).toBe('see');
});
});
describe('options_for', () => {
const values = (html) => [...html.matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
+6 -2
View File
@@ -19,6 +19,7 @@
import list_all_shared from './listAllShared.js';
import { is_owned_by_me } from './pathOwner.js';
import { strongerMode } from './shareModes.js';
import { is_share_root } from './sharePaths.js';
// What we hold on each shared root, by path: `{ mode, name }`. A `readdir`
@@ -29,13 +30,16 @@ let inflight = null;
/**
* Record what a Shared listing reported. Replaces rather than merges, so a
* share withdrawn elsewhere does not linger.
* share withdrawn elsewhere does not linger. Grants add up, so a path listed
* more than once keeps its strongest mode.
*/
export const remember_shared_roots = (shares) => {
roots.clear();
for ( const share of shares ) {
if ( ! share?.path || ! share?.mode ) continue;
roots.set(share.path, { mode: share.mode, name: share.name });
const held = roots.get(share.path);
const mode = held ? strongerMode(held.mode, share.mode) : share.mode;
roots.set(share.path, { mode, name: share.name });
}
loaded = true;
};
+13
View File
@@ -64,6 +64,19 @@ describe('shared_access', () => {
);
});
it('keeps the strongest mode when a path is granted more than once', async () => {
remember_shared_roots([
{ path: `/jf/${BUDGET}/Budget`, mode: 'manage' },
{ path: `/jf/${BUDGET}/Budget`, mode: 'read' },
{ path: `/jf/${REPORT}/report.pdf`, mode: 'read' },
{ path: `/jf/${REPORT}/report.pdf`, mode: 'write' },
]);
expect(await shared_mode_for(`/jf/${BUDGET}/Budget`)).toBe('manage');
expect(await shared_mode_for(`/jf/${REPORT}/report.pdf`)).toBe('write');
expect(await can_share(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
});
it('reports nothing outside every shared root', async () => {
expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null);
});
+66
View File
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { jwtPayload } from './jwtPayload.js';
const UUID_HEX = /^[0-9a-f]{32}$/;
/** @param {unknown} uuid */
const uuidHex = (uuid) => {
if ( typeof uuid !== 'string' ) return null;
const hex = uuid.replace(/-/g, '').toLowerCase();
return UUID_HEX.test(hex) ? hex : null;
};
/**
* The uuid of the user a session token was issued to. The token carries it as
* the `uu` claim: the uuid's 16 bytes in base64, or the dashed uuid itself.
*
* @param {string} token
* @returns {string | null} dashless lowercase hex, or null when the token doesn't say
*/
const tokenUserHex = (token) => {
const claim = jwtPayload(token)?.uu;
if ( typeof claim !== 'string' || !claim ) return null;
if ( claim.includes('-') ) return uuidHex(claim);
try {
const bytes = atob(claim);
let hex = '';
for ( let i = 0; i < bytes.length; i++ ) {
hex += bytes.charCodeAt(i).toString(16).padStart(2, '0');
}
return UUID_HEX.test(hex) ? hex : null;
} catch {
return null;
}
};
/**
* Whether a session token was issued to a user other than `user`. False when
* either side can't be read, so an unreadable token is never taken for a switch.
*
* @param {string} token
* @param {{ uuid?: string } | null | undefined} user
* @returns {boolean}
*/
export const isTokenForAnotherUser = (token, user) => {
const tokenUser = tokenUserHex(token);
const currentUser = uuidHex(user?.uuid);
return !!tokenUser && !!currentUser && tokenUser !== currentUser;
};
+69
View File
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { isTokenForAnotherUser } from './tokenUser.js';
const ALICE = '5d4adce0-a381-4982-9c02-6e2540026238';
const BOB = 'a1b2c3d4-0000-4000-8000-00000000beef';
const jwtWith = (claims) =>
`h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`;
// The claim as the server writes it: the uuid's bytes in base64.
const tokenFor = (uuid) =>
jwtWith({ uu: Buffer.from(uuid.replace(/-/g, ''), 'hex').toString('base64') });
describe('isTokenForAnotherUser', () => {
it('is true for a token issued to a different user', () => {
expect(isTokenForAnotherUser(tokenFor(BOB), { uuid: ALICE })).toBe(true);
});
it('is false for a token issued to the same user', () => {
expect(isTokenForAnotherUser(tokenFor(ALICE), { uuid: ALICE })).toBe(false);
});
it('compares uuids regardless of letter case', () => {
expect(isTokenForAnotherUser(tokenFor(ALICE), { uuid: ALICE.toUpperCase() })).toBe(false);
});
it('reads a claim that holds the dashed uuid itself', () => {
expect(isTokenForAnotherUser(jwtWith({ uu: BOB }), { uuid: ALICE })).toBe(true);
expect(isTokenForAnotherUser(jwtWith({ uu: ALICE }), { uuid: ALICE })).toBe(false);
});
it('is false when no user is signed in here', () => {
expect(isTokenForAnotherUser(tokenFor(BOB), null)).toBe(false);
expect(isTokenForAnotherUser(tokenFor(BOB), undefined)).toBe(false);
expect(isTokenForAnotherUser(tokenFor(BOB), {})).toBe(false);
});
it('is false when the token does not name a user', () => {
for ( const token of [
jwtWith({}),
jwtWith({ uu: '' }),
jwtWith({ uu: 42 }),
jwtWith({ uu: 'AAAA' }),
jwtWith({ uu: '!!not base64!!' }),
'not-a-jwt',
'',
] ) {
expect(isTokenForAnotherUser(token, { uuid: ALICE })).toBe(false);
}
});
});
+1
View File
@@ -1067,6 +1067,7 @@ const en = {
'window_click_to_go_back': 'Click to go back.',
'window_click_to_go_forward': 'Click to go forward.',
'window_click_to_go_up': 'Click to go one directory up.',
'window_click_to_change_layout': 'Click to change the layout.',
'window_title_public': 'Public',
'window_title_videos': 'Videos',
'window_title_pictures': 'Pictures',
+45
View File
@@ -69,6 +69,51 @@ describe('the popup token hand-off in postAuthActions', () => {
});
});
describe('the requests a sign-in popup makes after an account is picked', () => {
const postAuthActions = src.slice(
src.indexOf('const postAuthActions = async'),
src.indexOf('window.initgui = async'),
);
it('matches the early exchange before every exchange in postAuthActions', () => {
const exchanges = postAuthActions.split('await window.getUserAppToken(origin)').length - 1;
const matched = postAuthActions.split(
'reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??',
).length - 1;
expect(exchanges).toBe(2);
expect(matched).toBe(2);
expect(postAuthActions.split('getUserAppToken(').length - 1).toBe(2);
});
it('records every early exchange, so none is repeated unmatched', () => {
const boot = src.slice(src.indexOf('window.initgui = async'));
const sdkLogin = boot.slice(
boot.indexOf('// show signup window'),
boot.indexOf('window.popup_signin_consent = !!picked_a_user_for_sdk_login;'),
);
expect(sdkLogin.split('await exchangeUserAppTokenEarly();').length - 1).toBe(3);
expect(sdkLogin).not.toContain('getUserAppToken(');
});
it('skips the relationship check only for a picked account', () => {
// Signup in the popup sets consent without a pick, and still needs
// the check to keep a second account picker away.
const check = src.indexOf('await window.checkUserSiteRelationship(');
const guard = src.lastIndexOf('if (', check);
expect(src.slice(guard, check)).toContain('if (!picked_a_user_for_sdk_login) {');
expect(src.slice(guard, check)).not.toContain('popup_signin_consent');
});
it('reuses the early whoami only through reusablePopupUser', () => {
const reuse = src.indexOf('whoami = popup_whoami.user;');
expect(reuse).toBeGreaterThan(-1);
expect(src.split('popup_whoami.user').length - 1).toBe(1);
const guard = src.slice(src.lastIndexOf('if (', reuse), reuse);
expect(guard).toContain('picked_a_user_for_sdk_login &&');
expect(guard).toContain('reusablePopupUser(popup_whoami, {');
});
});
describe('the permission popup and browser push', () => {
const postAuthActions = src.slice(
src.indexOf('const postAuthActions = async'),
+59 -9
View File
@@ -112,6 +112,8 @@ import {
relationshipIsConsent,
relaysTokenAtBoot,
resolveOpenerOrigin,
reusablePopupUser,
reusableTokenExchange,
runsUserAppTokenExchange,
} from './util/popupAuth.js';
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
@@ -161,6 +163,21 @@ const follow_share_recipient = async (action) => {
}));
};
// A sign-in popup exchanges as soon as the user has chosen an account, and
// `postAuthActions` hands over that result instead of exchanging again.
let early_token_exchange = null;
const exchangeUserAppTokenEarly = async () => {
const authToken = window.auth_token;
const data = await window.getUserAppToken(window.openerOrigin);
let origin = null;
try {
origin = new URL(window.openerOrigin).origin;
} catch (e) {
// No origin to match on, so the result is not reused.
}
early_token_exchange = { origin, authToken, data };
};
const postAuthActions = async (action) => {
if ( await follow_share_recipient(action) ) return;
// Set when a popup's user-app token exchange fails. The exchange is what
@@ -440,7 +457,10 @@ const postAuthActions = async (action) => {
if (isolated) {
try {
const data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
const origin = new URL(window.openerOrigin).origin;
const data =
reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??
await window.getUserAppToken(origin);
// Same two failure modes the postMessage path below guards
// against: `getUserAppToken` reports a network failure by
// returning null, and an HTTP failure (a blocked origin, an
@@ -484,7 +504,10 @@ const postAuthActions = async (action) => {
// Its failure carries `token: null`, which the SDK sets too.
const deliver_token_to_opener = deliversTokenAtBoot(action);
try {
let data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
const origin = new URL(window.openerOrigin).origin;
let data =
reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??
await window.getUserAppToken(origin);
// `getUserAppToken` reports a network failure by returning
// null, and an HTTP failure (a blocked origin, a 5xx) by
// returning the parsed *error* body — which is truthy but
@@ -1514,6 +1537,8 @@ window.initgui = async function (options) {
let picked_a_user_for_sdk_login = false;
// Whether a popup put an account picker in front of the user.
let showed_account_picker = false;
// The `/whoami` a sign-in popup read before its account picker.
let popup_whoami = null;
// update SDK if auth_token is different from the one in the SDK
if (window.auth_token && puter.authToken !== window.auth_token) {
@@ -1868,7 +1893,7 @@ window.initgui = async function (options) {
// Completing signup in a sign-in popup is the user asking to
// be signed in to the opener.
window.popup_signin_consent = true;
await window.getUserAppToken(window.openerOrigin);
await exchangeUserAppTokenEarly();
}
} else if (
// An action-less popup is a sign-in popup — `postAuthActions`
@@ -1892,6 +1917,11 @@ window.initgui = async function (options) {
whoami_popup.token || window.auth_token,
whoami_popup,
);
popup_whoami = {
user: whoami_popup,
authToken: window.auth_token,
fetchedAt: Date.now(),
};
} catch (e) {
// session/auth errors will be handled further ahead;
// let's log the error for now in case a change in state occurred.
@@ -1917,7 +1947,7 @@ window.initgui = async function (options) {
String(currentUserUuid);
if (proofMatchesCurrentUser) {
picked_a_user_for_sdk_login = true;
await window.getUserAppToken(window.openerOrigin);
await exchangeUserAppTokenEarly();
} else {
// Show session list so user can pick which account to use
showed_account_picker = true;
@@ -1927,7 +1957,7 @@ window.initgui = async function (options) {
});
if (picked_a_user_for_sdk_login) {
await window.getUserAppToken(window.openerOrigin);
await exchangeUserAppTokenEarly();
}
}
// Picking an account here *is* the consent to sign the opener in.
@@ -2050,10 +2080,14 @@ window.initgui = async function (options) {
// if not, we need to ask the user for confirmation before proceeding BUT only if the action is a file-picker action
// -------------------------------------------------------------------------------------
if (window.embedded_in_popup && window.openerOrigin) {
let response = await window.checkUserSiteRelationship(
window.openerOrigin,
);
window.userAppToken = response?.token;
// An account picked above already answers both questions below, the
// picker and the consent. Signup in the popup is not a pick.
if (!picked_a_user_for_sdk_login) {
let response = await window.checkUserSiteRelationship(
window.openerOrigin,
);
window.userAppToken = response?.token;
}
// A framed picker or upgrade already holds the framing page's user,
// so it doesn't offer to switch.
@@ -2410,6 +2444,22 @@ window.initgui = async function (options) {
// Authed
// -------------------------------------------------------------------------------------
if (window.is_auth()) {
// A sign-in popup whose user kept the account it opened with, and whose
// exchange just proved the session, already has this user's data.
if (
!whoami &&
picked_a_user_for_sdk_login &&
reusablePopupUser(popup_whoami, {
userUuid: window.user?.uuid,
authToken: window.auth_token,
exchangedAuthToken: early_token_exchange?.data?.token
? early_token_exchange.authToken
: null,
now: Date.now(),
})
) {
whoami = popup_whoami.user;
}
// try to get user data using /whoami, only if that data is missing
if (!whoami) {
try {
+50 -31
View File
@@ -23,6 +23,25 @@ import launch_app from './helpers/launchApp.js';
import open_item from './helpers/openItem.js';
import determine_active_container_parent from './helpers/determineActiveContainerParent.js';
// The item container the user is working in: around or inside the element they
// last pressed, or elsewhere in its window. A pressed item that the window has
// since navigated away from leaves only the container it was in.
const selection_container = () => {
const $active = $(window.active_element);
let $container = $active.closest('.item-container');
if ( $container.length === 0 ) {
$container = $active.find('.item-container');
}
if ( $container.length === 0 ) {
$container = $active.closest('.window').find('.item-container');
}
if ( $container.length === 0 && window.active_element && !document.contains(window.active_element)
&& document.contains(window.active_item_container) ) {
$container = $(window.active_item_container);
}
return $container;
};
$(document).bind('keydown', async function (e) {
const focused_el = document.activeElement;
//-----------------------------------------------------------------------------
@@ -290,13 +309,14 @@ $(document).bind('keydown', async function (e) {
if ( selected.length === 1 ) {
return selected.get(0);
}
if ( selected.length > 1 && window.latest_selected_item ) {
// only an item that is still selected in this container can anchor the move
if ( selected.is(window.latest_selected_item) ) {
return window.latest_selected_item;
}
if ( window.active_element && $(window.active_element).hasClass('item') ) {
if ( selected.is(window.active_element) ) {
return window.active_element;
}
return $(window.active_item_container).find('.item').get(0);
return selected.get(-1);
}
function findNeighbor (current, direction) {
@@ -363,18 +383,19 @@ $(document).bind('keydown', async function (e) {
// select first item if none are already selected
const selected = $(window.active_item_container).find('.item-selected');
if ( selected.length === 0 ) {
const first = $(window.active_item_container).find('.item').get(0);
const first = $(window.active_item_container).find('.item:not(.item-hidden)').get(0);
if ( first ) {
$(first).addClass('item-selected');
window.active_element = first;
window.latest_selected_item = first;
first.scrollIntoView({ block: 'nearest', inline: 'nearest' });
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
}
return;
}
// virtual grid layout to determine item layout and next items
const items = Array.from($(window.active_item_container).find('.item'));
const items = Array.from($(window.active_item_container).find('.item:not(.item-hidden)'));
const grid = items.map(item => {
const rect = item.getBoundingClientRect();
return {
@@ -393,6 +414,7 @@ $(document).bind('keydown', async function (e) {
const currentEl = getActiveItem();
const current = grid.find(i => i.el === currentEl);
if ( ! current ) return;
const next = findNeighbor(current, dir);
// apply new selection(s)
@@ -409,6 +431,7 @@ $(document).bind('keydown', async function (e) {
$(next.el).addClass('item-selected');
}
next.el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
}
}
// ----------------------------------------------
@@ -500,14 +523,15 @@ $(document).bind('keydown', async function (e) {
// Shift+Delete (win)/ option+command+delete (Mac) key pressed
// Permanent delete bypassing trash after alert
//-----------------------------------------------------------------------
if ( (e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8) ) {
let $selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
if ( ((e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8)) && !$(focused_el).is('input, textarea') ) {
let $selected_items = selection_container().find('.item-selected');
if ( $selected_items.length > 0 ) {
const alert_resp = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
buttons: [
{
label: i18n('delete'),
value: 'delete',
type: 'primary',
},
{
@@ -515,7 +539,7 @@ $(document).bind('keydown', async function (e) {
},
],
});
if ( (alert_resp) === 'Delete' ) {
if ( (alert_resp) === 'delete' ) {
for ( let index = 0; index < $selected_items.length; index++ ) {
const element = $selected_items[index];
await window.delete_item(element);
@@ -528,15 +552,16 @@ $(document).bind('keydown', async function (e) {
// Delete (win)/ ctrl+delete (Mac) / cmd+delete (Mac) key pressed
// Permanent delete from trash after alert or move to trash
//-----------------------------------------------------------------------
if ( e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey)) ) {
if ( (e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey))) && !$(focused_el).is('input, textarea') ) {
// permanent delete?
let $selected_items = $(window.active_element).closest('.item-container').find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
let $selected_items = selection_container().find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
if ( $selected_items.length > 0 ) {
const alert_resp = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
buttons: [
{
label: i18n('delete'),
value: 'delete',
type: 'primary',
},
{
@@ -544,7 +569,7 @@ $(document).bind('keydown', async function (e) {
},
],
});
if ( (alert_resp) === 'Delete' ) {
if ( (alert_resp) === 'delete' ) {
for ( let index = 0; index < $selected_items.length; index++ ) {
const element = $selected_items[index];
await window.delete_item(element);
@@ -554,7 +579,7 @@ $(document).bind('keydown', async function (e) {
}
// regular delete?
else {
$selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
$selected_items = selection_container().find('.item-selected');
if ( $selected_items.length > 0 ) {
// Only delete the items if we're not renaming one.
if ( $selected_items.children('.item-name-editor-active').length === 0 ) {
@@ -569,6 +594,11 @@ $(document).bind('keydown', async function (e) {
// A letter or number is pressed and there is no context menu open: search items by name
//-----------------------------------------------------------------------
if ( !e.ctrlKey && !e.metaKey && !$(focused_el).is('input') && !$(focused_el).is('textarea') && $('.context-menu').length === 0 ) {
// Shift, arrows, Enter and other named keys are not part of a name
if ( e.key?.length !== 1 ) {
return false;
}
if ( window.keypress_item_seach_term !== '' )
{
clearTimeout(window.keypress_item_seach_buffer_timeout);
@@ -589,7 +619,7 @@ $(document).bind('keydown', async function (e) {
}
// search for matches
let haystack = $(window.active_item_container).find('.item').not('.item-disabled');
let haystack = $(window.active_item_container).find('.item').not('.item-disabled, .item-hidden');
for ( let j = 0; j < haystack.length; j++ ) {
if ( $(haystack[j]).attr('data-name').toLowerCase().startsWith(window.keypress_item_seach_term) ) {
matches.push(haystack[j]);
@@ -613,7 +643,7 @@ $(document).bind('keydown', async function (e) {
// select matching item
$(matches[0]).not('.item-disabled').addClass('item-selected');
matches[0].scrollIntoView(false);
window.update_explorer_footer_selected_items_count($(window.active_element).closest('.window'));
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
}
return false;
@@ -687,11 +717,7 @@ $(document).bind('keyup keydown', async function (e) {
// ctrl/command + a, will select all items on desktop and windows
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 65 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
let $parent_container = $(window.active_element).closest('.item-container');
if ( $parent_container.length === 0 )
{
$parent_container = $(window.active_element).find('.item-container');
}
const $parent_container = selection_container();
if ( $parent_container.attr('data-multiselectable') === 'false' )
{
@@ -699,7 +725,7 @@ $(document).bind('keyup keydown', async function (e) {
}
if ( $parent_container ) {
$($parent_container).find('.item').not('.item-disabled').addClass('item-selected');
$($parent_container).find('.item').not('.item-disabled, .item-hidden').addClass('item-selected');
window.update_explorer_footer_selected_items_count($parent_container.closest('.window'));
}
@@ -732,11 +758,7 @@ $(document).bind('keyup keydown', async function (e) {
!$(focused_el).is('textarea') ) {
let $selected_items;
let parent_container = $(window.active_element).closest('.item-container');
if ( parent_container.length === 0 )
{
parent_container = $(window.active_element).find('.item-container');
}
const parent_container = selection_container();
if ( parent_container !== null ) {
$selected_items = $(parent_container).find('.item-selected');
@@ -761,11 +783,7 @@ $(document).bind('keyup keydown', async function (e) {
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 88 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
let $selected_items;
let parent_container = $(window.active_element).closest('.item-container');
if ( parent_container.length === 0 )
{
parent_container = $(window.active_element).find('.item-container');
}
const parent_container = selection_container();
if ( parent_container !== null ) {
$selected_items = $(parent_container).find('.item-selected');
@@ -863,7 +881,8 @@ $(document).bind('keyup keydown', async function (e) {
// Paste
// ctrl/command + v, will paste items from the clipboard to the active element
//----------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
// Once per press: releasing V with Ctrl still held would paste a second time.
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && e.type === 'keydown' && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
let target_path, target_el;
// continue only if there is something in the clipboard
+59
View File
@@ -135,6 +135,65 @@ export const resolveOpenerOrigin = async ({
export const relationshipIsConsent = ({ hasRelationship, showedAccountPicker }) =>
hasRelationship && !showedAccountPicker;
/**
* The result of a popup's early user-app token exchange, when it stands in for
* a second one: it returned a token, for the same opener and the same session.
*
* @param {{
* origin: string|null;
* authToken: string|null|undefined;
* data: { token?: string; app_uid?: string }|null;
* }|null} early - The early exchange, or null when none ran.
* @param {string} origin - The opener origin the token is needed for.
* @param {string|null|undefined} authToken - The session token in use.
* @returns {{ token: string; app_uid?: string }|null} null means exchange again.
*/
export const reusableTokenExchange = (early, origin, authToken) => {
if ( !early?.data?.token || !origin || !authToken ) return null;
if ( early.origin !== origin || early.authToken !== authToken ) return null;
return /** @type {{ token: string; app_uid?: string }} */ (early.data);
};
/** How long a sign-in popup's first `/whoami` stays usable. */
export const POPUP_WHOAMI_MAX_AGE_MS = 60_000;
const VERIFICATION_GATE_FLAGS = [
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
];
/**
* Whether the `/whoami` a sign-in popup read before its account picker still
* describes the session, so boot can skip reading it again.
*
* A pending verification gate always gets a fresh read, and so does a session
* the token exchange has not just accepted.
*
* @param {{
* user: Record<string, unknown>;
* authToken: string|null|undefined;
* fetchedAt: number;
* }|null} early - The early read, or null when none succeeded.
* @param {{
* userUuid: string|null|undefined;
* authToken: string|null|undefined;
* exchangedAuthToken: string|null|undefined;
* now: number;
* }} session - The signed-in user and token now, and the session token the
* early token exchange succeeded with (null when it returned no token).
* @returns {boolean}
*/
export const reusablePopupUser = (early, { userUuid, authToken, exchangedAuthToken, now }) => {
if ( !early?.user?.uuid || !authToken ) return false;
if ( early.user.uuid !== userUuid ) return false;
if ( early.authToken !== authToken || exchangedAuthToken !== authToken ) return false;
if ( VERIFICATION_GATE_FLAGS.some(flag => early.user[flag]) ) return false;
const age = now - early.fetchedAt;
return age >= 0 && age <= POPUP_WHOAMI_MAX_AGE_MS;
};
/**
* Whether a popup running `action` runs the user-app token exchange.
*
+106
View File
@@ -26,7 +26,10 @@ import {
offersFederatedSignInInPopup,
relationshipIsConsent,
relaysTokenAtBoot,
POPUP_WHOAMI_MAX_AGE_MS,
resolveOpenerOrigin,
reusablePopupUser,
reusableTokenExchange,
runsUserAppTokenExchange,
} from './popupAuth.js';
@@ -315,3 +318,106 @@ describe('the retired opener_origin gate', () => {
expect(popupAuth.trustsOpenerOriginParam).toBeUndefined();
});
});
describe('reusableTokenExchange', () => {
const origin = 'https://app.example';
const early = {
origin,
authToken: 'session-a',
data: { token: 'app-token', app_uid: 'app-1' },
};
it('returns the early result for the same opener and session', () => {
expect(reusableTokenExchange(early, origin, 'session-a')).toBe(early.data);
});
it('exchanges again when no early exchange ran', () => {
expect(reusableTokenExchange(null, origin, 'session-a')).toBeNull();
});
it('exchanges again when the early one returned no token', () => {
// A network failure is null; an HTTP failure is the error body.
for ( const data of [null, { code: 'app_blocked', message: 'no' }, { token: '' }] ) {
expect(reusableTokenExchange({ ...early, data }, origin, 'session-a')).toBeNull();
}
});
it('exchanges again after the user switched accounts', () => {
expect(reusableTokenExchange(early, origin, 'session-b')).toBeNull();
});
it('exchanges again for another opener', () => {
expect(reusableTokenExchange(early, 'https://other.example', 'session-a')).toBeNull();
expect(reusableTokenExchange({ ...early, origin: null }, origin, 'session-a')).toBeNull();
});
it('exchanges again without a session token to match on', () => {
const unkeyed = { ...early, authToken: undefined };
expect(reusableTokenExchange(unkeyed, origin, undefined)).toBeNull();
expect(reusableTokenExchange({ ...early, authToken: null }, origin, null)).toBeNull();
});
});
describe('reusablePopupUser', () => {
const early = {
user: { uuid: 'user-a', username: 'a' },
authToken: 'session-a',
fetchedAt: 1_000,
};
const session = {
userUuid: 'user-a',
authToken: 'session-a',
exchangedAuthToken: 'session-a',
now: 2_000,
};
it('reuses the early read for the same user on a session the exchange accepted', () => {
expect(reusablePopupUser(early, session)).toBe(true);
});
it('reads again when the early read failed', () => {
expect(reusablePopupUser(null, session)).toBe(false);
expect(reusablePopupUser({ ...early, user: {} }, session)).toBe(false);
});
it('reads again after the user switched accounts', () => {
expect(reusablePopupUser(early, { ...session, userUuid: 'user-b' })).toBe(false);
expect(reusablePopupUser(early, {
...session,
authToken: 'session-b',
exchangedAuthToken: 'session-b',
})).toBe(false);
});
it('reads again when the exchange did not accept this session', () => {
// A dead session has to reach the read that signs the user out.
expect(reusablePopupUser(early, { ...session, exchangedAuthToken: null })).toBe(false);
expect(reusablePopupUser(early, { ...session, exchangedAuthToken: 'session-b' })).toBe(false);
});
it('reads again when a verification gate is pending', () => {
for ( const flag of [
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
] ) {
const gated = { ...early, user: { ...early.user, [flag]: true } };
expect(reusablePopupUser(gated, session)).toBe(false);
}
});
it('reads again once the early read is too old', () => {
const at = (now) => reusablePopupUser(early, { ...session, now });
expect(at(early.fetchedAt + POPUP_WHOAMI_MAX_AGE_MS)).toBe(true);
expect(at(early.fetchedAt + POPUP_WHOAMI_MAX_AGE_MS + 1)).toBe(false);
expect(at(early.fetchedAt - 1)).toBe(false);
});
it('reads again without a session token', () => {
expect(reusablePopupUser(
{ ...early, authToken: undefined },
{ ...session, authToken: undefined, exchangedAuthToken: undefined },
)).toBe(false);
});
});
+22
View File
@@ -52,6 +52,28 @@ export async function waitForPuterReady (page) {
await page.waitForFunction(() => !!window.puter?.apps?.create, null, { timeout: 10_000 });
}
/**
* Creates a second account, the one a first-time visitor is given, and
* returns its token and user. Sign-ups are rate limited, so create one per file.
*
* @param {import('@playwright/test').Browser} browser
* @returns {Promise<{ token: string, user: { uuid: string, username: string } }>}
*/
export async function createVisitorAccount (browser) {
const visitorContext = await browser.newContext({
storageState: { cookies: [], origins: [] },
});
const visitorPage = await visitorContext.newPage();
await visitorPage.goto('/');
await waitForPuterReady(visitorPage);
const account = await visitorPage.evaluate(() => ({
token: localStorage.getItem('auth_token_v2'),
user: JSON.parse(localStorage.getItem('user')),
}));
await visitorContext.close();
return account;
}
async function assertFixtureReachable (fixtureURL) {
let res;
try {
@@ -0,0 +1,144 @@
import { test, expect } from '@playwright/test';
import { createVisitorAccount, waitForPuterReady } from '../helpers/testApp.js';
// A second account: what a first-time visitor is given. Created once for the
// whole file, because sign-ups are rate limited.
let visitor;
const visitorAccount = (browser) => (visitor ??= createVisitorAccount(browser));
/**
* Every tab shares one stored session, so switching accounts in one tab hands
* the others a token for a different account. A tab that kept running would
* show its old account's desktop while its requests acted as the new one.
*/
test.describe('an account switch in another tab', () => {
test('reloads this tab as the new account instead of adopting its token', async ({ browser, context, page }) => {
const other = await visitorAccount(browser);
const switchingTab = page;
const otherTab = await context.newPage();
for ( const tab of [switchingTab, otherTab] ) {
await tab.goto('/');
await waitForPuterReady(tab);
}
const firstUser = await otherTab.evaluate(() => window.user.uuid);
expect(firstUser).not.toBe(other.user.uuid);
// Record what this tab holds once it has handled the new token.
// Session storage outlives the reload; `__beforeSwitch` doesn't.
await otherTab.evaluate(() => {
window.__beforeSwitch = true;
window.addEventListener('storage', (event) => {
if ( event.key !== 'auth_token_v2' || !event.newValue ) return;
sessionStorage.setItem('seenOnSwitch', JSON.stringify({
adopted: window.auth_token === event.newValue,
user: window.user?.uuid,
}));
});
});
await switchingTab.evaluate(
({ token, user }) => window.update_auth_data(token, user),
other,
);
await otherTab.waitForFunction(
(uuid) => !window.__beforeSwitch && window.user?.uuid === uuid,
other.user.uuid,
{ timeout: 60_000 },
);
await waitForPuterReady(otherTab);
expect(
await otherTab.evaluate(() => JSON.parse(sessionStorage.getItem('seenOnSwitch'))),
).toEqual({ adopted: false, user: firstUser });
// Booting renews the token for the same account, which the tab that
// switched picks up without reloading.
await expect.poll(() => switchingTab.evaluate(() => ({
user: window.user?.uuid,
current: window.auth_token === localStorage.getItem('auth_token_v2'),
}))).toEqual({ user: other.user.uuid, current: true });
});
test('reloads this tab only once the session cookie names the new account', async ({ browser, context, page }) => {
const other = await visitorAccount(browser);
const switchingTab = page;
const otherTab = await context.newPage();
for ( const tab of [switchingTab, otherTab] ) {
await tab.goto('/');
await waitForPuterReady(tab);
}
// A tab signs in from the cookie when it boots, so one that reloaded
// while the cookie still named the first account would stay on it.
await context.route('**/session/sync-cookie', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await otherTab.evaluate(() => {
window.__beforeSwitch = true;
});
await switchingTab.evaluate(
({ token, user }) => window.update_auth_data(token, user),
other,
);
await otherTab.waitForFunction(
(uuid) => !window.__beforeSwitch && window.user?.uuid === uuid,
other.user.uuid,
{ timeout: 30_000 },
);
await waitForPuterReady(otherTab);
await expect.poll(() => switchingTab.evaluate(() => ({
user: window.user?.uuid,
current: window.auth_token === localStorage.getItem('auth_token_v2'),
storedFor: JSON.parse(localStorage.getItem('user')).uuid,
}))).toEqual({ user: other.user.uuid, current: true, storedFor: other.user.uuid });
});
});
test.describe('a token another tab stores during an account switch', () => {
test('does not replace the token of the account being switched to', async ({ browser, context, page }) => {
const other = await visitorAccount(browser);
const switchingTab = page;
const otherTab = await context.newPage();
for ( const tab of [switchingTab, otherTab] ) {
await tab.goto('/');
await waitForPuterReady(tab);
}
const firstToken = await switchingTab.evaluate(() => window.auth_token);
let releaseSync;
const syncHeld = new Promise((resolve) => {
releaseSync = resolve;
});
await context.route('**/session/sync-cookie', async (route) => {
await syncHeld;
await route.continue();
});
const switched = switchingTab.evaluate(
({ token, user }) => window.update_auth_data(token, user),
other,
);
// Booting renews the first account's token while the switch waits.
// A token issued in the same second as the stored one is identical.
await otherTab.waitForTimeout(1100);
await otherTab.reload();
await expect.poll(() => switchingTab.evaluate(
() => localStorage.getItem('auth_token_v2'),
)).not.toBe(firstToken);
releaseSync();
await switched;
expect(await switchingTab.evaluate(() => ({
token: window.auth_token,
saved: window.logged_in_users.find((known) => known.uuid === window.user.uuid).auth_token,
}))).toEqual({ token: other.token, saved: other.token });
});
});
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { FIXTURE_URL } from '../helpers/testApp.js';
import { FIXTURE_URL, createVisitorAccount, waitForPuterReady } from '../helpers/testApp.js';
const FIXTURE = FIXTURE_URL.replace(
'menubar-contextmenu.html',
@@ -178,3 +178,116 @@ test.describe('popup sign-in cannot be driven from a link', () => {
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
});
});
test.describe('popup sign-in after an account is picked', () => {
// The popup's own `/whoami` read carries `icon_size`. puter.js sends a
// bare one on every `setAuthToken()`, which the popup does not wait on.
const COUNTED = ['/auth/get-user-app-token', '/auth/check-app', '/whoami?icon_size=64'];
/**
* Open a sign-in popup from the fixture, pick the signed-in account, and
* report the API requests the popup sent after the pick. With a `session`
* the page waits on `/login/wait` for it, as an isolated opener does. With
* an `other` account, that one is signed in on the browser too, and picked.
*/
const pickAccount = async (page, popupQuery, session = null, other = null) => {
await page.goto('/');
await page.waitForFunction(() => !!window.puter?.authToken, null, {
timeout: 60_000,
});
if ( other ) {
await waitForPuterReady(page);
await page.evaluate(({ token, user }) => {
const known = JSON.parse(localStorage.getItem('logged_in_users'));
known.push({ ...user, auth_token: token });
localStorage.setItem('logged_in_users', JSON.stringify(known));
}, other);
}
await page.goto(FIXTURE);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.evaluate(({ query, relaySession }) => {
if ( relaySession ) {
window.__relayed = fetch(`${puter.APIOrigin}/login/wait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: relaySession }),
}).then(async (response) => (await response.json())?.auth_token);
}
window.__delivered = null;
window.addEventListener('message', (event) => {
if ( event.data?.msg === 'puter.token' ) {
window.__delivered = event.data;
}
});
window.open(
`${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&${query}`,
'signin-pick',
'width=600,height=700',
);
}, { query: popupQuery, relaySession: session }),
]);
const accounts = popup.locator('.auth-dialog [data-screen="accounts"] .auth-account');
await expect(accounts).toHaveCount(other ? 2 : 1, { timeout: 60_000 });
const account = other ? accounts.and(popup.locator(`[data-uuid="${other.user.uuid}"]`)) : accounts;
const requests = [];
popup.on('request', (request) => {
if ( request.method() === 'OPTIONS' ) return;
const { pathname, search } = new URL(request.url());
if ( COUNTED.includes(pathname + search) ) requests.push(pathname + search);
});
await Promise.all([popup.waitForEvent('close'), account.click()]);
return requests;
};
/** The username the API resolves `token` to, asked from the fixture page. */
const usernameFor = (page, token) =>
page.evaluate(async (bearer) => {
const response = await fetch(`${puter.APIOrigin}/whoami`, {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.ok ? (await response.json()).username : null;
}, token);
test('hands the opener a working token after one exchange', async ({ page }) => {
const requests = await pickAccount(page, 'msg_id=92');
await page.waitForFunction(() => !!window.__delivered);
const delivered = await page.evaluate(() => window.__delivered);
expect(delivered.success).toBe(true);
expect(delivered.msg_id).toBe('92');
expect(delivered.app_uid).toMatch(/^app-/);
expect(await usernameFor(page, delivered.token)).toBe(delivered.username);
expect(requests).toEqual(['/auth/get-user-app-token']);
});
test('relays a working token to an isolated opener after one exchange', async ({ page }) => {
const session = '22222222-3333-4444-8555-aaaaaaaaaaaa';
const requests = await pickAccount(
page,
`cross_origin_isolated=true&signin_session=${session}&msg_id=93`,
session,
);
const token = await page.evaluate(() => window.__relayed);
expect(await usernameFor(page, token)).toBeTruthy();
expect(requests).toEqual(['/auth/get-user-app-token']);
});
test('hands over the picked account when it is not the one the popup opened with', async ({ browser, page }) => {
const other = await createVisitorAccount(browser);
const requests = await pickAccount(page, 'msg_id=94', null, other);
await page.waitForFunction(() => !!window.__delivered);
const delivered = await page.evaluate(() => window.__delivered);
expect(delivered.success).toBe(true);
expect(delivered.username).toBe(other.user.username);
expect(await usernameFor(page, delivered.token)).toBe(other.user.username);
// The popup has not read this account yet, so it reads it once.
expect(requests).toEqual(['/auth/get-user-app-token', '/whoami?icon_size=64']);
});
});