Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 5c406a5174 Remove the global menubar leftovers and more unused styles
Nothing creates a global menubar, so the window code stops hiding, showing
and removing one. Also drops stylesheet rules for classes nothing uses,
including the old auth dialog, the responsive hidden-* and visible-*
helpers, and two keyframes only those rules animated.
2026-11-12 15:15:33 -07:00
Nariman Jelveh 1b36805301 Remove more translation keys nothing references
Drops 56 keys in every language that no GUI code, extension or backend
message uses, such as the system information labels, the old 2FA setup and
confirm-code steps, and the auth token copy dialog strings.
2026-11-12 15:14:13 -07:00
Nariman Jelveh 163dfd737c Remove styles and icons nothing references
Drops the stylesheet rules of the removed Settings window, launch menu,
intro menu and a few other removed features, and 16 icons whose names
appear nowhere in the GUI.
2026-11-12 15:06:37 -07:00
Nariman Jelveh d1fc58c29a Remove the window color theme service
Nothing can set a window theme, so the GUI stops reading the saved theme
file on boot and windows keep the default colors. puter.ui.on() still
accepts themeChanged, which is never sent; its docs entry is removed.
2026-11-12 14:57:57 -07:00
Nariman Jelveh 7ee0075186 Remove window edge snapping
Dragging a window to a screen edge or corner no longer snaps it to half or a
quarter of the screen. Double-clicking the head and the scale button still
maximize.
2026-11-12 14:55:42 -07:00
Nariman Jelveh bbeb984a91 Inline the toolbar height, which is always zero
The toolbar is gone, so window.toolbar_height only added or subtracted zero
in window sizing and placement.
2026-11-12 14:54:52 -07:00
Nariman Jelveh fce5d2b08b Remove translation keys left by removed features
Drops 36 keys nothing references in every language: the keyboard shortcuts
guide, desktop shortcuts, personalization, the toolbar's fullscreen, GitHub
and refer entries, the referral strings, and the publishing option prompt.
2026-11-12 14:54:32 -07:00
Nariman Jelveh 3def512a9d Remove the unused component window and Flexer
Nothing opens UIComponentWindow or uses Flexer, and JustHTML was only the
component window's default body. Also drops the attach() shim UIQRCode kept
for component containers.
2026-11-12 14:53:48 -07:00
Nariman Jelveh a920b9782f Redirect /show/* to the dashboard's Files tab
/show/<path> redirects to /dashboard#files/<path> with its query intact,
instead of booting the desktop and opening Explorer there.

Also removes what only that route used: the shell's launch options, the
launch_options GUI param, and the GUI service that ran them.
2026-11-12 14:39:06 -07:00
Daniel Salazar 454f94fc54 fix(hosting): serve a launched public app's assets from its billing cookie without re-minting it (#4585)
Same-origin assets of a launched public app carry the launch token in the
Referer. When that token's signature verifies and it names the same user
and session as a valid public billing cookie (and, for an app token, the
same app), the cookie identity is used directly: no re-authentication, no
new cookie, no Set-Cookie. Any other combination resolves exactly as
before. Responses that do set the public cookie now carry
`Cache-Control: private`.
2026-11-12 14:28:35 -07:00
Daniel Salazar 8b7d380de1 fix(metering): a settle whose balance read fails still charges the allowance (#4584)
When the purchased-credit read or the near-allowance exact read fails while
settling a metered call, the call is now settled against the month record it
already has: the share that fits the monthly allowance is charged, and
purchased credit is left untouched since its balance could not be read.
Previously the whole settle was skipped, leaving the cost in the month total
but in neither pool.

Such settles skip the cached-balance update and overuse check, and raise one
info alarm id with the amount credit would have covered.
2026-11-12 14:27:29 -07:00
Daniel Salazar 8052a4287e fix(auth): close a revoked access token's sockets on every node (#4583)
Access-token revocation is announced as outer.auth.access-token.revoked,
so peer clusters evict the token's socket.io connections and realtime
relay sessions as the revoking cluster does. Peer payloads without a
string token_uid are ignored.
2026-11-12 14:27:10 -07:00
Daniel Salazar f114b13306 perf(gui): boot keeps a same-session token and skips /get-gui-token when the page already answers it (#4591)
* perf(gui): keep the stored token when /get-gui-token returns one for the same session

/get-gui-token signs a new string on every call, so every signed-in boot
stored a token that differed from the one it held only in `iat` and passed
it to puter.setAuthToken. That opened a second FS socket, re-sent /whoami
and the cache timestamp, and every other open tab picked the new token up
from storage and did the same.

The boot now keeps the stored token when the returned one carries exactly
the same claims apart from `iat`; any other difference replaces it as
before. A kept token still starts the boot from an empty FS cache, as the
swap did. A boot that keeps the persisted cache (no session cookie) now
waits for the cache timestamp check before the first render, which it
started before reading the user.

* perf(gui): skip /get-gui-token at boot when the page already says what it would return

Every signed-in boot waited on /get-gui-token before it read the user. The
shell is rendered from the same session cookie that route reads, so it now
carries `guiTokenDigest` in gui_params: a digest of the claims (minus
`iat`) of the GUI token the cookie's session would get, or null when the
request has no session cookie. A cookie that doesn't resolve to a user
session leaves it out.

The GUI skips the request when the digest matches its stored token (same
session, user and token type), or when it is null (the route would refuse).
It still asks when the parameter is missing, the digest names another
session, nothing is stored, the page and the route are on different
origins, or Navigation Timing doesn't show this page as freshly served for
this load (a history navigation or restored tab can reuse a page rendered
for an older cookie).
2026-11-12 14:26:09 -07:00
Nariman Jelveh ce70728eeb Remove the desktop's icons and right-click menu, and redirect /desktop (#4609)
* Remove the desktop's icons and right-click menu

The desktop no longer lists the Desktop folder's items, and right-clicking
its background opens no menu. It stays as the surface windows open on.

Also removes what only the icons used: file drops and item drops on the
desktop, rubber-band selection, auto arrange and saved icon positions, and
their two keys in puter.js's GUI boot cache. Clicking the desktop clears the
active item container so keyboard shortcuts stop acting on a window's items.

* Redirect /desktop and /settings to the dashboard

/desktop redirects to the root with its query intact, and /settings to the
dashboard's Home tab. /desktop drops out of the OIDC return_to allowlist,
sign-out returns to the root, and the auth cover page's logo links there.

Also removes eight GUI globals nothing references.

* Remove code paths that only ran for the desktop's item container

The desktop is no longer an item container, so the Trash item that
refresh_item_container added to it, the empty-folder message exemption,
and the context menu parent check for it can't be reached.
2026-11-12 14:00:29 -07:00
Nariman Jelveh 8518023b0b Remove more desktop-only features and entry points (#4607)
* Remove the desktop's Publish Website window

The right-click menu on desktop folders and on an Explorer window's
background no longer has a Publish as Website item. This removes
UIWindowPublishWebsite, the publishAsWebsite helper that opened it, both
menu items, the window's CSS, window.extractProtocol (only the window used
it), and the dir_published_as_website and pick_name_for_website i18n keys.

The dashboard keeps publishing websites through its own dialog.
generateFileContextMenu no longer falls back to the removed window, so it
offers Publish as Website only when the caller passes onPublishWebsite,
which the dashboard's Files tab does. The website badge on desktop items
and the backend hosting API are unchanged.

* Remove the desktop's Hide Desktop Icons toggle

The desktop's right-click menu no longer has a Hide/Show Desktop Icons
item. Without the wallpaper, hiding the icons left an empty page. This
removes the menu item, toggleDesktopIcons/hideDesktopIcons/
showDesktopIcons, the boot-time read of the saved setting, and the
.desktop-icons-hidden CSS rule.

puter.js's GUI boot cache no longer batch-fetches desktop_icons_hidden,
menubar_style or user_preferences.clock_visible, which nothing reads
anymore. Values already saved under those keys stay in KV, unread.

Also drops the desktop's BroadcastChannel, whose message handler was
empty and which nothing else referenced.

* Remove the desktop's ?download= link handler

Opening puter.com/?download=<url> or /desktop?download=<url> no longer
asks to save that remote file to the Desktop. The parameter is ignored,
and a root URL that carries it boots the dashboard like any other root
URL instead of forcing the desktop.

This removes the handler in UIDesktop, its UIWindowProgress import, the
download entry in the GUI's dashboard routing, and the
confirm_download_file_to_desktop, downloading, downloading_file,
uploading_file and error_download_failed i18n keys only it used.

The decision of whether a page load boots the dashboard moves from an
inline block in initgui into helpers/bootsDashboard.js, so it is tested.

* Stop granting Camera and Recorder Desktop access on desktop boot

The desktop no longer grants Camera and Recorder write access to the
user's Desktop folder the first time it loads. Those apps now ask
through the normal permission prompt, as they already did for users
who only used the dashboard. Grants already given stay in place.

puter.js's GUI boot cache no longer batch-fetches the
has_set_default_app_user_permissions flag that kept the grant to one
run. Values already saved under it stay in KV, unread.

* Redirect /@username links to the dashboard

puter.com/@<username> no longer opens that user's Public folder on the
desktop. The backend now redirects /@:username to /, so old links land
on the dashboard instead of booting the desktop.

This removes the GUI's /@ handler and the error_invalid_username i18n
key only it used. error_user_or_path_not_found stays, since share links
still use it.

* Redirect /desktop/app links and stop opening share links on the desktop

/desktop/app/<name> now redirects to /app/<name>, keeping its query
string, so the app opens in the dashboard. The backend no longer serves
an app shell there, OIDC sign-in no longer returns to it, and the GUI no
longer strips a leading /desktop from its route segments. The rate-limits
page lists only /app/<name>, the route the limit still applies to.

/desktop?shared=<path> no longer opens the shared item on the desktop.
The desktop ignores ?shared=: it doesn't ask a guest to sign in for it,
doesn't switch to the account a share email names, and OIDC sign-in from
/desktop doesn't carry it along. Share emails link to /?shared=, which
the dashboard handles as before. Opening a share notification on the
desktop still opens the item.

* Remove the desktop's ?app=explorer link handler

?app=explorer[&path=<folder>] no longer boots the desktop and opens an
Explorer window. ?app= is ignored and no longer forces the desktop, so a
root URL that carries it opens the dashboard, and /app/<name>?app=... now
launches its app in the dashboard instead of booting the desktop and
launching nothing.

* Stop ?puter.fullpage= from booting the desktop

The root and /app/<name> with ?puter.fullpage= now open in the dashboard
like a plain /app/<name>, with the app maximized. The dashboard always
shows apps full page, so the parameter no longer overrides that. Iframe
embeds and popups, which still boot the desktop, read it as before.

bootsDashboard no longer takes the query string, since no parameter
decides the interface anymore.
2026-11-12 11:54:40 -07:00
Nariman Jelveh 33988ac84c Remove the desktop taskbar (#4605)
* Remove the desktop taskbar

The desktop no longer has a taskbar. Windows on /desktop can't be
minimized, since nothing could bring them back; the dashboard still
minimizes into app tiles. The desktop and maximized windows use the
full page height.

Backend: POST /update-taskbar-items is removed, and /whoami and the
login response no longer include taskbar_items. The user table's
taskbar_items column stays until a migration drops it.

puter.ui.createWindow() still accepts show_in_taskbar, which has no
effect and is marked deprecated.

* Keep desktop backdrops above ordinary windows

A backdrop's z-index used to take the taskbar's as a floor, which kept
modal dialogs on /desktop above every ordinary window, including ones
focused after the dialog opened. Keep that floor on the desktop; the
dashboard and fullpage mode stack as before.

Also drops commented-out geometry that referenced window.taskbar_height,
and a dashboard comment about the removed fullpage minimize rule.

* Keep dropped desktop icons fully on screen, and fill the phone popover

A desktop icon drop is accepted only where the whole item fits above the
bottom edge. Phone popovers use the full viewport height.
2026-11-11 20:16:13 -07:00
169 changed files with 2836 additions and 9307 deletions

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ The following have already been reviewed and determined **not to be vulnerabilit
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (`/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
+19 -6
View File
@@ -257,8 +257,21 @@ describe('whoami extension — handleWhoami', () => {
expect(body.created_ts).toBeGreaterThan(0);
// `directories` is only sent to user actors — confirm it’s present.
expect(body.directories).toBeDefined();
// taskbar_items is only sent to user actors.
expect(body).toHaveProperty('taskbar_items');
});
it('leaves out taskbar items saved on the account', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
expect(captured.body).not.toHaveProperty('taskbar_items');
});
it('leaves out a desktop background saved on the account', async () => {
@@ -449,7 +462,7 @@ describe('whoami extension — handleWhoami', () => {
});
});
it('cannot add taskbar_items for an app actor, which core omits', async () => {
it('cannot add directories for an app actor, which core omits', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({
@@ -457,11 +470,11 @@ describe('whoami extension — handleWhoami', () => {
app: { uid: 'app-test-actor' },
}),
(_key, event) => {
event.details.taskbar_items = [{ name: 'injected' }];
event.details.directories = { '/injected': 'uuid' };
},
);
expect(body).not.toHaveProperty('taskbar_items');
expect(body).not.toHaveProperty('directories');
});
});
@@ -542,7 +555,7 @@ describe('whoami extension — handleWhoami', () => {
expect(body.otp_secret).toBeUndefined();
expect(body.signup_ip).toBeUndefined();
// Not just absent as a key — the values must not appear anywhere
// in the payload (nested under metadata, taskbar items, …).
// in the payload (nested under metadata, …).
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('5551234567');
expect(serialized).not.toContain('fp_ABC123');
+1 -11
View File
@@ -1,6 +1,5 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import {
buildUserDetails,
scrubSensitive,
@@ -54,19 +53,10 @@ export const handleWhoami = async (
return;
}
const rawIconSize =
typeof req.query?.icon_size === 'string'
? Number(req.query.icon_size)
: undefined;
const iconSize =
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
? rawIconSize
: undefined;
const details = await buildUserDetails(
user,
{ config: extension.config, clients, stores, services },
{ isUser, iconSize, noIcons: !iconSize },
{ isUser },
);
details.human_readable_age = user.timestamp
? timeago.format(new Date(user.timestamp as string))
-1
View File
@@ -125,7 +125,6 @@ describe('core without a teams registration', () => {
actor: { user: row },
} as never,
{ title: 'Puter' } as never,
{} as never,
);
const params = JSON.parse(
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
+5 -2
View File
@@ -874,8 +874,11 @@ export type EventMap = {
session_uids: string[];
};
/** One access token was revoked; only its own connections should drop. */
'auth.access-token.revoked': { token_uid: string };
/**
* One access token was revoked; only its own connections should drop.
* `outer.*` for the same reason as `outer.auth.sessions.revoked`.
*/
'outer.auth.access-token.revoked': { token_uid: string };
/** Same-cluster eviction for raw realtime WebSockets. */
'pubsub.realtime.auth-revoked': {
@@ -52,8 +52,8 @@ const APP_READ_LIMIT = {
/**
* Unauthenticated icon serving; no actor to key on, so the bucket is the
* address — and an address is a NAT, a campus or a carrier gateway that can
* hold hundreds of desktops. Each desktop boot pulls the taskbar's icons at
* once, so a single burst from one network is already thousands of requests.
* hold hundreds of desktops. Each dashboard load pulls its app icons at once,
* so a single burst from one network is already thousands of requests.
* Responses are publicly cacheable and usually a redirect, so the ceiling is
* not protecting bandwidth; it is there so a client looping on a broken icon
* can't spin unbounded.
@@ -523,7 +523,7 @@ export class AppController extends PuterController {
};
// Icons are <img src> targets from the GUI (root) AND resolved via
// api_base_url in taskbar payloads. Register on both so either origin
// api_base_url in app summaries. Register on both so either origin
// works without a cross-subdomain redirect.
router.get(
'/app-icon/:app_uid',
@@ -1271,7 +1271,7 @@ describe('AppController app-icon rate limit', () => {
it('sizes the icon bucket for a whole network rather than one desktop', () => {
// Icons are unauthenticated `<img src>` targets, so the only key is
// the address — which one NAT shares across every desktop behind it,
// and each boot pulls the taskbar's icons in a burst.
// and each dashboard load pulls its app icons in a burst.
for (const path of ['/app-icon/:app_uid', '/app-icon/:app_uid/:size']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
@@ -23,6 +23,7 @@ import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { tokenClaimsDigest } from '../../../gui/src/helpers/guiToken.js';
import {
createTestUser,
setupPuterTestEnv,
@@ -714,4 +715,67 @@ describe('session token helpers over HTTP', () => {
'puter_auth_token=',
);
});
describe("the shell's guiTokenDigest", () => {
/** The session cookie `/session/sync-cookie` sets for `token`. */
const sessionCookie = async (token: string) => {
const sync = await get('/session/sync-cookie', token);
expect(sync.status).toBe(204);
const value = /puter_auth_token=([^;]+)/.exec(
sync.headers.get('set-cookie') ?? '',
)?.[1];
expect(value).toBeTruthy();
return `puter_auth_token=${value}`;
};
/** What the shell at `path` renders for `guiTokenDigest`. */
const shellDigest = async (path: string, cookie?: string) => {
const res = await fetch(new URL(path, env.origin), {
headers: cookie ? { cookie } : {},
});
expect(res.status).toBe(200);
const html = await res.text();
const match = /"guiTokenDigest":(null|"[0-9a-f]{32}")/.exec(html);
if (!match) {
expect(html).not.toContain('guiTokenDigest');
return undefined;
}
return JSON.parse(match[1]) as string | null;
};
it("names the token /get-gui-token hands over for the session cookie", async () => {
const { token } = await signupTemp();
const cookie = await sessionCookie(token);
const gui = await fetch(new URL('/get-gui-token', env.origin), {
headers: { cookie },
});
expect(gui.status).toBe(200);
const { token: guiToken } = (await gui.json()) as { token: string };
const digest = await shellDigest('/', cookie);
expect(digest).toBe(await tokenClaimsDigest(guiToken));
// The token the browser stored at sign-up is for the same session.
expect(await tokenClaimsDigest(token)).toBe(digest);
});
it('is null without a session cookie, even with a token in the URL', async () => {
const { token } = await signupTemp();
expect(await shellDigest('/')).toBe(null);
expect(
await shellDigest(`/?auth_token=${encodeURIComponent(token)}`),
).toBe(null);
});
it('is left out when the session cookie does not resolve to a session', async () => {
const { token } = await signupTemp();
const cookie = await sessionCookie(token);
await env.server.services.auth.revokeSession(claims(token).session_uid);
expect(await shellDigest('/', cookie)).toBe(undefined);
expect(await shellDigest('/', 'puter_auth_token=garbage')).toBe(
undefined,
);
});
});
});
@@ -1416,7 +1416,7 @@ describe('AuthController.handleLogin', () => {
is_user_token: true,
});
expect(body.user.directories).toBeTypeOf('object');
expect(Array.isArray(body.user.taskbar_items)).toBe(true);
expect(body.user).not.toHaveProperty('taskbar_items');
});
// A deleted row still answering from cache reaches the session INSERT.
@@ -25,17 +25,13 @@ const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Desktop/UI preference routes.
*
* Two categories:
*
* - User-level: taskbar items (UserStore)
* - Folder-level: layout, sort_by/sort_order (fsentries table)
* Folder view preference routes: layout and sort_by/sort_order, stored on the
* folder's fsentries row.
*/
/**
* Desktop preference writes — taskbar, layout, sort order. Each persists on
* every call, and the GUI fires them on direct user action, so a per-minute
* ceiling well above human speed is enough to catch a stuck client.
* Desktop preference writes — layout, sort order. Each persists on every call,
* and the GUI fires them on direct user action, so a per-minute ceiling well
* above human speed is enough to catch a stuck client.
*/
const PREFERENCE_WRITE_LIMIT = {
scope: 'desktop-preference',
@@ -46,33 +42,6 @@ const PREFERENCE_WRITE_LIMIT = {
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Taskbar items -------------------------------------------
router.post(
'/update-taskbar-items',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { items } = req.body ?? {};
if (!Array.isArray(items)) {
throw new HttpError(
400,
'Missing or invalid `items` array',
{ legacyCode: 'bad_request' },
);
}
await this.stores.user.update(req.actor.user.id, {
taskbar_items: JSON.stringify(items),
});
res.json({});
},
);
// -- Folder layout -------------------------------------------
router.post(
@@ -136,50 +136,11 @@ describe('DesktopController routes', () => {
router.routes.some((r) => r.path === '/set-desktop-bg'),
).toBe(false);
});
});
// ── /update-taskbar-items ───────────────────────────────────────────
describe('DesktopController POST /update-taskbar-items', () => {
it('persists items as a JSON-encoded string', async () => {
const { actor, userId } = await makeUser();
const items = [{ name: 'editor' }, { name: 'browser' }];
const { res } = makeRes();
await callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: { items }, actor }),
res,
);
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.taskbar_items).toBe(JSON.stringify(items));
});
it('throws 400 when items is missing', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: {}, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when items is not an array', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: { items: 'oops' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
it('has no taskbar items route', () => {
expect(
router.routes.some((r) => r.path === '/update-taskbar-items'),
).toBe(false);
});
});
@@ -5111,9 +5111,8 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
// ── GET /get-launch-apps ────────────────────────────────────────────
//
// `recent` is a launch-metadata producer: it returns `index_url` for
// apps the user has opened before. The taskbar happens to launch these
// by name (so AppDriver's hosted-backing guard applies), but the field
// is served regardless, so the guard is applied here too.
// apps the user has opened before. The field is served whoever reads it,
// so AppDriver's hosted-backing guard is applied here too.
describe('LegacyFSController GET /get-launch-apps', () => {
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
@@ -401,11 +401,9 @@ export class LegacyFSController extends PuterController {
.filter(Boolean);
// Don't hand out an index_url whose puter-hosted backing is
// gone or reclaimed. The taskbar launches recents by name (so
// AppDriver's guard applies), but this list is a
// launch-metadata producer like any other — a future consumer
// reading index_url straight off it shouldn't inherit a stale
// origin.
// gone or reclaimed. This list is a launch-metadata producer
// like any other — a consumer reading index_url straight off
// it shouldn't inherit a stale origin.
let backingGoneFlags: boolean[];
try {
backingGoneFlags =
@@ -950,9 +948,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
const oldPath = source.path;
@@ -1426,8 +1422,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -2054,10 +2049,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
@@ -79,6 +79,7 @@ interface CapturedResponse {
statusCode: number;
body: unknown;
contentType?: string;
location?: string;
}
const makeReq = (init: {
@@ -87,6 +88,7 @@ const makeReq = (init: {
actor?: Actor;
hostname?: string;
protocol?: string;
originalUrl?: string;
}): Request => {
return {
body: {},
@@ -94,6 +96,7 @@ const makeReq = (init: {
headers: {},
params: init.params ?? {},
path: init.path ?? '/',
originalUrl: init.originalUrl ?? init.path ?? '/',
hostname: init.hostname ?? 'test.local',
protocol: init.protocol ?? 'http',
actor: init.actor,
@@ -131,6 +134,11 @@ const makeRes = () => {
captured.contentType = value;
return res;
}),
redirect: vi.fn((location: string) => {
captured.statusCode = 302;
captured.location = location;
return res;
}),
};
return { res: res as unknown as Response, captured };
};
@@ -214,23 +222,72 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
it('leaves guiTokenDigest out for a session cookie that is not a user session', async () => {
const { actor } = await makeUser();
const appActor = {
...actor,
app: { id: 1, uid: 'app-test' },
session: { uid: uuidv4() },
} as unknown as Actor;
const req = Object.assign(makeReq({ path: '/', actor: appActor }), {
tokenSource: 'cookie',
cookies: { puter_token_v2: 'legacy' },
});
const { res, captured } = makeRes();
await callRoute('get', '/', req, res);
expect(String(captured.body)).not.toContain('guiTokenDigest');
});
it('serves the shell on /dashboard and /action', async () => {
for (const path of ['/dashboard', '/dashboard/', '/action/*splat']) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(typeof captured.body).toBe('string');
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
}
});
it('redirects /desktop to the root, keeping the query', async () => {
for (const path of ['/desktop', '/desktop/']) {
const { res, captured } = makeRes();
await callRoute(
'get',
path,
makeReq({ path, originalUrl: `${path}?ref=abc&x=1` }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/?ref=abc&x=1');
expect(captured.body).toBeUndefined();
}
const { res, captured } = makeRes();
await callRoute('get', '/desktop', makeReq({ path: '/desktop' }), res);
expect(captured.location).toBe('/');
});
it('redirects /settings to the dashboard Home tab', async () => {
for (const path of ['/settings', '/settings/*splat']) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/dashboard#home');
expect(captured.body).toBeUndefined();
}
});
it('redirects /@:username to the dashboard', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/@:username',
makeReq({ path: '/@alice', params: { username: 'alice' } }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/');
expect(captured.body).toBeUndefined();
});
});
// ── /app/:name ──────────────────────────────────────────────────────
@@ -360,47 +417,21 @@ describe('HomepageController GET /app/:name', () => {
expect(html).not.toContain('owner_user_id');
});
it('serves the same app shell under /desktop/app/:name', async () => {
const { userId } = await makeUser();
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
await server.stores.app.create(
{
name,
title: 'Desktop App',
description: 'opens on the desktop instead of the dashboard',
index_url: `https://example.com/${name}/`,
approved_for_listing: 1,
},
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
expect(html).toMatch(/<!DOCTYPE html>/i);
expect(html).toContain('Desktop App');
});
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
it('redirects /desktop/app/:name to /app/:name, keeping the query', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'no-such-app' },
path: '/desktop/app/no-such-app',
params: { name: 'editor' },
path: '/desktop/app/editor',
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt&x=1',
}),
res,
);
expect(captured.statusCode).toBe(404);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt&x=1');
expect(captured.body).toBeUndefined();
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
@@ -434,40 +465,39 @@ describe('HomepageController GET /app/:name', () => {
// -- app landing rate limit --
describe('HomepageController app landing rate limit', () => {
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
for (const path of ['/app/:name', '/desktop/app/:name']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
);
if (!route) throw new Error(`No GET ${path} route`);
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
}
it('rate-limits /app/:name per network', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/app/:name',
);
if (!route) throw new Error('No GET /app/:name route');
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
});
});
// ── /show/* ─────────────────────────────────────────────────────────
describe('HomepageController GET /show/*splat', () => {
it('emits a launch_app explorer call with the post-/show path', async () => {
it('redirects to the path in the dashboard Files tab, keeping the query', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/show/*splat',
makeReq({ path: '/show/alice/Documents' }),
makeReq({
path: '/show/alice/My%20Documents',
originalUrl: '/show/alice/My%20Documents?ref=abc',
}),
res,
);
// The launch payload is JSON-serialized into the rendered HTML.
// We assert the rendered shell included the explorer launch hint
// pointing at the expected (slashed) path.
const html = String(captured.body);
expect(html).toContain('launch_app');
expect(html).toContain('explorer');
expect(html).toContain('/alice/Documents');
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe(
'/dashboard?ref=abc#files/alice/My%20Documents',
);
expect(captured.body).toBeUndefined();
});
});
@@ -20,6 +20,7 @@
import express from 'express';
import path from 'node:path';
import { PuterController } from '../types.js';
import { isPlainUserActor } from '../../core/actor.js';
import { toAppShellView } from '../../util/appShellView.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import {
@@ -27,11 +28,12 @@ import {
subdomainLabelOf,
} from '../../core/http/middleware/hostRedirects.js';
import type { IConfig } from '../../types';
import type { UserRow } from '../../stores/user/UserStore';
import type {
PuterHomepageService,
PageMeta,
LaunchOptions,
PopupFrame,
ShellContext,
} from '../../services/homepage/PuterHomepageService';
/**
@@ -170,7 +172,6 @@ export class HomepageController extends PuterController {
req: express.Request,
res: express.Response,
metaOverrides: Partial<PageMeta> = {},
launch: LaunchOptions = {},
popupFrame?: PopupFrame,
) => {
const meta = { ...defaultMeta(req), ...metaOverrides };
@@ -180,7 +181,16 @@ export class HomepageController extends PuterController {
actor?: Parameters<typeof homepage.send>[0]['actor'];
}
).actor ?? null;
await homepage.send({ req, res, actor }, meta, launch, popupFrame);
await homepage.send(
{
req,
res,
actor,
guiTokenDigest: this.#guiTokenDigest(req, actor),
},
meta,
popupFrame,
);
};
// -- Root + path-aliased shell routes ------------------------
@@ -252,7 +262,6 @@ export class HomepageController extends PuterController {
req,
res,
{},
{},
{
hostOrigin: embedHostOrigin(
this.config,
@@ -263,23 +272,42 @@ export class HomepageController extends PuterController {
});
}
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
// Old settings links land on the dashboard's Home tab.
router.get('/settings', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/settings/*splat', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
router.get('/desktop', {}, (req, res) => sendShell(req, res));
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
const queryOf = (req: express.Request) => {
const separator = req.originalUrl.indexOf('?');
return separator === -1 ? '' : req.originalUrl.slice(separator);
};
// Old desktop links land on the dashboard, query intact.
router.get('/desktop', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/desktop/', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
router.get('/@:username', {}, (req, res) => sendShell(req, res));
// Old links to a user's public folder land on the dashboard.
router.get('/@:username', {}, (_req, res) => res.redirect('/'));
// Old desktop app links open the app in the dashboard, query intact.
router.get('/desktop/app/:name', {}, (req, res) => {
const name = encodeURIComponent(String(req.params.name ?? ''));
res.redirect(`/app/${name}${queryOf(req)}`);
});
// -- /app/:name - app metadata baked into the shell ----------
// `/desktop/app/:name` is the same landing booted on the desktop
// instead of the dashboard; the GUI strips the prefix and treats the
// rest of the path identically, so both render the same shell.
const sendAppShell = async (
req: express.Request,
@@ -334,26 +362,11 @@ export class HomepageController extends PuterController {
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
router.get(
'/desktop/app/:name',
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
// -- /show/* - launch explorer with the requested file path --
// Old /show links open the path in the dashboard's Files tab, query intact.
router.get('/show/*splat', {}, (req, res) => {
const filePath = req.path.slice('/show'.length);
const launch: LaunchOptions = {
on_initialized: [
{
$: 'window-call',
fn_name: 'launch_app',
args: [{ name: 'explorer', path: filePath }],
},
],
};
return sendShell(req, res, {}, launch);
res.redirect(`/dashboard${queryOf(req)}#files${filePath}`);
});
// -- /sw.js - the service worker, scoped to the whole origin --
@@ -382,6 +395,38 @@ export class HomepageController extends PuterController {
);
}
}
/**
* Lets the GUI skip `/get-gui-token`, which reads the session cookie: the
* digest of the token it would hand over, null when there is no session
* cookie (either name the auth probe reads), and undefined (the GUI asks)
* when a cookie didn't resolve to a session.
*/
#guiTokenDigest(
req: express.Request,
actor: ShellContext['actor'],
): string | null | undefined {
if (req.tokenSource === 'cookie') {
if (!actor || !isPlainUserActor(actor) || !actor.session?.uid) {
return undefined;
}
return this.services.auth.guiTokenDigest(
actor.user as UserRow,
actor.session.uid,
);
}
const cookies = (req.cookies ?? {}) as Record<string, unknown>;
const hasSessionCookie = [
this.config.cookie_name,
'puter_token_v2',
].some(
(name) =>
!!name &&
typeof cookies[name] === 'string' &&
cookies[name] !== '',
);
return hasSessionCookie ? undefined : null;
}
}
const safeJsonParse = (s: string): Record<string, unknown> | null => {
@@ -372,26 +372,6 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
);
});
it('bakes a whitelisted desktop app landing (/desktop/app/<name>) into the state redirect_uri', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({
params: { provider: 'custom' },
query: { return_to: '/desktop/app/my-App_2' },
}),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(
`${TEST_ORIGIN}/desktop/app/my-App_2`,
);
});
it('ignores a non-whitelisted return_to', async () => {
const bad_values = [
'/app/evil/extra',
@@ -400,6 +380,8 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
'//evil.test',
'/settings',
`/app/${'a'.repeat(101)}`,
'/desktop',
'/desktop/app/my-App_2',
'/desktop/app/evil/extra',
'/desktop/app/',
'/dashboard/app/name',
@@ -476,11 +458,11 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
sharedPath('b.txt'),
sharedPath('a.txt'),
],
'/desktop',
'/dashboard',
),
);
const url = new URL(uri);
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/desktop`);
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/dashboard`);
expect(url.searchParams.getAll('shared')).toEqual([
sharedPath('a.txt'),
sharedPath('b.txt'),
@@ -102,16 +102,14 @@ function signupOptionsFromState(
}
// GUI pages an OIDC flow may return to: the root (where a share email lands),
// /desktop, /dashboard, and direct app landings (/app/<name> and its
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
// AppDriver). Strict whitelist — never a client-supplied URL (no open
// redirect).
// /dashboard, and direct app landings (/app/<name>, mirroring
// APP_NAME_REGEX in AppDriver). Strict whitelist — never a client-supplied URL
// (no open redirect).
function isWhitelistedReturnPath(path: string): boolean {
return (
path === '/' ||
path === '/desktop' ||
path === '/dashboard' ||
/^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
);
}
@@ -438,9 +436,8 @@ export class OIDCController extends PuterController {
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
// Optional GUI return path so login started from /desktop,
// /dashboard, an /app/<name> landing, or a share link lands
// back there.
// Optional GUI return path so login started from /dashboard,
// an /app/<name> landing, or a share link lands back there.
const rawReturnTo = Array.isArray(req.query.return_to)
? req.query.return_to[0]
: req.query.return_to;
@@ -314,8 +314,8 @@ describe('OIDC account second-factor login', () => {
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
await account(false);
const response = await callback('login', { return_to: '/desktop' });
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
const response = await callback('login', { return_to: '/dashboard' });
expect(response.headers.get('location')).toBe(`${env.origin}/dashboard`);
expect(
response.headers.get('set-cookie')?.includes('puter_auth_token='),
).toBe(true);
@@ -18,7 +18,7 @@
*/
import type { Request } from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor } from '../../actor';
import {
@@ -1132,14 +1132,150 @@ describe('resolvePublicHostedIdentity', () => {
const reqOf = (init: {
cookies?: Record<string, string>;
actor?: unknown;
headers?: Record<string, string>;
query?: Record<string, string>;
}): Request =>
({
cookies: init.cookies ?? {},
actor: init.actor,
headers: {},
query: {},
headers: init.headers ?? {},
query: init.query ?? {},
}) as unknown as Request;
describe('with the launch token in the Referer', () => {
const host = 'launch.site.puter.localhost';
const cookieName = HOSTED_ASSET_COOKIES.public.name;
const launch = async () => {
const user = await makeUser();
const sub = `launch-${uuidv4().slice(0, 8)}`;
const app = await server.stores.app.create(
{ name: sub, title: sub, index_url: `http://${host}/` },
{ ownerUserId: user.id },
);
const token = await authService.getUserAppToken(
{ user } as unknown as Parameters<
typeof authService.getUserAppToken
>[0],
app.uid,
);
const actor = (await authService.authenticateFromToken(token))!;
const cookie = await authService.createHostedAssetToken('public', {
appUid: app.uid,
userUid: user.uuid,
sessionUuid: actor.session?.uid,
subdomain: 'launch',
host,
});
return { user, appUid: app.uid, token, cookie, actor };
};
const resolve = (
appUid: string,
init: Parameters<typeof reqOf>[0],
expectedHost = host,
) =>
resolvePublicHostedIdentity({
req: reqOf(init),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: appUid,
expectedSubdomain: 'launch',
expectedHost,
});
const refererFor = (token: string) =>
`http://${host}/?puter.auth.token=${encodeURIComponent(token)}`;
it('takes the cookie when the token names its user and session', async () => {
const { user, appUid, token, cookie } = await launch();
const authenticate = vi.spyOn(authService, 'authenticate');
try {
const out = await resolve(appUid, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(token) },
});
expect(out).toMatchObject({
source: 'public-cookie',
userUid: user.uuid,
hasValidPublicCookie: true,
});
expect(authenticate).not.toHaveBeenCalled();
} finally {
authenticate.mockRestore();
}
});
it('authenticates a query token even when it names the cookie identity', async () => {
const { user, appUid, token, cookie } = await launch();
const out = await resolve(appUid, {
cookies: { [cookieName]: cookie },
query: { 'puter.auth.token': token },
});
expect(out.source).toBe('query');
expect(out.userUid).toBe(user.uuid);
expect(
(out as { hasValidPublicCookie?: boolean })
.hasValidPublicCookie,
).toBeUndefined();
});
it('authenticates the token when the cookie was minted for another host', async () => {
const { user, appUid, token, cookie } = await launch();
const out = await resolve(
appUid,
{
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(token) },
},
'other.site.puter.localhost',
);
expect(out.source).toBe('referrer');
expect(out.userUid).toBe(user.uuid);
});
it('authenticates a token that names another app under the same session', async () => {
const { user, appUid, cookie, actor } = await launch();
const otherAppToken = server.services.token.sign('auth', {
type: 'app-under-user',
version: '2',
user_uid: user.uuid,
app_uid: `app-${uuidv4()}`,
session_uid: actor.session!.uid,
});
const authenticate = vi.spyOn(authService, 'authenticate');
try {
const out = await resolve(appUid, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(otherAppToken) },
});
expect(authenticate).toHaveBeenCalledTimes(1);
expect(out.source).toBe('public-cookie');
} finally {
authenticate.mockRestore();
}
});
it('never resolves a scoped access token, with or without the cookie', async () => {
const { user, appUid, cookie } = await launch();
const scoped = await authService.createAccessToken(
makeActor({
user: { id: user.id, uuid: user.uuid, username: user.username },
}),
[[`user:${user.uuid}:email:read`]],
);
const withCookie = await resolve(appUid, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(scoped) },
});
expect(withCookie.source).toBe('public-cookie');
const withoutCookie = await resolve(appUid, {
headers: { referer: refererFor(scoped) },
});
expect(withoutCookie.source).toBe('none');
});
});
it('returns the cookie identity when present and valid', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
@@ -86,6 +86,10 @@ export interface PrivateIdentity {
hasValidPrivateCookie?: boolean;
}
type PublicCookieClaims = Awaited<
ReturnType<AuthService['verifyHostedAssetToken']>
>;
interface SubdomainLike {
user_id?: number | null;
}
@@ -533,8 +537,63 @@ export async function resolvePublicHostedIdentity(opts: {
}
}
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
HOSTED_ASSET_COOKIES.public;
const publicCookieToken =
(typeof cookies?.[publicCookieNameV2] === 'string'
? cookies[publicCookieNameV2]
: null) ??
(typeof cookies?.[publicCookieNameLegacy] === 'string'
? cookies[publicCookieNameLegacy]
: null);
let publicCookieClaims: Promise<PublicCookieClaims | null> | undefined;
const verifyPublicCookie = () =>
(publicCookieClaims ??= (async () => {
if (!publicCookieToken) return null;
try {
return await authService.verifyHostedAssetToken(
'public',
publicCookieToken,
{
appUid: expectedAppUid,
subdomain: expectedSubdomain,
host: expectedHost,
},
);
} catch {
// v1-signed cookies land here and get re-minted.
return null;
}
})());
const publicCookieIdentity = (claims: PublicCookieClaims) => ({
source: 'public-cookie' as const,
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
});
const bootstrap = getBootstrapToken(req);
if (bootstrap) {
// A launched app's own assets carry its launch token in the Referer.
// When that token names the cookie's user and session, the cookie
// already stands for it and nothing needs re-authenticating or minting.
if (
bootstrap.source === 'referrer' &&
publicCookieToken &&
expectedAppUid
) {
const named = authService.signedSessionClaims(bootstrap.token);
if (named && (!named.appUid || named.appUid === expectedAppUid)) {
const claims = await verifyPublicCookie();
if (
claims &&
claims.userUid === named.userUid &&
claims.sessionUuid === named.sessionUuid
) {
return publicCookieIdentity(claims);
}
}
}
try {
const actor = await authService.authenticateFromToken(
bootstrap.token,
@@ -554,36 +613,8 @@ export async function resolvePublicHostedIdentity(opts: {
}
}
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
HOSTED_ASSET_COOKIES.public;
const publicCookieToken =
(typeof cookies?.[publicCookieNameV2] === 'string'
? cookies[publicCookieNameV2]
: null) ??
(typeof cookies?.[publicCookieNameLegacy] === 'string'
? cookies[publicCookieNameLegacy]
: null);
if (publicCookieToken) {
try {
const claims = await authService.verifyHostedAssetToken(
'public',
publicCookieToken,
{
appUid: expectedAppUid,
subdomain: expectedSubdomain,
host: expectedHost,
},
);
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
};
} catch {
/* fall through — v1-signed cookies land here and get re-minted */
}
}
const claims = await verifyPublicCookie();
if (claims) return publicCookieIdentity(claims);
return { source: 'none' };
}
@@ -2763,6 +2763,321 @@ describe('public hosted asset billing identity', () => {
});
});
describe('public hosted assets requested with the launch token in the Referer', () => {
const cookieName = HOSTED_ASSET_COOKIES.public.name;
const makeLaunchedApp = async () => {
const owner = await makeUserWithHome();
const home = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}`,
))!;
const sub = `ref-${uuidv4().slice(0, 8)}`;
const host = `${sub}.site.puter.localhost`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: home.id,
});
await writeFile(
owner.id,
`/${owner.username}/index.html`,
Buffer.from('<html></html>'),
);
await writeFile(
owner.id,
`/${owner.username}/app.css`,
Buffer.from('body{}'),
);
const app = await server.stores.app.create(
{ name: sub, title: sub, index_url: `http://${host}/` },
{ ownerUserId: owner.id },
);
return { owner, sub, host, appUid: app.uid };
};
const cookieFor = async (
token: string,
site: { sub: string; host: string; appUid: string },
) => {
const actor = (await server.services.auth.authenticateFromToken(
token,
))!;
return server.services.auth.createHostedAssetToken('public', {
appUid: site.appUid,
userUid: actor.user.uuid,
sessionUuid: actor.session?.uid,
subdomain: site.sub,
host: site.host,
});
};
const refererFor = (host: string, token: string) =>
`http://${host}/?puter.app_instance_id=x&puter.auth.token=${encodeURIComponent(token)}`;
const serve = async (
host: string,
init: {
path?: string;
cookies?: Record<string, string>;
headers?: Record<string, string>;
query?: Record<string, string>;
} = {},
) => {
const req = makeReq({ hostname: host, path: '/app.css', ...init });
const { res, out } = makeRes();
await buildMiddleware()(req, res, vi.fn());
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
return { req, out };
};
const claimsOf = (cookie: string, appUid: string, host: string) =>
server.services.auth.verifyHostedAssetToken('public', cookie, {
appUid,
host,
});
const REDIS_METHODS = [
'get',
'mget',
'exists',
'hget',
'hgetall',
'smembers',
'set',
'del',
'expire',
'incr',
'incrby',
'hset',
'sadd',
'pipeline',
'multi',
];
/** Redis calls, JWT signs and full authentications made by `fn`. */
const measure = async <T>(fn: () => Promise<T>) => {
const redis = server.clients.redis as unknown as Record<
string,
unknown
>;
const redisSpies = REDIS_METHODS.filter(
(m) => typeof redis[m] === 'function',
).map((m) => [m, vi.spyOn(redis as never, m as never)] as const);
const sign = vi.spyOn(server.services.token, 'sign');
const authenticate = vi.spyOn(server.services.auth, 'authenticate');
try {
const result = await fn();
const redisCalls: Record<string, number> = {};
for (const [m, spy] of redisSpies) {
const n = (spy as unknown as { mock: { calls: unknown[] } })
.mock.calls.length;
if (n) redisCalls[m] = n;
}
return {
result,
redisCalls,
signs: sign.mock.calls.length,
authentications: authenticate.mock.calls.length,
};
} finally {
for (const [, spy] of redisSpies) spy.mockRestore();
sign.mockRestore();
authenticate.mockRestore();
}
};
it('costs the same as the cookie alone when the token names the cookie user and session', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(token, site) };
const headers = { referer: refererFor(site.host, token) };
// Warm the caches both paths read.
await serve(site.host, { cookies });
await serve(site.host, { cookies, headers });
const cookieOnly = await measure(() => serve(site.host, { cookies }));
const withReferer = await measure(() =>
serve(site.host, { cookies, headers }),
);
expect(withReferer.result.req.egressActor?.user?.uuid).toBe(
visitor.uuid,
);
expect(withReferer.result.out.cookies).toEqual({});
expect(withReferer.signs).toBe(0);
expect(withReferer.authentications).toBe(0);
expect(withReferer.redisCalls).toEqual(cookieOnly.redisCalls);
expect(cookieOnly.signs).toBe(0);
});
it('bills the token account when the launch could not replace an older cookie', async () => {
const site = await makeLaunchedApp();
const previous = await makeUser();
const current = await makeUser();
const previousToken = await server.services.auth.getUserAppToken(
actorFor(previous),
site.appUid,
);
const currentToken = await server.services.auth.getUserAppToken(
actorFor(current),
site.appUid,
);
const oldCookies = {
[cookieName]: await cookieFor(previousToken, site),
};
const mint = vi
.spyOn(server.services.auth, 'createHostedAssetToken')
.mockRejectedValueOnce(new Error('mint failed'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let launch;
try {
launch = await serve(site.host, {
path: '/index.html',
cookies: oldCookies,
query: { 'puter.auth.token': currentToken },
});
} finally {
mint.mockRestore();
warn.mockRestore();
}
expect(launch.out.cookies).toEqual({});
expect(launch.req.egressActor?.user?.uuid).toBe(current.uuid);
const asset = await serve(site.host, {
cookies: oldCookies,
headers: { referer: refererFor(site.host, currentToken) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(current.uuid);
const replaced = asset.out.cookies[cookieName];
expect(replaced).toBeTruthy();
expect((await claimsOf(replaced, site.appUid, site.host)).userUid).toBe(
current.uuid,
);
});
it('bills the token account when a cached page of another account loads assets', async () => {
// The document never reaches the server (served from an app's own
// cache), so only the assets show which account launched it.
const site = await makeLaunchedApp();
const previous = await makeUser();
const current = await makeUser();
const previousToken = await server.services.auth.getUserAppToken(
actorFor(previous),
site.appUid,
);
const currentToken = await server.services.auth.getUserAppToken(
actorFor(current),
site.appUid,
);
const oldCookies = {
[cookieName]: await cookieFor(previousToken, site),
};
for (const path of ['/app.css', '/index.html']) {
const asset = await serve(site.host, {
path,
cookies: oldCookies,
headers: { referer: refererFor(site.host, currentToken) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(current.uuid);
const replaced = asset.out.cookies[cookieName];
expect(
(await claimsOf(replaced, site.appUid, site.host)).userUid,
).toBe(current.uuid);
}
});
it('re-mints the cookie when the token names the same user under another session', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const appToken = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(appToken, site) };
const { session, token: sessionToken } =
await server.services.auth.createSessionToken(visitor as never);
const asset = await serve(site.host, {
cookies,
headers: { referer: refererFor(site.host, sessionToken) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(visitor.uuid);
const replaced = asset.out.cookies[cookieName];
expect(replaced).toBeTruthy();
expect(
(await claimsOf(replaced, site.appUid, site.host)).sessionUuid,
).toBe(session.uuid);
});
it('falls back to the cookie when the Referer token does not verify', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(token, site) };
const tampered = `${token.slice(0, -4)}AAAA`;
const asset = await serve(site.host, {
cookies,
headers: { referer: refererFor(site.host, tampered) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(visitor.uuid);
expect(asset.out.cookies).toEqual({});
});
it('bills the site owner once the shared session is revoked', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(token, site) };
await server.services.auth.revokeAllSessionsForUserId(visitor.id);
const asset = await serve(site.host, {
cookies,
headers: { referer: refererFor(site.host, token) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(site.owner.uuid);
expect(asset.out.cookies).toEqual({});
});
it('keeps responses that set the cookie out of shared caches', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const launch = await serve(site.host, {
path: '/index.html',
query: { 'puter.auth.token': token },
});
const cookie = launch.out.cookies[cookieName];
expect(cookie).toBeTruthy();
expect(launch.out.headers['Cache-Control']).toBe('private');
const asset = await serve(site.host, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(site.host, token) },
});
expect(asset.out.cookies).toEqual({});
expect(asset.out.headers['Cache-Control']).toBeUndefined();
});
});
describe('site rooted in the owner tree, root moved to Trash', () => {
it('stops serving a trashed folder and resumes when restored', async () => {
const owner = await makeUserWithHome();
@@ -548,6 +548,8 @@ export const createPuterSiteMiddleware = (
requestHostname: host,
}),
);
// A shared cache must not hand this visitor's cookie to others.
res.set('Cache-Control', 'private');
}
} catch (e) {
// Best-effort — don't block the public file serve.
+120 -1
View File
@@ -186,6 +186,96 @@ describe('AuthService (integration)', () => {
});
});
describe('signedSessionClaims', () => {
it('names the user and session of a session token', async () => {
const user = await makeUser();
const { token, session } = await authService.createSessionToken(
user,
{},
);
expect(authService.signedSessionClaims(token)).toEqual({
userUid: user.uuid,
sessionUuid: (session as { uuid: string }).uuid,
});
});
it('names the app too for an app token', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const sessionUuid = uuidv4();
const token = server.services.token.sign('auth', {
type: 'app-under-user',
version: '2',
user_uid: user.uuid,
app_uid: appUid,
session_uid: sessionUuid,
});
expect(authService.signedSessionClaims(token)).toEqual({
userUid: user.uuid,
sessionUuid,
appUid,
});
});
it('reads the session from `uuid` on a token without `session_uid`', () => {
const [userUid, sessionUuid] = [uuidv4(), uuidv4()];
const token = server.services.token.sign('auth', {
type: 'gui',
version: '0.0.0',
uuid: sessionUuid,
user_uid: userUid,
});
expect(authService.signedSessionClaims(token)).toEqual({
userUid,
sessionUuid,
});
});
it('returns null for worker, access, expired, tampered and session-less tokens', async () => {
const user = await makeUser();
const accessToken = await authService.createAccessToken(
{
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
},
} as Actor,
[[`user:${user.uuid}:email:read`]],
);
const sign = (claims: Record<string, unknown>, expiresIn?: number) =>
server.services.token.sign(
'auth',
claims,
expiresIn === undefined ? undefined : { expiresIn },
);
const sessionUuid = uuidv4();
const session = {
type: 'session',
version: '2',
uuid: sessionUuid,
session_uid: sessionUuid,
user_uid: user.uuid,
};
const valid = sign(session);
for (const token of [
accessToken,
sign({ ...session, worker: true, worker_name: 'w' }),
sign({
type: 'app-under-user',
version: '2',
user_uid: user.uuid,
app_uid: `app-${uuidv4()}`,
}),
sign(session, -120),
`${valid.slice(0, -4)}AAAA`,
'not-a-jwt',
]) {
expect(authService.signedSessionClaims(token)).toBeNull();
}
});
});
// ── Rich `authenticate()` result shape ──────────────────────────
describe('authenticate (reauth signal)', () => {
@@ -3099,7 +3189,7 @@ describe('AuthService (integration)', () => {
const emit = vi.spyOn(server.clients.event, 'emit');
const announced = () =>
emit.mock.calls.filter(
(c) => c[0] === 'auth.access-token.revoked',
(c) => c[0] === 'outer.auth.access-token.revoked',
).length;
const scoped = await authService.createAccessToken(actor, [
@@ -3117,6 +3207,35 @@ describe('AuthService (integration)', () => {
emit.mockRestore();
});
it('announces a desktop app token it replaces past its max age', async () => {
const user = await makeUser();
const { session } = await authService.createSessionToken(user);
const actor = makeActor({
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: String(session.uuid), kind: 'web' },
});
const app = { uid: `app-${uuidv4()}`, title: 'Retire probe' };
const { token } = await authService.getGodmodeAppToken(actor, app);
const { token_uid } = server.services.token.verify<{
token_uid: string;
}>('auth', token);
const emit = vi.spyOn(server.clients.event, 'emit');
const later = Date.now() + 8 * 24 * 60 * 60 * 1000;
vi.spyOn(Date, 'now').mockReturnValue(later);
try {
await authService.getGodmodeAppToken(actor, app);
expect(emit).toHaveBeenCalledWith(
'outer.auth.access-token.revoked',
{ token_uid },
{},
);
} finally {
vi.mocked(Date.now).mockRestore();
emit.mockRestore();
}
});
it('revokeAccessToken rejects with 404 when the token belongs to another user', async () => {
const u1 = await makeUser();
const u2 = await makeUser();
+79 -10
View File
@@ -106,6 +106,15 @@ const HOSTED_ASSET_LABELS = {
export type ReauthReason = 'session_revoked' | 'session_expired';
/** Worker claims a session/gui token can carry. */
interface SessionTypeTokenOptions {
worker?: boolean;
workerName?: string;
workerUid?: string;
workerSiteUid?: string;
workerSite?: string;
}
/**
* Request metadata for `authenticate`. `touch: false` checks the token without
* recording activity on its session, for re-checks no user made.
@@ -367,6 +376,22 @@ export class AuthService extends PuterService {
);
}
/**
* Digest of the claims `createGuiToken` signs for this session, which
* differ between two of its tokens only in `iat`.
*/
guiTokenDigest(user: UserRow, sessionUuid: string): string {
return this.services.token.claimsDigest(
'auth',
this.#sessionTypeClaims(
'gui',
user,
sessionUuid,
this.#authIdFor(user),
),
);
}
/** Sign a session token for an existing session (upgrade from GUI token). */
createSessionTokenForSession(user: UserRow, sessionUuid: string): string {
return this.#signSessionTypeToken(
@@ -386,14 +411,21 @@ export class AuthService extends PuterService {
user: UserRow,
sessionUuid: string,
authId: string,
opts: {
worker?: boolean;
workerName?: string;
workerUid?: string;
workerSiteUid?: string;
workerSite?: string;
} = {},
opts: SessionTypeTokenOptions = {},
): string {
return this.services.token.sign(
'auth',
this.#sessionTypeClaims(type, user, sessionUuid, authId, opts),
);
}
#sessionTypeClaims(
type: 'session' | 'gui',
user: UserRow,
sessionUuid: string,
authId: string,
opts: SessionTypeTokenOptions = {},
): Record<string, unknown> {
const claims: Record<string, unknown> = {
type,
version: '2',
@@ -410,7 +442,7 @@ export class AuthService extends PuterService {
if (opts.workerUid) claims.worker_uid = opts.workerUid;
if (opts.workerSiteUid) claims.workerSiteUid = opts.workerSiteUid;
if (opts.workerSite) claims.workerSite = opts.workerSite;
return this.services.token.sign('auth', claims);
return claims;
}
/**
@@ -1889,7 +1921,7 @@ export class AuthService extends PuterService {
}
if (row.access_token_uid) {
this.clients.event?.emit(
'auth.access-token.revoked',
'outer.auth.access-token.revoked',
{ token_uid: row.access_token_uid },
{},
);
@@ -2051,6 +2083,43 @@ export class AuthService extends PuterService {
};
}
/**
* User, session and app a session, GUI or app token names, checked against
* its signature and expiry only: nothing is read from the stores, so this
* says nothing about whether the session or user is still live. Null for
* worker and access tokens, and for anything that doesn't verify.
*/
signedSessionClaims(
token: string,
): { userUid: string; sessionUuid: string; appUid?: string } | null {
let decoded: AnyTokenPayload;
try {
decoded = this.services.token.verify<AnyTokenPayload>(
'auth',
token,
);
} catch {
return null;
}
if (decoded.type === 'session' || decoded.type === 'gui') {
if (decoded.worker) return null;
const sessionUuid = decoded.session_uid ?? decoded.uuid;
if (!decoded.user_uid || !sessionUuid) return null;
return { userUid: decoded.user_uid, sessionUuid };
}
if (decoded.type === 'app-under-user') {
if (decoded.worker) return null;
if (!decoded.user_uid || !decoded.session_uid || !decoded.app_uid)
return null;
return {
userUid: decoded.user_uid,
sessionUuid: decoded.session_uid,
appUid: decoded.app_uid,
};
}
return null;
}
#verifyHostedAssetToken(
token: string,
expectedKind: 'private' | 'public',
@@ -2511,7 +2580,7 @@ export class AuthService extends PuterService {
// Only a token the handshake admits has a socket to drop.
if (mayBeFullAccess) {
this.clients.event?.emit(
'auth.access-token.revoked',
'outer.auth.access-token.revoked',
{ token_uid: tokenUid },
{},
);
@@ -20,6 +20,7 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { describe, expect, it } from 'vitest';
import { tokenClaimsDigest } from '../../../gui/src/helpers/guiToken.js';
import { isTokenForAnotherUser } from '../../../gui/src/helpers/tokenUser.js';
import { TokenService } from './TokenService.js';
@@ -403,3 +404,35 @@ describe('session token read by the desktop', () => {
expect(isTokenForAnotherUser(token, { uuid: otherUid })).toBe(true);
});
});
describe('TokenService.claimsDigest', () => {
const claims = {
type: 'gui',
version: '2',
uuid: '6f1e2d3c-0000-4000-8000-000000000001',
session_uid: '6f1e2d3c-0000-4000-8000-000000000001',
user_uid: '5d4adce0-a381-4982-9c02-6e2540026238',
auth_id: '5d4adce0-a381-4982-9c02-6e2540026238',
};
it('is what the desktop computes over a token signed with those claims', async () => {
const svc = createTokenService();
const digest = svc.claimsDigest('auth', claims);
expect(digest).toMatch(/^[0-9a-f]{32}$/);
expect(await tokenClaimsDigest(svc.sign('auth', claims))).toBe(digest);
});
it('differs for another session or token type', () => {
const svc = createTokenService();
const digest = svc.claimsDigest('auth', claims);
expect(
svc.claimsDigest('auth', {
...claims,
session_uid: 'b0b0b0b0-0000-4000-8000-000000000002',
}),
).not.toBe(digest);
expect(svc.claimsDigest('auth', { ...claims, type: 'session' })).not.toBe(
digest,
);
});
});
+19
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHash } from 'node:crypto';
import jwt, { type SignOptions } from 'jsonwebtoken';
import { PuterService } from '../types';
@@ -268,6 +269,24 @@ export class TokenService extends PuterService {
});
}
/**
* Digest of the claims `sign(scope, payload)` writes, minus `iat`: SHA-256
* over the claims as key-sorted `[key, value]` pairs in JSON, first 32 hex
* chars. The GUI computes the same over a stored token
* (`gui/src/helpers/guiToken.js`), so the two must stay in step.
*/
claimsDigest(scope: string, payload: Record<string, unknown>): string {
const claims = this.#compressPayload(COMPRESSION[scope], payload);
const pairs = Object.keys(claims)
.filter((key) => key !== 'iat')
.sort()
.map((key) => [key, claims[key]]);
return createHash('sha256')
.update(JSON.stringify(pairs))
.digest('hex')
.slice(0, 32);
}
/**
* Verify and decompress. Only v2 tokens (`kid: 'v2'`) verify; any other
* token — including the retired v1 format — is rejected as invalid, with no
@@ -137,7 +137,6 @@ const render = async (
service: PuterHomepageService,
req: Request = makeReq(),
meta: Record<string, unknown> = { title: 'Puter' },
launchOptions: Record<string, unknown> = {},
actor: unknown = null,
): Promise<string> => {
let sent = '';
@@ -149,7 +148,6 @@ const render = async (
await service.send(
{ req, res, actor } as never,
meta as never,
launchOptions as never,
);
return sent;
};
@@ -292,7 +290,6 @@ describe('PuterHomepageService — gui() parameters', () => {
asset_dir: '/dist',
title: 'Editor',
app: { name: 'editor' },
launch_options: {},
});
// No `config.origin`: fall back to the request's protocol + domain.
expect(params.app_origin).toBe('http://puter.test');
@@ -338,7 +335,7 @@ describe('PuterHomepageService — gui() parameters', () => {
const staff = { user: { id: 7, email: '[email protected]' } };
expect(
guiParamsOf(await render(service, makeReq(), undefined, {}, staff))
guiParamsOf(await render(service, makeReq(), undefined, staff))
.teams_ui,
).toBe(true);
expect(teamsAvailableTo).toHaveBeenCalledWith(7, '[email protected]');
@@ -349,7 +346,7 @@ describe('PuterHomepageService — gui() parameters', () => {
teamsAvailableTo.mockResolvedValueOnce(false);
expect(
guiParamsOf(
await render(service, makeReq(), undefined, {}, {
await render(service, makeReq(), undefined, {
user: { id: 9, email: '[email protected]' },
}),
).teams_ui,
@@ -420,22 +417,6 @@ describe('PuterHomepageService — gui() parameters', () => {
expect(html).toContain('\\u003c/script>');
expect(guiParamsOf(html).injected).toBe('</script><img src=x>');
});
it('passes launch options straight through', async () => {
const params = guiParamsOf(
await render(
makeService(),
makeReq(),
{ title: 'Puter' },
{
on_initialized: [{ do: 'thing' }],
},
),
);
expect(params.launch_options).toEqual({
on_initialized: [{ do: 'thing' }],
});
});
});
describe('PuterHomepageService — extension hooks', () => {
@@ -44,10 +44,6 @@ export interface PageMeta {
app?: { name?: string; [k: string]: unknown } | null;
}
export interface LaunchOptions {
on_initialized?: Array<Record<string, unknown>>;
}
/**
* A GUI popup a first-party page frames at `/embed/<action>`. `hostOrigin` is
* that page, read from the referrer and pinned with frame-ancestors; null when
@@ -57,6 +53,17 @@ export interface PopupFrame {
hostOrigin: string | null;
}
export interface ShellContext {
req: Request;
res: Response;
actor?: Actor | null;
/**
* `AuthService.guiTokenDigest` for the request's session cookie, or null
* when it carries none. Omitted when the caller doesn't work it out.
*/
guiTokenDigest?: string | null;
}
interface PuterGuiAddonsEvent {
req: Request;
path: string;
@@ -309,12 +316,11 @@ export class PuterHomepageService extends PuterService {
* called on the happy path.
*/
async send(
ctx: { req: Request; res: Response; actor?: Actor | null },
ctx: ShellContext,
meta: PageMeta,
launchOptions: LaunchOptions = {},
popupFrame?: PopupFrame,
): Promise<void> {
const { req, res, actor } = ctx;
const { req, res, actor, guiTokenDigest } = ctx;
// Easter egg: puter-in-puter detection → render error page instead.
if (
@@ -334,9 +340,8 @@ export class PuterHomepageService extends PuterService {
}
const html = await this.#renderShell(
{ req, actor: actor ?? null },
{ req, actor: actor ?? null, guiTokenDigest },
meta,
launchOptions,
popupFrame,
);
res.send(html);
@@ -345,12 +350,13 @@ export class PuterHomepageService extends PuterService {
// -- Internals ----------------------------------------------------
async #renderShell(
ctx: { req: Request; actor: Actor | null },
ctx: Pick<ShellContext, 'req' | 'guiTokenDigest'> & {
actor: Actor | null;
},
meta: PageMeta,
launchOptions: LaunchOptions,
popupFrame?: PopupFrame,
): Promise<string> {
const { req, actor } = ctx;
const { req, actor, guiTokenDigest } = ctx;
const env = this.config.env ?? 'prod';
const bundled = env !== 'dev' || this.config.use_bundled_gui === true;
const assetDir = env === 'dev' ? '/src' : '/dist';
@@ -393,10 +399,13 @@ export class PuterHomepageService extends PuterService {
// instead of the socket wire.
eventsNotifications: notificationsFoldInEnabled(this.config),
...meta,
launch_options: launchOptions,
// After `meta`, so nothing page-specific can name the opener.
...(popupFrame ? { embed_host_origin: popupFrame.hostOrigin } : {}),
};
// Only ever this request's; without one the GUI asks for its token as
// it always has.
if (guiTokenDigest === undefined) delete guiParams.guiTokenDigest;
else guiParams.guiTokenDigest = guiTokenDigest;
const event: PuterGuiAddonsEvent = {
req,
@@ -4579,6 +4579,209 @@ describe('MeteringService', () => {
});
});
describe('settling when a balance read fails', () => {
const monthKey = (a: Actor) =>
`${METRICS_V2_PREFIX}:actor:${a.user!.uuid}:${currentMonthString()}`;
/** Rejects the next `times` reads `match` picks; the rest go through. */
const failReads = (
match: (args: {
key: unknown;
consistentRead?: boolean;
}) => boolean,
times = Infinity,
) => {
const kv = server.stores.kv;
const realGet = kv.get.bind(kv);
let left = times;
return vi
.spyOn(kv, 'get')
.mockImplementation(
async (...args: Parameters<typeof realGet>) => {
if (left > 0 && match(args[0] as never)) {
left--;
throw new Error(
'ProvisionedThroughputExceededException',
);
}
return realGet(...args);
},
);
};
const isAddonsRead = ({ key }: { key: unknown }) =>
typeof key === 'string' && key.endsWith(':addons');
const pools = async (a: Actor) => {
await server.stores.meteringBuffer.flushCycle();
const { usage } = await target.getActorCurrentMonthUsageDetails(a);
const addons = await target.getActorAddons(a, {
consistentRead: true,
});
return {
total: usage.total,
allowanceUsed: usage.allowanceUsed ?? 0,
consumed: addons.consumedPurchaseCredits ?? 0,
};
};
it('charges the allowance when the purchased-credit read fails', async () => {
await target.updateAddonCredit(actor.user.uuid!, 1_000_000);
await target.incrementUsage(actor, 'ai:chat', 1, 1000);
const before = await target.getAllowedUsage(actor);
const get = failReads(isAddonsRead);
const result = await target.incrementUsage(
actor,
'ai:chat',
1,
5000,
);
get.mockRestore();
expect(result.total).toBe(6000);
expect(await pools(actor)).toEqual({
total: 6000,
allowanceUsed: 6000,
consumed: 0,
});
const after = await target.getAllowedUsage(actor);
expect(before.remaining - after.remaining).toBe(5000);
});
it('charges only the allowance share past the allowance, and nothing later charges the rest', async () => {
const { monthUsageAllowance: allowance } =
await target.getActorSubscription(actor);
await target.updateAddonCredit(actor.user.uuid!, 1_000_000);
await target.incrementUsage(actor, 'ai:chat', 1, allowance - 100);
expect(await target.hasAnyUsageCached(actor)).toBe(true);
const alarm = vi.spyOn(server.clients.alarm, 'create');
const emit = vi.spyOn(server.clients.event, 'emit');
const get = failReads(isAddonsRead);
await target.incrementUsage(actor, 'ai:chat', 1, 1000);
get.mockRestore();
// 100 fits the allowance; the 900 credit would cover isn't drawn.
expect(await pools(actor)).toEqual({
total: allowance + 900,
allowanceUsed: allowance,
consumed: 0,
});
expect(alarm).toHaveBeenCalledWith(
'metering_settle_read_failed',
expect.any(String),
expect.objectContaining({
userId: actor.user.uuid,
unchargedCost: 900,
}),
'info',
);
expect(
alarm.mock.calls.some(([id]) =>
String(id).startsWith('metering service error'),
),
).toBe(false);
// The unread credit isn't taken as spent.
expect(
emit.mock.calls.some(
([name]) => name === 'metering.credit-state',
),
).toBe(false);
expect(await target.hasAnyUsageCached(actor)).toBe(true);
alarm.mockRestore();
emit.mockRestore();
// The next settle charges its own cost once, and only that.
await target.incrementUsage(actor, 'ai:chat', 1, 700);
expect(await pools(actor)).toEqual({
total: allowance + 1600,
allowanceUsed: allowance,
consumed: 700,
});
});
it('charges the allowance from the buffered total when the exact read fails', async () => {
const Service = target.constructor as typeof MeteringService;
const interval = Service.EXACT_READ_MIN_INTERVAL_MS;
Service.EXACT_READ_MIN_INTERVAL_MS = 0;
try {
const { monthUsageAllowance: allowance } =
await target.getActorSubscription(actor);
await target.incrementUsage(
actor,
'ai:chat',
1,
allowance - 3000,
);
await server.stores.meteringBuffer.flushCycle();
const key = monthKey(actor);
const alarm = vi.spyOn(server.clients.alarm, 'create');
const get = failReads(
(args) => args.key === key && !!args.consistentRead,
);
const result = await target.incrementUsage(
actor,
'ai:chat',
1,
1000,
);
const failedReads = get.mock.calls.length;
get.mockRestore();
expect(failedReads).toBeGreaterThan(0);
expect(result.total).toBe(allowance - 2000);
expect(await pools(actor)).toEqual({
total: allowance - 2000,
allowanceUsed: allowance - 2000,
consumed: 0,
});
expect(alarm).toHaveBeenCalledWith(
'metering_settle_read_failed',
expect.any(String),
expect.objectContaining({ unchargedCost: 0 }),
'info',
);
alarm.mockRestore();
} finally {
Service.EXACT_READ_MIN_INTERVAL_MS = interval;
}
});
it('charges each of two racing calls once when one of their reads fails', async () => {
await target.updateAddonCredit(actor.user.uuid!, 1_000_000);
await target.incrementUsage(actor, 'ai:chat', 1, 1000);
const get = failReads(isAddonsRead, 1);
await Promise.all([
target.incrementUsage(actor, 'ai:chat', 1, 2000),
target.incrementUsage(actor, 'ai:chat', 1, 3000),
]);
get.mockRestore();
expect(await pools(actor)).toEqual({
total: 6000,
allowanceUsed: 6000,
consumed: 0,
});
});
it('charges a failed call and the calls after it exactly once each', async () => {
await target.updateAddonCredit(actor.user.uuid!, 1_000_000);
const costs = [1000, 2000, 3000, 4000];
for (const [i, cost] of costs.entries()) {
const get = failReads(isAddonsRead, i % 2 === 0 ? 1 : 0);
await target.incrementUsage(actor, 'ai:chat', 1, cost);
get.mockRestore();
}
expect(await pools(actor)).toEqual({
total: 10_000,
allowanceUsed: 10_000,
consumed: 0,
});
});
});
// ── Resolver registration ────────────────────────────────────────
describe('resolver registration', () => {
@@ -342,6 +342,9 @@ export class MeteringService extends PuterService {
/** Bumped on every drop, so a read that straddles one isn't cached. */
#spentAddonsDrops = 0;
/** Running sum reported by `#alarmSettleReadFailed`. */
#unchargedSinceStart = 0;
/**
* Uuid → until when a near-allowance read bypasses the exact-read throttle.
* Set on a credits change (a purchase, an admin correction): for a short
@@ -675,49 +678,84 @@ export class MeteringService extends PuterService {
appCallCount: usages.length,
});
// The usage is already being recorded, so a failed balance read
// must not skip settling it.
let settleReadError: Error | null = null;
const [usageResult, subscription, actorAddons] = await Promise.all([
usageResultPromise,
this.#actorSubscriptionWithStatus(actor),
this.#addonsForSettle(actor),
(async () => {
try {
return await this.#addonsForSettle(actor);
} catch (e) {
settleReadError = e as Error;
return null;
}
})(),
]);
const { policy: actorSubscription, provisionalUntil } =
subscription;
const actorUsages = await this.exactUsageNearAllowance(
userId,
actorUsageKey,
usageResult,
actorSubscription.monthUsageAllowance,
);
let actorUsages: UsageByType;
try {
actorUsages = await this.exactUsageNearAllowance(
userId,
actorUsageKey,
usageResult,
actorSubscription.monthUsageAllowance,
);
} catch (e) {
settleReadError ??= e as Error;
actorUsages = usageResult.res as unknown as UsageByType;
}
// After a failed read only the allowance share is charged:
// purchased credit is never drawn against an unread balance.
const readAddons = settleReadError ? null : actorAddons;
const settledAllowanceUsed = await this.settleIncrementCharges(
userId,
actorUsageKey,
actorUsages,
actorSubscription,
actorAddons,
readAddons ?? ({} as UsageAddons),
totalBatchCost,
);
// Before any hold this usage stands for is released.
await this.stores.creditHold.bump(userId);
this.maybeAlertOveruse({
actor,
userId,
actorUsages,
actorSubscription,
actorAddons,
incrementCost: totalBatchCost,
batchUsages: usages,
});
if (!readAddons) {
const usedBefore = MeteringService.allowanceUsedBefore(
actorUsages,
actorSubscription.monthUsageAllowance,
totalBatchCost,
);
this.#alarmSettleReadFailed(
actor,
settleReadError,
Math.max(
0,
totalBatchCost - (settledAllowanceUsed - usedBefore),
),
);
} else {
this.maybeAlertOveruse({
actor,
userId,
actorUsages,
actorSubscription,
actorAddons,
incrementCost: totalBatchCost,
batchUsages: usages,
});
this.rememberRemainingCredits(
userId,
settledAllowanceUsed,
actorSubscription,
actorAddons,
provisionalUntil,
);
this.rememberRemainingCredits(
userId,
settledAllowanceUsed,
actorSubscription,
actorAddons,
provisionalUntil,
);
}
const own = this.#withDetail(actorUsages, usageResult.detail);
const charged = await this.applyMonthlyCharges(
@@ -2957,6 +2995,31 @@ export class MeteringService extends PuterService {
return usedBefore + allowanceCharge;
}
/**
* One alarm id for every actor, so a store brownout is a single alarm with
* a count. `unchargedCost` is what credit would have covered, had any been
* left; nothing charges it later.
*/
#alarmSettleReadFailed(
actor: Actor,
error: Error | null,
unchargedCost: number,
): void {
this.#unchargedSinceStart += unchargedCost;
this.clients.alarm.create(
'metering_settle_read_failed',
'Metering settled usage to the allowance only: a balance read failed',
{
userId: actor.user?.uuid,
appId: actor.effectiveApp?.uid,
unchargedCost,
unchargedSinceStart: this.#unchargedSinceStart,
error: error ?? undefined,
},
'info',
);
}
private maybeAlertOveruse(ctx: {
actor: Actor;
userId: string;
@@ -2354,6 +2354,35 @@ describe('OpenAIRealtimeRelayService relay', () => {
b.ws.close();
});
it('evicts an access token revoked on another node', async () => {
const row = await server.stores.user.getByUsername(user.username);
const actor = makeActor({ user: row! });
const token = await server.services.auth.createAccessToken(actor, [
[FULL_API_ACCESS],
]);
const { token_uid } = server.services.token.verify<{
token_uid: string;
}>('auth', token);
const c = open(`${relayUrl}?auth_token=${token}`);
await c.waitFor('session.created');
// As a peer cluster delivers it, after a malformed payload it ignores.
server.clients.event.emit(
'outer.auth.access-token.revoked',
{ token_uid: 42 } as never,
{ from_outside: true },
);
server.clients.event.emit(
'outer.auth.access-token.revoked',
{ token_uid },
{ from_outside: true },
);
expect(await c.waitFor('error')).toMatchObject({
error: { code: 'invalid_api_key' },
});
await c.closed;
});
it('closes with a retryable error after a sustained verification outage', async () => {
const c = open(`${relayUrl}?auth_token=${user.token}`);
await c.waitFor('session.created');
@@ -838,13 +838,19 @@ export class OpenAIRealtimeRelayService extends PuterService {
{},
);
});
this.clients.event.on('auth.access-token.revoked', (_key, data) => {
this.clients.event.emit(
'pubsub.realtime.auth-revoked',
{ tokenUid: data.token_uid },
{},
);
});
this.clients.event.on(
'outer.auth.access-token.revoked',
(_key, data) => {
const tokenUid = (data as { token_uid?: unknown } | null)
?.token_uid;
if (typeof tokenUid !== 'string' || !tokenUid) return;
this.clients.event.emit(
'pubsub.realtime.auth-revoked',
{ tokenUid },
{},
);
},
);
// Raw WebSockets do not participate in socket.io's cluster adapter.
this.clients.event.on('pubsub.realtime.auth-revoked', (_key, data) => {
const revoked = data.sessionUids
@@ -428,6 +428,43 @@ describe('SocketService (live socket.io)', () => {
sessionSocket.disconnect();
});
it('drops a token`s socket on a revocation that reached it from elsewhere', async () => {
const row = await server.stores.user.getByUsername(user.username);
const actor = makeActor({ user: row as never });
const doomed = await runWithContext({ actor }, () =>
server.services.auth.createAccessToken(actor, [[FULL_API_ACCESS]], {
label: 'revoke-fanout-probe',
}),
);
const { token_uid } = server.services.token.verify<{
token_uid: string;
}>('auth', doomed);
const tokenSocket = await connect({ auth_token: doomed });
expect(tokenSocket.connected).toBe(true);
// A malformed peer payload is ignored.
for (const data of [null, {}, { token_uid: 42 }]) {
server.clients.event.emit(
'outer.auth.access-token.revoked',
data as never,
{ from_outside: true },
);
}
await new Promise((r) => setTimeout(r, 200));
expect(tokenSocket.connected).toBe(true);
// As a peer cluster delivers it: the token was revoked on another node.
server.clients.event.emit(
'outer.auth.access-token.revoked',
{ token_uid },
{ from_outside: true },
);
await vi.waitFor(() => expect(tokenSocket.connected).toBe(false), {
timeout: 5000,
});
});
it('rejects an app-under-user token while events are off', async () => {
const row = await server.stores.user.getByUsername(user.username);
const appUid = `app-${uuidv4()}`;
+5 -3
View File
@@ -1175,10 +1175,12 @@ export class SocketService extends PuterService {
);
this.clients.event.on(
'auth.access-token.revoked',
'outer.auth.access-token.revoked',
(_key: string, data: unknown) => {
const { token_uid } = data as { token_uid: string };
this.#evictAccessTokenSockets(token_uid).catch(
const tokenUid = (data as { token_uid?: unknown } | null)
?.token_uid;
if (typeof tokenUid !== 'string') return;
this.#evictAccessTokenSockets(tokenUid).catch(
(err: unknown) => {
console.error('[socket] token eviction failed', err);
},
+2 -2
View File
@@ -159,8 +159,8 @@ export interface AppSummaryOptions {
/**
* The summary the desktop launches from directly, without re-reading the app
* (suggested, recommended, recent and taskbar apps), so every launch-relevant
* flag belongs here.
* (suggested, recommended and recent apps), so every launch-relevant flag
* belongs here.
*/
export function toAppSummary(
app: AppRow,
-271
View File
@@ -1,271 +0,0 @@
/**
* 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 { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PuterServer } from '../server.ts';
import { setupTestServer } from '../testUtil.ts';
import { getTaskbarItems } from './taskbarItems.ts';
describe('getTaskbarItems', () => {
let server: PuterServer;
let deps: Parameters<typeof getTaskbarItems>[1];
beforeAll(async () => {
server = await setupTestServer();
deps = {
clients: server.clients,
stores: server.stores,
apiBaseUrl: 'https://api.puter.com',
config: { static_hosting_domain: 'puter.site' },
} as unknown as Parameters<typeof getTaskbarItems>[1];
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (taskbarItems?: unknown) => {
const username = `tb-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
} as never);
if (taskbarItems !== undefined) {
await server.stores.user.update(created.id, {
taskbar_items:
typeof taskbarItems === 'string'
? taskbarItems
: JSON.stringify(taskbarItems),
});
}
return (await server.stores.user.getById(created.id))!;
};
const makeApp = async (ownerUserId: number, overrides = {}) => {
const name = `tb-app-${Math.random().toString(36).slice(2, 10)}`;
return server.stores.app.create(
{
name,
title: `Title ${name}`,
index_url: `https://${name}.example.com/`,
description: 'a test app',
...overrides,
},
{ ownerUserId },
);
};
it('seeds the default taskbar for a user that has none, and persists it', async () => {
const user = await makeUser();
expect(user.taskbar_items).toBeFalsy();
await getTaskbarItems(user as never, deps);
const [row] = await server.clients.db.read(
'SELECT `taskbar_items` FROM `user` WHERE `id` = ?',
[user.id],
);
const stored = JSON.parse(String(row.taskbar_items));
expect(stored.map((e: { name: string }) => e.name)).toContain(
'app-center',
);
// The cached row must not keep serving the pre-seed value.
expect(
(await server.stores.user.getById(user.id))?.taskbar_items,
).toBeTruthy();
});
it('resolves entries by name, uid and id', async () => {
const owner = await makeUser([]);
const byName = await makeApp(owner.id);
const byUid = await makeApp(owner.id);
const byId = await makeApp(owner.id);
const user = await makeUser([
{ name: byName.name, type: 'app' },
{ uid: byUid.uid, type: 'app' },
{ id: byId.id, type: 'app' },
]);
const items = await getTaskbarItems(user as never, deps);
expect(items.map((i) => i.uid)).toEqual([
byName.uid,
byUid.uid,
byId.uid,
]);
expect(items[0]).toMatchObject({
uuid: byName.uid,
name: byName.name,
title: byName.title,
godmode: false,
maximize_on_start: false,
description: 'a test app',
});
});
it('resolves the whole taskbar with one batched lookup per key, not one per entry', async () => {
const owner = await makeUser([]);
const apps = await Promise.all([0, 1, 2].map(() => makeApp(owner.id)));
const byUid = await makeApp(owner.id);
const user = await makeUser([
...apps.map((app) => ({ name: app.name, type: 'app' })),
{ uid: byUid.uid, type: 'app' },
]);
const appStore = server.stores.app;
const spies = {
getByName: vi.spyOn(appStore, 'getByName'),
getByUid: vi.spyOn(appStore, 'getByUid'),
getById: vi.spyOn(appStore, 'getById'),
getByNames: vi.spyOn(appStore, 'getByNames'),
getByUids: vi.spyOn(appStore, 'getByUids'),
};
try {
const items = await getTaskbarItems(user as never, deps);
expect(items.map((i) => i.uid)).toEqual([
...apps.map((app) => app.uid),
byUid.uid,
]);
expect(spies.getByName).not.toHaveBeenCalled();
expect(spies.getByUid).not.toHaveBeenCalled();
expect(spies.getById).not.toHaveBeenCalled();
expect(spies.getByNames).toHaveBeenCalledTimes(1);
expect(spies.getByUids).toHaveBeenCalledTimes(1);
} finally {
for (const spy of Object.values(spies)) spy.mockRestore();
}
});
it('skips non-app entries, the explorer pin, and unresolvable apps', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([
{ name: 'explorer', type: 'app' },
{ name: 'nothing-here', type: 'app' },
{ name: app.name, type: 'separator' },
{ type: 'app' },
{ name: app.name, type: 'app' },
]);
const items = await getTaskbarItems(user as never, deps);
expect(items).toHaveLength(1);
expect(items[0].name).toBe(app.name);
});
it('falls back to an empty taskbar when the stored JSON is corrupt', async () => {
const user = await makeUser('{not json');
expect(await getTaskbarItems(user as never, deps)).toEqual([]);
});
it('accepts an already-parsed taskbar array on the user row', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([]);
const items = await getTaskbarItems(
{ ...user, taskbar_items: [{ name: app.name, type: 'app' }] },
deps,
);
expect(items.map((i) => i.name)).toEqual([app.name]);
});
it('builds a sized icon URL by default and drops the icon on request', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const withIcon = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(withIcon[0].icon).toBe(
`https://api.puter.com/app-icon/${app.uid}/64`,
);
const withoutIcon = await getTaskbarItems(user as never, deps, {
noIcons: true,
});
expect('icon' in withoutIcon[0]).toBe(false);
});
it('pairs the icon URL with the direct subdomain URL when one exists', async () => {
const owner = await makeUser([]);
// An http(s) icon column is what the resize pipeline leaves behind, so
// the sized PNG is on the subdomain.
const app = await makeApp(owner.id, {
icon: 'https://api.puter.com/app-icon/x',
});
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(items[0].icon).toBe(
`https://api.puter.com/app-icon/${app.uid}/64`,
);
expect(items[0].iconCdnUrl).toBe(
`https://puter-app-icons.puter.site/${app.uid}-64.png`,
);
});
it('reports no subdomain URL for an app whose icon was never generated', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(items[0].iconCdnUrl).toBeNull();
});
it('falls back to the raw icon column when no API base URL is configured', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id, {
icon: 'https://cdn.example.com/i.png',
});
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(
user as never,
{
...deps,
apiBaseUrl: undefined,
} as never,
);
expect(items[0].icon).toBe('https://cdn.example.com/i.png');
});
it('reports null for an app with no icon and no API base URL', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(
user as never,
{
...deps,
apiBaseUrl: undefined,
} as never,
);
expect(items[0].icon).toBeNull();
});
});
-134
View File
@@ -1,134 +0,0 @@
/*
* 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 type { AppIconHostConfig } from './appIcon.js';
import { toAppSummary, type AppRow } from './appView.js';
interface TaskbarEntry {
name?: string;
id?: number;
uid?: string;
type?: string;
}
interface TaskbarOptions {
iconSize?: number;
noIcons?: boolean;
}
interface TaskbarDeps {
apiBaseUrl?: string;
config?: AppIconHostConfig;
stores: {
app: {
getByNames: (names: string[]) => Promise<Map<string, AppRow>>;
getByUids: (uids: string[]) => Promise<Map<string, AppRow>>;
getByIds: (ids: number[]) => Promise<Map<number, AppRow>>;
};
user: {
update: (
id: number,
patch: Record<string, unknown>,
) => Promise<unknown>;
};
};
}
const DEFAULT_TASKBAR_ITEMS: TaskbarEntry[] = [
{ name: 'app-center', type: 'app' },
{ name: 'builder', type: 'app' },
{ name: 'dev-center', type: 'app' },
{ name: 'editor', type: 'app' },
{ name: 'code', type: 'app' },
{ name: 'camera', type: 'app' },
{ name: 'recorder', type: 'app' },
{ name: 'browser', type: 'app' },
];
export async function getTaskbarItems(
user: Record<string, unknown>,
deps: TaskbarDeps,
options: TaskbarOptions = {},
): Promise<Array<Record<string, unknown>>> {
let raw: TaskbarEntry[];
if (!user.taskbar_items) {
raw = DEFAULT_TASKBAR_ITEMS;
await deps.stores.user.update(user.id as number, {
taskbar_items: JSON.stringify(raw),
});
} else {
try {
raw =
typeof user.taskbar_items === 'string'
? JSON.parse(user.taskbar_items as string)
: (user.taskbar_items as TaskbarEntry[]);
} catch {
raw = [];
}
}
const entries = raw.filter(
(entry) => entry.type === 'app' && entry.name !== 'explorer',
);
const [byName, byUid, byId] = await Promise.all([
deps.stores.app.getByNames(
entries.flatMap((entry) => (entry.name ? [entry.name] : [])),
),
deps.stores.app.getByUids(
entries.flatMap((entry) =>
!entry.name && entry.uid ? [entry.uid] : [],
),
),
deps.stores.app.getByIds(
entries.flatMap((entry) =>
!entry.name && !entry.uid && entry.id ? [entry.id] : [],
),
),
]);
const items: Array<Record<string, unknown>> = [];
for (const entry of entries) {
const app = entry.name
? byName.get(entry.name)
: entry.uid
? byUid.get(entry.uid)
: entry.id
? byId.get(entry.id)
: undefined;
if (!app) continue;
const item: Record<string, unknown> = {
uid: app.uid,
...toAppSummary(app, {
apiBaseUrl: deps.apiBaseUrl,
config: deps.config,
iconSize: options.iconSize,
}),
description: app.description,
};
if (options.noIcons) {
delete item.icon;
delete item.iconCdnUrl;
}
items.push(item);
}
return items;
}
+13 -21
View File
@@ -85,7 +85,17 @@ describe('buildUserDetails', () => {
[`/${user.username}/Desktop`]: user.desktop_uuid,
[`/${user.username}/Trash`]: user.trash_uuid,
});
expect(Array.isArray(details.taskbar_items)).toBe(true);
});
it('leaves out taskbar items saved on the account', async () => {
const user = {
...(await makeUser()),
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
};
const details = await buildUserDetails(user, deps(), {
isUser: true,
});
expect(details).not.toHaveProperty('taskbar_items');
});
it('leaves the desktop-only parts out for an app actor', async () => {
@@ -93,7 +103,6 @@ describe('buildUserDetails', () => {
const details = await buildUserDetails(user, deps(), {
isUser: false,
});
expect(details).not.toHaveProperty('taskbar_items');
expect(details).not.toHaveProperty('directories');
expect(details.card_fallback_available).toBe(false);
});
@@ -124,7 +133,7 @@ describe('buildUserDetails lookups', () => {
return keys.slice(keys.indexOf('hasDevAccountAccess') + 1);
};
it('reads the linked provider, taskbar and seat together, listed in a fixed order', async () => {
it('reads the linked provider and seat together, listed in a fixed order', async () => {
const user = {
...(await makeUser()),
password: null,
@@ -162,7 +171,6 @@ describe('buildUserDetails lookups', () => {
);
expect(tailKeys(details)).toEqual([
'oidc_revalidate_url',
'taskbar_items',
'directories',
'team',
'last_activity_ts',
@@ -177,7 +185,6 @@ describe('buildUserDetails lookups', () => {
const user = {
...(await makeUser()),
password: null,
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
last_activity_ts: '2026-01-02 03:04:05',
};
const events: string[] = [];
@@ -199,15 +206,6 @@ describe('buildUserDetails lookups', () => {
},
},
stores: Object.assign(Object.create(d.stores), {
app: {
getByNames: () =>
timed(events, 'taskbar', 20, () => {
throw new Error('taskbar down');
}),
getByUids: async () => new Map(),
getByIds: async () => new Map(),
},
// Fails first, but is reported after the taskbar.
team: {
getOrgSeat: () =>
timed(events, 'seat', 5, () => {
@@ -219,18 +217,12 @@ describe('buildUserDetails lookups', () => {
{ isUser: true },
);
expect(warn.mock.calls).toEqual([
['[user-details] taskbar_items failed:', expect.any(Error)],
['[user-details] team lookup failed:', expect.any(Error)],
]);
} finally {
warn.mockRestore();
}
expect(tailKeys(details)).toEqual([
'taskbar_items',
'directories',
'last_activity_ts',
]);
expect(details.taskbar_items).toEqual([]);
expect(tailKeys(details)).toEqual(['directories', 'last_activity_ts']);
});
});
+7 -46
View File
@@ -27,18 +27,12 @@ import {
isCardFallbackEligible,
type CardFallbackClients,
} from './cardFallback.js';
import { getTaskbarItems } from './taskbarItems.js';
import { isEmailGateLast } from './verificationOrder.js';
type TaskbarDeps = Parameters<typeof getTaskbarItems>[1];
export interface UserDetailsDeps {
config: IConfig;
// `db.write` backs the taskbar's first-read default row.
clients: CardFallbackClients & {
db: { write: (query: string, params?: unknown[]) => Promise<unknown> };
};
stores: TaskbarDeps['stores'] & {
clients: CardFallbackClients;
stores: {
kv: Pick<SystemKVStore, 'get'>;
team?: Pick<TeamStoreContract, 'getOrgSeat'>;
};
@@ -78,8 +72,8 @@ const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
]);
/**
* Delete every sensitive key at any depth (`metadata` and `taskbar_items` are
* nested). Depth-limited and cycle-safe. Runs last over a whole payload.
* Delete every sensitive key at any depth (`metadata` is nested). Depth-limited
* and cycle-safe. Runs last over a whole payload.
*/
export const scrubSensitive = (
value: unknown,
@@ -124,7 +118,7 @@ export const toUnixSeconds = (value: unknown): number | undefined => {
export async function buildUserDetails(
user: UserRow,
deps: UserDetailsDeps,
opts: { isUser: boolean; iconSize?: number; noIcons?: boolean },
opts: { isUser: boolean },
): Promise<Record<string, unknown>> {
const { config, clients, stores, services } = deps;
const { isUser } = opts;
@@ -182,14 +176,6 @@ export async function buildUserDetails(
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
// Not an inline literal, so a `TaskbarDeps` without `clients` still
// accepts it.
const taskbarDeps = {
clients,
stores,
apiBaseUrl: String(config.api_base_url ?? ''),
config,
};
const linkedProvider = async () => {
if (!oidcOnly) return null;
try {
@@ -199,19 +185,6 @@ export async function buildUserDetails(
return null;
}
};
const taskbarItems = async () => {
if (!isUser) return null;
try {
return {
items: await getTaskbarItems(user, taskbarDeps, {
iconSize: opts.iconSize,
noIcons: opts.noIcons,
}),
};
} catch (error) {
return { error };
}
};
// The team that pays for the account; every seat restriction keys on it.
const orgSeat = async () => {
if (!isUser || config.teams_enabled !== true) return null;
@@ -223,26 +196,14 @@ export async function buildUserDetails(
};
// Independent and each best-effort, so read together; the results land
// below in the order the payload has always listed them.
const [provider, taskbar, team] = await Promise.all([
linkedProvider(),
taskbarItems(),
orgSeat(),
]);
const [provider, team] = await Promise.all([linkedProvider(), orgSeat()]);
if (provider) {
const origin = (config.origin ?? '').replace(/\/$/, '');
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
}
if (taskbar) {
// Best-effort: the account still works without them.
if ('error' in taskbar) {
console.warn('[user-details] taskbar_items failed:', taskbar.error);
details.taskbar_items = [];
} else {
details.taskbar_items = taskbar.items;
}
if (isUser) {
const directories: Record<string, unknown> = {};
const nameToProp: Record<string, string> = {
desktop_uuid: `/${user.username}/Desktop`,
+1 -2
View File
@@ -27,7 +27,7 @@ A set of key/value pairs that configure the window.
| `is_resizable` | `Boolean` | If set to `true`, the user can resize the window. |
| `has_head` | `Boolean` | If set to `true`, the window has a head which contains the icon and the close, minimize, and maximize buttons. |
| `disable_parent_window` | `Boolean` | If set to `true`, the parent window is blocked until the current window is closed. |
| `show_in_taskbar` | `Boolean` | If set to `true`, the window is represented in the taskbar. |
| `show_in_taskbar` | `Boolean` | Deprecated. It has no effect. |
## Return value
A `Promise` that resolves to a window handle object with an `id` (String) property identifying the created window. This `id` can be passed as the `window_id` argument to the `setWindow*` methods.
@@ -50,7 +50,6 @@ A `Promise` that resolves to a window handle object with an `id` (String) proper
is_resizable: false,
has_head: true,
center: true,
show_in_taskbar: false,
})
</script>
</body>
+5 -6
View File
@@ -36,12 +36,11 @@ Without `background`, Puter creates and shows the window before the app's own co
runs, so a service app cannot avoid briefly appearing on screen.
A background instance stays private to your app for as long as it is hidden: it has no
taskbar item and no running mark on its icon, and opening the app from the
taskbar or from Puter's app list starts a separate, ordinary instance for the
user rather than handing them the one you are talking to. It can show itself at
any time with [`puter.ui.showWindow()`](/UI/showWindow), and from that moment it
is an ordinary window. It takes its place in the taskbar, and the user can
return to it, hide it, or close it like any other.
running mark on its icon, and opening the app from Puter's app list starts a
separate, ordinary instance for the user rather than handing them the one you
are talking to. It can show itself at any time with
[`puter.ui.showWindow()`](/UI/showWindow), and from that moment it is an
ordinary window that the user can return to, hide, or close like any other.
A background app closes when the app that launched it closes: it was launched to
serve that app, and the user never saw it. Once it has shown itself it keeps
-14
View File
@@ -33,20 +33,6 @@ Sent on app startup, and whenever the user's locale on Puter is changed. The val
}
```
#### `themeChanged`
Sent on app startup, and whenever the user's desktop theme on Puter is changed. The value passed to `handler` is:
```js
{
palette: {
primaryHue, // (Float) Hue of the theme color
primarySaturation, // (String) Saturation of the theme color as a percentage, with % sign
primaryLightness, // (String) Lightness of the theme color as a percentage, with % sign
primaryAlpha, // (Float) Opacity of the theme color from 0 to 1
primaryColor, // (String) CSS color value for text
}
}
```
#### `connection`
Sent when another app requests a connection to your app. The value passed to `handler` is:
```js
+1 -1
View File
@@ -418,7 +418,7 @@ For long-lived mounts, sign in with a `-token` username and an API token as the
| ------------------------------------------------------------------ | ------------------------------- | ----------- |
| `nameAvailable` | 60/min | |
| App icon | 12,000/min | Per network |
| App landing page (`/app/<name>`, `/desktop/app/<name>`) | 600/min | Per network |
| App landing page (`/app/<name>`) | 600/min | Per network |
| Names per batch app lookup (`GET /apps/:names`, `POST /query/app`) | 200, up to 200 characters each | |
| `metadata` | 16 KiB, measured as JSON | |
| `filetypeAssociations` | 200 entries, 60 characters each | |
+1 -1
View File
@@ -144,7 +144,7 @@ try {
startServer(1);
app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/desktop/app/*splat', '/dashboard'], (req, res) => {
app.get(['/', '/app/*splat', '/action/*splat', '/dashboard'], (req, res) => {
res.send(generateDevHtml({
env: env,
api_origin: apiOrigin,
+4 -5
View File
@@ -484,7 +484,6 @@ const ipc_listener = async (event, handled) => {
is_resizable: event.data.options.is_resizable,
has_head: event.data.options.has_head,
center: event.data.options.center,
show_in_taskbar: event.data.options.show_in_taskbar,
iframe_srcdoc: event.data.options.content,
parent_uuid: event.data.appInstanceID,
});
@@ -1149,9 +1148,9 @@ const ipc_listener = async (event, handled) => {
if ( event.data.y !== undefined ) {
event.data.y = parseFloat(event.data.y);
// we don't want the window to go off the top edge of the screen
if ( event.data.y < window.taskbar_height )
if ( event.data.y < 0 )
{
event.data.y = window.taskbar_height;
event.data.y = 0;
}
// we don't want the window to go off the bottom edge of the screen
if ( event.data.y > window.innerHeight - 100 )
@@ -1217,9 +1216,9 @@ const ipc_listener = async (event, handled) => {
if ( event.data.y !== undefined ) {
event.data.y = parseFloat(event.data.y);
// we don't want the window to go off the top edge of the screen
if ( event.data.y < window.taskbar_height )
if ( event.data.y < 0 )
{
event.data.y = window.taskbar_height;
event.data.y = 0;
}
// we don't want the window to go off the bottom edge of the screen
if ( event.data.y > window.innerHeight - 100 )
+2 -3
View File
@@ -424,15 +424,14 @@ export const authHeadHtml = (flow, title, { openerNotice = true } = {}) => {
};
/**
* On a cover page, the logo is a way out: desktop users stay on the desktop,
* everyone else goes to the root dashboard.
* On a cover page, the logo is a way out, to the root dashboard.
*
* @param {AuthFlow} flow
* @returns {string} Empty where the logo leads nowhere.
*/
const logoHref = (flow) => {
if ( ! flow.page || window.embedded_in_popup ) return '';
return window.location.pathname === '/desktop' ? '/desktop' : '/';
return '/';
};
/**
-61
View File
@@ -1,61 +0,0 @@
/*
* 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/>.
*/
const Component = use('util.Component');
/**
* Allows a flex layout of composed components to be
* treated as a component.
*/
export default def(class Flexer extends Component {
static ID = 'ui.component.Flexer';
static PROPERTIES = {
children: {},
gap: { value: '20pt' },
};
static CSS = `
:host > div {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
`;
create_template ({ template }) {
// TODO: The way we handle loading assets doesn't work well
// with web components, so for now it goes in the template.
$(template).html(`
<div><slot name="inside"></slot></div>
`);
}
on_ready ({ listen }) {
for ( const child of this.get('children') ) {
child.setAttribute('slot', 'inside');
child.attach(this);
}
listen('gap', gap => {
$(this.dom_).find('div').first().css('gap', gap);
});
}
});
+1 -2
View File
@@ -75,8 +75,7 @@ async function UIDashboard (options) {
// eslint-disable-next-line no-unused-vars
options = options ?? {};
// Mark dashboard mode on <body> so window chrome can adapt — e.g. app
// windows keep their minimize button even though fullpage-mode hides it.
// Mark dashboard mode on <body> so window chrome and notifications can adapt.
$('body').addClass('dashboard-mode');
// Create mutable tabs array from built-in tabs
@@ -79,7 +79,6 @@ async function UIWindowDisable2FA (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
on_before_exit: async () => {
if ( ! disabled_successfully ) {
promise.resolve(false);
-1
View File
@@ -136,7 +136,6 @@ function UIAlert (options) {
selectable_body: false,
draggable_body: options.draggable_body ?? true,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
body_content: h,
-47
View File
@@ -1,47 +0,0 @@
/*
* 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 UIWindow from './UIWindow.js';
import Placeholder from '../util/Placeholder.js';
import JustHTML from './Components/JustHTML.js';
/**
* @typedef {Object} UIComponentWindowOptions
* @property {Component} [component] A component to render in the window
* @property {string} [html] HTML string to render in the window (uses JustHTML component)
*/
/**
* Render a UIWindow that contains an instance of Component or HTML string
* @param {UIComponentWindowOptions} options
*/
export default async function UIComponentWindow (options) {
const component = options.component ?? new JustHTML({ html: options.html ?? '' });
const placeholder = Placeholder();
const win = await UIWindow({
...options,
body_content: placeholder.html,
});
component.attach(placeholder);
component.focus();
return win;
}
+2 -5
View File
@@ -568,9 +568,9 @@ function UIContextMenu (options) {
// Y position
// is the menu going to go out of the window from the bottom?
if ( (start_y + menu_height) > (window.innerHeight - window.taskbar_height - 10) )
if ( (start_y + menu_height) > (window.innerHeight - 10) )
{
y_pos = window.innerHeight - menu_height - window.taskbar_height - 10;
y_pos = window.innerHeight - menu_height - 10;
}
else
{
@@ -801,9 +801,6 @@ function UIContextMenu (options) {
$(options.parent_element).css('overflow', 'scroll');
$(options.parent_element).removeClass('has-open-contextmenu');
if ( $(options.parent_element).hasClass('taskbar-item') ) {
window.make_taskbar_sortable();
}
}
});
+13 -856
View File
@@ -19,24 +19,16 @@
import path from '../lib/path.js';
import UIContextMenu from './UIContextMenu.js';
import UIItem from './UIItem.js';
import UIAlert from './UIAlert.js';
import UIWindow from './UIWindow.js';
import UIWindowSaveAccount from './UIWindowSaveAccount.js';
import UIAuthLogin from './Auth/UIAuthLogin.js';
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, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
import truncate_filename from '../helpers/truncateFilename.js';
import UINotification from './UINotification.js';
import launch_app from '../helpers/launchApp.js';
import { urlFileLaunchOptions } from '../helpers/confirmUrlFileAccess.js';
import item_icon from '../helpers/itemIcon.js';
import { SHARED_PATH_PARAM, clear_shared_param } from '../helpers/parseSharedPath.js';
import resolve_shared_item from '../helpers/resolveSharedItem.js';
import {
answerNotificationReceipts,
@@ -63,7 +55,7 @@ import apply_item_added_to_containers from '../helpers/applyItemAddedToContainer
import parse_item_metadata from '../helpers/parseItemMetadata.js';
import { resolve_default_app } from '../helpers/defaultApp.js';
async function UIDesktop (options) {
async function UIDesktop () {
// start a transaction if we're not in embedded or fullpage mode
let transaction;
if ( !window.is_embedded && !window.is_fullpage_mode ) {
@@ -73,58 +65,6 @@ async function UIDesktop (options) {
let h = '';
// Set up the desktop channel for communication between different tabs in the same browser
window.channel = new BroadcastChannel('puter-desktop-channel');
channel.onmessage = function (e) {
};
// Initialize desktop icons visibility preference - move this earlier in the initialization
// Add this near the very beginning of the UIDesktop function
window.desktop_icons_hidden = false; // Set default value immediately
// Give Camera and Recorder write permissions to Desktop
puter.kv.get('has_set_default_app_user_permissions').then(async (user_permissions) => {
if ( ! user_permissions ) {
// Camera
try {
await fetch(`${window.api_origin }/auth/grant-user-app`, {
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ window.auth_token}`,
},
'body': JSON.stringify({
app_uid: 'app-5584fbf7-ed69-41fc-99cd-85da21b1ef51',
permission: `fs:${html_encode(window.desktop_path)}:write`,
}),
'method': 'POST',
});
} catch ( err ) {
console.error(err);
}
// Recorder
try {
await fetch(`${window.api_origin }/auth/grant-user-app`, {
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ window.auth_token}`,
},
'body': JSON.stringify({
app_uid: 'app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1',
permission: `fs:${html_encode(window.desktop_path)}:write`,
}),
'method': 'POST',
});
} catch ( err ) {
console.error(err);
}
// Set flag to true
await puter.kv.set('has_set_default_app_user_permissions', true);
}
// Awaited above so a failed write reaches this: it leaves the flag unset
// and the whole thing is retried next boot, which is what we want.
}).catch(err => console.warn('Could not apply default app permissions:', err));
// connect socket.
window.socket = io(`${window.gui_origin }/`, {
auth: {
@@ -714,16 +654,8 @@ async function UIDesktop (options) {
h += '<div class="window-container"></div>';
// Desktop
// If desktop is not in fullpage/embedded mode, we hide it until files and directories are loaded and then fade in the UI
// This gives a calm and smooth experience for the user
h += `<div class="desktop item-container disable-user-select"
data-uid="${options.desktop_fsentry?.uid ?? ''}"
data-sort_by="${!options.desktop_fsentry?.sort_by ? 'name' : options.desktop_fsentry.sort_by}"
data-sort_order="${!options.desktop_fsentry?.sort_order ? 'asc' : options.desktop_fsentry.sort_order}"
data-path="${html_encode(window.desktop_path)}"
>`;
h += '</div>';
// The surface windows open on. It holds no items.
h += '<div class="desktop disable-user-select"></div>';
// Get window sidebar width
puter.kv.get('window_sidebar_width').catch(() => null).then(async (val) => {
@@ -759,430 +691,25 @@ async function UIDesktop (options) {
// Append to <body>
$('body').append(h);
// Set desktop height based on taskbar height
$('.desktop').css('height', `calc(100vh - ${window.taskbar_height + window.toolbar_height}px)`);
// Initialize the preference early
puter.kv.get('desktop_icons_hidden').then(async (val) => {
window.desktop_icons_hidden = (val === 'true' || val === true);
// Apply the setting immediately if needed
if ( window.desktop_icons_hidden ) {
hideDesktopIcons();
}
}).catch(err => console.warn('Could not load desktop_icons_hidden:', err));
// ---------------------------------------------------------------
// Taskbar
// In fullpage mode the desktop only hosts a single app, so there's
// nothing for the taskbar to do; skip building it entirely.
// ---------------------------------------------------------------
if ( !window.is_fullpage_mode ) {
UITaskbar();
}
// Update desktop dimensions after taskbar is initialized with position
window.update_desktop_dimensions_for_taskbar();
const el_desktop = document.querySelector('.desktop');
window.active_element = el_desktop;
window.active_item_container = el_desktop;
// --------------------------------------------------------
// Dragster
// Allow dragging of local files onto desktop.
// --------------------------------------------------------
$(el_desktop).dragster({
enter: function (dragsterEvent, event) {
$('.context-menu').remove();
},
leave: function (dragsterEvent, event) {
},
drop: async function (dragsterEvent, event) {
const e = event.originalEvent;
// no drop on item
if ( $(event.target).hasClass('item') || $(event.target).parent('.item').length > 0 )
{
return false;
}
// recursively create directories and upload files
if ( e.dataTransfer?.items?.length > 0 ) {
window.upload_items(e.dataTransfer.items, window.desktop_path);
}
e.stopPropagation();
e.preventDefault();
return false;
},
// The desktop has no menu of its own, and the browser's doesn't belong here.
$(el_desktop).on('contextmenu', function (event) {
if ( event.target === el_desktop ) event.preventDefault();
});
// --------------------------------------------------------
// Droppable
// --------------------------------------------------------
$(el_desktop).droppable({
accept: '.item',
tolerance: 'intersect',
drop: function (event, ui) {
// Check if item was actually dropped on desktop and not a window
if ( window.mouseover_window !== undefined )
{
return;
}
// Can't drop anything but UIItems on desktop
if ( ! $(ui.draggable).hasClass('item') )
{
return;
}
// Don't move an item to its current directory
if ( path.dirname($(ui.draggable).attr('data-path')) === window.desktop_path && !event.ctrlKey )
{
return;
}
// If ctrl is pressed and source is Trashed, cancel whole operation
if ( event.ctrlKey && path.dirname($(ui.draggable).attr('data-path')) === window.trash_path )
{
return;
}
// Unselect previously selected items
$(el_desktop).children('.item-selected').removeClass('item-selected');
const items_to_move = [];
// first item
items_to_move.push(ui.draggable);
// all subsequent items
const cloned_items = document.getElementsByClassName('item-selected-clone');
for ( let i = 0; i < cloned_items.length; i++ ) {
const source_item = document.getElementById(`item-${ $(cloned_items[i]).attr('data-id')}`);
if ( source_item !== null )
{
items_to_move.push(source_item);
}
}
// if ctrl key is down, copy items
if ( event.ctrlKey ) {
// unless source is Trash
if ( path.dirname($(ui.draggable).attr('data-path')) === window.trash_path )
{
return;
}
window.copy_items(items_to_move, window.desktop_path);
}
// otherwise, move items
else {
window.move_items(items_to_move, window.desktop_path);
}
},
});
//--------------------------------------------------
// ContextMenu
//--------------------------------------------------
$(el_desktop).bind('contextmenu taphold', function (event) {
// dismiss taphold on regular devices
if ( event.type === 'taphold' && !isMobile.phone && !isMobile.tablet )
{
return;
}
const $target = $(event.target);
// elements that should retain native ctxmenu
if ( $target.is('input') || $target.is('textarea') )
{
return true;
}
// custom ctxmenu for all other elements
if ( event.target === el_desktop ) {
event.preventDefault();
UIContextMenu({
position: event.type === 'taphold' ? undefined : { left: event.pageX, top: event.pageY },
items: [
// -------------------------------------------
// Sort by
// -------------------------------------------
{
html: i18n('sort_by'),
items: [
{
html: i18n('auto_arrange'),
icon: window.is_auto_arrange_enabled ? '✓' : '',
onClick: async function () {
window.is_auto_arrange_enabled = !window.is_auto_arrange_enabled;
window.store_auto_arrange_preference(window.is_auto_arrange_enabled);
if ( window.is_auto_arrange_enabled ) {
window.sort_items(el_desktop, $(el_desktop).attr('data-sort_by'), $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, $(el_desktop).attr('data-sort_by'), $(el_desktop).attr('data-sort_order'));
window.clear_desktop_item_positions(el_desktop);
} else {
window.set_desktop_item_positions(el_desktop);
}
},
},
// -------------------------------------------
// -
// -------------------------------------------
'-',
{
html: i18n('name'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'name' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'name', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'name', $(el_desktop).attr('data-sort_order'));
},
},
{
html: i18n('date_modified'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'modified' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'modified', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'modified', $(el_desktop).attr('data-sort_order'));
},
},
{
html: i18n('type'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'type' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'type', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'type', $(el_desktop).attr('data-sort_order'));
},
},
{
html: i18n('size'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'size' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'size', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'size', $(el_desktop).attr('data-sort_order'));
},
},
// -------------------------------------------
// -
// -------------------------------------------
'-',
{
html: i18n('ascending'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_order') === 'asc' ? '✓' : '',
onClick: async function () {
const sort_by = $(el_desktop).attr('data-sort_by');
window.sort_items(el_desktop, sort_by, 'asc');
window.set_sort_by(options.desktop_fsentry.uid, sort_by, 'asc');
},
},
{
html: i18n('descending'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_order') === 'desc' ? '✓' : '',
onClick: async function () {
const sort_by = $(el_desktop).attr('data-sort_by');
window.sort_items(el_desktop, sort_by, 'desc');
window.set_sort_by(options.desktop_fsentry.uid, sort_by, 'desc');
},
},
],
},
// -------------------------------------------
// Refresh
// -------------------------------------------
{
html: i18n('refresh'),
onClick: function () {
refresh_item_container(el_desktop, { consistency: 'strong' });
},
},
// -------------------------------------------
// Show/Hide hidden files
// -------------------------------------------
{
html: i18n('show_hidden'),
icon: window.user_preferences.show_hidden_files ? '✓' : '',
onClick: function () {
window.mutate_user_preferences({
show_hidden_files: !window.user_preferences.show_hidden_files,
});
window.show_or_hide_files(document.querySelectorAll('.item-container'));
},
},
// -------------------------------------------
// Hide Desktop Icons
// -------------------------------------------
{
html: window.desktop_icons_hidden ? i18n('Show desktop icons') : i18n('Hide desktop icons'),
onClick: function () {
toggleDesktopIcons();
},
},
// -------------------------------------------
// -
// -------------------------------------------
'-',
// -------------------------------------------
// New File
// -------------------------------------------
new_context_menu_item(window.desktop_path, el_desktop),
// -------------------------------------------
// -
// -------------------------------------------
'-',
// -------------------------------------------
// Paste
// -------------------------------------------
{
html: i18n('paste'),
disabled: window.clipboard.length > 0 ? false : true,
onClick: function () {
if ( window.clipboard_op === 'copy' )
{
window.copy_clipboard_items(window.desktop_path, el_desktop);
}
else if ( window.clipboard_op === 'move' )
{
window.move_clipboard_items(el_desktop);
}
},
},
// -------------------------------------------
// Undo
// -------------------------------------------
{
html: i18n('undo'),
disabled: window.actions_history.length > 0 ? false : true,
onClick: function () {
window.undo_last_action();
},
},
// -------------------------------------------
// Upload Here
// -------------------------------------------
{
html: i18n('upload_here'),
onClick: function () {
window.init_upload_using_dialog(el_desktop);
},
},
],
});
}
});
//-------------------------------------------
// Desktop Files/Folders
// we don't need to get the desktop items if we're in embedded or fullpage mode
// because the items aren't visible anyway and we don't need to waste bandwidth/server resources
//-------------------------------------------
if ( !window.is_embedded && !window.is_fullpage_mode ) {
refresh_item_container(el_desktop, {
fadeInItems: true,
onComplete: () => {
// End transaction when desktop is fully ready for user interaction
transaction.end();
},
});
transaction.end();
// perform readdirs for caching purposes
// home directory
// warm the home directory listing for the first Explorer window
puter.fs.readdir({ path: window.home_path, consistency: 'strong' });
}
// -------------------------------------------
// Selectable
// Only for desktop
// -------------------------------------------
if ( !isMobile.phone && !isMobile.tablet ) {
let selected_ctrl_items = [];
const selection = new SelectionArea({
selectionContainerClass: '.selection-area-container',
container: '.desktop',
selectables: ['.desktop.item-container > .item'],
startareas: ['.desktop'],
boundaries: ['.desktop'],
behaviour: {
overlap: 'drop',
intersect: 'touch',
startThreshold: 10,
scrolling: {
speedDivider: 10,
manualSpeed: 750,
startScrollMargins: { x: 0, y: 0 },
},
},
features: {
touch: true,
range: true,
singleTap: {
allow: true,
intersect: 'native',
},
},
});
selection.on('beforestart', ({ event }) => {
selected_ctrl_items = [];
// Returning false prevents a selection
return $(event.target).hasClass('item-container');
})
.on('beforedrag', evt => {
})
.on('start', ({ store, event }) => {
if ( !event.ctrlKey && !event.metaKey ) {
for ( const el of store.stored ) {
el.classList.remove('item-selected');
}
selection.clearSelection();
}
// mark desktop as selectable active
$('.desktop').addClass('desktop-selectable-active');
})
.on('move', ({ store: { changed: { added, removed } }, event }) => {
window.desktop_selectable_is_active = true;
for ( const el of added ) {
// if ctrl or meta key is pressed and the item is already selected, then unselect it
if ( (event.ctrlKey || event.metaKey) && $(el).hasClass('item-selected') ) {
el.classList.remove('item-selected');
selected_ctrl_items.push(el);
}
// otherwise select it
else {
el.classList.add('item-selected');
}
}
for ( const el of removed ) {
el.classList.remove('item-selected');
// in case this item was selected by ctrl+click before, then reselect it again
if ( selected_ctrl_items.includes(el) )
{
$(el).not('.item-disabled').addClass('item-selected');
}
}
})
.on('stop', evt => {
window.desktop_selectable_is_active = false;
$('.desktop').removeClass('desktop-selectable-active');
});
}
// notification container
$('body').append(`<div class="notification-container"><div class="notifications-close-all">${i18n('close_all')}</div></div>`);
// adjust window container to take into account the toolbar height
$('.window-container').css('top', window.toolbar_height);
// track: checkpoint
//-----------------------------
// GUI is ready to launch apps!
@@ -1216,41 +743,10 @@ async function UIDesktop (options) {
}
}
}
//--------------------------------------------------------------------------------------
// /settings redirects to /dashboard
//--------------------------------------------------------------------------------------
else if ( window.url_paths[0]?.toLocaleLowerCase() === 'settings' ) {
window.open('/dashboard/#home', '_blank');
}
// ---------------------------------------------
// Run apps from insta-login URL
// ---------------------------------------------
if ( window.url_query_params.has('app') ) {
let url_app_name = window.url_query_params.get('app');
if ( url_app_name === 'explorer' ) {
let predefined_path = window.home_path;
if ( window.url_query_params.has('path') )
{
predefined_path = window.url_query_params.get('path');
}
// launch explorer
UIWindow({
path: predefined_path,
title: path.basename(predefined_path),
icon: await item_icon({ is_dir: true, path: predefined_path }),
// todo
// uid: $(el_item).attr('data-uid'),
is_dir: true,
// todo
// sort_by: $(el_item).attr('data-sort_by'),
app: 'explorer',
});
}
}
// ---------------------------------------------
// load from direct app URLs: /app/app-name
// ---------------------------------------------
else if ( window.app_launched_from_url ) {
if ( window.app_launched_from_url ) {
if ( ! window.url_query_params.has('c') ) {
let posargs = undefined;
if ( window.app_query_params && window.app_query_params.posargs ) {
@@ -1286,56 +782,17 @@ async function UIDesktop (options) {
return;
}
// disable pointer-events for all app iframes, this is to make sure selectable works
$('.window-app-iframe').css('pointer-events', 'none');
$('.window').find('.item-selected').addClass('item-blurred');
$('.desktop').find('.item-blurred').removeClass('item-blurred');
// Keyboard shortcuts act on the active container, and a click here leaves every window's.
window.active_item_container = null;
});
$(el_desktop).on('click', function (e) {
$(el_desktop).on('click', function () {
// blur all windows
$('.window-active').removeClass('window-active');
// hide all global menubars
$('.window-menubar-global').hide();
});
//--------------------------------------------------------------------------------------
// Trying to view a user's public folder?
// i.e. https://puter.com/@<username>
//--------------------------------------------------------------------------------------
const url_paths = window.location.pathname.split('/').filter(element => element);
if ( window.url_paths[0]?.startsWith('@') ) {
const username = window.url_paths[0].substring(1);
let item_path = `/${ username }/Public`;
if ( window.url_paths.length > 1 ) {
item_path += `/${ window.url_paths.slice(1).join('/')}`;
}
// GUARD: avoid invalid user directories
{
if ( ! username.match(/^[a-z0-9_]+$/i) ) {
UIAlert({
message: i18n('error_invalid_username'),
});
return;
}
}
let stat;
try {
stat = await puter.fs.stat({ path: item_path, consistency: 'eventual' });
} catch ( e ) {
window.history.replaceState(null, document.title, '/desktop');
UIAlert({
message: i18n('error_user_or_path_not_found'),
type: 'error',
});
return;
}
await open_path_target(item_path, stat);
}
/**
* Open a path as double-clicking would: the associated app for a file, an
* explorer window for a directory.
@@ -1390,7 +847,6 @@ async function UIDesktop (options) {
file_path: item_path,
app_obj: suggested_apps[0],
window_title: path.basename(item_path),
maximized: options.maximized,
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
});
@@ -1428,278 +884,15 @@ async function UIDesktop (options) {
await open_path_target(stat.path ?? shared_path, stat);
return true;
}
/**
* Act on a share link. A share to a person only ever reaches a real
* account, so for a temporary session the item is tried first — an item
* open to anyone with the link resolves for it — and only a miss asks
* for a sign-in, rather than burning the link on a "not found" as
* somebody who can't see it. The link stays in the address bar across
* the prompt because login reloads on success, which brings it back for
* the account that can actually open it.
*/
async function handle_shared_link (shared_path) {
if ( window.user?.is_temp ) {
const stat = await resolve_shared_item(puter.fs, shared_path);
if ( stat ) {
clear_shared_param();
await open_path_target(stat.path ?? shared_path, stat);
return;
}
await UIAuthLogin({
reload_on_success: true,
page: true,
});
// Dismissed without signing in: drop the link rather than loop.
if ( window.user?.is_temp ) clear_shared_param();
return;
}
clear_shared_param();
await open_shared_item(shared_path);
}
//--------------------------------------------------------------------------------------
// Opening an item someone shared, on the desktop
// i.e. https://puter.com/desktop?shared=%2F<owner>%2F<uuid>%2F<name>
// (the same link at the root lands in the dashboard's Shared view instead)
//--------------------------------------------------------------------------------------
if ( window.url_query_params.has(SHARED_PATH_PARAM) ) {
await handle_shared_link(window.url_query_params.get(SHARED_PATH_PARAM));
}
//--------------------------------------------------------------------------------------
// Direct download link
// i.e. https://puter.com/?download=<file_url> or https://puter.com/desktop?download=<file_url>
//--------------------------------------------------------------------------------------
const is_bare_desktop_path = window.url_paths.length === 0
|| (window.url_paths.length === 1 && window.url_paths[0]?.toLocaleLowerCase() === 'desktop');
if ( is_bare_desktop_path && window.url_query_params.has('download') ) {
const url = window.url_query_params.get('download');
let file_name = url.split('/').pop().split('?')[0];
let response = await UIAlert({
message: i18n('confirm_download_file_to_desktop', file_name),
type: 'confirm',
buttons: [
{ label: i18n('alert_yes'), value: true, type: 'primary' },
{ label: i18n('alert_no'), value: false, type: 'secondary' },
],
});
if ( ! response )
{
return;
}
let cancelled = false;
let upload_xhr = null;
const abort_controller = new AbortController();
// create progressbar dialog
let progwin = await UIWindowProgress({
title: i18n('downloading'),
icon: window.icons['app-icon-uploader.svg'],
operation_id: window.uuidv4(),
show_progress: true,
on_cancel: () => {
cancelled = true;
abort_controller.abort();
if ( upload_xhr ) {
upload_xhr.abort();
}
},
});
progwin?.set_status(i18n('downloading_file', file_name));
(async () => {
try {
// download the file
const response = await puter.net.fetch(url, {
signal: abort_controller.signal,
});
const total = Number(response.headers.get('content-length'));
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while ( true ) {
const { done, value } = await reader.read();
if ( done || cancelled ) break;
if ( value ) {
// store the chunk
chunks.push(value);
received += value.length;
// calculate progress
const progress = Number.isFinite(total) && total > 0
? received / total
: 0;
// update progressbar
progwin?.set_progress(Math.floor(progress * 100));
}
}
if ( cancelled ) {
progwin?.close();
return;
}
// combine chunks into a blob
let blob = new Blob(chunks, {
type: response.headers.get('content-type') ?? 'application/octet-stream',
});
// reset progressbar
progwin?.set_progress(0);
progwin?.set_status(i18n('uploading_file', file_name));
// upload to user's desktop
await puter.fs.write(`~/Desktop/${file_name}`, blob, {
dedupeName: true,
progress: (_, percent) => {
// update progressbar
progwin?.set_progress(percent);
},
init: (_, xhr) => {
upload_xhr = xhr;
},
});
} catch (e) {
// alert the user if there's a genuine error
if ( !cancelled && e.name !== 'AbortError' ) {
await UIAlert({
message: `${i18n('error_download_failed') }: ${ e.message}`,
type: 'error',
});
}
}
// close progress window
progwin?.close();
})();
}
}
$(document).on('contextmenu taphold', '.taskbar', function (event) {
// dismiss taphold on regular devices
if ( event.type === 'taphold' && !isMobile.phone && !isMobile.tablet )
{
return;
}
event.preventDefault();
event.stopPropagation();
// Get current taskbar position
const currentPosition = window.taskbar_position || 'bottom';
// Create base menu items
let menuItems = [];
// Only show position submenu on desktop devices
if ( !isMobile.phone && !isMobile.tablet ) {
menuItems.push({
html: i18n('desktop_position'),
items: [
{
html: i18n('desktop_position_left'),
checked: currentPosition === 'left',
onClick: function () {
window.update_taskbar_position('left');
},
},
{
html: i18n('desktop_position_bottom'),
checked: currentPosition === 'bottom',
onClick: function () {
window.update_taskbar_position('bottom');
},
},
{
html: i18n('desktop_position_right'),
checked: currentPosition === 'right',
onClick: function () {
window.update_taskbar_position('right');
},
},
],
});
menuItems.push('-'); // divider
}
// Add the "Show open windows" option for all devices
menuItems.push({
html: i18n('desktop_show_open_windows'),
onClick: function () {
$('.window').showWindow();
},
});
// Add the "Show the desktop" option for all devices
menuItems.push({
html: i18n('desktop_show_desktop'),
onClick: function () {
$('.window').hideWindow();
},
});
UIContextMenu({
parent_element: $('.taskbar'),
items: menuItems,
});
return false;
});
window.update_taskbar = function () {
let items = [];
$('.taskbar-item-sortable[data-keep-in-taskbar="true"]').each(function (index) {
items.push({
name: $(this).attr('data-app'),
type: 'app',
});
});
// update taskbar in the server-side
$.ajax({
url: `${window.api_origin }/update-taskbar-items`,
type: 'POST',
data: JSON.stringify({
items: items,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${ window.auth_token}`,
},
});
};
window.remove_taskbar_item = function (item) {
$(item).find('*').fadeOut(100, function () {
});
$(item).animate({ width: 0 }, 200, function () {
$(item).remove();
// Adjust taskbar item sizes after removing an item
if ( window.adjust_taskbar_item_sizes ) {
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
}
});
};
window.enter_fullpage_mode = (el_window) => {
$('.taskbar').hide();
$(el_window).find('.window-head').hide();
$('body').addClass('fullpage-mode');
$(el_window).css({
width: '100%',
height: '100%',
top: `${window.toolbar_height }px`,
top: 0,
left: 0,
'border-radius': 0,
});
@@ -1707,23 +900,11 @@ window.enter_fullpage_mode = (el_window) => {
window.exit_fullpage_mode = (el_window) => {
$('body').removeClass('fullpage-mode');
window.taskbar_height = window.default_taskbar_height;
// In fullpage mode the taskbar is never built, so create it on exit; otherwise just restore it.
if ( $('.taskbar').length === 0 ) {
UITaskbar();
} else {
$('.taskbar').css('height', window.taskbar_height);
$('.taskbar').show();
}
refresh_item_container($('.desktop.item-container'), { fadeInItems: true });
$(el_window).removeAttr('data-is_fullpage');
if ( el_window ) {
window.reset_window_size_and_position(el_window);
$(el_window).find('.window-head').show();
}
// reset dektop height to take into account the taskbar height
$('.desktop').css('height', `calc(100vh - ${window.taskbar_height + window.toolbar_height}px)`);
};
window.reset_window_size_and_position = (el_window) => {
@@ -1736,28 +917,4 @@ window.reset_window_size_and_position = (el_window) => {
});
};
// Modify the hide/show functions to use CSS rules that will apply to all icons, including future ones
window.hideDesktopIcons = function () {
$('.desktop.item-container').addClass('desktop-icons-hidden');
};
window.showDesktopIcons = function () {
$('.desktop.item-container').removeClass('desktop-icons-hidden');
};
// Add this function to the global scope
window.toggleDesktopIcons = function () {
window.desktop_icons_hidden = !window.desktop_icons_hidden;
if ( window.desktop_icons_hidden ) {
hideDesktopIcons();
} else {
showDesktopIcons();
}
// Save preference
puter.kv.set('desktop_icons_hidden', window.desktop_icons_hidden.toString())
.catch(err => console.warn('Could not save desktop_icons_hidden:', err));
};
export default UIDesktop;
+4 -53
View File
@@ -25,7 +25,6 @@ import path from '../lib/path.js';
import truncate_filename from '../helpers/truncateFilename.js';
import launch_app from '../helpers/launchApp.js';
import open_item from '../helpers/openItem.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js';
import { is_owned_by_me } from '../helpers/pathOwner.js';
import { can_rename, can_restructure, invalidate_shared_roots } from '../helpers/sharedAccess.js';
@@ -235,17 +234,6 @@ async function UIItem (options) {
window.update_explorer_footer_item_count(el_window);
}
// manual positioning
if ( !window.is_auto_arrange_enabled &&
options.position &&
// item is on the desktop (must be desktop itself and not a window, hence the '.desktop' class check)
$(el_item).closest('.item-container.desktop').attr('data-path') === window.desktop_path
) {
el_item.style.position = 'absolute';
el_item.style.left = `${options.position.left }px`;
el_item.style.top = `${options.position.top }px`;
}
// --------------------------------------------------------
// Dragster
// allow dragging of local files on this window, if it's is_dir
@@ -330,8 +318,8 @@ async function UIItem (options) {
// Constrain item within desktop bounds
const minLeft = -50;
const maxLeft = window.desktop_width - 50;
const minTop = window.toolbar_height;
const maxTop = window.desktop_height + window.toolbar_height;
const minTop = 0;
const maxTop = window.desktop_height;
// Apply constraints to ui.position
ui.position.left = Math.max(minLeft, Math.min(maxLeft, ui.position.left));
@@ -410,21 +398,6 @@ 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
// 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 ) {
el_item.style.position = 'absolute';
el_item.style.left = `${ui.position.left }px`;
el_item.style.top = `${ui.position.top }px`;
$('.ui-draggable-dragging').remove();
window.desktop_item_positions[$(el_item).attr('data-uid')] = ui.position;
window.save_desktop_item_positions();
}
$('.item-selected-clone').remove();
$('.draggable-count-badge').remove();
// re-enable all droppable UIItems that are not a dir
@@ -547,11 +520,6 @@ async function UIItem (options) {
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 ) {
// 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_drop, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
}
}
@@ -1242,23 +1210,6 @@ async function UIItem (options) {
}
}
// -------------------------------------------
// Publish As Website
// -------------------------------------------
if ( !is_trashed && !is_trash && options.is_dir ) {
menu_items.push({
html: i18n('publish_as_website'),
disabled: !options.is_dir,
onClick: async function () {
await publish_as_website({
uid: options.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
});
},
});
}
// -------------------------------------------
// Deploy As App
// -------------------------------------------
if ( !is_trashed && !is_trash && options.is_dir ) {
@@ -1554,7 +1505,7 @@ async function UIItem (options) {
left = left > (window.innerWidth - window_width) ? (window.innerWidth - window_width) : left;
let top = $(el_item).position().top + $(el_item).height();
top = top > (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) ? (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) : top;
top = top > (window.innerHeight - window_height) ? (window.innerHeight - window_height) : top;
UIWindowItemProperties(
$(el_item).attr('data-name'),
@@ -1571,7 +1522,7 @@ async function UIItem (options) {
// Create ContextMenu
UIContextMenu({
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
parent_element: options.appendTo,
position: menu_position,
items: menu_items,
});
+21 -1
View File
@@ -29,7 +29,6 @@ vi.mock('./UIContextMenu.js', () => ({ default: vi.fn() }));
vi.mock('./UIAlert.js', () => ({ default: vi.fn() }));
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() }));
@@ -175,3 +174,24 @@ describe('UIItem Publish as Worker', () => {
expect(labels).not.toContain('publish_as_serverless_worker');
});
});
describe('UIItem Publish as Website', () => {
beforeEach(() => {
window.user = { username: 'alice' };
window.trash_path = '/alice/Trash';
});
it('is not offered, even on a folder the user owns', async () => {
const labels = await menuLabels({
path: '/alice/Site',
name: 'Site',
is_dir: true,
owner: 'alice',
shared_with_me: false,
share_mode: undefined,
shared_by: undefined,
});
expect(labels).not.toContain('publish_as_website');
expect(labels).toContain('deploy_as_app');
});
});
+8 -38
View File
@@ -62,47 +62,17 @@ function UIPopover (options) {
function position_popover () {
// X position
const popover_width = options.width ?? $(el_popover).width();
if ( options.center_horizontally ) {
// Check taskbar position to determine popover positioning
const taskbar_position = window.taskbar_position || 'bottom';
if ( taskbar_position === 'left' ) {
// Position in top-left corner for left taskbar
x_pos = window.taskbar_height + 10; // Just to the right of the taskbar
} else if ( taskbar_position === 'right' ) {
// Position in top-right corner for right taskbar
x_pos = window.innerWidth - popover_width - window.taskbar_height - 40; // Just to the left of the taskbar
} else {
// Default bottom taskbar behavior - center horizontally
x_pos = window.innerWidth / 2 - popover_width / 2 - 15;
}
} else {
if ( options.position === 'bottom' || options.position === 'top' )
{
x_pos = options.left ?? ($(options.snapToElement).offset().left - (popover_width / 2) + 10);
}
else
{
x_pos = options.left ?? ($(options.snapToElement).offset().left + 5);
}
if ( options.position === 'bottom' || options.position === 'top' )
{
x_pos = options.left ?? ($(options.snapToElement).offset().left - (popover_width / 2) + 10);
}
else
{
x_pos = options.left ?? ($(options.snapToElement).offset().left + 5);
}
// Y position
const popover_height = options.height ?? $(el_popover).height();
if ( options.center_horizontally ) {
// Check taskbar position to determine popover positioning
const taskbar_position = window.taskbar_position || 'bottom';
if ( taskbar_position === 'left' || taskbar_position === 'right' ) {
// Position at top for left/right taskbars
y_pos = window.toolbar_height + 10; // Just below the toolbar
} else {
// Default bottom taskbar behavior - position above taskbar
y_pos = options.top ?? (window.innerHeight - (window.taskbar_height + popover_height + 10));
}
} else {
y_pos = options.top ?? ($(options.snapToElement).offset().top + $(options.snapToElement).height() + 5);
}
y_pos = options.top ?? ($(options.snapToElement).offset().top + $(options.snapToElement).height() + 5);
$(el_popover).css({
left: `${x_pos }px`,
-1
View File
@@ -71,7 +71,6 @@ function UIPrompt (options) {
selectable_body: false,
draggable_body: true,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
body_content: h,
-15
View File
@@ -82,21 +82,6 @@ function UIQRCode (options) {
}
}
// Compatibility shim: Component-based containers (e.g. Flexer) call
// `.attach(parent)` on their children. Returning a plain DOM element with
// an `attach` method lets this work without making UIQRCode a Component.
el.attach = function (destination) {
if ( destination instanceof HTMLElement || destination instanceof ShadowRoot ) {
destination.appendChild(el);
return;
}
if ( destination && destination.$ === 'placeholder' ) {
destination.replaceWith(el);
return;
}
throw new Error(`Unknown destination type: ${destination}`);
};
return el;
}
-468
View File
@@ -1,468 +0,0 @@
/*
* 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 UITaskbarItem from './UITaskbarItem.js';
import launch_app from '../helpers/launchApp.js';
import { appIconSrc } from '../helpers/appIcon.js';
const TASKBAR_POSITIONS = ['left', 'bottom', 'right'];
async function UITaskbar (options) {
window.global_element_id++;
options = options ?? {};
options.content = options.content ?? '';
let taskbar_position;
// if first visit ever, set taskbar position to left
if ( window.first_visit_ever ) {
puter.kv.set('taskbar_position', 'left').catch(err => console.warn('Could not save taskbar_position:', err));
taskbar_position = 'left';
} else {
taskbar_position = await puter.kv.get('taskbar_position').catch(() => null);
// if this is not first visit, set taskbar position to bottom since it's from a user that
// used puter before customizing taskbar position was added and the taskbar position was set to bottom
// Unknown values are discarded too: this ends up in markup and stored values may be poisoned.
if ( ! TASKBAR_POSITIONS.includes(taskbar_position) ) {
taskbar_position = 'bottom'; // default position
puter.kv.set('taskbar_position', taskbar_position).catch(err => console.warn('Could not save taskbar_position:', err));
}
}
// Force bottom position on mobile devices
if ( isMobile.phone || isMobile.tablet ) {
taskbar_position = 'bottom';
}
// Set global taskbar position
window.taskbar_position = taskbar_position;
let h = '';
h += `<div id="ui-taskbar_${window.global_element_id}" class="taskbar taskbar-position-${taskbar_position}" style="height:${window.taskbar_height}px;">`;
h += '<div class="taskbar-sortable" style="display: flex; justify-content: center; z-index: 99999;"></div>';
h += '</div>';
if ( taskbar_position === 'left' || taskbar_position === 'right' ) {
$('.desktop').addClass(`desktop-taskbar-position-${taskbar_position}`);
}
$('.desktop').append(h);
//---------------------------------------------
// add `Explorer` to the taskbar
//---------------------------------------------
UITaskbarItem({
icon: window.icons['folders.svg'],
app: 'explorer',
name: 'Explorer',
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
onClick: function () {
let open_window_count = parseInt($('.taskbar-item[data-app="explorer"]').attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({ name: 'explorer', path: window.home_path });
} else {
return false;
}
},
});
//---------------------------------------------
// add separator before trash
//---------------------------------------------
UITaskbarItem({
icon: '', // No icon for separator
name: 'separator',
app: 'separator',
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
disable_context_menu: true,
style: 'pointer-events: none;', // Make it non-interactive
onClick: function () {
// Separator is non-interactive
return false;
},
});
//---------------------------------------------
// Add other useful apps to the taskbar
//---------------------------------------------
if ( window.user.taskbar_items && window.user.taskbar_items.length > 0 ) {
for ( let index = 0; index < window.user.taskbar_items.length; index++ ) {
const app_info = window.user.taskbar_items[index];
const icon = appIconSrc(app_info);
// add taskbar item for each app
UITaskbarItem({
icon: icon.src,
iconFallback: icon.fallback,
app: app_info.name,
name: app_info.title,
keep_in_taskbar: true,
onClick: function () {
let open_window_count = parseInt($(`.taskbar-item[data-app="${app_info.name}"]`).attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({
name: app_info.name,
});
} else {
return false;
}
},
});
}
}
//---------------------------------------------
// add `Trash` to the taskbar
//---------------------------------------------
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
UITaskbarItem({
icon: trash.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'],
app: 'trash',
name: `${i18n('trash')}`,
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
onClick: function () {
let open_windows = $(`.window[data-path="${html_encode(window.trash_path)}"]`);
if ( open_windows.length === 0 ) {
launch_app({ name: 'explorer', path: window.trash_path });
} else {
open_windows.focusWindow();
}
},
onItemsDrop: function (items) {
window.move_items(items, window.trash_path);
},
});
//---------------------------------------------
// add separator before trash
//---------------------------------------------
UITaskbarItem({
icon: '', // No icon for separator
name: 'separator',
app: 'separator',
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
disable_context_menu: true,
style: 'pointer-events: none;', // Make it non-interactive
onClick: function () {
// Separator is non-interactive
return false;
},
});
window.make_taskbar_sortable();
}
//-------------------------------------------
// Taskbar is sortable
//-------------------------------------------
window.make_taskbar_sortable = function () {
const position = window.taskbar_position || 'bottom';
const axis = position === 'bottom' ? 'x' : 'y';
$('.taskbar-sortable').sortable({
axis: axis,
items: '.taskbar-item-sortable:not(.has-open-contextmenu):not([data-app="separator"])',
cancel: '.has-open-contextmenu',
placeholder: 'taskbar-item-sortable-placeholder',
helper: 'clone',
distance: 5,
revert: 10,
update: function (event, ui) {
// only update the DB if the sorted item is pinned
if ( $(ui.item).attr('data-keep-in-taskbar') === 'true' ) {
window.update_taskbar();
}
},
});
};
// Function to update taskbar position
window.update_taskbar_position = async function (new_position) {
// Prevent position changes on mobile devices - always keep bottom
if ( isMobile.phone || isMobile.tablet ) {
return;
}
if ( ! TASKBAR_POSITIONS.includes(new_position) ) {
return;
}
// Store the new position
puter.kv.set('taskbar_position', new_position).catch(err => console.warn('Could not save taskbar_position:', err));
window.taskbar_position = new_position;
// Remove old position classes and add new one
$('.taskbar').removeClass('taskbar-position-left taskbar-position-bottom taskbar-position-right');
$('.taskbar').addClass(`taskbar-position-${new_position}`);
// update desktop class, if left or right, add `desktop-taskbar-position-left` or `desktop-taskbar-position-right`
$('.desktop').removeClass('desktop-taskbar-position-left');
$('.desktop').removeClass('desktop-taskbar-position-right');
$('.desktop').addClass(`desktop-taskbar-position-${new_position}`);
// Update desktop height/width calculations based on new position
window.update_desktop_dimensions_for_taskbar();
// Update window positions if needed (for maximized windows)
$('.window[data-is_maximized="1"]').each(function () {
const el_window = this;
window.update_maximized_window_for_taskbar(el_window);
});
// Re-initialize sortable with correct axis
$('.taskbar-sortable').sortable('destroy');
window.make_taskbar_sortable();
// Adjust taskbar item sizes for the new position
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
// Reinitialize all taskbar item tooltips with new position
$('.taskbar-item').each(function () {
const $item = $(this);
// Destroy existing tooltip
if ( $item.data('ui-tooltip') ) {
$item.tooltip('destroy');
}
// Helper function to get tooltip position based on taskbar position
function getTooltipPosition () {
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
return {
my: 'center bottom-20',
at: 'center top',
};
} else if ( taskbarPosition === 'top' ) {
return {
my: 'center top+20',
at: 'center bottom',
};
} else if ( taskbarPosition === 'left' ) {
return {
my: 'left+20 center',
at: 'right center',
};
} else if ( taskbarPosition === 'right' ) {
return {
my: 'right-20 center',
at: 'left center',
};
}
return {
my: 'center bottom-20',
at: 'center top',
}; // fallback
}
const tooltipPosition = getTooltipPosition();
// Reinitialize tooltip with new position
$item.tooltip({
items: ".taskbar:not(.children-have-open-contextmenu) .taskbar-item:not([data-app='separator'])",
position: {
my: tooltipPosition.my,
at: tooltipPosition.at,
using: function ( position, feedback ) {
$(this).css( position);
$('<div>')
.addClass( 'arrow')
.addClass( feedback.vertical)
.addClass( feedback.horizontal)
.appendTo( this);
},
},
});
});
};
// Function to update desktop dimensions based on taskbar position
window.update_desktop_dimensions_for_taskbar = function () {
const position = window.taskbar_position || 'bottom';
if ( position === 'bottom' ) {
$('.desktop').css({
'height': `calc(100vh - ${window.taskbar_height + window.toolbar_height}px)`,
'width': '100%',
'left': '0',
'top': `${window.toolbar_height}px`,
});
} else if ( position === 'left' ) {
$('.desktop').css({
'height': `calc(100vh - ${window.toolbar_height}px)`,
'width': `calc(100% - ${window.taskbar_height}px)`,
'left': `${window.taskbar_height}px`,
'top': `${window.toolbar_height}px`,
});
} else if ( position === 'right' ) {
$('.desktop').css({
'height': `calc(100vh - ${window.toolbar_height}px)`,
'width': `calc(100% - ${window.taskbar_height}px)`,
'left': '0',
'top': `${window.toolbar_height}px`,
});
}
};
//-------------------------------------------
// Dynamic taskbar item resizing for left/right positions
//-------------------------------------------
window.adjust_taskbar_item_sizes = function () {
const position = window.taskbar_position || 'bottom';
// Only apply to left and right positions
if ( position !== 'left' && position !== 'right' ) {
// Reset to default sizes for bottom position
$('.taskbar .taskbar-item').css({
'width': '40px',
'height': '40px',
'min-width': '40px',
'min-height': '40px',
});
$('.taskbar-icon').css('height', '40px');
return;
}
const taskbar = $('.taskbar')[0];
const taskbarItems = $('.taskbar .taskbar-item:visible');
if ( !taskbar || taskbarItems.length === 0 ) return;
// Get available height (minus padding)
const totalItemsNeeded = taskbarItems.length;
const taskbarHeight = taskbar.clientHeight;
const paddingTop = 20; // from CSS
const paddingBottom = 20; // from CSS
const availableHeight = taskbarHeight - paddingTop - paddingBottom - 180;
// Calculate space needed with default sizes
const defaultItemSize = 40;
const defaultMargin = 5;
const spaceNeededDefault = (totalItemsNeeded * defaultItemSize) + ((totalItemsNeeded - 1) * defaultMargin);
if ( spaceNeededDefault <= availableHeight ) {
// No overflow, use default sizes
taskbarItems.css({
'width': '40px',
'height': '40px',
'min-width': '40px',
'min-height': '40px',
'padding': '6px 5px 10px 5px', // default padding
});
$('.taskbar-icon').css('height', `${defaultItemSize }px`);
$('.taskbar-icon').css('width', '40px');
$('.taskbar-icon > img').css('width', 'auto');
$('.taskbar-icon > img').css('margin', 'auto');
$('.taskbar-icon > img').css('display', 'block');
// Reset margins to default
taskbarItems.css('margin-bottom', '5px');
taskbarItems.last().css('margin-bottom', '0px');
} else {
// Overflow detected, calculate smaller sizes
// Reserve some margin space (minimum 2px between items)
const minMargin = 2;
const marginSpace = (totalItemsNeeded - 1) * minMargin;
const availableForItems = availableHeight - marginSpace;
const newItemSize = Math.floor(availableForItems / totalItemsNeeded);
// Ensure minimum size of 20px
const finalItemSize = Math.max(20, newItemSize);
// Calculate proportional padding based on size ratio
const sizeRatio = finalItemSize / defaultItemSize;
const paddingTop = Math.max(1, Math.floor(6 * sizeRatio));
const paddingRight = Math.max(1, Math.floor(5 * sizeRatio));
const paddingBottom = Math.max(1, Math.floor(10 * sizeRatio));
const paddingLeft = Math.max(1, Math.floor(5 * sizeRatio));
// Apply new sizes and padding
taskbarItems.css({
'width': '40px',
'height': `${finalItemSize }px`,
'min-width': '40px',
'min-height': `${finalItemSize }px`,
'padding': `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px`,
});
$('.taskbar-icon').css('height', `${finalItemSize }px`);
$('.taskbar-icon').css('width', '40px');
$('.taskbar-icon > img').css('width', 'auto');
$('.taskbar-icon > img').css('margin', 'auto');
$('.taskbar-icon > img').css('display', 'block');
// Adjust margins
taskbarItems.css('margin-bottom', `${minMargin }px`);
taskbarItems.last().css('margin-bottom', '0px');
}
};
// Hook into existing taskbar functionality
$(document).ready(function () {
// Watch for taskbar item changes
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if ( mutation.type === 'childList' || mutation.type === 'attributes' ) {
// Delay to ensure DOM updates are complete
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
}
});
});
// Start observing when taskbar is available
const checkTaskbar = setInterval(() => {
const taskbar = document.querySelector('.taskbar-sortable');
if ( taskbar ) {
observer.observe(taskbar, {
childList: true,
attributes: true,
subtree: true,
});
clearInterval(checkTaskbar);
// Initial call
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 100);
}
}, 100);
// Also watch for window resize events
window.addEventListener('resize', () => {
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
});
});
export default UITaskbar;
-75
View File
@@ -1,75 +0,0 @@
// @vitest-environment jsdom
/*
* 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 { afterEach, beforeEach, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
globalThis.$ = globalThis.jQuery = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => String(value ?? '');
globalThis.isMobile = { phone: false, tablet: false };
await import('../lib/jquery-ui-1.13.2/jquery-ui.js');
const { default: UITaskbar } = await import('./UITaskbar.js');
let requests;
afterEach(() => {
$('.taskbar').remove();
$(document).off();
$(window).off();
document.body.innerHTML = '';
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
beforeEach(async () => {
document.body.innerHTML = '<div class="desktop"></div>';
Object.assign(window, {
global_element_id: 0,
taskbar_height: 50,
toolbar_height: 0,
api_origin: 'http://api.test',
auth_token: 'test-token',
icons: {},
user: { taskbar_items: [{ name: 'notes', title: 'Notes' }] },
trash_path: '/test/Trash',
});
vi.stubGlobal('puter', {
kv: { get: async () => 'bottom' },
fs: { stat: async () => ({ is_empty: true }) },
});
requests = [];
$.ajaxTransport('+*', (options) => {
requests.push(options.url);
return { send () {}, abort () {} };
});
await UITaskbar();
});
it('starts the taskbar at Explorer, followed by pinned apps and Trash', () => {
const apps = $('.taskbar .taskbar-item').map((_, el) => el.dataset.app).get();
expect(apps).toEqual(['explorer', 'separator', 'notes', 'separator', 'trash']);
});
it('does not fetch launch apps when the taskbar loads', () => {
expect(requests.filter(url => url.includes('/get-launch-apps'))).toEqual([]);
});
-531
View File
@@ -1,531 +0,0 @@
/*
* 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 UIContextMenu from './UIContextMenu.js';
import path from '../lib/path.js';
import launch_app from '../helpers/launchApp.js';
import { user_facing_windows } from '../helpers/windowVisibility.js';
import { appIconFallbackAttr } from '../helpers/appIcon.js';
let tray_item_id = 1;
function UITaskbarItem (options) {
let h = '';
tray_item_id++;
options.sortable = options.sortable ?? true;
options.open_windows_count = options.open_windows_count ?? 0;
options.lock_keep_in_taskbar = options.lock_keep_in_taskbar ?? false;
options.before_trash = options.before_trash ?? false;
// The windows this item stands for: the user's own. An instance another
// app launched in the background is not counted in data-open-windows and
// has no item of its own (see UIWindow's taskbar block) — it must not be
// shown, hidden, or closed through the item its app's OTHER windows own.
const item_windows = () => $(user_facing_windows($(`.window[data-app="${options.app}"]`)));
const element_id = window.global_element_id++;
h += `<div class = "taskbar-item ${options.sortable ? 'taskbar-item-sortable' : ''} disable-user-select"
id = "taskbar-item-${tray_item_id}"
data-taskbar-item-id = "${tray_item_id}"
data-element-id = "${html_encode(element_id)}"
data-name = "${html_encode(options.name)}"
data-app = "${html_encode(options.app)}"
data-keep-in-taskbar = "${html_encode(options.keep_in_taskbar ?? 'false')}"
data-open-windows="${(options.open_windows_count)}"
title = "${html_encode(options.name)}"
style= "${options.style ? html_encode(options.style) : ''}"
>`;
let icon = options.icon ? options.icon : window.icons['app.svg'];
// App icons carry a retry URL (see helpers/appIcon.js); the bundled icons
// below never need one.
let icon_fallback = options.iconFallback ?? '';
if ( options.app === 'explorer' )
{
icon = window.icons['folders.svg'];
icon_fallback = '';
}
// taskbar icon
h += '<div class="taskbar-icon">';
// Don't add img tag for separator
if ( options.app !== 'separator' ) {
h += `<img src="${html_encode(icon)}"${appIconFallbackAttr(icon_fallback)} style="${options.group === 'apps' ? 'filter:none;' : ''}">`;
}
h += '</div>';
// active indicator
if ( options.app !== 'apps' )
{
h += '<span class="active-taskbar-indicator"></span>';
}
h += '</div>';
if ( options.before_trash ) {
$('.taskbar-sortable').append(h);
} else {
if ( options.sortable )
{
$('.taskbar-sortable').append(h);
}
else {
// if taskbar-sortable is empty then append before it
if ( $('.taskbar-sortable').children().length === 0 )
{
$('.taskbar').find('.taskbar-sortable').before(h);
}
else
{
$('.taskbar').find('.taskbar-sortable').after(h);
}
}
}
const el_taskbar_item = document.querySelector(`#taskbar-item-${tray_item_id}`);
// fade in the taskbar item
$(el_taskbar_item).show(50);
// Adjust taskbar item sizes after adding new item
if ( window.adjust_taskbar_item_sizes ) {
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 100);
}
$(el_taskbar_item).on('click', function (e) {
e.preventDefault();
e.stopPropagation();
// Don't handle clicks for separators
if ( options.app === 'separator' ) {
return;
}
// If this item has an open context menu, don't do anything
if ( $(el_taskbar_item).hasClass('has-open-contextmenu') )
{
return;
}
if ( options.onClick === undefined || options.onClick(el_taskbar_item) === false ) {
// re-show each window in this app group
item_windows().showWindow();
}
});
$(el_taskbar_item).on('contextmenu taphold', function (e) {
// seems like the only way to stop sortable is to destroy it
if ( options.sortable ) {
$('.taskbar-sortable').sortable('destroy');
}
e.preventDefault();
e.stopPropagation();
// Don't show context menu for separators
if ( options.app === 'separator' ) {
return;
}
// If context menu is disabled on this item, return
if ( options.disable_context_menu )
{
return;
}
// don't allow context menu to open if it's already open
if ( $(el_taskbar_item).hasClass('has-open-contextmenu') )
{
return;
}
const menu_items = [];
const open_windows = parseInt($(el_taskbar_item).attr('data-open-windows'));
// -------------------------------------------
// List of open windows belonging to this app
// -------------------------------------------
item_windows().each(function () {
menu_items.push({
html: $(this).find('.window-head-title').html(),
val: $(this).attr('data-id'),
onClick: function (e) {
$(`.window[data-id="${e.value}"]`).showWindow();
},
});
});
// -------------------------------------------
// divider
// -------------------------------------------
if ( menu_items.length > 0 )
{
menu_items.push('-');
}
//------------------------------------------
// New Window
//------------------------------------------
if ( options.app && options.app !== 'trash' ) {
menu_items.push({
html: i18n('new_window'),
val: $(this).attr('data-id'),
onClick: function () {
// is trash?
launch_app({
name: options.app,
maximized: (isMobile.phone || isMobile.tablet),
});
},
});
}
//------------------------------------------
// Open Trash
//------------------------------------------
else if ( options.app && options.app === 'trash' ) {
menu_items.push({
html: i18n('open_trash'),
val: $(this).attr('data-id'),
onClick: function () {
launch_app({
name: options.app,
path: window.trash_path,
maximized: (isMobile.phone || isMobile.tablet),
});
},
});
}
//------------------------------------------
// Empty Trash
//------------------------------------------
if ( options.app && options.app === 'trash' ) {
// divider
menu_items.push('-');
// Empty Trash menu item
menu_items.push({
html: i18n('empty_trash'),
val: $(this).attr('data-id'),
onClick: async function () {
window.empty_trash();
},
});
}
//------------------------------------------
// Remove from Taskbar
//------------------------------------------
if ( options.keep_in_taskbar && !options.lock_keep_in_taskbar ) {
menu_items.push({
html: i18n('remove_from_taskbar'),
val: $(this).attr('data-id'),
onClick: function () {
$(el_taskbar_item).attr('data-keep-in-taskbar', 'false');
if ( $(el_taskbar_item).attr('data-open-windows') === '0' ) {
window.remove_taskbar_item(el_taskbar_item);
}
window.update_taskbar();
options.keep_in_taskbar = false;
},
});
}
//------------------------------------------
// Keep in Taskbar
//------------------------------------------
else if ( ! options.keep_in_taskbar ) {
menu_items.push({
html: i18n('keep_in_taskbar'),
val: $(this).attr('data-id'),
onClick: function () {
$(el_taskbar_item).attr('data-keep-in-taskbar', 'true');
window.update_taskbar();
options.keep_in_taskbar = true;
},
});
}
if ( open_windows > 0 ) {
// -------------------------------------------
// divider
// -------------------------------------------
menu_items.push('-');
// -------------------------------------------
// Show All Windows
// -------------------------------------------
menu_items.push({
html: i18n('show_all_windows'),
onClick: function () {
item_windows().showWindow();
},
});
// -------------------------------------------
// Hide All Windows
// -------------------------------------------
menu_items.push({
html: i18n('hide_all_windows'),
onClick: function () {
if ( open_windows > 0 )
{
item_windows().hideWindow();
}
},
});
// -------------------------------------------
// Close All Windows
// -------------------------------------------
menu_items.push({
html: i18n('close_all_windows'),
onClick: function () {
// "All" means the windows this item lists — a background
// helper belongs to the app running it, and goes when
// that app does (UIWindow's close path).
item_windows().close();
},
});
}
const pos = el_taskbar_item.getBoundingClientRect();
UIContextMenu({
parent_element: el_taskbar_item,
position: getContextMenuPosition(pos),
items: menu_items,
});
return false;
});
// Helper function to get tooltip position based on taskbar position
function getTooltipPosition () {
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
return {
my: 'center bottom-20',
at: 'center top',
};
} else if ( taskbarPosition === 'top' ) {
return {
my: 'center top+20',
at: 'center bottom',
};
} else if ( taskbarPosition === 'left' ) {
return {
my: 'left+20 center',
at: 'right center',
};
} else if ( taskbarPosition === 'right' ) {
return {
my: 'right-20 center',
at: 'left center',
};
}
return {
my: 'center bottom-20',
at: 'center top',
}; // fallback
}
// Helper function to get context menu position based on taskbar position
function getContextMenuPosition (pos) {
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
return {
top: pos.top - 15,
left: pos.left + 5,
};
} else if ( taskbarPosition === 'top' ) {
return {
top: pos.bottom + 15,
left: pos.left + 5,
};
} else if ( taskbarPosition === 'left' ) {
return {
top: pos.top + 5,
left: pos.right + 5,
};
} else if ( taskbarPosition === 'right' ) {
return {
top: pos.top + 5,
left: pos.left - 20,
};
}
return {
top: pos.top - 15,
left: pos.left + 5,
}; // fallback
}
const tooltipPosition = getTooltipPosition();
$(el_taskbar_item).tooltip({
// only show tooltip if desktop is not selectable active
items: ".desktop:not(.desktop-selectable-active) .taskbar:not(.children-have-open-contextmenu) .taskbar-item:not([data-app='separator'])",
position: {
my: tooltipPosition.my,
at: tooltipPosition.at,
using: function ( position, feedback ) {
$(this).css( position);
$('<div>')
.addClass( 'arrow')
.addClass( feedback.vertical)
.addClass( feedback.horizontal)
.appendTo( this);
},
},
});
// --------------------------------------------------------
// Droppable
// --------------------------------------------------------
// Don't make separators droppable
if ( options.app !== 'separator' ) {
$(el_taskbar_item).droppable({
accept: '.item',
// 'pointer' is very important because of active window tracking is based on the position of cursor.
tolerance: 'pointer',
drop: async function ( event, ui ) {
// Check if hovering over an item that is VISIBILE
if ( $(event.target).closest('.window').attr('data-id') !== $(window.mouseover_window).attr('data-id') )
{
return;
}
// If ctrl is pressed and source is Trashed, cancel whole operation
if ( event.ctrlKey && path.dirname($(ui.draggable).attr('data-path')) === window.trash_path )
{
return;
}
const items_to_move = [];
// First item
items_to_move.push(ui.draggable);
// All subsequent items
const cloned_items = document.getElementsByClassName('item-selected-clone');
for ( let i = 0; i < cloned_items.length; i++ ) {
const source_item = document.getElementById(`item-${ $(cloned_items[i]).attr('data-id')}`);
if ( source_item !== null )
{
items_to_move.push(source_item);
}
}
// --------------------------------------------------------
// If `options.onItemsDrop` is set, call it with the items to move
//--------------------------------------------------------
if ( options.onItemsDrop && typeof options.onItemsDrop === 'function' ) {
options.onItemsDrop(items_to_move);
return;
}
// --------------------------------------------------------
// If dropped on an app, open the app with the dropped item as an argument
//--------------------------------------------------------
else if ( options.app ) {
// an array that hold the items to sign
const items_to_sign = [];
// prepare items to sign
for ( let i = 0; i < items_to_move.length; i++ ) {
items_to_sign.push({
name: $(items_to_move[i]).attr('data-name'),
uid: $(items_to_move[i]).attr('data-uid'),
action: 'write',
path: $(items_to_move[i]).attr('data-path'),
});
}
// open each item
for ( let i = 0; i < items_to_sign.length; i++ ) {
const item = items_to_sign[i];
launch_app({
name: options.app,
file_path: item.path,
// app_obj: open_item_meta.suggested_apps[0],
window_title: item.name,
file_uid: item.uid,
// file_signature: item,
});
}
// deselect dragged item
for ( let i = 0; i < items_to_move.length; i++ )
{
$(items_to_move[i]).removeClass('item-selected');
}
}
// Unselect directory/app if item is dropped
if ( options.is_dir || options.app ) {
$(el_taskbar_item).removeClass('active');
$(el_taskbar_item).tooltip('close');
$('.ui-draggable-dragging .item-name, .item-selected-clone .item-name').css('opacity', 'initial');
$('.item-container').removeClass('item-container-transparent-border');
}
// Re-enable droppable on all item-container
$('.item-container').droppable('enable');
return false;
},
over: function (event, ui) {
// Check hovering over an item that is VISIBILE
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') )
{
return;
}
// Don't do anything if the dragged item is NOT a UIItem
if ( ! $(ui.draggable).hasClass('item') )
{
return;
}
// If this is a directory or an app, and an item was dragged over it, highlight it.
if ( options.is_dir || options.app ) {
$(el_taskbar_item).addClass('active');
// show tooltip of this item
$(el_taskbar_item).tooltip().mouseover();
// make item name partially transparent
$('.ui-draggable-dragging .item-name, .item-selected-clone .item-name').css('opacity', 0.1);
// remove all item-container active borders
$('.item-container').addClass('item-container-transparent-border');
}
// Disable all window bodies
$('.item-container').droppable( 'disable');
},
out: function (event, ui) {
// Don't do anything if the dragged item is NOT a UIItem
if ( ! $(ui.draggable).hasClass('item') )
{
return;
}
// Unselect directory/app if item is dragged out
if ( options.is_dir || options.app ) {
$(el_taskbar_item).removeClass('active');
$(el_taskbar_item).tooltip('close');
$('.ui-draggable-dragging .item-name, .item-selected-clone .item-name').css('opacity', 'initial');
$('.item-container').removeClass('item-container-transparent-border');
}
$('.item-container').droppable( 'enable');
},
});
}
return el_taskbar_item;
}
export default UITaskbarItem;
+122
View File
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
/*
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
globalThis.$ = globalThis.jQuery = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => String(value ?? '');
globalThis.isMobile = { phone: false, tablet: false };
globalThis.def = globalThis.def ?? ((value) => value);
await import('../lib/jquery-ui-1.13.2/jquery-ui.js');
// The page loads this plugin from a vendored script; nothing here drops files.
$.fn.dragster = function () {
return this;
};
const { default: UIWindow } = await import('./UIWindow.js');
const openWindow = (options = {}) => UIWindow({
title: 'Notes',
app: 'notes',
body_content: '<p>hello</p>',
width: 400,
height: 300,
...options,
});
/** Opens the window head's context menu and returns its item labels. */
const headMenuLabels = (el_window) => {
$(el_window).find('.window-head').trigger('contextmenu');
return $('.context-menu .contextmenu-label').map((_, el) => $(el).text().toLowerCase()).get();
};
beforeEach(() => {
document.body.innerHTML = '<div class="desktop"></div>';
Object.assign(window, {
global_element_id: 0,
window_counter: 0,
last_window_zindex: 1,
window_stack: [],
icons: {},
uuidv4: () => crypto.randomUUID(),
is_dashboard_mode: false,
is_fullpage_mode: false,
is_embedded: false,
});
});
afterEach(() => {
$(document).off();
document.body.innerHTML = '';
});
describe('minimize', () => {
it('is not offered on the desktop, where nothing could restore the window', async () => {
const el_window = await openWindow();
expect($(el_window).find('.window-minimize-btn').length).toBe(0);
const labels = headMenuLabels(el_window);
expect(labels.some((label) => label === 'maximize')).toBe(true);
expect(labels.some((label) => label === 'minimize')).toBe(false);
});
it('is offered on the dashboard, which restores the window from its tile', async () => {
window.is_dashboard_mode = true;
const el_window = await openWindow();
expect($(el_window).find('.window-minimize-btn').length).toBe(1);
expect(headMenuLabels(el_window).some((label) => label === 'minimize')).toBe(true);
});
it('hides the window in place and brings it back', async () => {
window.is_dashboard_mode = true;
const el_window = await openWindow();
const before = { top: el_window.style.top, left: el_window.style.left };
await $(el_window).hideWindow();
expect($(el_window).attr('data-is_minimized')).toBe('true');
expect($(el_window).attr('data-minimized_in_place')).toBe('1');
await $(el_window).showWindow({ no_history: true });
expect($(el_window).attr('data-is_minimized')).toBe('false');
expect({ top: el_window.style.top, left: el_window.style.left }).toEqual(before);
});
});
describe('backdrop', () => {
const backdropZ = (el_window) => Number($(el_window).closest('.window-backdrop').css('z-index'));
it('stays above ordinary windows on the desktop', async () => {
const el_window = await openWindow({ backdrop: true });
expect(backdropZ(el_window)).toBeGreaterThan(window.last_window_zindex + 1000);
});
it('stacks at the window counter on the dashboard', async () => {
window.is_dashboard_mode = true;
const el_window = await openWindow({ backdrop: true });
expect(backdropZ(el_window)).toBeLessThanOrEqual(window.last_window_zindex);
});
});
+267 -842
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -782,7 +782,6 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
width: Math.min(480, window.innerWidth - 24),
height: 'auto',
dominant: true,
show_in_taskbar: false,
is_draggable: false,
backdrop: true,
on_before_exit: async () => {
-1
View File
@@ -215,7 +215,6 @@ async function UIWindowAuthMe (options = {}) {
width: 400,
height: 'auto',
dominant: true,
show_in_taskbar: false,
window_class: 'window-authme',
body_css: {
width: 'initial',
-1
View File
@@ -87,7 +87,6 @@ async function UIWindowBlockedSenders (options) {
width: 420,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.blocked-username').get(0)?.focus({ preventScroll: true });
},
-1
View File
@@ -68,7 +68,6 @@ async function UIWindowChangeUsername (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.new-username').get(0)?.focus({ preventScroll: true });
const oidc_only = !!(window.user && window.user.oidc_only);
-1
View File
@@ -114,7 +114,6 @@ function UIWindowCopyToken (options = {}) {
width: 450,
height: 'auto',
dominant: true,
show_in_taskbar: false,
window_class: 'window-copy-auth-token',
body_css: {
width: 'initial',
@@ -57,7 +57,6 @@ async function UIWindowLoginInProgress (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
backdrop: true,
stay_on_top: true,
window_class: 'window-login-progress',
-1
View File
@@ -100,7 +100,6 @@ async function UIWindowNewPassword (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.new-password').get(0)?.focus({ preventScroll: true });
},
@@ -71,7 +71,6 @@ function UIWindowPasswordChangeRequired (options) {
width: 390,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.pcr-current').get(0)?.focus({ preventScroll: true });
},
-136
View File
@@ -1,136 +0,0 @@
/*
* 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 UIWindow from './UIWindow.js';
import { underPath } from '../helpers/pathElements.js';
async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_dir_path) {
let h = '';
h += '<div class="window-publishWebsite-content" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
// success
h += '<div class="window-publishWebsite-success">';
h += `<img src="${html_encode(window.icons['c-check.svg'])}" style="width:80px; height:80px; display: block; margin:10px auto;">`;
h += `<p style="text-align:center;">${i18n('dir_published_as_website', `<strong>${html_encode(target_dir_name)}</strong>`, false)}<p>`;
h += `<p style="text-align:center;"><a class="publishWebsite-published-link" target="_blank"></a><img class="publishWebsite-published-link-icon" src="${html_encode(window.icons['launch.svg'])}"></p>`;
h += `<button class="button button-normal button-block button-primary publish-window-ok-btn" style="margin-top:20px;">${i18n('ok')}</button>`;
h += '</div>';
// form
h += '<form class="window-publishWebsite-form">';
// error msg
h += '<div class="publish-website-error-msg"></div>';
// Subdomain input
h += '<div class="puter-subdomain-section" style="overflow: hidden; margin-bottom: 20px;">';
h += `<label style="margin-bottom: 10px; display: block;">${i18n('pick_name_for_website')}</label>`;
h += '<div style="font-family: monospace; display: flex; align-items: center; background: #f8f9fa; padding: 8px; border-radius: 6px; border: 1px solid #dee2e6;">';
h += `<span style="color: #666;">${html_encode(window.extractProtocol(window.url))}://</span>`;
h += '<input class="publish-website-subdomain" style="border: none; background: #ffffff; outline: none; padding: 7px !important; " type="text" autocomplete="subdomain" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>';
h += `<span style="color: #666;">.${html_encode(window.hosting_domain)}</span>`;
h += '</div>';
h += '</div>';
// uid
h += `<input class="publishWebsiteTargetDirUID" type="hidden" value="${html_encode(target_dir_uid)}"/>`;
// Publish
h += `<button class="publish-btn button button-action button-block button-normal">${i18n('publish')}</button>`;
h += '</form>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('window_title_publish_website'),
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: true,
selectable_body: false,
draggable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: true,
allow_user_select: true,
width: 450,
dominant: true,
onAppend: function (this_window) {
$(this_window).find('.publish-website-subdomain').val(window.generate_identifier());
$(this_window).find('.publish-website-subdomain').get(0).focus({ preventScroll: true });
},
window_class: 'window-publishWebsite',
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
},
});
$(el_window).find('.publish-btn').on('click', async function (e) {
e.preventDefault();
// disable 'Publish' button
$(el_window).find('.publish-btn').prop('disabled', true);
try {
let subdomain = $(el_window).find('.publish-website-subdomain').val();
if ( ! subdomain.trim() ) {
throw new Error('Please enter a subdomain name');
}
const res = await puter.hosting.create(subdomain, target_dir_path);
let url = `https://${ subdomain }.${ window.hosting_domain }/`;
// Show success
$(el_window).find('.window-publishWebsite-form').hide(100, function () {
$(el_window).find('.publishWebsite-published-link').attr('href', url);
$(el_window).find('.publishWebsite-published-link').text(url);
$(el_window).find('.window-publishWebsite-success').show(100);
$(`.item[data-uid="${target_dir_uid}"] .item-has-website-badge`).show();
});
// find all items whose path starts with target_dir_path
underPath('.item', target_dir_path).each(function () {
// show the link badge
$(this).find('.item-has-website-url-badge').show();
// update item's website_url attribute
$(this).attr('data-website_url', url + $(this).attr('data-path').substring(target_dir_path.length));
});
window.update_sites_cache();
} catch ( err ) {
const errorMessage = err.message || (err.error && err.error.message) || 'An error occurred while publishing';
$(el_window).find('.publish-website-error-msg').html(errorMessage);
$(el_window).find('.publish-website-error-msg').fadeIn();
// re-enable 'Publish' button
$(el_window).find('.publish-btn').prop('disabled', false);
}
});
$(el_window).find('.publish-window-ok-btn').on('click', function () {
$(el_window).close();
});
}
export default UIWindowPublishWebsite;
-1
View File
@@ -91,7 +91,6 @@ async function UIWindowSaveAccount (options) {
allow_user_select: true,
width: 350,
dominant: true,
show_in_taskbar: false,
...options.window_options,
onAppend: function (this_window) {
if ( options.default_username )
-7
View File
@@ -8691,13 +8691,6 @@ body.myapps-reordering .myapps-tile {
margin-left: auto;
}
/* Dashboard mode runs fullpage (which normally hides the minimize button
because there's no taskbar to restore from), but here a minimized app can
be brought back from its Apps-tab tile — so keep the button available. */
body.dashboard-mode .window-minimize-btn {
display: inline;
}
/* Fullpage mode sizes .window-body-app to 100% because fullpage windows have
no titlebar — but dashboard-mode app windows WITH a head still lose 29px to
it, so compensate for those. Headless dashboard app windows
+12 -1518
View File
File diff suppressed because it is too large. Load diff
+3 -22
View File
@@ -25,12 +25,9 @@ window.window_nav_history_current_position = {};
window.dashboard_nav_history = [];
window.dashboard_nav_history_current_position = 0;
window.progress_tracker = [];
window.upload_item_global_id = 0;
window.app_instance_ids = new Set();
window.menubars = [];
window.download_progress = [];
window.download_item_global_id = 0;
// This is the minimum width of the window for the sidebar to be shown
window.window_width_threshold_for_sidebar = 500;
@@ -113,9 +110,6 @@ if ( window.user_preferences === null ) {
}
window.window_stack = [];
window.toolbar_height = 0;
window.default_taskbar_height = 50;
window.taskbar_height = window.default_taskbar_height;
window.upload_progress_hide_delay = 500;
window.active_uploads = {};
window.copy_progress_hide_delay = 1000;
@@ -130,7 +124,6 @@ window.window_counter = 0;
window.keypress_item_seach_term = '';
window.keypress_item_seach_buffer_timeout = undefined;
window.first_visit_animation = false;
window.show_twitter_link = true;
window.animate_window_opening = true;
window.animate_window_closing = true;
window.desktop_loading_fade_delay = (window.first_visit_ever && window.first_visit_animation ? 6000 : 1000);
@@ -153,20 +146,15 @@ window.launch_apps = [];
window.launch_apps.recent = [];
window.launch_apps.recommended = [];
// Map of { child_instance_id -> { parent_instance_id, launch_msg_id } }
window.child_launch_callbacks = {};
// Is puter being loaded inside an iframe?
if ( window.location !== window.parent.location ) {
window.is_embedded = true;
// taskbar is not needed in embedded mode
window.taskbar_height = 0;
} else {
window.is_embedded = false;
}
// calculate desktop height and width
window.desktop_height = window.innerHeight - window.toolbar_height - window.taskbar_height;
window.desktop_height = window.innerHeight;
window.desktop_width = window.innerWidth;
// {id: {left: 0, top: 0}}
@@ -180,7 +168,7 @@ $(window).on( 'resize', function () {
if ( window.a_window_is_resizing ) return;
if ( window.a_window_sidebar_is_resizing ) return;
const new_desktop_height = window.innerHeight - window.toolbar_height - window.taskbar_height;
const new_desktop_height = window.innerHeight;
const new_desktop_width = window.innerWidth;
window.desktop_height = new_desktop_height;
@@ -188,7 +176,7 @@ $(window).on( 'resize', function () {
// Update all maximized windows to fit the new viewport
$('.window[data-is_maximized="1"]').each(function () {
window.update_maximized_window_for_taskbar(this);
window.update_maximized_window(this);
});
});
@@ -200,10 +188,6 @@ window.active_element = null;
// The number of recent apps to show in the launch menu
window.launch_recent_apps_count = 10;
// indicated if the mouse is in one of the window snap zones or not
// if yes, which one?
window.current_active_snap_zone = undefined;
//
window.is_fullpage_mode = false;
@@ -220,9 +204,6 @@ window.feature_flags = {
download_directory: true,
};
window.is_auto_arrange_enabled = true;
window.desktop_item_positions = {};
window.reset_item_positions = true; // The variable decides if the item positions should be reset when the user enabled auto arrange
window.file_templates = [];
+4 -108
View File
@@ -615,7 +615,7 @@ window.check_fsentry_against_allowed_file_types_string = function (fsentry, allo
window.refresh_user_data = async (auth_token) => {
let whoami;
try {
whoami = await puter.os.user({ query: 'icon_size=64' });
whoami = await puter.os.user();
} catch (e) {
// Ignored
}
@@ -677,6 +677,7 @@ window.update_auth_data = async (auth_token, user) => {
const to_storable_user = user => {
const storable_user = { ...user };
// Older sessions stored taskbar_items, with app icons, in these copies.
delete storable_user.taskbar_items;
return storable_user;
};
@@ -1726,14 +1727,13 @@ window.trigger_download = (paths) => {
/**
* Updates every Trash icon in the UI to reflect whether the trash is empty:
* the taskbar item, desktop items (and shortcuts to Trash), open explorer
* window head icons, and the Dashboard's Files-tab sidebar.
* desktop items (and shortcuts to Trash), open explorer window head icons,
* and the Dashboard's Files-tab sidebar.
*
* @param {boolean} is_empty - Whether the trash is empty
*/
window.update_trash_icons = function (is_empty) {
const icon = is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
$('[data-app="trash"]').find('.taskbar-icon > img').attr('src', icon);
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', icon);
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.window-head-icon').attr('src', icon);
$('.directories [data-folder="Trash"] img').attr('src', icon);
@@ -2819,10 +2819,6 @@ window.extractSubdomain = function (url) {
return subdomain;
};
window.extractProtocol = function (url) {
var protocol = url.split('://')[0];
return protocol;
};
window.sleep = function (ms) {
return new Promise(resolve => setTimeout(resolve, ms));
};
@@ -3420,57 +3416,6 @@ window.undo_delete = async (items) => {
}
};
window.store_auto_arrange_preference = (preference) => {
// localStorage still carries it for this device either way.
puter.kv.set('user_preferences.auto_arrange_desktop', preference)
.catch(err => console.warn('Could not save auto_arrange_desktop:', err));
localStorage.setItem('auto_arrange', preference);
};
window.get_auto_arrange_data = async () => {
// Best-effort, like every other preference read: falling back to the
// default keeps the desktop arranging itself rather than not appearing.
const preferenceValue = await puter.kv.get('user_preferences.auto_arrange_desktop').catch(() => null);
window.is_auto_arrange_enabled = preferenceValue === null ? true : preferenceValue;
const positions = await puter.kv.get('desktop_item_positions').catch(() => null);
window.desktop_item_positions = (!positions || typeof positions !== 'object' || Array.isArray(positions)) ? {} : positions;
};
window.clear_desktop_item_positions = async (el_desktop) => {
$(el_desktop).find('.item').each(function () {
const el_item = $(this)[0];
$(el_item).css('position', '');
$(el_item).css('left', '');
$(el_item).css('top', '');
});
if ( window.reset_item_positions ) {
window.delete_desktop_item_positions();
}
};
window.set_desktop_item_positions = async (el_desktop) => {
$(el_desktop).find('.item').each(async function () {
const position = window.desktop_item_positions[$(this).attr('data-uid')];
const el_item = $(this)[0];
if ( position ) {
$(el_item).css('position', 'absolute');
$(el_item).css('left', `${position.left }px`);
$(el_item).css('top', `${position.top }px`);
}
});
};
window.save_desktop_item_positions = () => {
puter.kv.set('desktop_item_positions', window.desktop_item_positions)
.catch(err => console.warn('Could not save desktop_item_positions:', err));
};
window.delete_desktop_item_positions = () => {
window.desktop_item_positions = {};
puter.kv.del('desktop_item_positions')
.catch(err => console.warn('Could not clear desktop_item_positions:', err));
};
// Finds the `.window` element for the given app instance ID
window.window_for_app_instance = (instance_id) => {
return $(`.window[data-element_uuid="${instance_id}"]`).get(0);
@@ -3526,22 +3471,6 @@ window.set_menu_item_prop = (items, item_id, prop, val) => {
}
};
window.countSubstr = (str, substring) => {
if ( substring.length === 0 ) {
return 0;
}
let count = 0;
let pos = str.indexOf(substring);
while ( pos !== -1 ) {
count++;
pos = str.indexOf(substring, pos + 1);
}
return count;
};
// Updates the signed-in user's profile; `username` is kept for callers that
// still pass it.
window.update_profile = function (username, key_vals) {
@@ -3556,44 +3485,11 @@ window.update_profile = function (username, key_vals) {
});
};
window.blob2str = (blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(blob);
});
};
// Another user's picture is only available while they are on a paid plan.
window.get_profile_picture = async function (username) {
return (await puter.auth.getProfilePicture(username)) ?? undefined;
};
window.format_with_units = (num, { mulUnits, divUnits, precision = 3 }) => {
if ( num === 0 ) return '0';
mulUnits = mulUnits ?? ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
divUnits = divUnits ?? ['m', 'µ', 'n', 'p', 'f', 'a', 'z', 'y'];
const abs = Math.abs(num);
let exp = Math.floor(Math.log10(abs) / 3);
let symbol = '';
symbol = exp >= 0
? mulUnits[exp]
: divUnits[-exp - 1] ;
if ( ! symbol ) {
symbol = `e${exp * 3}`;
}
const scaled = num / Math.pow(10, exp * 3);
const rounded = Number.parseFloat(scaled.toPrecision(precision));
return `${rounded}${symbol}`;
};
/**
* General-purpose number formatting function with support for decimal places,
* thousand separators, and various formatting options.
+2 -2
View File
@@ -29,8 +29,8 @@ const FALLBACK_ATTR = 'data-icon-fallback';
/**
* Pick the URL to load an app's icon from, plus the one URL to retry with.
*
* @param {Object} app - App record from the backend (taskbar item, launch app,
* installedApps row). Reads `iconCdnUrl` and `iconUrl`/`icon`.
* @param {Object} app - App record from the backend (launch app, installedApps
* row). Reads `iconCdnUrl` and `iconUrl`/`icon`.
* @param {string} [defaultIcon] - Used when the app carries no icon at all.
* @returns {{ src: string; fallback: string }} `fallback` is empty when there
* is nothing left to try.
+2 -2
View File
@@ -57,8 +57,8 @@ describe('authLogoHeader', () => {
});
it('makes the logo a named link when it leads somewhere', () => {
const h = authLogoHeader({ logoSrc: LOGO, logoHref: '/desktop' });
expect(h).toContain('<a class="auth-logo-link" href="/desktop">');
const h = authLogoHeader({ logoSrc: LOGO, logoHref: '/' });
expect(h).toContain('<a class="auth-logo-link" href="/">');
expect(h).toContain('alt="Puter"');
});
+5 -6
View File
@@ -63,11 +63,11 @@ export const get_auth_redirect_url = () => {
*/
const oidc_return_path = () => {
const pathname = window.location.pathname;
if ( pathname === '/' || pathname === '/desktop' || pathname === '/dashboard' ) {
if ( pathname === '/' || pathname === '/dashboard' ) {
return pathname;
}
// app landing: normalize away a trailing slash to match the backend whitelist
if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) {
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
return pathname.replace(/\/$/, '');
}
return null;
@@ -76,10 +76,9 @@ const oidc_return_path = () => {
/**
* The `return_to` to send along when starting an OIDC flow, or null if the
* current page isn't one the backend will return to. The backend strictly
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
* and direct app landings (`/app/<name>`, plus the desktop-booted
* `/desktop/app/<name>`), so OIDC login started from an app landing comes back
* to the app — and to the same interface it was opened in.
* whitelists these (never a client-supplied URL): `/dashboard` and direct app
* landings (`/app/<name>`), so OIDC login started from an app landing comes
* back to the app.
*
* A share link (`?shared=`, from an email) is carried along with the path: the
* recipient usually has to sign in before they can see what was shared, and an
+7 -11
View File
@@ -42,7 +42,6 @@ afterEach(() => {
describe('get_oidc_return_to', () => {
it('returns the interface pages the backend whitelists', () => {
expect(at('/desktop')).toBe('/desktop');
expect(at('/dashboard')).toBe('/dashboard');
});
@@ -51,17 +50,12 @@ describe('get_oidc_return_to', () => {
expect(at('/app/editor/')).toBe('/app/editor');
});
it('keeps a desktop app landing on the desktop', () => {
expect(at('/desktop/app/editor')).toBe('/desktop/app/editor');
expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
});
it('carries a share link so the item survives the round trip', () => {
expect(at('/', share_search(shared_path('Report.pdf')))).toBe(
`/${share_search(shared_path('Report.pdf'))}`,
);
expect(at('/desktop', share_search(shared_path('Report.pdf')))).toBe(
`/desktop${share_search(shared_path('Report.pdf'))}`,
expect(at('/dashboard', share_search(shared_path('Report.pdf')))).toBe(
`/dashboard${share_search(shared_path('Report.pdf'))}`,
);
expect(
at('/', share_search(shared_path('a.txt'), shared_path('b.txt'))),
@@ -83,10 +77,10 @@ describe('get_oidc_return_to', () => {
expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
expect(at('/', '?shared=')).toBe(null);
// other parameters are not ours to carry
expect(at('/desktop', '?app=editor')).toBe('/desktop');
expect(at('/dashboard', '?app=editor')).toBe('/dashboard');
expect(
at('/desktop', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/desktop${share_search(shared_path('a.txt'))}`);
at('/dashboard', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/dashboard${share_search(shared_path('a.txt'))}`);
});
it('returns null for anything the backend would reject', () => {
@@ -94,6 +88,8 @@ describe('get_oidc_return_to', () => {
expect(at('/settings')).toBe(null);
expect(at('/action/login')).toBe(null);
expect(at('/app/editor/extra')).toBe(null);
expect(at('/desktop')).toBe(null);
expect(at('/desktop/app/editor')).toBe(null);
expect(at('/desktop/app/editor/extra')).toBe(null);
expect(at('/dashboard/app/editor')).toBe(null);
});
@@ -17,30 +17,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const Component = use('util.Component');
/**
* Allows using an HTML string as a component.
* Whether a page load boots the dashboard instead of the desktop. The root
* path and `/app/<name>` boot the dashboard unless the page is an iframe embed
* or a popup, which the desktop still handles; `/dashboard` always does.
*
* @param {Object} location
* @param {string} location.pathname
* @param {boolean} location.inIframe
* @param {boolean} location.embeddedInPopup
* @returns {boolean}
*/
export default def(class JustHTML extends Component {
static ID = 'ui.component.JustHTML';
static PROPERTIES = { html: { value: '' } };
create_template ({ template }) {
$(template).html('<span></span>');
}
on_ready ({ listen }) {
listen('html', html => {
$(this.dom_).find('span').html(html);
});
}
_set_dom_based_on_render_mode ({ property_values }) {
if ( property_values.no_shadow ) {
this.dom_ = this;
return;
}
return super._set_dom_based_on_render_mode();
}
});
export function bootsDashboard ({ pathname, inIframe, embeddedInPopup }) {
if ( pathname === '/dashboard' || pathname === '/dashboard/' ) return true;
const isAppLanding = /^\/app\/[^/]+\/?$/.test(pathname);
if ( pathname !== '/' && !isAppLanding ) return false;
return !embeddedInPopup && !inIframe;
}
@@ -0,0 +1,58 @@
/*
* 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, it, expect } from 'vitest';
import { bootsDashboard } from './bootsDashboard.js';
const boots = (url, { inIframe = false, embeddedInPopup = false } = {}) => {
const { pathname } = new URL(url, 'https://puter.com');
return bootsDashboard({ pathname, inIframe, embeddedInPopup });
};
describe('bootsDashboard', () => {
it('boots the dashboard at the root, its alias, and app landings', () => {
expect(boots('/')).toBe(true);
expect(boots('/dashboard')).toBe(true);
expect(boots('/dashboard/')).toBe(true);
expect(boots('/app/editor')).toBe(true);
});
it('boots the desktop on action pages', () => {
expect(boots('/action/sign-in')).toBe(false);
});
it('boots the desktop for flows only the desktop handles', () => {
expect(boots('/', { inIframe: true })).toBe(false);
expect(boots('/', { embeddedInPopup: true })).toBe(false);
});
it('keeps /dashboard on the dashboard even inside an iframe', () => {
expect(boots('/dashboard', { inIframe: true })).toBe(true);
});
it('boots the dashboard whatever the query asks for', () => {
expect(boots('/?shared=%2Fann%2Fuid%2Fnotes.txt')).toBe(true);
expect(boots('/?download=https%3A%2F%2Fexample.com%2Ffile.zip')).toBe(true);
expect(boots('/?app=explorer&path=%2Falice%2FDocuments')).toBe(true);
expect(boots('/app/editor?app=explorer')).toBe(true);
expect(boots('/app/editor?puter.fullpage=true')).toBe(true);
expect(boots('/app/editor?puter.fullpage=false')).toBe(true);
expect(boots('/?puter.fullpage=1')).toBe(true);
});
});
+6 -12
View File
@@ -20,7 +20,6 @@
import UIAlert from '../UI/UIAlert.js';
import UIWindowItemProperties from '../UI/UIWindowItemProperties.js';
import publish_as_website from './publishAsWebsite.js';
import open_item from './openItem.js';
import openWithAI from './openWithAI.js';
import launch_app from './launchApp.js';
@@ -44,7 +43,7 @@ import { is_default_app } from './defaultApp.js';
* @param {boolean} options.openWithInNewTab - Open the "Open With" apps in a new browser tab instead of an in-page window (used by Dashboard)
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onShare - Opens sharing for the item; Share is left out of the menu without it. Receives {name, path, uid, element}
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPublishWebsite - Publishes the folder as a website; Publish as Website is left out of the menu without it. Receives {uid, name, path}
* @param {Function} options.onPublishWorker - Publishes the file as a worker; Publish as Worker is left out of the menu without it. 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 dropped the user's own access (used by Dashboard)
@@ -135,24 +134,19 @@ const generate_file_context_menu = async function (options) {
// -------------------------------------------
// Publish As Website
// -------------------------------------------
if ( !is_trashed && !is_trash && fsentry.is_dir ) {
if ( !is_trashed && !is_trash && fsentry.is_dir && options.onPublishWebsite ) {
menu_items.push({
html: i18n('publish_as_website'),
// Publishing serves the folder to anyone, for good, so it takes the
// same own-it-or-`manage` right as sharing it does (SubdomainDriver
// enforces that). `write` on someone else's folder is not enough.
disabled: !fsentry.is_dir || fsentry.has_website || !may_share,
onClick: async function () {
const target = {
onClick: function () {
options.onPublishWebsite({
uid: fsentry.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
};
if ( options.onPublishWebsite ) {
options.onPublishWebsite(target);
} else {
await publish_as_website(target);
}
});
},
});
}
@@ -465,7 +459,7 @@ const generate_file_context_menu = async function (options) {
left = left > (window.innerWidth - window_width) ? (window.innerWidth - window_width) : left;
let top = $(el_item).position().top + $(el_item).height();
top = top > (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) ? (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) : top;
top = top > (window.innerHeight - window_height) ? (window.innerHeight - window_height) : top;
UIWindowItemProperties(
$(el_item).attr('data-name'),
@@ -23,7 +23,6 @@ import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIWindowItemProperties.js', () => ({ default: vi.fn() }));
vi.mock('./publishAsWebsite.js', () => ({ default: vi.fn() }));
vi.mock('./openItem.js', () => ({ default: vi.fn() }));
vi.mock('./openWithAI.js', () => ({ default: vi.fn() }));
vi.mock('./launchApp.js', () => ({ default: vi.fn() }));
@@ -114,6 +113,37 @@ describe('Share', () => {
});
});
describe('Publish as Website', () => {
const publishItem = async (options) => {
is_owned_by_me.mockReturnValueOnce(true);
can_share.mockResolvedValueOnce(true);
const row = document.createElement('div');
row.setAttribute('data-path', '/me/Site');
row.setAttribute('data-name', 'Site');
const items = await generate_file_context_menu({
element: row,
fsentry: { uid: 'dir-1', name: 'Site', path: '/me/Site', is_dir: true },
associated_app_name: 'editor',
...options,
});
return items.find((item) => item !== '-' && item.html === 'publish_as_website');
};
it('hands the folder to onPublishWebsite', async () => {
const onPublishWebsite = vi.fn();
await (await publishItem({ onPublishWebsite })).onClick();
expect(onPublishWebsite).toHaveBeenCalledWith({
uid: 'dir-1',
name: 'Site',
path: '/me/Site',
});
});
it('is left out without onPublishWebsite', async () => {
expect(await publishItem({})).toBeUndefined();
});
});
describe('Publish as Worker', () => {
const publishItem = async (options) => {
is_owned_by_me.mockReturnValueOnce(true);
+105
View File
@@ -0,0 +1,105 @@
/*
* 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';
/**
* A token's claims as key-sorted `[key, value]` pairs in JSON, without `iat`.
* Read without checking the signature, so it only says what the token claims.
*
* @param {unknown} token
* @returns {string | null} null when the token isn't a readable JWT
*/
export const tokenClaimsKey = (token) => {
const claims = typeof token === 'string' ? jwtPayload(token) : null;
if ( ! claims || Array.isArray(claims) ) return null;
return JSON.stringify(Object.keys(claims)
.filter(key => key !== 'iat')
.sort()
.map(key => [key, claims[key]]));
};
/**
* Whether two tokens claim exactly the same things apart from when they were
* signed. Two GUI tokens that do are for the same session, user and type.
*
* @param {unknown} a
* @param {unknown} b
* @returns {boolean}
*/
export const sameTokenClaims = (a, b) => {
const key = tokenClaimsKey(a);
return key !== null && key === tokenClaimsKey(b);
};
/**
* The digest the shell sends as `guiTokenDigest`: SHA-256 of
* {@link tokenClaimsKey}, first 32 hex chars. Must match
* `TokenService.claimsDigest` on the server.
*
* @param {unknown} token
* @param {SubtleCrypto | null} [subtle] - null on an insecure origin
* @returns {Promise<string | null>} null when the token isn't readable or
* there is no SubtleCrypto
*/
export const tokenClaimsDigest = async (token, subtle = globalThis.crypto?.subtle) => {
const key = tokenClaimsKey(token);
if ( key === null || ! subtle ) return null;
const hash = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(key)));
return Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('').slice(0, 32);
};
/**
* Whether the server rendered this page for this load. A history navigation or
* a restored tab can be a cached copy, rendered for a cookie that has changed.
*
* @param {Performance} [perf]
* @returns {boolean}
*/
export const pageRenderedForThisLoad = (perf = globalThis.performance) => {
const nav = perf?.getEntriesByType?.('navigation')?.[0];
if ( ! nav ) return false;
return (nav.type === 'navigate' || nav.type === 'reload') && nav.transferSize > 0;
};
/**
* What boot does about `/get-gui-token`, which hands over a token for the
* session cookie:
* - `fetch`: ask for it.
* - `same-session`: the stored token is already the one it would hand over.
* - `no-session`: there is no session cookie, so it would refuse.
*
* Anything short of a `guiTokenDigest` from this very page load is `fetch`.
*
* @param {Object} opts
* @param {Record<string, unknown> | undefined} opts.params - gui_params
* @param {string | null} opts.storedToken
* @param {boolean} opts.sameOrigin - whether the page and `/get-gui-token` share an origin
* @param {boolean} opts.renderedForThisLoad - {@link pageRenderedForThisLoad}
* @param {SubtleCrypto | null} [opts.subtle]
* @returns {Promise<'fetch' | 'same-session' | 'no-session'>}
*/
export const guiTokenBootStep = async ({ params, storedToken, sameOrigin, renderedForThisLoad, subtle }) => {
if ( ! params || ! Object.hasOwn(params, 'guiTokenDigest') ) return 'fetch';
if ( ! sameOrigin || ! renderedForThisLoad ) return 'fetch';
const hint = params.guiTokenDigest;
if ( hint === null ) return 'no-session';
if ( typeof hint !== 'string' || ! storedToken ) return 'fetch';
return await tokenClaimsDigest(storedToken, subtle) === hint ? 'same-session' : 'fetch';
};
+134
View File
@@ -0,0 +1,134 @@
/*
* 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 { createHash, webcrypto } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
guiTokenBootStep,
pageRenderedForThisLoad,
sameTokenClaims,
tokenClaimsDigest,
tokenClaimsKey,
} from './guiToken.js';
const jwtWith = (claims) =>
`h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`;
const CLAIMS = { t: 'gui', v: '2', u: 'c2Vzc2lvbg==', su: 'c2Vzc2lvbg==', uu: 'dXNlcg==', ai: 'dXNlcg==' };
const TOKEN = jwtWith({ ...CLAIMS, iat: 1 });
const subtle = webcrypto.subtle;
describe('tokenClaimsKey', () => {
it('sorts the claims and leaves out iat', () => {
expect(tokenClaimsKey(jwtWith({ b: 1, a: 'x', iat: 5 }))).toBe('[["a","x"],["b",1]]');
});
it('is null for anything that is not a readable JWT', () => {
expect(tokenClaimsKey('not-a-token')).toBe(null);
expect(tokenClaimsKey(null)).toBe(null);
expect(tokenClaimsKey(`h.${Buffer.from('[1]').toString('base64url')}.s`)).toBe(null);
});
});
describe('sameTokenClaims', () => {
it('is true for two tokens signed at different times for the same session', () => {
expect(sameTokenClaims(TOKEN, jwtWith({ ...CLAIMS, iat: 2 }))).toBe(true);
});
it('is false when any other claim differs, is added or is missing', () => {
expect(sameTokenClaims(TOKEN, jwtWith({ ...CLAIMS, su: 'b3RoZXI=', iat: 2 }))).toBe(false);
expect(sameTokenClaims(TOKEN, jwtWith({ ...CLAIMS, uu: 'b3RoZXI=', iat: 2 }))).toBe(false);
expect(sameTokenClaims(TOKEN, jwtWith({ ...CLAIMS, t: 's', iat: 2 }))).toBe(false);
expect(sameTokenClaims(TOKEN, jwtWith({ ...CLAIMS, x: true, iat: 2 }))).toBe(false);
const { ai, ...withoutAuthId } = CLAIMS;
expect(sameTokenClaims(TOKEN, jwtWith({ ...withoutAuthId, iat: 2 }))).toBe(false);
});
it('is false when either token is unreadable', () => {
expect(sameTokenClaims('garbage', 'garbage')).toBe(false);
expect(sameTokenClaims(TOKEN, null)).toBe(false);
expect(sameTokenClaims(null, null)).toBe(false);
});
});
describe('tokenClaimsDigest', () => {
it('is the first 32 hex chars of SHA-256 over the claims key', async () => {
const expected = createHash('sha256').update(tokenClaimsKey(TOKEN)).digest('hex').slice(0, 32);
expect(await tokenClaimsDigest(TOKEN, subtle)).toBe(expected);
expect(await tokenClaimsDigest(jwtWith({ ...CLAIMS, iat: 9 }), subtle)).toBe(expected);
});
it('is null without SubtleCrypto or a readable token', async () => {
expect(await tokenClaimsDigest(TOKEN, null)).toBe(null);
expect(await tokenClaimsDigest('garbage', subtle)).toBe(null);
});
});
describe('pageRenderedForThisLoad', () => {
const perfWith = (entry) => ({ getEntriesByType: (type) => (type === 'navigation' && entry ? [entry] : []) });
it('is true for a navigation or reload the server answered', () => {
expect(pageRenderedForThisLoad(perfWith({ type: 'navigate', transferSize: 900 }))).toBe(true);
expect(pageRenderedForThisLoad(perfWith({ type: 'reload', transferSize: 300 }))).toBe(true);
});
it('is false for a history navigation, a cached copy, or no timing at all', () => {
expect(pageRenderedForThisLoad(perfWith({ type: 'back_forward', transferSize: 900 }))).toBe(false);
expect(pageRenderedForThisLoad(perfWith({ type: 'navigate', transferSize: 0 }))).toBe(false);
expect(pageRenderedForThisLoad(perfWith({ type: 'navigate' }))).toBe(false);
expect(pageRenderedForThisLoad(perfWith(null))).toBe(false);
expect(pageRenderedForThisLoad({})).toBe(false);
expect(pageRenderedForThisLoad(undefined)).toBe(false);
});
});
describe('guiTokenBootStep', () => {
const step = async (overrides) => guiTokenBootStep({
params: { guiTokenDigest: await tokenClaimsDigest(TOKEN, subtle) },
storedToken: jwtWith({ ...CLAIMS, iat: 3 }),
sameOrigin: true,
renderedForThisLoad: true,
subtle,
...overrides,
});
it('keeps the stored token when the shell names its session', async () => {
expect(await step({})).toBe('same-session');
});
it('skips the request when the shell says there is no session cookie', async () => {
expect(await step({ params: { guiTokenDigest: null } })).toBe('no-session');
expect(await step({ params: { guiTokenDigest: null }, storedToken: null })).toBe('no-session');
});
it('asks when the shell names another session or nothing is stored', async () => {
expect(await step({ storedToken: jwtWith({ ...CLAIMS, su: 'b3RoZXI=' }) })).toBe('fetch');
expect(await step({ storedToken: null })).toBe('fetch');
expect(await step({ params: { guiTokenDigest: 42 } })).toBe('fetch');
});
it('asks whenever the shell\'s word can\'t be taken', async () => {
// An older server, or one that couldn't tell.
expect(await step({ params: {} })).toBe('fetch');
expect(await step({ params: undefined })).toBe('fetch');
expect(await step({ sameOrigin: false })).toBe('fetch');
expect(await step({ renderedForThisLoad: false })).toBe('fetch');
expect(await step({ renderedForThisLoad: false, params: { guiTokenDigest: null } })).toBe('fetch');
expect(await step({ subtle: null })).toBe('fetch');
});
});
+2 -18
View File
@@ -106,9 +106,6 @@ const runLaunch = async (options) => {
const launchAttributes = {
'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown',
'launch.dashboard_mode': !! window.is_dashboard_mode,
// url_paths has the `/desktop` prefix stripped, so this counts the
// desktop-booted landing (`/desktop/app/<name>`) as the app URL
// it is.
'launch.from_app_url':
window.url_paths?.[0]?.toLocaleLowerCase() === 'app'
&& !! window.url_paths?.[1],
@@ -625,14 +622,14 @@ const runLaunch = async (options) => {
window_height = parseFloat(app_info.metadata.window_size.height);
} if ( options.maximized )
{
window_height = `calc(100% - ${window.taskbar_height + window.toolbar_height + 1}px)`;
window_height = 'calc(100% - 1px)';
}
// top
let top;
if ( app_info.metadata?.window_position?.top !== undefined && app_info.metadata?.window_position?.top !== '' )
{
top = parseFloat(app_info.metadata.window_position.top) + window.toolbar_height + 1;
top = parseFloat(app_info.metadata.window_position.top) + 1;
}
if ( options.maximized )
{
@@ -678,18 +675,6 @@ const runLaunch = async (options) => {
title = options.file_path ? path.basename(options.file_path) : title;
}
// show_in_taskbar
// Deliberately keyed on the app's own `background`, not on this launch's:
// an app that always runs windowless has nothing to put in the taskbar,
// ever. A background *launch* is a normal app that happens to start
// hidden, so it keeps asking for an item — UIWindow just holds it back
// until the window is first shown (see its taskbar block).
let show_in_taskbar = app_info.background ? false : window_options?.show_in_taskbar;
if ( window_options?.show_in_taskbar !== undefined )
{
show_in_taskbar = window_options.show_in_taskbar;
}
// has_head
let has_head = app_info.metadata?.has_head !== undefined ? app_info.metadata.has_head : window_options?.has_head;
if ( app_info.metadata?.hide_titlebar !== undefined && typeof app_info.metadata.hide_titlebar === 'boolean' && app_info.metadata.hide_titlebar === true )
@@ -748,7 +733,6 @@ const runLaunch = async (options) => {
...window_options,
is_resizable: window_resizable,
has_head: has_head,
show_in_taskbar: show_in_taskbar,
update_window_url: update_window_url,
custom_path: custom_path,
});
+4 -4
View File
@@ -276,9 +276,9 @@ Please try recreating the link.`);
}
}
//----------------------------------------------------------------
// Dashboard: is this file already open in an app window? In
// dashboard mode there is no taskbar — the file's row is where a
// user goes looking for a window they minimized — so restore that
// Dashboard: is this file already open in an app window? The
// file's row is where a user goes looking for a window they
// minimized, so restore that
// window (edits intact) instead of minting a second instance.
//----------------------------------------------------------------
// The user's own windows only: a file opened in an instance another app
@@ -323,7 +323,7 @@ Please try recreating the link.`);
app: 'explorer',
top: options.maximized ? 0 : undefined,
left: options.maximized ? 0 : undefined,
height: options.maximized ? `calc(100% - ${window.taskbar_height + window.toolbar_height + 1}px)` : undefined,
height: options.maximized ? 'calc(100% - 1px)' : undefined,
width: options.maximized ? '100%' : undefined,
});
}
+5 -3
View File
@@ -84,8 +84,9 @@ export function shared_link_recipient_uuid (params) {
* that account's saved session, ask whether to sign in to it, or nothing.
*
* Popups, embeds (`embedded`) and `action` flows act for whoever opened them,
* so a link must not change their account. A temporary session is left to the
* share link's own sign-in prompt.
* so a link must not change their account. The desktop (`desktop`) doesn't
* open share links, so it never acts on the hint. A temporary session is left
* to the share link's own sign-in prompt.
*
* @returns {{ switch_to: object } | { ask: true } | null}
*/
@@ -93,10 +94,11 @@ export function shared_link_account_step ({
params,
action,
embedded,
desktop,
current_user,
logged_in_users,
}) {
if ( action || embedded ) return null;
if ( action || embedded || desktop ) return null;
const uuid = shared_link_recipient_uuid(params);
if ( ! uuid || uuid === current_user?.uuid?.toLowerCase() ) return null;
const saved = (logged_in_users ?? []).find(user =>
@@ -127,6 +127,10 @@ describe('shared_link_account_step', () => {
expect(step({ action: 'login' })).toBeNull();
});
it('leaves the desktop alone, since it does not open share links', () => {
expect(step({ desktop: true })).toBeNull();
});
it('leaves a temporary session to the share link sign-in', () => {
const temp = { uuid: CURRENT, is_temp: true };
expect(step({ current_user: temp, logged_in_users: [temp] }))
-40
View File
@@ -1,40 +0,0 @@
/*
* 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 UIWindowPublishWebsite from '../UI/UIWindowPublishWebsite.js';
import ensure_can_publish from './ensureCanPublish.js';
/**
* Opens the "Publish as website" flow for a directory, first walking the user
* through account creation and email confirmation when the deployment requires
* a verified email to publish. Returns without publishing if the user backs out
* of either step.
*
* @param {Object} dir - The directory to publish
* @param {string} dir.uid - UID of the directory
* @param {string} dir.name - Display name of the directory
* @param {string} dir.path - Full path of the directory
* @returns {Promise<void>}
*/
const publish_as_website = async function ({ uid, name, path }) {
if ( ! await ensure_can_publish(window.require_email_verification_to_publish_website) ) return;
await UIWindowPublishWebsite(uid, name, path);
};
export default publish_as_website;
@@ -246,7 +246,6 @@ const refresh_item_container = function (el_item_container, options) {
if ( item_path !== window.trash_path && item_path !== window.appdata_path ) {
// if this is trash, get original name from item metadata
fsentry.name = metadata.original_name ?? fsentry.name;
const position = window.desktop_item_positions[fsentry.uid] ?? undefined;
UIItem({
appendTo: el_item_container,
uid: fsentry.uid,
@@ -269,7 +268,6 @@ const refresh_item_container = function (el_item_container, options) {
suggested_apps: fsentry.suggested_apps,
disabled: is_disabled,
visible: visible,
position: position,
shared_with_me: fsentry.shared_with_me,
share_mode: fsentry.share_mode,
shared_by: fsentry.shared_by,
@@ -281,31 +279,6 @@ const refresh_item_container = function (el_item_container, options) {
}
}
// if this is desktop, add Trash
if ( $(el_item_container).hasClass('desktop') ) {
try {
const trash = await puter.fs.stat({ path: window.trash_path, consistency: options.consistency ?? 'eventual' });
if ( ! is_current() ) {
return;
}
UIItem({
appendTo: el_item_container,
uid: trash.id,
immutable: trash.immutable,
path: window.trash_path,
icon: { image: (trash.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']), type: 'icon' },
name: trash.name,
is_dir: trash.is_dir,
sort_by: trash.sort_by,
type: trash.type,
is_trash: true,
sortable: false,
});
window.sort_items(el_item_container, $(el_item_container).attr('data-sort_by'), $(el_item_container).attr('data-sort_order'));
} catch (e) {
// Ignored
}
}
// sort items
window.sort_items(
el_item_container,
@@ -47,7 +47,6 @@ Object.assign(window, {
shared_path: '/alice/Shared',
trash_path: '/alice/Trash',
user_preferences: {},
desktop_item_positions: {},
check_fsentry_against_allowed_file_types_string: () => true,
sort_items: () => {},
update_explorer_footer_item_count: () => {},
+3 -4
View File
@@ -23,10 +23,9 @@
* for one (`puter.ui.launchApp(name, args, { background: true })`), which lets a
* dual-purpose app serve another app without a window flashing on screen.
*
* Either way the window starts out of sight and out of reach: no taskbar item,
* no running dot, and not what reopening the app finds (see
* user_facing_windows) — it earns all three the moment it first becomes
* visible, and an always-windowless app never asks for a taskbar item at all.
* Either way the window starts out of sight and out of reach: no running dot,
* and not what reopening the app finds (see user_facing_windows). It earns both
* the moment it first becomes visible.
*
* @param {{ background?: boolean }} [appInfo] the app record being launched
* @param {{ background?: boolean }} [options] this launch's options
@@ -21,47 +21,6 @@ const update_mouse_position = function (x, y) {
window.mouseX = x;
window.mouseY = y;
// mouse in top-left corner of screen
if ( (window.mouseX < 150 && window.mouseY < window.toolbar_height + 20) || (window.mouseX < 20 && window.mouseY < 150) )
{
window.current_active_snap_zone = 'nw';
}
// mouse in left edge of screen
else if ( window.mouseX < 20 && window.mouseY >= 150 && window.mouseY < window.desktop_height - 150 )
{
window.current_active_snap_zone = 'w';
}
// mouse in bottom-left corner of screen
else if ( window.mouseX < 20 && window.mouseY > window.desktop_height - 150 )
{
window.current_active_snap_zone = 'sw';
}
// mouse in right edge of screen
else if ( window.mouseX > window.desktop_width - 20 && window.mouseY >= 150 && window.mouseY < window.desktop_height - 150 )
{
window.current_active_snap_zone = 'e';
}
// mouse in top-right corner of screen
else if ( (window.mouseX > window.desktop_width - 150 && window.mouseY < window.toolbar_height + 20) || (window.mouseX > window.desktop_width - 20 && window.mouseY < 150) )
{
window.current_active_snap_zone = 'ne';
}
// mouse in bottom-right corner of screen
else if ( window.mouseX > window.desktop_width - 20 && window.mouseY >= window.desktop_height - 150 )
{
window.current_active_snap_zone = 'se';
}
// mouse in top edge of screen
else if ( window.mouseY < window.toolbar_height + 20 && window.mouseX >= 150 && window.mouseX < window.desktop_width - 150 )
{
window.current_active_snap_zone = 'n';
}
// not in any snap zone
else
{
window.current_active_snap_zone = undefined;
}
// mouseover_window
var windows = document.getElementsByClassName('window');
let active_win;
+4 -17
View File
@@ -21,22 +21,9 @@
* Splits a GUI pathname into the route segments the boot code matches on
* (`window.url_paths`), dropping empty segments.
*
* A leading `desktop` segment is dropped with them: `/desktop` only picks the
* interface, and everything after it is the same route it would be at the
* root. So `/desktop/app/<name>` yields `['app', '<name>']` — the app landing
* every route check downstream already understands — and the only difference
* from `/app/<name>` is that the dashboard-mode check (see initgui.js) doesn't
* claim the path, leaving the app to open on the desktop.
*
* @param {string} pathname - the pathname to parse, e.g. `/desktop/app/editor`
* @param {string} pathname - the pathname to parse, e.g. `/app/editor`
* @returns {string[]} route segments
*/
export const parse_url_paths = (pathname) => {
const paths = String(pathname ?? '')
.split('/')
.filter((element) => element);
if ( paths[0]?.toLocaleLowerCase() === 'desktop' ) {
paths.shift();
}
return paths;
};
export const parse_url_paths = (pathname) => String(pathname ?? '')
.split('/')
.filter((element) => element);
+3 -20
View File
@@ -28,29 +28,12 @@ describe('parse_url_paths', () => {
expect(parse_url_paths('')).toEqual([]);
});
it('drops a leading desktop segment so the rest reads as a root route', () => {
it('keeps a leading desktop segment, so /desktop/app/<name> is not an app landing', () => {
expect(parse_url_paths('/desktop')).toEqual(['desktop']);
expect(parse_url_paths('/desktop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/DeskTop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/desktop')).toEqual([]);
expect(parse_url_paths('/desktop/')).toEqual([]);
});
it('only drops the prefix, not a later or lookalike segment', () => {
expect(parse_url_paths('/app/desktop')).toEqual(['app', 'desktop']);
expect(parse_url_paths('/desktops/app/editor')).toEqual([
'desktops',
'app',
'editor',
]);
expect(parse_url_paths('/desktop/desktop/app')).toEqual([
'desktop',
'app',
'editor',
]);
});
});
+1 -1
View File
@@ -83,7 +83,7 @@ export const is_unseen_background_window = (el_window) => {
* Of the windows an app has open, the ones that are the USER's. An instance
* another app launched in the background and never showed exists to serve that
* app alone, so everything the user drives — reopening the app, its running
* dot, its taskbar item, the dashboard's Back/Forward — has to look straight
* dot, the dashboard's Back/Forward — has to look straight
* past it and act on the user's own instances instead, launching a fresh one
* when there are none. (Paths that act on the APP rather than on a window it
* happens to have open — uninstall, and the launcher's close taking its
+1 -1
View File
@@ -132,7 +132,7 @@ describe('user_facing_windows', () => {
it('keeps a background instance once it has been shown', () => {
// makeWindowVisible drops the marker: from then on the window is the
// user's, and the tile/taskbar/history paths act on it again.
// user's, and the tile and history paths act on it again.
const shown = win({ visible: '1', parent: 'parent-uuid' });
expect(user_facing_windows([shown])).toEqual([shown]);
});
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more