mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c406a5174 | ||
|
|
1b36805301 | ||
|
|
163dfd737c | ||
|
|
d1fc58c29a | ||
|
|
7ee0075186 | ||
|
|
bbeb984a91 | ||
|
|
fce5d2b08b | ||
|
|
3def512a9d | ||
|
|
a920b9782f | ||
|
|
454f94fc54 | ||
|
|
8b7d380de1 | ||
|
|
8052a4287e | ||
|
|
f114b13306 | ||
|
|
ce70728eeb | ||
|
|
8518023b0b | ||
|
|
33988ac84c |
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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', '<'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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
@@ -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 )
|
||||
|
||||
@@ -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 '/';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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 )
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+3
-22
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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] }))
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user