Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 08a1a24dfe fix(homepage): serve the shell for /@<user>/<path>
Only /@<user> was routed, so a link to something inside a user's Public
folder fell through to the not-found redirect and landed on / without
opening anything. The GUI already opens the folder in Files, or the
file in its app, once the shell is served.
2026-11-04 11:40:47 -07:00
Nariman Jelveh 620a94c7fc refactor(gui): remove the desktop; every boot opens the dashboard
The desktop (UIDesktop, the taskbar, toolbar, start menu, search, task
manager, welcome, QR and wallpaper windows) is gone. Every boot outside an
auth popup opens the dashboard, including iframe embeds and URLs that used
to load the desktop:

- `/desktop`, `/desktop/app/<name>` redirect to `/` and `/app/<name>`,
  keeping the query; the GUI also drops the prefix if one reaches it.
- `/@<user>/<path>` and `/show/<path>` open the folder in Files, or the
  file in its app. `/show` no longer carries a boot hook naming a GUI
  global that no longer exists, which had stopped the GUI booting.
- `/settings` opens Home; `?app=explorer[&path=]` opens Files.
- `/?download=<url>` keeps its confirm-and-save-to-Desktop flow.

The window system stays: the dashboard, its apps and the file pickers
run on it. Snapping, the taskbar-height layout math and the folder
layout/sort endpoints are untouched. The backend drops the
`/set-desktop-bg` and `/update-taskbar-items` endpoints and the
`taskbar_items` and `desktop_bg_*` user fields (columns kept).
2026-11-04 10:57:52 -07:00
108 changed files with 1223 additions and 11136 deletions

No files matched your search

+5 -10
View File
@@ -257,8 +257,6 @@ 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('reports the SMS-to-card fallback only once a send has opened it', async () => {
@@ -322,7 +320,7 @@ describe('whoami extension — handleWhoami', () => {
expect(flags.payment_bypass).toBeUndefined();
});
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
it('strips created_ts and human_readable_age fields for app actors', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
@@ -340,9 +338,6 @@ describe('whoami extension — handleWhoami', () => {
expect(body.app_name).toBe('app-test-actor');
// is_user_token is stripped for app actors.
expect(body.is_user_token).toBeUndefined();
expect(body.desktop_bg_url).toBeUndefined();
expect(body.desktop_bg_color).toBeUndefined();
expect(body.desktop_bg_fit).toBeUndefined();
expect(body.human_readable_age).toBeUndefined();
// Account age, in either form, is not exposed to apps.
expect(body.created_ts).toBeUndefined();
@@ -432,7 +427,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({
@@ -440,11 +435,11 @@ describe('whoami extension — handleWhoami', () => {
app: { uid: 'app-test-actor' },
}),
(_key, event) => {
event.details.taskbar_items = [{ name: 'injected' }];
event.details.directories = { '/injected': 'x' };
},
);
expect(body).not.toHaveProperty('taskbar_items');
expect(body).not.toHaveProperty('directories');
});
});
@@ -525,7 +520,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');
+3 -17
View File
@@ -1,6 +1,5 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import {
buildUserDetails,
scrubSensitive,
@@ -26,7 +25,7 @@ const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
]);
export const handleWhoami = async (
req: Request,
_req: Request,
res: Response,
): Promise<void> => {
const actor = Context.get('actor');
@@ -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))
@@ -81,9 +71,6 @@ export const handleWhoami = async (
delete details.email;
delete details.unconfirmed_email;
}
delete details.desktop_bg_url;
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.created_ts;
delete details.is_user_token;
@@ -121,8 +108,7 @@ export const handleWhoami = async (
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
{ offering?: Record<string, unknown> } | undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
+5 -19
View File
@@ -200,16 +200,6 @@ export type EventMap = {
};
// ---- Auth / signup ----
/**
* Which signup methods are open, emitted via `emitAndWait` before every
* signup and whenever the GUI reads the provider list. A listener sets
* `oidcOnly` to refuse form and temp signups, leaving OIDC and team
* provisioning open; `message` is what a refused form signup is told.
*/
'puter.signup.policy': {
oidcOnly: boolean;
message: string | null;
};
'puter.signup.validate': {
allow: boolean;
email?: string;
@@ -1051,11 +1041,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -1066,10 +1055,7 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
| 'received'
| 'unreceived'
| 'no-socket'
| 'remote-only';
'received' | 'unreceived' | 'no-socket' | 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
@@ -1416,7 +1416,6 @@ describe('AuthController.handleLogin', () => {
is_user_token: true,
});
expect(body.user.directories).toBeTypeOf('object');
expect(Array.isArray(body.user.taskbar_items)).toBe(true);
});
// A deleted row still answering from cache reaches the session INSERT.
@@ -25,18 +25,13 @@ const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Desktop/UI preference routes.
*
* Two categories:
*
* - User-level: desktop background, taskbar items (UserStore)
* - Folder-level: layout, sort_by/sort_order (fsentries table)
* Folder view preference routes: an explorer window's layout and sort order,
* kept on the folder's fsentry.
*/
/**
* Desktop preference writes — background, taskbar, layout, sort order. All four
* persist to the user row 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.
* Folder preference writes — layout, sort order. 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',
@@ -47,89 +42,6 @@ const PREFERENCE_WRITE_LIMIT = {
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Desktop background --------------------------------------
router.post(
'/set-desktop-bg',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { url, color, fit } = req.body ?? {};
const patch = {};
if (url !== undefined) {
if (url !== null && typeof url !== 'string') {
throw new HttpError(
400,
'`url` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_url = url;
}
if (color !== undefined) {
if (color !== null && typeof color !== 'string') {
throw new HttpError(
400,
'`color` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_color = color;
}
if (fit !== undefined) {
if (fit !== null && typeof fit !== 'string') {
throw new HttpError(
400,
'`fit` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_fit = fit;
}
if (Object.keys(patch).length === 0) {
throw new HttpError(400, 'No fields provided', {
legacyCode: 'bad_request',
});
}
await this.stores.user.update(req.actor.user.id, patch);
res.json({});
},
);
// -- 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(
@@ -130,150 +130,6 @@ const callRoute = async (
});
};
// ── /set-desktop-bg ─────────────────────────────────────────────────
describe('DesktopController POST /set-desktop-bg', () => {
it('persists url/color/fit on the user row', async () => {
const { actor, userId } = await makeUser();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({
body: {
url: 'https://cdn.test/wallpaper.png',
color: '#000000',
fit: 'cover',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.desktop_bg_url).toBe(
'https://cdn.test/wallpaper.png',
);
expect(refreshed?.desktop_bg_color).toBe('#000000');
expect(refreshed?.desktop_bg_fit).toBe('cover');
});
it('persists only the supplied fields (partial update)', async () => {
const { actor, userId } = await makeUser();
const { res } = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { color: '#ffffff' }, actor }),
res,
);
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.desktop_bg_color).toBe('#ffffff');
// Untouched fields remain at their column default.
expect(refreshed?.desktop_bg_url).toBeFalsy();
expect(refreshed?.desktop_bg_fit).toBeFalsy();
});
it('passes through `null` to clear a field', async () => {
const { actor, userId } = await makeUser();
// First populate, then clear.
const populate = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({
body: { url: 'https://cdn.test/x.png' },
actor,
}),
populate.res,
);
const before = await server.stores.user.getById(userId);
expect(before?.desktop_bg_url).toBe('https://cdn.test/x.png');
const clear = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { url: null }, actor }),
clear.res,
);
const after = await server.stores.user.getById(userId);
expect(after?.desktop_bg_url).toBeNull();
});
it('throws 400 when url is not a string or null', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { url: 123 }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when no fields are provided', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: {}, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── /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 });
});
});
// ── /set_layout ─────────────────────────────────────────────────────
describe('DesktopController POST /set_layout', () => {
@@ -78,11 +78,13 @@ interface CapturedResponse {
statusCode: number;
body: unknown;
contentType?: string;
location?: string;
}
const makeReq = (init: {
params?: Record<string, unknown>;
path?: string;
originalUrl?: string;
actor?: Actor;
hostname?: string;
protocol?: string;
@@ -93,6 +95,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,
@@ -130,6 +133,11 @@ const makeRes = () => {
captured.contentType = value;
return res;
}),
redirect: vi.fn((code: number, location: string) => {
captured.statusCode = code;
captured.location = location;
return res;
}),
};
return { res: res as unknown as Response, captured };
};
@@ -213,14 +221,12 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
@@ -230,6 +236,18 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
}
});
it('serves the shell on a path inside a public folder, /@:username/*splat', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/@:username/*splat',
makeReq({ path: '/@alice/photos/cat.png' }),
res,
);
expect(captured.statusCode).toBe(200);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
});
// ── /app/:name ──────────────────────────────────────────────────────
@@ -359,49 +377,6 @@ 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 () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'no-such-app' },
path: '/desktop/app/no-such-app',
}),
res,
);
expect(captured.statusCode).toBe(404);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
const { userId } = await makeUser();
const name = `pub-${Math.random().toString(36).slice(2, 10)}`;
@@ -433,8 +408,8 @@ 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']) {
it('rate-limits /app/:name per network', () => {
for (const path of ['/app/:name']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
);
@@ -452,7 +427,7 @@ describe('HomepageController app landing rate limit', () => {
// ── /show/* ─────────────────────────────────────────────────────────
describe('HomepageController GET /show/*splat', () => {
it('emits a launch_app explorer call with the post-/show path', async () => {
it('serves the plain shell; the GUI opens the path itself', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
@@ -460,13 +435,52 @@ describe('HomepageController GET /show/*splat', () => {
makeReq({ path: '/show/alice/Documents' }),
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(html).toMatch(/<!DOCTYPE html>/i);
// No boot-time launch hook: the one it used to carry named a GUI
// global that no longer exists, which stopped the GUI booting.
expect(html).not.toContain('launch_app');
});
});
// ── /desktop ────────────────────────────────────────────────────────
describe('HomepageController GET /desktop', () => {
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 }), res);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/');
}
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop',
makeReq({
path: '/desktop',
originalUrl: '/desktop?shared=%2Falice%2Fx%2Fy&download=a',
}),
res,
);
expect(captured.location).toBe('/?shared=%2Falice%2Fx%2Fy&download=a');
});
it('redirects /desktop/app/:name to the app landing', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'editor' },
path: '/desktop/app/editor',
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt',
}),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt');
});
});
@@ -25,7 +25,6 @@ import type { PuterRouter } from '../../core/http/PuterRouter';
import type {
PuterHomepageService,
PageMeta,
LaunchOptions,
} from '../../services/homepage/PuterHomepageService';
/** Longest description/og:description/twitter:description on `/app/<name>`. */
@@ -43,9 +42,9 @@ const APP_LANDING_LIMIT = {
};
/**
* App descriptions can run long; search snippets and social cards want a
* short blurb. Collapses whitespace, then cuts at a word boundary with an
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
* App descriptions can run long; search snippets and social cards want a short
* blurb. Collapses whitespace, then cuts at a word boundary with an ellipsis so
* the result never exceeds `APP_META_DESCRIPTION_MAX`.
*/
export function appMetaDescription(text: string): string {
const clean = text.replace(/\s+/g, ' ').trim();
@@ -89,7 +88,6 @@ export class HomepageController extends PuterController {
req: express.Request,
res: express.Response,
metaOverrides: Partial<PageMeta> = {},
launch: LaunchOptions = {},
) => {
const meta = { ...defaultMeta(req), ...metaOverrides };
const actor =
@@ -98,7 +96,7 @@ export class HomepageController extends PuterController {
actor?: Parameters<typeof homepage.send>[0]['actor'];
}
).actor ?? null;
await homepage.send({ req, res, actor }, meta, launch);
await homepage.send({ req, res, actor }, meta);
};
// -- Root + path-aliased shell routes ------------------------
@@ -111,17 +109,41 @@ export class HomepageController extends PuterController {
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));
// `/desktop` used to pick the desktop interface. What follows it is
// the same route at the root, so it redirects there, keeping the query
// (`?shared=`, `?download=`); the browser carries the hash over.
const redirectWithoutDesktop = (
req: express.Request,
res: express.Response,
target: string,
) => {
const url = req.originalUrl ?? req.url ?? '';
const query = url.includes('?') ? url.slice(url.indexOf('?')) : '';
res.redirect(302, `${target}${query}`);
};
router.get('/desktop', {}, (req, res) =>
redirectWithoutDesktop(req, res, '/'),
);
router.get('/desktop/', {}, (req, res) =>
redirectWithoutDesktop(req, res, '/'),
);
router.get('/desktop/app/:name', {}, (req, res) =>
redirectWithoutDesktop(
req,
res,
`/app/${encodeURIComponent(String(req.params.name))}`,
),
);
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
// `/@<user>/<path>` names something in that user's Public folder; the
// GUI opens it.
router.get('/@:username', {}, (req, res) => sendShell(req, res));
router.get('/@:username/*splat', {}, (req, res) => sendShell(req, res));
// -- /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,
@@ -176,27 +198,10 @@ 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 --
// -- /show/* - the GUI opens the path that follows ----------
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);
});
router.get('/show/*splat', {}, (req, res) => sendShell(req, res));
// -- /sw.js - the service worker, scoped to the whole origin --
@@ -244,10 +244,7 @@ describe('OIDCController GET /auth/oidc/providers', () => {
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
// We configured a single `custom` provider; the live service
// walks the config and returns its id.
expect(captured.body).toEqual({
providers: ['custom'],
signupOidcOnly: false,
});
expect(captured.body).toEqual({ providers: ['custom'] });
});
});
@@ -396,12 +396,9 @@ export class OIDCController extends PuterController {
},
},
async (_req: Request, res: Response) => {
const [providers, policy] = await Promise.all([
this.services.oidc.getEnabledProviderIds(),
this.services.signup.signupPolicy(),
]);
// The signup window drops its email form when this is set.
res.json({ providers, signupOidcOnly: policy.oidcOnly });
const providers =
await this.services.oidc.getEnabledProviderIds();
res.json({ providers });
},
);
@@ -235,56 +235,6 @@ describe('OpenAiImageProvider.generate argument validation', () => {
// ── Credit gate ─────────────────────────────────────────────────────
describe('OpenAiImageProvider.generate credit gate', () => {
it('waits for the real usage write before returning an image', async () => {
const actor = makeActor({
user: {
id: 62,
uuid: `image-write-${Date.now()}`,
username: 'metered',
},
});
const redis = server.clients.redis as unknown as {
meterIncr: (...args: string[]) => Promise<unknown>;
};
const realIncr = redis.meterIncr.bind(redis);
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let blocked = false;
vi.spyOn(redis, 'meterIncr').mockImplementation(async (...args) => {
if (!blocked && args[5].includes(`:actor:${actor.user!.uuid}:`)) {
blocked = true;
await held;
}
return realIncr(...args);
});
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://image.example/result.png' }],
});
const provider = makeProvider();
let finished = false;
const generating = withTestActor(
() =>
provider.generate({
model: 'gpt-image-2',
prompt: 'an image',
ratio: { w: 1024, h: 1024 },
}),
actor,
).then((result) => {
finished = true;
return result;
});
try {
await vi.waitFor(() => expect(blocked).toBe(true));
expect(finished).toBe(false);
} finally {
release();
await generating;
}
});
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
const provider = makeProvider();
hasCreditsSpy.mockResolvedValueOnce(false);
@@ -293,10 +293,7 @@ export class OpenAiImageProvider implements IImageProvider {
});
}
if (usageEntries.length) {
await this.#meteringService.batchIncrementUsages(
actor,
usageEntries,
);
this.#meteringService.batchIncrementUsages(actor, usageEntries);
}
const url =
-165
View File
@@ -1492,104 +1492,6 @@ describe('PuterServer keep-alive timeout', () => {
});
describe('PuterServer lifecycle hooks', () => {
it('fails startup before running later hooks when a start hook rejects', async () => {
const failure = new Error('startup failed');
const later = vi.fn();
class FailingStart extends JobScheduler {
override async onServerStart() {
throw failure;
}
}
class LaterStart extends JobScheduler {
override async onServerStart() {
later();
}
}
extension.registerService('failingStart', FailingStart as never);
extension.registerService('laterStart', LaterStart as never);
try {
await expect(setupTestServer()).rejects.toBe(failure);
expect(later).not.toHaveBeenCalled();
} finally {
delete (extensionStore.services as Record<string, unknown>)
.failingStart;
delete (extensionStore.services as Record<string, unknown>)
.laterStart;
}
});
it.each([false, true])(
'continues teardown after failing hooks with listener callback %s',
async (prepareFirst) => {
const port = await allocateEphemeralPort();
const server = await setupTestServer(
{ port } as unknown as IConfig,
{ listen: true },
);
const logged = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const ran: string[] = [];
class FailingHooks {
onServerPrepareShutdown() {
ran.push('prepare:failed');
throw new Error('prepare failed');
}
async onServerShutdown() {
ran.push('shutdown:failed');
throw new Error('shutdown failed');
}
}
(server.services as Record<string, unknown>).fails =
new FailingHooks();
(server.services as Record<string, unknown>).after = {
onServerPrepareShutdown: () => ran.push('prepare:after'),
onServerShutdown: () => ran.push('shutdown:after'),
};
(server.clients as Record<string, unknown>).last = {
onServerShutdown: () => ran.push('shutdown:client'),
};
try {
if (prepareFirst) {
await server.prepareShutdown();
await vi.waitFor(() =>
expect(ran).toContain('prepare:after'),
);
}
await server.shutdown();
expect(ran).toEqual([
'prepare:failed',
'prepare:after',
'shutdown:failed',
'shutdown:after',
'shutdown:client',
]);
expect(logged).toHaveBeenCalledWith(
'[server] onServerPrepareShutdown failed in FailingHooks',
expect.any(Error),
);
expect(logged).toHaveBeenCalledWith(
'[server] onServerShutdown failed in FailingHooks',
expect.any(Error),
);
await expect(
new Promise<void>((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1');
socket.on('connect', () => {
socket.destroy();
resolve();
});
socket.on('error', reject);
}),
).rejects.toMatchObject({ code: 'ECONNREFUSED' });
} finally {
delete (server.services as Record<string, unknown>).fails;
await server.shutdown();
logged.mockRestore();
}
},
);
it('runs drain-start and prepare-shutdown bottom-up, once, before tearing down', async () => {
const port = await allocateEphemeralPort();
const server = await setupTestServer({ port } as unknown as IConfig, {
@@ -1639,73 +1541,6 @@ describe('PuterServer lifecycle hooks', () => {
'shutdown:client',
]);
});
it('keeps accepting connections until every drain-start hook settles, without one holding up another', async () => {
const port = await allocateEphemeralPort();
const server = await setupTestServer({ port } as unknown as IConfig, {
listen: true,
});
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
let otherRan = false;
(server.services as unknown as Record<string, unknown>).holdsListener =
{ onServerDrainStart: () => held };
(server.drivers as unknown as Record<string, unknown>).other = {
onServerDrainStart: () => {
otherRan = true;
},
};
const connect = () =>
new Promise<void>((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1');
socket.on('connect', () => {
socket.destroy();
resolve();
});
socket.on('error', reject);
});
const prepared = server.prepareShutdown({ drainMs: 1_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(otherRan).toBe(true);
await expect(connect()).resolves.toBeUndefined();
release();
await prepared;
await expect(connect()).rejects.toMatchObject({
code: 'ECONNREFUSED',
});
await server.shutdown();
});
it('runs every drain-start hook when one of them throws', async () => {
const port = await allocateEphemeralPort();
const server = await setupTestServer({ port } as unknown as IConfig, {
listen: true,
});
vi.spyOn(console, 'error').mockImplementation(() => {});
const ran: string[] = [];
(server.stores as unknown as Record<string, unknown>).fails = {
onServerDrainStart: () => {
throw new Error('drain hook failed');
},
};
(server.services as unknown as Record<string, unknown>).after = {
onServerDrainStart: () => {
ran.push('after');
},
};
await server.prepareShutdown({ drainMs: 1_000 });
await server.shutdown();
expect(ran).toEqual(['after']);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('onServerDrainStart failed'),
expect.any(Error),
);
});
});
describe('PuterServer shutdown during prepare', () => {
+13 -47
View File
@@ -1743,7 +1743,12 @@ export class PuterServer {
*/
async #runHook(
order: 'bottomUp' | 'topDown',
hook: 'onServerStart' | 'onServerPrepareShutdown' | 'onServerShutdown',
hook:
| 'onServerStart'
| 'onServerDrainStart'
| 'onServerPrepareShutdown'
| 'onServerShutdown',
info?: ServerDrainInfo,
) {
const layers = [
this.clients,
@@ -1755,47 +1760,11 @@ export class PuterServer {
if (order === 'topDown') layers.reverse();
for (const layer of layers) {
for (const instance of Object.values(layer) as WithLifecycle[]) {
try {
await instance[hook]?.();
} catch (err) {
if (hook === 'onServerStart') throw err;
console.error(
`[server] ${hook} failed in ${instance.constructor.name}`,
err,
);
}
await instance[hook]?.(info);
}
}
}
/**
* Run every `onServerDrainStart` concurrently, so one hook holding the
* listener open doesn't delay the others, and one failing doesn't skip the
* rest.
*/
async #runDrainStartHooks(info: ServerDrainInfo) {
const instances = [
this.clients,
this.stores,
this.services,
this.controllers,
this.drivers,
].flatMap((layer) => Object.values(layer) as WithLifecycle[]);
const results = await Promise.allSettled(
instances.map(async (instance) =>
instance.onServerDrainStart?.(info),
),
);
results.forEach((result, i) => {
if (result.status === 'rejected') {
console.error(
`[server] onServerDrainStart failed in ${instances[i].constructor.name}`,
result.reason,
);
}
});
}
#prepareShutdownHooks: Promise<void> | null = null;
/**
@@ -1813,19 +1782,16 @@ export class PuterServer {
async prepareShutdown(info: ServerDrainInfo = {}) {
if (this.#server) {
await this.#runDrainStartHooks(info);
try {
await this.#runHook('bottomUp', 'onServerDrainStart', info);
} catch (err) {
console.error('[server] onServerDrainStart failed', err);
}
this.#server.close(async () => {
console.log(
'PuterServer has stopped accepting new connections',
);
try {
await this.#runPrepareShutdownHooks();
} catch (err) {
console.error(
'[server] prepare-shutdown hooks failed',
err,
);
}
await this.#runPrepareShutdownHooks();
});
}
}
+1 -1
View File
@@ -2864,7 +2864,7 @@ export class AuthService extends PuterService {
#actorUserFromRow(user: UserRow) {
// Strip the password hash; pass everything else through so callers
// can read metadata, desktop_bg_*, otp_enabled, etc. without
// can read metadata, otp_enabled, etc. without
// re-fetching the user. Mirrors what /whoami exposes off of UserRow.
const { password: _password, ...rest } = user;
return {
@@ -1149,154 +1149,3 @@ describe('BroadcastService — same-cluster pub/sub fan-out', () => {
}
});
});
describe('BroadcastService shutdown drain', () => {
let server: PuterServer;
let broadcast: BroadcastService;
beforeEach(async () => {
axiosRequestMock.mockReset();
axiosRequestMock.mockResolvedValue({ status: 200, data: 'ok' });
server = await setupTestServer({
broadcast: {
webhook: { peerId: SELF_PEER_ID, secret: SELF_SECRET },
peers: [
{
peerId: PEER_ID,
webhook: true,
webhook_url: PEER_URL,
webhook_secret: PEER_SECRET,
},
],
outbound_flush_ms: 10,
},
} as never);
broadcast = server.services.broadcast as unknown as BroadcastService;
});
afterEach(async () => {
vi.useRealTimers();
await broadcast.onServerPrepareShutdown();
await broadcast.onServerShutdown();
await server.shutdown();
});
const emit = (hash: string) =>
server.clients.event.emit(
'outer.fs.write-hash' as never,
{ hash, uuid: hash } as never,
{},
);
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'waits for the active flush and sends its tail in %s',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let releaseTail!: () => void;
const tailHeld = new Promise<void>((resolve) => {
releaseTail = resolve;
});
axiosRequestMock
.mockImplementationOnce(async () => {
await held;
return { status: 200, data: 'ok' };
})
.mockImplementationOnce(async () => {
await tailHeld;
return { status: 200, data: 'ok' };
});
emit('first');
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(1),
);
emit('tail');
let finished = false;
const draining = broadcast[hook]().then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
release();
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(2),
);
expect(finished).toBe(false);
} finally {
release();
releaseTail();
await draining;
}
expect(axiosRequestMock).toHaveBeenCalledTimes(2);
expect(axiosRequestMock.mock.calls[1][0].data).toContain('tail');
},
);
it('keeps normal timer flushing after prepare on an idle server', async () => {
await broadcast.onServerPrepareShutdown();
vi.useFakeTimers();
axiosRequestMock.mockClear();
emit('during-drain');
await vi.advanceTimersByTimeAsync(10);
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
expect(axiosRequestMock.mock.calls[0][0].data).toContain(
'during-drain',
);
});
it('sends events queued after prepare during shutdown and clears the timer', async () => {
await broadcast.onServerPrepareShutdown();
vi.useFakeTimers();
axiosRequestMock.mockClear();
emit('after-prepare');
await broadcast.onServerShutdown();
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
expect(axiosRequestMock.mock.calls[0][0].data).toContain(
'after-prepare',
);
axiosRequestMock.mockClear();
await vi.advanceTimersByTimeAsync(100);
expect(axiosRequestMock).not.toHaveBeenCalled();
});
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'bounds a hung flush in %s',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
axiosRequestMock.mockImplementationOnce(async () => {
await held;
return { status: 200, data: 'ok' };
});
emit('hung');
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(1),
);
vi.useFakeTimers();
emit('tail');
let finished = false;
const draining = broadcast[hook]().then(() => {
finished = true;
});
try {
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
release();
await vi.advanceTimersByTimeAsync(100);
expect(axiosRequestMock).toHaveBeenCalledTimes(
hook === 'onServerPrepareShutdown' ? 2 : 1,
);
} finally {
release();
vi.useRealTimers();
}
},
);
});
@@ -21,8 +21,6 @@ import axios from 'axios';
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { Agent as HttpsAgent } from 'node:https';
import { IBroadcastPeerConfig } from '../../types.js';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types.js';
// -- Wire types ------------------------------------------------------
@@ -123,8 +121,7 @@ export class BroadcastService extends PuterService {
/** Per peer, serialized events it has not accepted yet, oldest first. */
#outboundBacklogs = new Map<IBroadcastPeerConfig, string[]>();
#outboundFlushTimer: ReturnType<typeof setTimeout> | null = null;
#outboundFlush: Promise<void> | null = null;
#outboundDraining = false;
#outboundIsFlushing = false;
#webhookReplayWindowSeconds = 300;
#outboundFlushMs = 2000;
@@ -168,40 +165,21 @@ export class BroadcastService extends PuterService {
}
override async onServerPrepareShutdown(): Promise<void> {
await this.#drainOutboundEvents();
if (this.#redisSub) {
try {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
} catch (err) {
console.warn('[broadcast] unsubscribe failed', err);
} finally {
this.#redisSub = null;
}
}
}
override async onServerShutdown(): Promise<void> {
this.#outboundDraining = true;
await this.#drainOutboundEvents();
}
async #drainOutboundEvents(): Promise<void> {
if (this.#outboundFlushTimer) {
clearTimeout(this.#outboundFlushTimer);
this.#outboundFlushTimer = null;
}
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const drain = async () => {
try {
await this.#outboundFlush;
if (Date.now() < deadline) await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
};
if (!(await settleWithin(drain(), FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[broadcast] final flush timed out');
// Best-effort drain — try one final flush so events queued near
// shutdown make it out.
try {
await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
if (this.#redisSub) {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
this.#redisSub = null;
}
}
@@ -475,27 +453,24 @@ export class BroadcastService extends PuterService {
}
#scheduleOutboundFlush(): void {
if (this.#outboundDraining || this.#outboundFlushTimer) return;
this.#outboundFlushTimer = setTimeout(async () => {
if (this.#outboundFlushTimer) return;
this.#outboundFlushTimer = setTimeout(() => {
this.#outboundFlushTimer = null;
try {
await this.#flushOutboundEvents();
} catch (err) {
void this.#flushOutboundEvents().catch((err) => {
console.warn('[broadcast] outbound flush failed', err);
}
});
}, this.#outboundFlushMs);
}
#flushOutboundEvents(): Promise<void> {
if (this.#outboundFlush) return this.#outboundFlush;
if (this.#outboundQueue.size === 0 && this.#outboundBacklogs.size === 0)
return Promise.resolve();
async #flushOutboundEvents(): Promise<void> {
if (
this.#outboundIsFlushing ||
(this.#outboundQueue.size === 0 &&
this.#outboundBacklogs.size === 0)
)
return;
this.#outboundFlush = this.#sendOutboundEvents();
return this.#outboundFlush;
}
async #sendOutboundEvents(): Promise<void> {
this.#outboundIsFlushing = true;
try {
const fresh = this.#outboundQueue;
this.#outboundQueue = new Set();
@@ -529,7 +504,7 @@ export class BroadcastService extends PuterService {
for (const result of flushed)
if (result.status === 'rejected') throw result.reason;
} finally {
this.#outboundFlush = null;
this.#outboundIsFlushing = false;
// Anything that arrived during flush, or is still owed to a peer,
// gets the next tick.
if (
@@ -217,97 +217,6 @@ describe('the addressed peer queue', () => {
).toEqual(['b']);
});
it.each([false, true])(
'waits for an active send and ships its tail after failure %s',
async (failFirst) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let releaseTail!: () => void;
const tailHeld = new Promise<void>((resolve) => {
releaseTail = resolve;
});
const sent: ForwardItem[][] = [];
const { queue } = makeQueue({
maxItems: 1,
flushMs: 60_000,
send: async (_peerId, items) => {
sent.push(items);
if (sent.length === 1) {
await held;
if (failFirst) throw new Error('peer unavailable');
} else {
await tailHeld;
}
},
});
queue.push('east', delivery('first'));
await vi.waitFor(() => expect(sent).toHaveLength(1));
queue.push('east', delivery('tail'));
let finished = false;
const draining = queue.flushAll().then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
release();
await vi.waitFor(() => expect(sent).toHaveLength(2));
expect(finished).toBe(false);
} finally {
release();
releaseTail();
await draining;
queue.stop();
}
expect(sent).toEqual([[delivery('first')], [delivery('tail')]]);
},
);
it('releases the peer after a send throws synchronously', async () => {
const send = vi.fn(() => {
throw new Error('send failed');
});
const { queue } = makeQueue({ maxItems: 1, send });
queue.push('east', delivery('first'));
await queue.flushAll();
queue.push('east', delivery('second'));
await queue.flushAll();
expect(send).toHaveBeenCalledTimes(2);
});
it('bounds a final drain while a send is hung', async () => {
vi.useFakeTimers();
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi.fn(async () => {
await held;
});
const { queue } = makeQueue({ maxItems: 1, send });
try {
queue.push('east', delivery('first'));
queue.push('east', delivery('tail'));
let finished = false;
const draining = queue.flushAll().then(() => {
finished = true;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
queue.stop();
release();
await vi.advanceTimersByTimeAsync(100);
expect(send).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
release();
vi.useRealTimers();
}
});
it('sends nothing more once it is stopped', async () => {
const { queue, sent } = makeQueue();
queue.stop();
+9 -28
View File
@@ -18,8 +18,6 @@
*/
import type { EventKey, KvOp } from '../../clients/event/types.js';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import type { DeliverableEvent } from './registry.js';
/**
@@ -206,7 +204,7 @@ interface PeerQueue {
items: ForwardItem[];
bytes: number;
timer: ReturnType<typeof setTimeout> | null;
flushing: Promise<void> | null;
flushing: boolean;
}
export class PeerForwardQueue {
@@ -274,27 +272,19 @@ export class PeerForwardQueue {
}
/** Ship whatever is held for one peer. */
flush(peerId: string): Promise<void> {
async flush(peerId: string): Promise<void> {
const queue = this.#peers.get(peerId);
if (!queue) return Promise.resolve();
if (queue.flushing) return queue.flushing;
if (queue.items.length === 0) return Promise.resolve();
if (!queue || queue.flushing || queue.items.length === 0) return;
if (queue.timer) {
clearTimeout(queue.timer);
queue.timer = null;
}
queue.flushing = this.#send(peerId, queue);
return queue.flushing;
}
async #send(peerId: string, queue: PeerQueue): Promise<void> {
queue.flushing = true;
const batch = queue.items;
queue.items = [];
queue.bytes = 0;
try {
// Register the in-flight promise before a send can throw synchronously.
await Promise.resolve();
await this.#options.send(peerId, batch);
} catch (err) {
// A lost batch is a lost at-most-once delivery, and a `single`
@@ -302,7 +292,7 @@ export class PeerForwardQueue {
// hand the client an event whose moment has passed.
console.warn(`[events] forward to ${peerId} failed`, err);
} finally {
queue.flushing = null;
queue.flushing = false;
// Anything that arrived mid-flush gets the next window.
if (queue.items.length > 0 && !queue.timer && !this.#stopped) {
queue.timer = setTimeout(() => {
@@ -315,16 +305,7 @@ export class PeerForwardQueue {
}
async flushAll(): Promise<void> {
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const work = Promise.allSettled(
[...this.#peers.entries()].map(async ([id, queue]) => {
await queue.flushing;
if (Date.now() < deadline) await this.flush(id);
}),
);
if (!(await settleWithin(work, FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[events] final flush timed out');
}
await Promise.all([...this.#peers.keys()].map((id) => this.flush(id)));
}
stop(): void {
@@ -342,7 +323,7 @@ export class PeerForwardQueue {
items: [],
bytes: 0,
timer: null,
flushing: null,
flushing: false,
};
this.#peers.set(peerId, queue);
return queue;
@@ -368,7 +349,7 @@ const isGapMarker = (item: ForwardItem): boolean =>
*/
const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
const dropped: ForwardItem[] = [];
for (let i = 0; i < queue.items.length && dropped.length < count; ) {
for (let i = 0; i < queue.items.length && dropped.length < count;) {
if (isGapMarker(queue.items[i])) {
i++;
continue;
@@ -391,7 +372,7 @@ const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
const shedBytes = (queue: PeerQueue, maxBytesHeld: number): ForwardItem[] => {
const dropped: ForwardItem[] = [];
let remaining = queue.bytes;
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld; ) {
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld;) {
if (isGapMarker(queue.items[i])) {
i++;
continue;
@@ -1,99 +0,0 @@
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
const { disposals } = vi.hoisted(() => ({
disposals: [] as Array<ReturnType<typeof vi.fn>>,
}));
vi.mock('miniflare', () => ({
Miniflare: class {
dispose = vi.fn(async () => {});
constructor() {
disposals.push(this.dispose);
}
},
}));
describe('LocalWorkerService shutdown disposal', () => {
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer({
api_base_url: 'http://api.puter.localhost',
} as never);
});
afterAll(async () => {
await server.shutdown();
});
afterEach(() => {
vi.useRealTimers();
disposals.length = 0;
});
it('awaits every disposal even if one fails', async () => {
const service = server.services.localworkerservice;
await service.cfDeployLocal('first', 'auth', 'export default {}');
await service.cfDeployLocal('second', 'auth', 'export default {}');
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
disposals[0].mockRejectedValueOnce(new Error('dispose failed'));
disposals[1].mockImplementationOnce(async () => {
await held;
});
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
() => {
finished = true;
},
);
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
disposals.every((dispose) => dispose.mock.calls.length === 1),
).toBe(true);
expect(finished).toBe(false);
} finally {
release();
await draining;
}
});
it('bounds disposal of a hung worker to five seconds', async () => {
const service = server.services.localworkerservice;
await service.cfDeployLocal('hung', 'auth', 'export default {}');
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
disposals[0].mockImplementationOnce(async () => {
await held;
});
vi.useFakeTimers();
try {
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
() => {
finished = true;
},
);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
expect(vi.getTimerCount()).toBe(0);
} finally {
release();
vi.useRealTimers();
}
});
});
@@ -10,8 +10,6 @@ import {
import { puterStores } from '../../stores';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore';
import { LayerInstances } from '../../types';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types';
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
@@ -242,16 +240,13 @@ export class LocalWorkerService extends PuterService {
idleSweepTimer.unref?.();
}
override async onServerShutdown(): Promise<void> {
override onServerShutdown(): void {
if (idleSweepTimer) {
clearInterval(idleSweepTimer);
idleSweepTimer = null;
}
const work = Promise.allSettled(
[...activeWorkers.keys()].map((name) => this.#disposeWorker(name)),
);
if (!(await settleWithin(work, FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[localworker] final flush timed out');
for (const name of [...activeWorkers.keys()]) {
void this.#disposeWorker(name);
}
}
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
@@ -1,175 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { ResolvedShare } from './ShareService.js';
describe('ShareNotificationService shutdown drain', () => {
let server: PuterServer;
beforeEach(async () => {
server = await setupTestServer({
email: { from: '[email protected]', host: '127.0.0.1', port: 1 },
share_notify_limits: { emailBatchSeconds: 0.01 },
} as never);
});
afterEach(async () => {
vi.useRealTimers();
await server.services.shareNotification.onServerPrepareShutdown();
await server.services.shareNotification.onServerShutdown();
await server.shutdown();
vi.restoreAllMocks();
});
const queueInvite = async (email: string, issuerId = 7) => {
await server.services.shareNotification.notifyShared(
{
user: { id: issuerId, username: 'sender' },
effectiveApp: null,
} as Actor,
[
{
pending: true,
isNew: true,
recipientEmail: email,
name: 'shared.txt',
} as ResolvedShare,
],
);
};
it.each([false, true])(
'awaits an active timer send and its failure requeue %s',
async (failSend) => {
const email = `active-${failSend}@puter.local`;
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementation(async () => {
await held;
if (failSend) throw new Error('transport unavailable');
return null;
});
await queueInvite(email);
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
const list = async () =>
server.stores.kv.list({
as: 'keys',
pattern: `share:digest:invite:${email}:`,
});
expect((await list()).res).toEqual([]);
let finished = false;
const draining = server.services.shareNotification
.onServerPrepareShutdown()
.then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
} finally {
release();
await draining;
}
expect((await list()).res).toHaveLength(failSend ? 1 : 0);
},
);
it('waits for an active send before draining entries queued meanwhile', async () => {
let releaseFirst!: () => void;
let releaseTail!: () => void;
const first = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const tail = new Promise<void>((resolve) => {
releaseTail = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementationOnce(async () => {
await first;
return null;
})
.mockImplementationOnce(async () => {
await tail;
return null;
});
await queueInvite('[email protected]');
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
(
server.services.shareNotification.config.share_notify_limits as {
emailBatchSeconds: number;
}
).emailBatchSeconds = 600;
await queueInvite('[email protected]', 8);
let finished = false;
const draining = server.services.shareNotification
.onServerPrepareShutdown()
.then(() => {
finished = true;
});
try {
releaseFirst();
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2));
expect(finished).toBe(false);
} finally {
releaseFirst();
releaseTail();
await draining;
}
expect(send).toHaveBeenCalledTimes(2);
});
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'bounds %s while an active send is hung',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementationOnce(async () => {
await held;
return null;
});
await queueInvite(`hung-${hook}@puter.local`);
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
vi.useFakeTimers();
try {
let finished = false;
const draining = server.services.shareNotification[hook]().then(
() => {
finished = true;
},
);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
} finally {
release();
vi.useRealTimers();
}
},
);
it('drains a digest queued after prepare at shutdown', async () => {
await server.services.shareNotification.onServerPrepareShutdown();
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
(
server.services.shareNotification.config.share_notify_limits as {
emailBatchSeconds: number;
}
).emailBatchSeconds = 600;
await queueInvite('[email protected]');
await server.services.shareNotification.onServerShutdown();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0].to).toBe('[email protected]');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(send).toHaveBeenCalledTimes(1);
});
});
@@ -20,11 +20,7 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Actor } from '../../core/actor';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import {
runWithConcurrencyLimitSettled,
settleWithin,
} from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import { PuterService } from '../types';
import {
digestItemPaths,
@@ -182,7 +178,6 @@ export class ShareNotificationService extends PuterService {
* queued sends live in KV, where any node's flush can pick them up.
*/
#digestTimers = new Map<string, ReturnType<typeof setTimeout>>();
#digestFlushes = new Map<string, Promise<void>>();
/** The recovery sweep; see `onServerStart`. */
#digestSweep: ReturnType<typeof setInterval> | null = null;
@@ -204,46 +199,12 @@ export class ShareNotificationService extends PuterService {
/** Send what's still waiting while the transport is alive to send it. */
override async onServerPrepareShutdown(): Promise<void> {
await this.#drainDigests();
}
override async onServerShutdown(): Promise<void> {
await this.#drainDigests();
}
async #drainDigests(): Promise<void> {
if (this.#digestSweep) clearInterval(this.#digestSweep);
this.#digestSweep = null;
const keys = new Set([
...this.#digestTimers.keys(),
...this.#digestFlushes.keys(),
]);
const keys = [...this.#digestTimers.keys()];
for (const [, timer] of this.#digestTimers) clearTimeout(timer);
this.#digestTimers.clear();
const active = [...this.#digestFlushes.values()];
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const drain = async () => {
try {
await Promise.allSettled(active);
if (Date.now() >= deadline) return;
for (const [key, timer] of this.#digestTimers) {
keys.add(key);
clearTimeout(timer);
}
this.#digestTimers.clear();
await Promise.allSettled(
[...keys].map((key) => this.#flushDigest(key)),
);
} catch (err) {
console.warn(
'[share-notify] shutdown digest flush failed:',
err,
);
}
};
if (!(await settleWithin(drain(), FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[share-notify] final flush timed out');
}
await Promise.all(keys.map((key) => this.#flushDigest(key)));
}
/** The recovery sweep, for tests that can't wait out an interval. */
@@ -917,37 +878,25 @@ export class ShareNotificationService extends PuterService {
* everything queued), and across regions the KV `take` is the real claim,
* handing each entry to exactly one flusher.
*/
#flushDigest(key: string): Promise<void> {
const active = this.#digestFlushes.get(key);
if (active) return active;
const flushing = this.#sendDigest(key);
this.#digestFlushes.set(key, flushing);
return flushing;
}
async #sendDigest(key: string): Promise<void> {
try {
await withRedisLock(
this.clients.redis,
`share:digest:lock:${key}`,
() => this.#flushDigestLocked(key),
{
ttlMs: DIGEST_LOCK_SECONDS * 1000,
// No lock beats no mail; `take` still prevents double sends.
onUnavailable: 'run',
onBusy: () => {
console.log(
'[share-notify] another flush holds the lock:',
{ key },
);
},
async #flushDigest(key: string): Promise<void> {
await withRedisLock(
this.clients.redis,
`share:digest:lock:${key}`,
() => this.#flushDigestLocked(key),
{
ttlMs: DIGEST_LOCK_SECONDS * 1000,
// No lock beats no mail; `take` still prevents double sends.
onUnavailable: 'run',
onBusy: () => {
console.log(
'[share-notify] another flush holds the lock:',
{
key,
},
);
},
);
} catch (err) {
console.warn('[share-notify] digest flush failed:', err);
} finally {
this.#digestFlushes.delete(key);
}
},
);
}
async #flushDigestLocked(key: string): Promise<void> {
@@ -122,59 +122,6 @@ describe('SignupService.runSignupGate', () => {
}
});
it('an OIDC-only policy refuses form and temp signups before the validate hook, and lets OIDC and team provisioning through', async () => {
const oidcOnly = (_k: string, data: unknown) => {
Object.assign(data as object, { oidcOnly: true });
};
server.clients.event.on('puter.signup.policy', oidcOnly);
try {
const gate = (input: {
isTemp: boolean;
source?: 'oidc' | 'team_provision';
}) =>
capture('puter.signup.validate', () =>
server.services.signup.runSignupGate({
req,
data: {},
email: `policy-${slug()}@example.com`,
bonusCode: null,
...input,
}),
);
const form = await gate({ isTemp: false });
const temp = await gate({ isTemp: true });
const oidc = await gate({ isTemp: false, source: 'oidc' });
const team = await gate({
isTemp: false,
source: 'team_provision',
});
expect(form.result).toMatchObject({
verdict: 'blocked',
code: 'signup_oidc_only',
});
expect(temp.result).toEqual({
verdict: 'no_temp_user',
message: null,
code: null,
});
expect(form.seen).toHaveLength(0);
expect(temp.seen).toHaveLength(0);
expect(oidc.result.verdict).toBe('allowed');
expect(team.result.verdict).toBe('allowed');
expect(await server.services.signup.signupPolicy()).toEqual({
oidcOnly: true,
message: null,
});
} finally {
server.clients.event.off('puter.signup.policy', oidcOnly);
}
expect(await server.services.signup.signupPolicy()).toEqual({
oidcOnly: false,
message: null,
});
});
it('refuses an unknown bonus code before the validate hook runs', async () => {
const { result, seen } = await capture('puter.signup.validate', () =>
server.services.signup.runSignupGate({
@@ -89,9 +89,6 @@ export interface SignupInput {
export const signupClientIp = (req: Request): string | null =>
req.ip || req.socket?.remoteAddress || null;
const OIDC_ONLY_MESSAGE =
'New accounts can only be created with a sign-in provider right now.';
/**
* Account creation for every signup route (form, temp, OIDC) and email
* confirmation for every confirmation route. Callers parse requests and set
@@ -150,30 +147,6 @@ export class SignupService extends PuterService {
}
}
/**
* The signup methods extensions allow right now; a failing hook restricts
* nothing.
*/
async signupPolicy(): Promise<{
oidcOnly: boolean;
message: string | null;
}> {
const policy = { oidcOnly: false, message: null as string | null };
try {
await this.clients.event?.emitAndWait(
'puter.signup.policy',
policy,
{},
);
} catch (e) {
console.warn('[signup] policy hook failed:', e);
}
return {
oidcOnly: policy.oidcOnly === true,
message: typeof policy.message === 'string' ? policy.message : null,
};
}
/**
* The extension-owned verdict on a signup: a dead bonus code is refused
* first (before `puter.signup.validate` records the attempt), then the
@@ -184,23 +157,6 @@ export class SignupService extends PuterService {
const clientIp = signupClientIp(req);
const fingerprint = input.fingerprint ?? null;
// Only form and temp signups carry no source; OIDC and team
// provisioning are never restricted by the policy.
if (!input.source) {
const policy = await this.signupPolicy();
if (policy.oidcOnly) {
// A refused temp signup sends the GUI to its signup window,
// which then offers only the providers.
return isTemp
? { verdict: 'no_temp_user', message: null, code: null }
: {
verdict: 'blocked',
message: policy.message ?? OIDC_ONLY_MESSAGE,
code: 'signup_oidc_only',
};
}
}
if (
bonusCode &&
!(
+4 -10
View File
@@ -256,12 +256,7 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
| 'sms'
| 'rcs'
| 'whatsapp'
| 'viber'
| 'zalo'
| 'telegram';
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
}
/**
@@ -1380,9 +1375,7 @@ export interface WithLifecycle extends Object {
/**
* When the drain starts, before `onServerPrepareShutdown`, which waits for
* every connection (upgraded sockets included) to close or the drain to
* end. Hooks run concurrently, and the server keeps accepting connections
* until all of them settle, so a hook can hold the listener open (until
* traffic stops arriving, say) by returning a promise.
* end.
*/
onServerDrainStart?: (info?: ServerDrainInfo) => Promise<void> | void;
}
@@ -1393,7 +1386,8 @@ export interface ServerDrainInfo {
}
export interface WithCostsReporting extends WithLifecycle {
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
getReportedCosts?: () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];
+10 -63
View File
@@ -3,25 +3,23 @@
*
* 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.
* 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.
* 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/](https://www.gnu.org/licenses/).
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
awaitLater,
settleWithin,
runWithConcurrencyLimit,
runWithConcurrencyLimitSettled,
} from './concurrency.ts';
@@ -223,54 +221,3 @@ describe('awaitLater', () => {
expect(await awaitLater(Promise.resolve(42))).toBe(42);
});
});
describe('settleWithin', () => {
afterEach(() => vi.useRealTimers());
it('returns true and clears the timer when work settles in time', async () => {
vi.useFakeTimers();
const work = Promise.withResolvers<void>();
const waiting = settleWithin(work.promise, 5000);
expect(vi.getTimerCount()).toBe(1);
work.resolve();
await expect(waiting).resolves.toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it('returns false and clears the timer when time runs out', async () => {
vi.useFakeTimers();
const cleared = vi.spyOn(globalThis, 'clearTimeout');
try {
const waiting = settleWithin(new Promise(() => {}), 5000);
await vi.advanceTimersByTimeAsync(5000);
await expect(waiting).resolves.toBe(false);
expect(cleared).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
cleared.mockRestore();
}
});
it.each([false, true])(
'handles a rejection after timeout %s',
async (afterTimeout) => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
const work = Promise.withResolvers<void>();
const waiting = settleWithin(
work.promise,
afterTimeout ? 0 : 5000,
);
if (afterTimeout) await expect(waiting).resolves.toBe(false);
work.reject(new Error('work failed'));
await expect(waiting).resolves.toBe(!afterTimeout);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
},
);
});
-29
View File
@@ -110,32 +110,3 @@ export function awaitLater<T>(promise: Promise<T>): Promise<T> {
})();
return promise;
}
/**
* Waits for `promise` for at most `ms`, without rejecting. Resolves `true` if
* it settled in time, `false` if the time ran out first.
*/
export async function settleWithin(
promise: Promise<unknown>,
ms: number,
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
const settled = async () => {
try {
await promise;
} catch {
// Callers report work failures.
}
return true;
};
try {
return await Promise.race([
settled(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}
-46
View File
@@ -83,52 +83,6 @@ describe('createGracefulShutdown', () => {
expect(drainInfo).toEqual([{ drainMs: 90_000 }]);
});
it('counts time spent in prepareShutdown against the drain window', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const target = makeTarget();
const trigger = createGracefulShutdown(
{
...target,
prepareShutdown: async (info) => {
await target.prepareShutdown(info);
await new Promise((resolve) => setTimeout(resolve, 15_000));
},
},
{ drainMs: 90_000, exit },
);
const done = trigger('SIGTERM');
await vi.advanceTimersByTimeAsync(89_999);
expect(events).toEqual(['prepare']);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(events).toEqual(['prepare', 'shutdown', 'telemetry', 'exit:0']);
});
it('waits for prepareShutdown when it outlasts the drain window', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const target = makeTarget();
const trigger = createGracefulShutdown(
{
...target,
prepareShutdown: async (info) => {
await target.prepareShutdown(info);
await new Promise((resolve) => setTimeout(resolve, 2_000));
},
},
{ drainMs: 1_000, exit },
);
const done = trigger('SIGTERM');
await vi.advanceTimersByTimeAsync(1_999);
expect(events).toEqual(['prepare']);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(events).toEqual(['prepare', 'shutdown', 'telemetry', 'exit:0']);
});
it('SIGINT with a drain function returning 0 for SIGINT exits without waiting', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const drainMs = (signal: string) => (signal === 'SIGTERM' ? 90_000 : 0);
+6 -12
View File
@@ -27,9 +27,9 @@ export interface ShutdownTarget {
export interface GracefulShutdownOptions {
/**
* How long to keep serving after the signal, before teardown. A function is
* called with the signal that triggered shutdown, so a deployment can drain
* on `SIGTERM` but exit `SIGINT` (Ctrl-C) instantly.
* How long to keep serving after new connections stop, before teardown. A
* function is called with the signal that triggered shutdown, so a
* deployment can drain on `SIGTERM` but exit `SIGINT` (Ctrl-C) instantly.
*/
drainMs: number | ((signal: string) => number);
/** Cap on the final telemetry flush. */
@@ -45,8 +45,6 @@ export interface GracefulShutdownOptions {
export const DEFAULT_TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5_000;
export const DEFAULT_SHUTDOWN_HARD_LIMIT_MS = 25_000;
/** Cap on one component's final flush, so teardown fits the hard limit. */
export const FINAL_FLUSH_TIMEOUT_MS = 5_000;
/**
* The process's one shutdown sequence: stop accepting connections, drain, tear
@@ -81,18 +79,14 @@ export const createGracefulShutdown = (
exitOnce(1);
}, drain + hardLimitMs);
let exitCode = 0;
// The drain window starts at the signal, so time `prepareShutdown`
// spends holding the listener open counts against it.
const drained =
drain > 0
? new Promise<void>((resolve) => setTimeout(resolve, drain))
: Promise.resolve();
try {
await target.prepareShutdown({ drainMs: drain });
} catch (err) {
console.error('[shutdown] prepareShutdown failed', err);
}
await drained;
if (drain > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, drain));
}
try {
await target.shutdown();
} catch (err) {
-271
View File
@@ -1,271 +0,0 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PuterServer } from '../server.ts';
import { setupTestServer } from '../testUtil.ts';
import { getTaskbarItems } from './taskbarItems.ts';
describe('getTaskbarItems', () => {
let server: PuterServer;
let deps: Parameters<typeof getTaskbarItems>[1];
beforeAll(async () => {
server = await setupTestServer();
deps = {
clients: server.clients,
stores: server.stores,
apiBaseUrl: 'https://api.puter.com',
config: { static_hosting_domain: 'puter.site' },
} as unknown as Parameters<typeof getTaskbarItems>[1];
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (taskbarItems?: unknown) => {
const username = `tb-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
} as never);
if (taskbarItems !== undefined) {
await server.stores.user.update(created.id, {
taskbar_items:
typeof taskbarItems === 'string'
? taskbarItems
: JSON.stringify(taskbarItems),
});
}
return (await server.stores.user.getById(created.id))!;
};
const makeApp = async (ownerUserId: number, overrides = {}) => {
const name = `tb-app-${Math.random().toString(36).slice(2, 10)}`;
return server.stores.app.create(
{
name,
title: `Title ${name}`,
index_url: `https://${name}.example.com/`,
description: 'a test app',
...overrides,
},
{ ownerUserId },
);
};
it('seeds the default taskbar for a user that has none, and persists it', async () => {
const user = await makeUser();
expect(user.taskbar_items).toBeFalsy();
await getTaskbarItems(user as never, deps);
const [row] = await server.clients.db.read(
'SELECT `taskbar_items` FROM `user` WHERE `id` = ?',
[user.id],
);
const stored = JSON.parse(String(row.taskbar_items));
expect(stored.map((e: { name: string }) => e.name)).toContain(
'app-center',
);
// The cached row must not keep serving the pre-seed value.
expect(
(await server.stores.user.getById(user.id))?.taskbar_items,
).toBeTruthy();
});
it('resolves entries by name, uid and id', async () => {
const owner = await makeUser([]);
const byName = await makeApp(owner.id);
const byUid = await makeApp(owner.id);
const byId = await makeApp(owner.id);
const user = await makeUser([
{ name: byName.name, type: 'app' },
{ uid: byUid.uid, type: 'app' },
{ id: byId.id, type: 'app' },
]);
const items = await getTaskbarItems(user as never, deps);
expect(items.map((i) => i.uid)).toEqual([
byName.uid,
byUid.uid,
byId.uid,
]);
expect(items[0]).toMatchObject({
uuid: byName.uid,
name: byName.name,
title: byName.title,
godmode: false,
maximize_on_start: false,
description: 'a test app',
});
});
it('resolves the whole taskbar with one batched lookup per key, not one per entry', async () => {
const owner = await makeUser([]);
const apps = await Promise.all([0, 1, 2].map(() => makeApp(owner.id)));
const byUid = await makeApp(owner.id);
const user = await makeUser([
...apps.map((app) => ({ name: app.name, type: 'app' })),
{ uid: byUid.uid, type: 'app' },
]);
const appStore = server.stores.app;
const spies = {
getByName: vi.spyOn(appStore, 'getByName'),
getByUid: vi.spyOn(appStore, 'getByUid'),
getById: vi.spyOn(appStore, 'getById'),
getByNames: vi.spyOn(appStore, 'getByNames'),
getByUids: vi.spyOn(appStore, 'getByUids'),
};
try {
const items = await getTaskbarItems(user as never, deps);
expect(items.map((i) => i.uid)).toEqual([
...apps.map((app) => app.uid),
byUid.uid,
]);
expect(spies.getByName).not.toHaveBeenCalled();
expect(spies.getByUid).not.toHaveBeenCalled();
expect(spies.getById).not.toHaveBeenCalled();
expect(spies.getByNames).toHaveBeenCalledTimes(1);
expect(spies.getByUids).toHaveBeenCalledTimes(1);
} finally {
for (const spy of Object.values(spies)) spy.mockRestore();
}
});
it('skips non-app entries, the explorer pin, and unresolvable apps', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([
{ name: 'explorer', type: 'app' },
{ name: 'nothing-here', type: 'app' },
{ name: app.name, type: 'separator' },
{ type: 'app' },
{ name: app.name, type: 'app' },
]);
const items = await getTaskbarItems(user as never, deps);
expect(items).toHaveLength(1);
expect(items[0].name).toBe(app.name);
});
it('falls back to an empty taskbar when the stored JSON is corrupt', async () => {
const user = await makeUser('{not json');
expect(await getTaskbarItems(user as never, deps)).toEqual([]);
});
it('accepts an already-parsed taskbar array on the user row', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([]);
const items = await getTaskbarItems(
{ ...user, taskbar_items: [{ name: app.name, type: 'app' }] },
deps,
);
expect(items.map((i) => i.name)).toEqual([app.name]);
});
it('builds a sized icon URL by default and drops the icon on request', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const withIcon = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(withIcon[0].icon).toBe(
`https://api.puter.com/app-icon/${app.uid}/64`,
);
const withoutIcon = await getTaskbarItems(user as never, deps, {
noIcons: true,
});
expect('icon' in withoutIcon[0]).toBe(false);
});
it('pairs the icon URL with the direct subdomain URL when one exists', async () => {
const owner = await makeUser([]);
// An http(s) icon column is what the resize pipeline leaves behind, so
// the sized PNG is on the subdomain.
const app = await makeApp(owner.id, {
icon: 'https://api.puter.com/app-icon/x',
});
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(items[0].icon).toBe(
`https://api.puter.com/app-icon/${app.uid}/64`,
);
expect(items[0].iconCdnUrl).toBe(
`https://puter-app-icons.puter.site/${app.uid}-64.png`,
);
});
it('reports no subdomain URL for an app whose icon was never generated', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(items[0].iconCdnUrl).toBeNull();
});
it('falls back to the raw icon column when no API base URL is configured', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id, {
icon: 'https://cdn.example.com/i.png',
});
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(
user as never,
{
...deps,
apiBaseUrl: undefined,
} as never,
);
expect(items[0].icon).toBe('https://cdn.example.com/i.png');
});
it('reports null for an app with no icon and no API base URL', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(
user as never,
{
...deps,
apiBaseUrl: undefined,
} as never,
);
expect(items[0].icon).toBeNull();
});
});
-134
View File
@@ -1,134 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { AppIconHostConfig } from './appIcon.js';
import { toAppSummary, type AppRow } from './appView.js';
interface TaskbarEntry {
name?: string;
id?: number;
uid?: string;
type?: string;
}
interface TaskbarOptions {
iconSize?: number;
noIcons?: boolean;
}
interface TaskbarDeps {
apiBaseUrl?: string;
config?: AppIconHostConfig;
stores: {
app: {
getByNames: (names: string[]) => Promise<Map<string, AppRow>>;
getByUids: (uids: string[]) => Promise<Map<string, AppRow>>;
getByIds: (ids: number[]) => Promise<Map<number, AppRow>>;
};
user: {
update: (
id: number,
patch: Record<string, unknown>,
) => Promise<unknown>;
};
};
}
const DEFAULT_TASKBAR_ITEMS: TaskbarEntry[] = [
{ name: 'app-center', type: 'app' },
{ name: 'builder', type: 'app' },
{ name: 'dev-center', type: 'app' },
{ name: 'editor', type: 'app' },
{ name: 'code', type: 'app' },
{ name: 'camera', type: 'app' },
{ name: 'recorder', type: 'app' },
{ name: 'browser', type: 'app' },
];
export async function getTaskbarItems(
user: Record<string, unknown>,
deps: TaskbarDeps,
options: TaskbarOptions = {},
): Promise<Array<Record<string, unknown>>> {
let raw: TaskbarEntry[];
if (!user.taskbar_items) {
raw = DEFAULT_TASKBAR_ITEMS;
await deps.stores.user.update(user.id as number, {
taskbar_items: JSON.stringify(raw),
});
} else {
try {
raw =
typeof user.taskbar_items === 'string'
? JSON.parse(user.taskbar_items as string)
: (user.taskbar_items as TaskbarEntry[]);
} catch {
raw = [];
}
}
const entries = raw.filter(
(entry) => entry.type === 'app' && entry.name !== 'explorer',
);
const [byName, byUid, byId] = await Promise.all([
deps.stores.app.getByNames(
entries.flatMap((entry) => (entry.name ? [entry.name] : [])),
),
deps.stores.app.getByUids(
entries.flatMap((entry) =>
!entry.name && entry.uid ? [entry.uid] : [],
),
),
deps.stores.app.getByIds(
entries.flatMap((entry) =>
!entry.name && !entry.uid && entry.id ? [entry.id] : [],
),
),
]);
const items: Array<Record<string, unknown>> = [];
for (const entry of entries) {
const app = entry.name
? byName.get(entry.name)
: entry.uid
? byUid.get(entry.uid)
: entry.id
? byId.get(entry.id)
: undefined;
if (!app) continue;
const item: Record<string, unknown> = {
uid: app.uid,
...toAppSummary(app, {
apiBaseUrl: deps.apiBaseUrl,
config: deps.config,
iconSize: options.iconSize,
}),
description: app.description,
};
if (options.noIcons) {
delete item.icon;
delete item.iconCdnUrl;
}
items.push(item);
}
return items;
}
+9 -23
View File
@@ -64,7 +64,7 @@ const makeUser = async () => {
};
describe('buildUserDetails', () => {
it('builds the desktop payload for a user actor', async () => {
it('builds the GUI payload for a user actor', async () => {
const user = await makeUser();
const details = await buildUserDetails(user, deps(), {
isUser: true,
@@ -85,15 +85,18 @@ describe('buildUserDetails', () => {
[`/${user.username}/Desktop`]: user.desktop_uuid,
[`/${user.username}/Trash`]: user.trash_uuid,
});
expect(Array.isArray(details.taskbar_items)).toBe(true);
// The desktop's taskbar and wallpaper are gone with it.
expect(details).not.toHaveProperty('taskbar_items');
expect(details).not.toHaveProperty('desktop_bg_url');
expect(details).not.toHaveProperty('desktop_bg_color');
expect(details).not.toHaveProperty('desktop_bg_fit');
});
it('leaves the desktop-only parts out for an app actor', async () => {
it('leaves the GUI-only parts out for an app actor', async () => {
const user = await makeUser();
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 +127,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 +165,6 @@ describe('buildUserDetails lookups', () => {
);
expect(tailKeys(details)).toEqual([
'oidc_revalidate_url',
'taskbar_items',
'directories',
'team',
'last_activity_ts',
@@ -177,7 +179,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 +200,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 +211,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']);
});
});
+8 -50
View File
@@ -27,18 +27,12 @@ import {
isCardFallbackEligible,
type CardFallbackClients,
} from './cardFallback.js';
import { getTaskbarItems } from './taskbarItems.js';
import { isEmailGateLast } from './verificationOrder.js';
type TaskbarDeps = Parameters<typeof getTaskbarItems>[1];
export interface UserDetailsDeps {
config: IConfig;
// `db.write` backs the taskbar's first-read default row.
clients: CardFallbackClients & {
db: { write: (query: string, params?: unknown[]) => Promise<unknown> };
};
stores: TaskbarDeps['stores'] & {
clients: CardFallbackClients;
stores: {
kv: Pick<SystemKVStore, 'get'>;
team?: Pick<TeamStoreContract, 'getOrgSeat'>;
};
@@ -78,8 +72,8 @@ const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
]);
/**
* Delete every sensitive key at any depth (`metadata` and `taskbar_items` are
* nested). Depth-limited and cycle-safe. Runs last over a whole payload.
* Delete every sensitive key at any depth (`metadata` is nested). Depth-limited
* and cycle-safe. Runs last over a whole payload.
*/
export const scrubSensitive = (
value: unknown,
@@ -118,13 +112,13 @@ export const toUnixSeconds = (value: unknown): number | undefined => {
/**
* The account as the client keeps it in `window.user`: what `/whoami` answers
* and what a sign-in hands back. `isUser` false (an app acting for the user)
* leaves out the desktop-only parts; trimming fields an app may not see is the
* leaves out the GUI-only parts; trimming fields an app may not see is the
* caller's job. Not scrubbed: run `scrubSensitive` on the final payload.
*/
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;
@@ -170,9 +164,6 @@ export async function buildUserDetails(
cardFallbackDepsFrom(clients),
)
: false,
desktop_bg_url: user.desktop_bg_url,
desktop_bg_color: user.desktop_bg_color,
desktop_bg_fit: user.desktop_bg_fit,
is_temp: user.password === null && user.email === null,
is_user_token: true,
// Null until the account asks for a code; never minted here.
@@ -185,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 {
@@ -202,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;
@@ -226,26 +196,14 @@ export async function buildUserDetails(
};
// Independent and each best-effort, so read together; the results land
// below in the order the payload has always listed them.
const [provider, taskbar, team] = await Promise.all([
linkedProvider(),
taskbarItems(),
orgSeat(),
]);
const [provider, team] = await Promise.all([linkedProvider(), orgSeat()]);
if (provider) {
const origin = (config.origin ?? '').replace(/\/$/, '');
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
}
if (taskbar) {
// Best-effort: the account still works without them.
if ('error' in taskbar) {
console.warn('[user-details] taskbar_items failed:', taskbar.error);
details.taskbar_items = [];
} else {
details.taskbar_items = taskbar.items;
}
if (isUser) {
const directories: Record<string, unknown> = {};
const nameToProp: Record<string, string> = {
desktop_uuid: `/${user.username}/Desktop`,
+1 -2
View File
@@ -23,7 +23,7 @@ A set of key/value pairs that configure the window.
* `has_head` (Boolean): if set to `true`, window will have a head which contains the icon and close, minimize, and maximize buttons.
* `height` (Float): height of window in pixels.
* `is_resizable` (Boolean): if set to `true`, user will be able to resize the window.
* `show_in_taskbar` (Boolean): if set to `true`, window will be represented in the taskbar.
* `show_in_taskbar` (Boolean): deprecated, and has no effect. Puter no longer has a taskbar.
* `title` (String): title of the window.
* `width` (Float): width of window in pixels.
@@ -46,7 +46,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>
+6 -6
View File
@@ -46,12 +46,12 @@ the window before the app's own code runs, so a service app cannot avoid briefly
appearing on screen.
The 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. Defaults to `false`.
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 — the user can return to it, hide it, or close it like any
other. Defaults to `false`.
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
+1 -1
View File
@@ -398,7 +398,7 @@ For long-lived mounts, sign in with a `-token` username and an API token as the
| ------------------------------------------------------------------ | ------------------------------- | ----------- |
| `nameAvailable` | 60/min | |
| App icon | 12,000/min | Per network |
| App landing page (`/app/<name>`, `/desktop/app/<name>`) | 600/min | Per network |
| App landing page (`/app/<name>`) | 600/min | Per network |
| Names per batch app lookup (`GET /apps/:names`, `POST /query/app`) | 200, up to 200 characters each | |
| `metadata` | 16 KiB, measured as JSON | |
| `filetypeAssociations` | 200 entries, 60 characters each | |
+1 -1
View File
@@ -118,7 +118,7 @@ const startServer = (attempt, useAnyFreePort = false) => {
if ( process.env.PUTER_NO_BROWSER ) return;
// Open the GUI rather than the sign-in link directly: it's idempotent.
// An already-signed-in GUI loads straight to the desktop instead of
// An already-signed-in GUI loads straight to the dashboard instead of
// re-prompting for consent on every restart.
open(guiUrl).catch((e) => {
console.log(chalk.dim(` (could not open a browser: ${e.message})`));
-1
View File
@@ -522,7 +522,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,
});
-19
View File
@@ -291,25 +291,6 @@ const TabHome = {
h += '</div>';
h += '</div>';
// Open Desktop card (spans full width, links to the desktop interface)
h +=
'<a href="/desktop" target="_blank" rel="noopener" class="bento-card bento-desktop allow-native-ctxmenu">';
h +=
'<div class="bento-card-fancy-icon bento-card-fancy-icon-desktop">';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += `<h2>${i18n('open_desktop')}</h2>`;
h += '<span class="bento-card-fancy-subtitle">';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>';
h += i18n('open_desktop_hint');
h += '</span>';
h += '</div>';
h += '<span class="bento-desktop-arrow">›</span>';
h += '</a>';
h += '</div>';
return h;
},
+5 -6
View File
@@ -205,8 +205,8 @@ async function UIDashboard (options) {
$item.addClass('active').attr('aria-current', 'page');
};
// Remove `?ref=...` from the URL, keeping the path and tab hash (mirrors
// UIDesktop). Replaced, not pushed, so Back leaves the dashboard.
// Remove `?ref=...` from the URL, keeping the path and tab hash.
// Replaced, not pushed, so Back leaves the dashboard.
if ( window.url_query_params?.has('ref') ) {
window.history.replaceState(null, document.title, window.location.pathname + window.location.hash);
}
@@ -273,7 +273,6 @@ async function UIDashboard (options) {
// =========================================================================
// Socket initialization
// In dashboard mode, UIDesktop is never loaded, so we create the socket here.
// This runs inside the function (not at module level) to ensure window.gui_origin
// and window.auth_token are already set.
// =========================================================================
@@ -368,8 +367,8 @@ async function UIDashboard (options) {
// =========================================================================
// Item event handlers
// Incremental DOM updates using UIDashboardFileItem for item creation and
// direct jQuery manipulation for removals/updates. Mirrors UIDesktop's
// approach but adapted for Dashboard's list-view structure.
// direct jQuery manipulation for removals/updates, adapted for the
// dashboard's list-view structure.
// =========================================================================
// A row taken away from elsewhere: keep the footer's count and size in
@@ -601,7 +600,7 @@ async function UIDashboard (options) {
window.addEventListener('popstate', handleRouteChange);
// Sidebar item click handler — only tab items ([data-section]); plain links
// like the "Open Desktop" button navigate natively
// navigate natively
$el_window.on('click', '.dashboard-sidebar-item[data-section]', function (e) {
// A modified click opens the tab's link in a new browser tab, natively.
if ( e.metaKey || e.ctrlKey || e.shiftKey ) return;
@@ -79,7 +79,6 @@ async function UIWindowDisable2FA (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
on_before_exit: async () => {
if ( ! disabled_successfully ) {
promise.resolve(false);
-1
View File
@@ -136,7 +136,6 @@ function UIAlert (options) {
selectable_body: false,
draggable_body: options.draggable_body ?? true,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
body_content: h,
-3
View File
@@ -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();
}
}
});
File diff suppressed because it is too large. Load diff
+1 -75
View File
@@ -30,7 +30,6 @@ 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 mime from '../lib/mime.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, shared_mode_for } from '../helpers/sharedAccess.js';
@@ -237,17 +236,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
@@ -327,7 +315,6 @@ async function UIItem (options) {
last_window_dragged_over = null;
window.an_item_is_being_dragged = true;
$('.toolbar').css('pointer-events', 'none');
},
drag: function (event, ui) {
// Constrain item within desktop bounds
@@ -413,20 +400,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
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path &&
!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
@@ -437,7 +410,6 @@ async function UIItem (options) {
clearTimeout(longer_hover_timeout);
last_window_dragged_over = null;
window.an_item_is_being_dragged = false;
$('.toolbar').css('pointer-events', 'auto');
},
});
@@ -546,10 +518,6 @@ async function UIItem (options) {
}
// Otherwise, move items
else if ( options.is_dir ) {
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
delete window.desktop_item_positions[$(el_item).attr('data-uid')];
window.save_desktop_item_positions();
}
window.move_items(items_to_move, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
}
}
@@ -1342,48 +1310,6 @@ async function UIItem (options) {
});
}
// -------------------------------------------
// Set as Wallpaper
// -------------------------------------------
const mime_type = mime.getType($(el_item).attr('data-name')) ??
'application/octet-stream';
if ( !is_trashed && !is_trash && !options.is_dir && mime_type.startsWith('image/') ) {
menu_items.push({
html: i18n('set_as_background'),
onClick: async function () {
const read_url = await puter.fs.sign(undefined, { uid: $(el_item).attr('data-uid'), action: 'read' });
window.set_desktop_background({
url: read_url.items.read_url,
fit: window.desktop_bg_fit,
});
try {
$.ajax({
url: `${window.api_origin }/set-desktop-bg`,
type: 'POST',
data: JSON.stringify({
url: window.desktop_bg_url,
color: window.desktop_bg_color,
fit: window.desktop_bg_fit,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
$(el_window).close();
resolve(true);
} catch ( err ) {
// Ignore
}
},
});
}
// -------------------------------------------
// Zip
// -------------------------------------------
if ( !is_trash && !is_trashed && !$(el_item).attr('data-path').endsWith('.zip') ) {
@@ -1638,7 +1564,7 @@ async function UIItem (options) {
// Create ContextMenu
UIContextMenu({
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
parent_element: options.appendTo,
items: menu_items,
});
-1
View File
@@ -71,7 +71,6 @@ function UIPrompt (options) {
selectable_body: false,
draggable_body: true,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
body_content: h,
-769
View File
@@ -1,769 +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 { createUploadThumbnailGenerator } from '../services/pdfThumbnails/index.js';
import UITaskbarItem from './UITaskbarItem.js';
import UIPopover from './UIPopover.js';
import launch_app from '../helpers/launchApp.js';
import UIContextMenu from './UIContextMenu.js';
import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js';
import { filterLaunchableApps } from '../helpers/launchAppsList.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 launchAppsRequest = 0;
let pendingLaunchApps;
const fetchLaunchApps = () => {
pendingLaunchApps?.abort();
pendingLaunchApps = $.ajax({
url: `${window.api_origin}/get-launch-apps?icon_size=64`,
type: 'GET',
contentType: 'application/json',
timeout: 30000,
headers: { 'Authorization': `Bearer ${window.auth_token}` },
});
return pendingLaunchApps;
};
const logRefreshFailure = (xhr, status) => {
if ( status !== 'abort' ) console.error('Failed to refresh launch apps:', status);
};
fetchLaunchApps().done(apps => {
if ( launchAppsRequest === 0 ) window.launch_apps = apps;
}).fail(logRefreshFailure);
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 `Start` to taskbar
//---------------------------------------------
UITaskbarItem({
icon: window.icons['start.svg'],
name: i18n('start'),
sortable: false,
keep_in_taskbar: true,
disable_context_menu: true,
onClick: async function (item) {
// skip if popover already open
if ( $(item).hasClass('has-open-popover') )
{
$('.popover-launcher').remove();
return;
}
// show popover
let popover = UIPopover({
content: '<div class="launch-popover hide-scrollbar"></div>',
snapToElement: item,
parent_element: item,
width: 500,
height: 500,
class: 'popover-launcher',
center_horizontally: true,
});
$(popover).on('remove', () => pendingLaunchApps?.abort());
const request = ++launchAppsRequest;
if ( ! window.launch_apps?.recent ) {
window.launch_apps = { recent: [], recommended: [] };
}
$(popover).find('.launch-popover').append(`
<div class="launch-search-wrapper">
<input style="background-image:url('${window.icons['magnifier-outline.svg']}');" class="launch-search">
<img class="launch-search-clear" src="${window.icons['close.svg']}">
</div>
`);
const renderApps = () => {
const recentApps = filterLaunchableApps(window.launch_apps.recent);
const recommendedApps = filterLaunchableApps(window.launch_apps.recommended);
let apps_str = '';
// -------------------------------------------
// Recent apps
// -------------------------------------------
if ( recentApps.length > 0 ) {
// heading
apps_str += `<h1 class="start-section-heading start-section-heading-recent">${i18n('recent')}</h1>`;
// apps
apps_str += '<div class="launch-apps-recent">';
for ( let index = 0; index < window.launch_recent_apps_count && index < recentApps.length; index++ ) {
const app_info = recentApps[index];
apps_str += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
const icon = appIconSrc(app_info);
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
apps_str += '</div>';
apps_str += '</div>';
}
apps_str += '</div>';
}
// -------------------------------------------
// Recommended apps
// -------------------------------------------
if ( recommendedApps.length > 0 ) {
// heading
apps_str += `<h1 class="start-section-heading start-section-heading-recommended" style="${recentApps.length > 0 ? 'padding-top: 30px;' : ''}">${i18n('recommended')}</h1>`;
// apps
apps_str += '<div class="launch-apps-recommended">';
for ( let index = 0; index < recommendedApps.length; index++ ) {
const app_info = recommendedApps[index];
apps_str += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
const icon = appIconSrc(app_info);
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
apps_str += '</div>';
apps_str += '</div>';
}
apps_str += '</div>';
}
// Replace tiles only, so the search input keeps its text and focus.
$(popover).find('.start-section-heading, .launch-apps-recent, .launch-apps-recommended').remove();
$(popover).find('.launch-popover').append(apps_str);
// make apps draggable
$(popover).find('.start-app').draggable({
appendTo: 'body',
revert: 'invalid',
connectToSortable: '.taskbar-sortable',
zIndex: parseInt($(popover).css('z-index')) + 1,
scroll: false,
distance: 5,
revertDuration: 100,
helper: 'clone',
cursorAt: { left: 18, top: 20 },
start: function (event, ui) {
},
drag: function (event, ui) {
},
stop: function () {
},
});
};
renderApps();
if ( ! isMobile.phone ) $(popover).find('.launch-search').focus();
fetchLaunchApps().done(apps => {
if ( request !== launchAppsRequest || !popover.isConnected ) return;
window.launch_apps = apps;
renderApps();
$(popover).find('.launch-search').trigger('change');
}).fail(logRefreshFailure);
$(popover).on('click', function () {
// close other context menus
$('.context-menu').fadeOut(200, function () {
$(this).remove();
$('.launch-app-selected').removeClass('launch-app-selected');
});
});
$(popover).on('contextmenu taphold', function (e) {
if ( ! e.target.closest('.launch-search') ) {
e.preventDefault();
}
});
$(document).on('contextmenu taphold', '.start-app', function (e) {
if ( e.type === 'taphold' && !isMobile.phone && !isMobile.tablet )
{
return;
}
e.stopImmediatePropagation();
e.preventDefault();
// close other context menus
$('.context-menu').fadeOut(200, function () {
$(this).remove();
});
let items = [{
html: i18n('open'),
onClick: function () {
$(e.currentTarget).trigger('click');
},
}];
$('.launch-app-selected').removeClass('launch-app-selected');
$(e.currentTarget).parent().addClass('launch-app-selected');
// Determine pin state
const $existingTaskbarItem = $(`.taskbar-item[data-app="${e.currentTarget.dataset.appName}"]`);
const isPinned = $existingTaskbarItem.length > 0 && $existingTaskbarItem.attr('data-keep-in-taskbar') === 'true';
items.push({
html: i18n('add_to_desktop'),
onClick: async function () {
try {
const fileName = `${e.currentTarget.dataset.appTitle}.app`;
const content = JSON.stringify({
app: e.currentTarget.dataset.appName,
title: e.currentTarget.dataset.appTitle,
icon: e.currentTarget.dataset.appIcon,
});
await puter.fs.upload(new File([content], fileName), window.desktop_path, {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
});
} catch ( err ) {
console.error('Failed to add shortcut to desktop:', err);
// Consider showing a user-facing error notification
}
},
});
if ( ! isPinned ) {
items.push({
html: i18n('keep_in_taskbar'),
onClick: function () {
const $taskbarItem = $(`.taskbar-item[data-app="${e.currentTarget.dataset.appName}"]`);
if ( $taskbarItem.length === 0 ) {
// No taskbar item yet: create a new pinned one
UITaskbarItem({
icon: e.currentTarget.dataset.appIcon,
iconFallback: e.currentTarget.dataset.appIconFallback,
app: e.currentTarget.dataset.appName,
name: e.currentTarget.dataset.appTitle,
keep_in_taskbar: true,
});
} else if ( $taskbarItem.attr('data-keep-in-taskbar') !== 'true' ) {
// mark as pinned
$taskbarItem.attr('data-keep-in-taskbar', 'true');
}
// Persist
window.update_taskbar();
},
});
} else {
items.push({
html: i18n('remove_from_taskbar'),
onClick: function () {
const $taskbarItem = $(`.taskbar-item[data-app="${e.currentTarget.dataset.appName}"]`);
if ( $taskbarItem.length === 0 ) return; // nothing to do
// Unpin
$taskbarItem.attr('data-keep-in-taskbar', 'false');
// If no open windows for this app, remove the item
if ( $taskbarItem.attr('data-open-windows') === '0' ) {
if ( window.remove_taskbar_item ) {
window.remove_taskbar_item($taskbarItem.get(0));
} else {
$taskbarItem.remove();
}
}
window.update_taskbar();
},
});
}
UIContextMenu({
items: items,
});
return false;
});
},
});
//---------------------------------------------
// 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,
receive: function (event, ui) {
if ( ! $(ui.item).hasClass('taskbar-item') ) {
// if app is already in taskbar, cancel
if ( $(`.taskbar-item[data-app="${$(ui.item).attr('data-app-name')}"]`).length !== 0 ) {
$(this).sortable('cancel');
$('.taskbar .start-app').remove();
return;
}
}
},
update: function (event, ui) {
if ( ! $(ui.item).hasClass('taskbar-item') ) {
// if app is already in taskbar, cancel
if ( $(`.taskbar-item[data-app="${$(ui.item).attr('data-app-name')}"]`).length !== 0 ) {
$(this).sortable('cancel');
$('.taskbar .start-app').remove();
return;
}
let item = UITaskbarItem({
icon: $(ui.item).attr('data-app-icon'),
iconFallback: $(ui.item).attr('data-app-icon-fallback'),
app: $(ui.item).attr('data-app-name'),
name: $(ui.item).attr('data-app-title'),
append_to_taskbar: false,
keep_in_taskbar: true,
onClick: function () {
let open_window_count = parseInt($(`.taskbar-item[data-app="${$(ui.item).attr('data-app-name')}"]`).attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({
name: $(ui.item).attr('data-app-name'),
});
} else {
return false;
}
},
});
let el = ($(item).detach());
$(el).insertAfter(ui.item);
$(el).show();
$(ui.item).removeItems();
window.update_taskbar();
}
// only proceed to update DB if the item sorted was a pinned item otherwise no point in updating the taskbar in DB
else 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;
-181
View File
@@ -1,181 +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() }));
vi.mock('./UIContextMenu.js', () => ({ default: vi.fn() }));
globalThis.$ = globalThis.jQuery = jQuery;
globalThis.i18n = (key) => (key === 'start' ? 'Start' : 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');
const app = { name: 'test-app', uuid: 'test-uid', title: 'Test app' };
let launchApps;
let start;
let requests;
afterEach(() => {
$('.popover-launcher, .taskbar').remove();
$(document).off();
$(window).off();
document.body.innerHTML = '';
delete window.launch_apps;
vi.unstubAllGlobals();
vi.useRealTimers();
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: {},
launch_recent_apps_count: 10,
});
vi.stubGlobal('puter', {
kv: { get: async () => 'bottom' },
fs: { stat: async () => ({ is_empty: true }) },
});
launchApps = { recent: [app], recommended: [] };
requests = [];
$.ajaxTransport('+*', () => {
let complete;
const request = {
send(headers, done) {
complete = done;
},
abort: vi.fn(),
resolve(data) {
complete(
200,
'OK',
{ text: JSON.stringify(data) },
'Content-Type: application/json',
);
},
};
requests.push(request);
return request;
});
await UITaskbar();
requests[0].resolve(launchApps);
start = $('.taskbar-item[data-name="Start"]');
});
it.each([true, false])(
'keeps cached search usable after a timeout, cached=%s',
async (cached) => {
if (!cached) window.launch_apps = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.useFakeTimers();
start.trigger('click');
await vi.advanceTimersByTimeAsync(30000);
expect(requests.at(-1).abort).toHaveBeenCalledOnce();
expect($('.launch-search')).toHaveLength(1);
expect($('.start-app')).toHaveLength(cached ? 1 : 0);
},
);
function deferRefresh() {
return (data) => requests.at(-1).resolve(data);
}
it('refreshes reopened Start without losing cached tiles or search input', async () => {
const survivor = {
name: 'survivor',
uuid: 'survivor-uid',
title: 'Survivor',
};
launchApps.recent.push(survivor);
start.trigger('click');
requests.at(-1).resolve(launchApps);
start.trigger('click');
const resolve = deferRefresh();
start.trigger('click');
const input = $('.launch-search').get(0);
expect(input).toBeDefined();
expect($('.start-app[data-app-name="test-app"]')).toHaveLength(1);
input.value = 'notes';
const onChange = vi.fn();
$(input).on('change', onChange);
resolve({
recent: [survivor],
recommended: [{ name: 'notes', uuid: 'notes-uid', title: 'Notes' }],
});
await vi.waitFor(() =>
expect($('.start-app[data-app-name="notes"]')).toHaveLength(1),
);
expect($('.launch-search').get(0)).toBe(input);
expect(input.value).toBe('notes');
expect(document.activeElement).toBe(input);
expect(onChange).toHaveBeenCalledOnce();
expect($('.start-app[data-app-name="survivor"]')).toHaveLength(1);
expect($('.start-app[data-app-name="test-app"]')).toHaveLength(0);
});
it('cancels closed menus and ignores their late responses', () => {
start.trigger('click');
const old = requests.at(-1);
start.trigger('click');
expect(old.abort).toHaveBeenCalledOnce();
start.trigger('click');
requests.at(-1).resolve({ recent: [], recommended: [] });
old.resolve({ recent: [app], recommended: [] });
expect(window.launch_apps.recent).toHaveLength(0);
expect($('.start-app')).toHaveLength(0);
});
it('cancels the startup request when Start opens', async () => {
requests = [];
document.body.innerHTML = '<div class="desktop"></div>';
await UITaskbar();
const startup = requests[0];
$('.taskbar-item[data-name="Start"]').trigger('click');
expect(startup.abort).toHaveBeenCalledOnce();
});
it('hides apps the backend marks unlaunchable', () => {
start.trigger('click');
requests.at(-1).resolve({
recent: [
app,
{
name: 'reclaimed',
uuid: 'reclaimed-uid',
title: 'Reclaimed',
index_url: null,
privateAccess: { hasAccess: false },
},
],
recommended: [],
});
expect($('.start-app[data-app-name="test-app"]')).toHaveLength(1);
expect($('.start-app[data-app-name="reclaimed"]')).toHaveLength(0);
});
-543
View File
@@ -1,543 +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.append_to_taskbar = options.append_to_taskbar ?? true;
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.append_to_taskbar ) {
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);
}
}
}
} else {
$('body').prepend(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 is for the launcher popover, and it's mobile, and has-open-popover, close the popover
if ( $(el_taskbar_item).attr('data-name') === 'Start'
&& (isMobile.phone || isMobile.tablet) && $(el_taskbar_item).hasClass('has-open-popover') ) {
$('.popover').remove();
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;
+177 -323
View File
@@ -20,7 +20,6 @@
import UIAlert from './UIAlert.js';
import UIContextMenu from './UIContextMenu.js';
import path from '../lib/path.js';
import UITaskbarItem from './UITaskbarItem.js';
import UIWindowLogin from './UIWindowLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
@@ -117,7 +116,6 @@ async function UIWindow (options) {
options.on_close = options.on_close ?? undefined;
options.parent_uuid = options.parent_uuid ?? null;
options.selectable_body = (options.selectable_body === undefined || options.selectable_body === true) ? true : false;
options.show_in_taskbar = options.show_in_taskbar ?? true;
options.show_maximize_button = options.show_maximize_button ?? true;
options.single_instance = options.single_instance ?? false;
options.sort_by = options.sort_by ?? 'name';
@@ -526,17 +524,7 @@ async function UIWindow (options) {
// backdrop
if ( options.backdrop ) {
let backdrop_zindex;
// backdrop should also cover over taskbar
let taskbar_zindex = $('.taskbar').css('z-index');
if ( taskbar_zindex === null || taskbar_zindex === undefined )
{
backdrop_zindex = zindex;
}
else {
taskbar_zindex = parseInt(taskbar_zindex);
backdrop_zindex = taskbar_zindex > zindex ? taskbar_zindex : zindex;
}
let backdrop_zindex = zindex;
// dominant backdrop will cover over toolbar as well
if ( options.backdrop_covers_toolbar )
@@ -586,56 +574,6 @@ async function UIWindow (options) {
const el_directorypicker_select_btn = document.querySelector(`#window-${win_id} .directorypicker-select-btn`);
const el_window_filedialog_upload_here = document.querySelector(`#window-${win_id} .window-filedialog-upload-here`);
// Add Taskbar Item
// data-in_taskbar records that this window is one of the item's
// data-open-windows, so the close path only decrements a count this
// window actually added to (see the close handler) — the two must stay
// in step, and a window that never took an item must not take one away.
const add_taskbar_item = () => {
$(el_window).attr('data-in_taskbar', '1');
// add icon if there is no similar app already open
if ( $(`.taskbar-item[data-app="${options.app}"]`).length === 0 ) {
UITaskbarItem({
icon: options.icon,
name: options.title,
app: options.app,
open_windows_count: 1,
before_trash: true,
onClick: function () {
let open_window_count = parseInt($(`.taskbar-item[data-app="${options.app}"]`).attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({
name: options.app,
});
} else {
return false;
}
},
});
if ( options.app )
{
$(`.taskbar-item[data-app="${options.app}"] .active-taskbar-indicator`).show();
}
} else {
if ( options.app ) {
$(`.taskbar-item[data-app="${options.app}"]`).attr('data-open-windows', parseInt($(`.taskbar-item[data-app="${options.app}"]`).attr('data-open-windows')) + 1);
$(`.taskbar-item[data-app="${options.app}"] .active-taskbar-indicator`).show();
}
}
};
if ( !options.is_openFileDialog && !options.is_saveFileDialog && !options.is_directoryPicker && options.show_in_taskbar ) {
// A window started hidden by a background launch gets no taskbar item
// yet: it runs for the app that launched it, and an item would both
// advertise and hand out an instance the user never asked for. The
// item is what the window earns the moment it first becomes visible
// — makeWindowVisible calls this, whether the app showed itself with
// puter.ui.showWindow() or something else revealed it. An app that is
// ALWAYS windowless never reaches here: launch_app leaves
// show_in_taskbar false for it, so it keeps having no item at all.
if ( options.launched_hidden ) el_window._add_taskbar_item = add_taskbar_item;
else add_taskbar_item();
}
if ( el_window_filedialog_upload_here ) {
el_window_filedialog_upload_here.addEventListener('click', function () {
window.init_upload_using_dialog(el_window_body, `${$(el_window).attr('data-path') }/`);
@@ -1923,7 +1861,6 @@ async function UIWindow (options) {
snap_trigger_timeout = undefined;
}
hideSnapPlaceholder();
$('.toolbar').css('pointer-events', 'none');
// if window is snapped, unsnap it and reset its position to where it was before snapping
if ( options.is_resizable && window_is_snapped ) {
window_is_snapped = false;
@@ -2018,7 +1955,6 @@ async function UIWindow (options) {
$(el_window_app_iframe).css('pointer-events', 'all');
$('.window').css('pointer-events', 'initial');
$('.toolbar').css('pointer-events', 'auto');
if ( options.is_resizable && snap_placeholder_active && !window_is_snapped ) {
window_will_snap = true;
@@ -2859,7 +2795,7 @@ async function UIWindow (options) {
if ( options.is_fullpage ) {
$(el_window).hide();
setTimeout(function () {
window.enter_fullpage_mode(el_window);
enter_fullpage_mode(el_window);
$(el_window).show();
}, 50);
}
@@ -3651,32 +3587,8 @@ $.fn.close = async function (options) {
let window_uuid = $(this).attr('data-element_uuid');
// remove all instances of win_id from window.window_stack
window.window_stack = window.window_stack.filter(id => id !== win_id);
// taskbar update — only for a window that IS one of the item's
// open windows (data-in_taskbar, stamped when it took its place
// in the count). A window that never counted must not decrement:
// an unseen background instance closing alongside the user's own
// window would otherwise zero the count and take the item away
// while that window is still open.
if ( $(this).attr('data-in_taskbar') === '1' ) {
let open_window_count = parseInt($(`.taskbar-item[data-app="${$(this).attr('data-app')}"]`).attr('data-open-windows'));
// update open window count of corresponding taskbar item
if ( open_window_count > 0 ) {
$(`.taskbar-item[data-app="${$(this).attr('data-app')}"]`).attr('data-open-windows', open_window_count - 1);
}
// decide whether to remove taskbar item
if ( open_window_count === 1 ) {
$(`.taskbar-item[data-app="${$(this).attr('data-app')}"] .active-taskbar-indicator`).hide();
window.remove_taskbar_item($(`.taskbar-item[data-app="${$(this).attr('data-app')}"][data-keep-in-taskbar="false"]`));
}
// if no more windows of this app are open, remove taskbar item
if ( open_window_count - 1 === 0 )
{
$(`.taskbar-item[data-app="${$(this).attr('data-app')}"] .active-taskbar-indicator`).hide();
}
}
// if a fullpage window is closed, show desktop and taskbar
if ( $(this).attr('data-is_fullpage') === '1' ) {
window.exit_fullpage_mode();
exit_fullpage_mode();
}
// FileDialog closed
@@ -3797,9 +3709,6 @@ $.fn.close = async function (options) {
document.activeElement.blur();
$('input').blur();
}
// focus back to desktop
$('.desktop').find('.item-blurred').removeClass('item-blurred');
window.active_item_container = $('.desktop.item-container').get(0);
}
});
@@ -3824,11 +3733,6 @@ window.scale_window = (el_window) => {
// Use taskbar position-aware window positioning
window.update_maximized_window_for_taskbar(el_window);
// hide toolbar
if ( !isMobile.phone && !isMobile.tablet ) {
window.hide_toolbar();
}
}
//shrink
else {
@@ -3953,15 +3857,11 @@ $.fn.makeWindowVisible = function (options) {
// Seen by the user, so no longer a window that exists purely to
// serve whoever launched it: it outlives its launcher from here on
// (see the close path's cleanup of background children), and it
// becomes a window the user can act on — hence the taskbar item
// its launch deferred, and the dashboard tile's running dot.
// Both happen BEFORE the window shows: focusWindow() below marks
// the app's taskbar item active, which needs the item to exist.
// becomes a window the user can act on — hence the dashboard
// tile's running dot.
const was_launched_hidden = $(this).attr('data-launched_hidden') === '1';
$(this).removeAttr('data-launched_hidden');
if ( was_launched_hidden ) {
this._add_taskbar_item?.();
delete this._add_taskbar_item;
if ( window.is_dashboard_mode && $(this).attr('data-app') ) {
document.dispatchEvent(new CustomEvent('dashboard-app-windows-changed'));
}
@@ -4233,8 +4133,7 @@ $.fn.focusWindow = function (event) {
// remove blurred class from items on this window
$(window.active_item_container).find('.item-blurred').removeClass('item-blurred');
// Update the tab title to the focused window. The URL itself is
// deliberately NOT touched here: on the desktop it stays /desktop
// (apps don't rewrite it), and in dashboard mode the open app owns
// deliberately NOT touched here: in dashboard mode the open app owns
// it through real history entries (push_dashboard_app_url).
if ( ! window.is_dashboard_mode ) {
const update_window_url = $(this).attr('data-update_window_url');
@@ -4242,10 +4141,8 @@ $.fn.focusWindow = function (event) {
document.title = $(this).attr('data-name');
}
}
$(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).addClass('taskbar-item-active');
} else {
$('.window').find('.item-selected').addClass('item-blurred');
$('.desktop').find('.item-blurred').removeClass('item-blurred');
}
return this;
@@ -5224,230 +5121,167 @@ function morph_window_from_tile (el_window, tile, morph_options = {}) {
$.fn.hideWindow = async function (options) {
$(this).each(async function () {
if ( $(this).hasClass('window') ) {
// get taskbar item location
let taskbar_item_pos = $(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).position();
// If the app's tile is visible on the Apps tab's current page,
// shrink the window into it so the user sees where the app went;
// otherwise simply hide the window in place. Either way the
// geometry is untouched afterwards and showWindow() un-hides via
// the data-minimized_in_place flag.
const el_window = this;
const $win = $(this);
const reduce_motion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// (A morph already in flight — either direction — owns the
// window's inline transform/opacity and will restore them at
// its end, so don't start another on top; fall back to the
// plain fade below, exactly as before the morphs existed.)
const tile = (reduce_motion
|| $win.attr('data-window_morphing') === '1')
? null
: dashboard_tile_in_view($win.attr('data-app'));
// No taskbar item to animate toward (e.g. dashboard mode, which
// has no taskbar): if the app's tile is visible on the Apps tab's
// current page, shrink the window into it so the user sees where
// the app went; otherwise simply hide the window in place. Either
// way the geometry is untouched afterwards and showWindow()
// un-hides via the data-minimized_in_place flag.
if ( ! taskbar_item_pos ) {
const el_window = this;
const $win = $(this);
const reduce_motion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// (A morph already in flight — either direction — owns the
// window's inline transform/opacity and will restore them at
// its end, so don't start another on top; fall back to the
// plain fade below, exactly as before the morphs existed.)
const tile = (reduce_motion
|| $win.attr('data-window_morphing') === '1')
? null
: dashboard_tile_in_view($win.attr('data-app'));
if ( tile ) {
// iOS-style zoom-to-icon morph, two congruent layers on
// the same 450ms decelerating path so it reads as ONE
// object transforming:
// 1. the LIVE window scales onto the tile's icon box,
// its corner radius growing toward the squircle,
// fading OUT once it has mostly shrunk;
// 2. an enlarged clone of the icon ("ghost") starts
// covering the window and flies/shrinks with it,
// fading IN as the window fades out, landing exactly
// on the real icon's slot (the real icon is hidden
// until the ghost lands, so nothing doubles).
// Transform + opacity animate on the compositor (no
// per-frame reflow of the app's iframe); the window's
// geometry (top/left/width/height) is never touched, so
// restore is trivial.
const icon = tile.querySelector('.myapps-tile-icon') || tile;
const win_rect = el_window.getBoundingClientRect();
const icon_rect = icon.getBoundingClientRect();
if ( win_rect.width > 0 && win_rect.height > 0
&& icon_rect.width > 0 && icon_rect.height > 0 ) {
// Everything touched is restored from these exact
// inline values once the animation ends.
const orig_style = {
transform: el_window.style.transform,
transformOrigin: el_window.style.transformOrigin,
opacity: el_window.style.opacity,
borderRadius: el_window.style.borderRadius,
overflow: el_window.style.overflow,
willChange: el_window.style.willChange,
pointerEvents: el_window.style.pointerEvents,
transition: el_window.style.transition,
};
const icon_orig_visibility = icon.style.visibility;
// Map the window rect exactly onto the icon rect (and
// the ghost the exact inverse: icon rect onto window
// rect), so the two layers stay congruent throughout.
const scale_x = icon_rect.width / win_rect.width;
const scale_y = icon_rect.height / win_rect.height;
const dx = icon_rect.left - win_rect.left;
const dy = icon_rect.top - win_rect.top;
if ( tile ) {
// iOS-style zoom-to-icon morph, two congruent layers on
// the same 450ms decelerating path so it reads as ONE
// object transforming:
// 1. the LIVE window scales onto the tile's icon box,
// its corner radius growing toward the squircle,
// fading OUT once it has mostly shrunk;
// 2. an enlarged clone of the icon ("ghost") starts
// covering the window and flies/shrinks with it,
// fading IN as the window fades out, landing exactly
// on the real icon's slot (the real icon is hidden
// until the ghost lands, so nothing doubles).
// Transform + opacity animate on the compositor (no
// per-frame reflow of the app's iframe); the window's
// geometry (top/left/width/height) is never touched, so
// restore is trivial.
const icon = tile.querySelector('.myapps-tile-icon') || tile;
const win_rect = el_window.getBoundingClientRect();
const icon_rect = icon.getBoundingClientRect();
if ( win_rect.width > 0 && win_rect.height > 0
&& icon_rect.width > 0 && icon_rect.height > 0 ) {
// Everything touched is restored from these exact
// inline values once the animation ends.
const orig_style = {
transform: el_window.style.transform,
transformOrigin: el_window.style.transformOrigin,
opacity: el_window.style.opacity,
borderRadius: el_window.style.borderRadius,
overflow: el_window.style.overflow,
willChange: el_window.style.willChange,
pointerEvents: el_window.style.pointerEvents,
transition: el_window.style.transition,
};
const icon_orig_visibility = icon.style.visibility;
// Map the window rect exactly onto the icon rect (and
// the ghost the exact inverse: icon rect onto window
// rect), so the two layers stay congruent throughout.
const scale_x = icon_rect.width / win_rect.width;
const scale_y = icon_rect.height / win_rect.height;
const dx = icon_rect.left - win_rect.left;
const dy = icon_rect.top - win_rect.top;
const ghost = icon.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${icon_rect.left}px`;
ghost.style.top = `${icon_rect.top}px`;
ghost.style.width = `${icon_rect.width}px`;
ghost.style.height = `${icon_rect.height}px`;
ghost.style.margin = '0';
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 1;
ghost.style.pointerEvents = 'none';
ghost.style.transformOrigin = 'top left';
ghost.style.willChange = 'transform, opacity';
ghost.style.opacity = '0';
// UNIFORM start scale so the icon stays square (a
// rect-onto-rect mapping stretches it), sized to fit
// INSIDE the window (contain, centered) — a covering
// square would protrude past the card's short edge
// and the protruding halo pops in mid-crossfade
// (visible jitter). Contained, the ghost's side
// tracks the card's short edge exactly for the whole
// flight (both interpolate linearly between the same
// start and end sizes), so the card's rectangular
// flanks simply melt away around a steady square.
const ghost_scale = Math.min(
win_rect.width / icon_rect.width,
win_rect.height / icon_rect.height,
);
const ghost_left = win_rect.left + (win_rect.width - icon_rect.width * ghost_scale) / 2;
const ghost_top = win_rect.top + (win_rect.height - icon_rect.height * ghost_scale) / 2;
ghost.style.transform = `translate(${ghost_left - icon_rect.left}px, ${ghost_top - icon_rect.top}px) scale(${ghost_scale})`;
document.body.appendChild(ghost);
icon.style.visibility = 'hidden';
const ghost = icon.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${icon_rect.left}px`;
ghost.style.top = `${icon_rect.top}px`;
ghost.style.width = `${icon_rect.width}px`;
ghost.style.height = `${icon_rect.height}px`;
ghost.style.margin = '0';
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 1;
ghost.style.pointerEvents = 'none';
ghost.style.transformOrigin = 'top left';
ghost.style.willChange = 'transform, opacity';
ghost.style.opacity = '0';
// UNIFORM start scale so the icon stays square (a
// rect-onto-rect mapping stretches it), sized to fit
// INSIDE the window (contain, centered) — a covering
// square would protrude past the card's short edge
// and the protruding halo pops in mid-crossfade
// (visible jitter). Contained, the ghost's side
// tracks the card's short edge exactly for the whole
// flight (both interpolate linearly between the same
// start and end sizes), so the card's rectangular
// flanks simply melt away around a steady square.
const ghost_scale = Math.min(
win_rect.width / icon_rect.width,
win_rect.height / icon_rect.height,
);
const ghost_left = win_rect.left + (win_rect.width - icon_rect.width * ghost_scale) / 2;
const ghost_top = win_rect.top + (win_rect.height - icon_rect.height * ghost_scale) / 2;
ghost.style.transform = `translate(${ghost_left - icon_rect.left}px, ${ghost_top - icon_rect.top}px) scale(${ghost_scale})`;
document.body.appendChild(ghost);
icon.style.visibility = 'hidden';
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
'data-window_morphing': '1',
});
el_window.style.transformOrigin = 'top left';
el_window.style.overflow = 'hidden'; // let the radius clip the content
el_window.style.willChange = 'transform, opacity';
el_window.style.pointerEvents = 'none';
// Flush the starting state (both layers) so the
// transitions animate from it rather than snapping.
void el_window.offsetWidth;
// The handoff: by ~130ms the decelerating curve has
// done ~80% of the shrink — the ghost fades in just
// before (80→240ms) and the window fades out just
// after (120→320ms). The overlap means there is never
// a hole, and since both ride the same path the swap
// is invisible: the window "becomes" the icon.
el_window.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.2s ease-out 0.12s',
].join(', ');
el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`;
// Percentage radius stays proportional under the
// scale — ~22% is the icon-squircle look.
el_window.style.borderRadius = '22%';
el_window.style.opacity = '0';
ghost.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.16s ease-in 0.08s',
].join(', ');
ghost.style.transform = 'none';
ghost.style.opacity = '1';
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
'data-window_morphing': '1',
});
el_window.style.transformOrigin = 'top left';
el_window.style.overflow = 'hidden'; // let the radius clip the content
el_window.style.willChange = 'transform, opacity';
el_window.style.pointerEvents = 'none';
// Flush the starting state (both layers) so the
// transitions animate from it rather than snapping.
void el_window.offsetWidth;
// The handoff: by ~130ms the decelerating curve has
// done ~80% of the shrink — the ghost fades in just
// before (80→240ms) and the window fades out just
// after (120→320ms). The overlap means there is never
// a hole, and since both ride the same path the swap
// is invisible: the window "becomes" the icon.
el_window.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.2s ease-out 0.12s',
].join(', ');
el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`;
// Percentage radius stays proportional under the
// scale — ~22% is the icon-squircle look.
el_window.style.borderRadius = '22%';
el_window.style.opacity = '0';
ghost.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.16s ease-in 0.08s',
].join(', ');
ghost.style.transform = 'none';
ghost.style.opacity = '1';
setTimeout(() => {
// End of the zoom: the ghost has landed on the
// real icon's slot — swap them back in the same
// frame, hide the window, and put every touched
// inline property back while nothing is visible.
// If the user re-opened the window mid-animation
// (tile click → showWindow cleared the flags),
// restore but leave it visible.
ghost.remove();
icon.style.visibility = icon_orig_visibility;
$win.attr('data-window_morphing', '0');
const minimized = $win.attr('data-is_minimized');
if ( minimized === '1' || minimized === 'true' ) $win.hide();
el_window.style.transition = 'none';
el_window.style.transform = orig_style.transform;
el_window.style.transformOrigin = orig_style.transformOrigin;
el_window.style.opacity = orig_style.opacity;
el_window.style.borderRadius = orig_style.borderRadius;
el_window.style.overflow = orig_style.overflow;
el_window.style.willChange = orig_style.willChange;
el_window.style.pointerEvents = orig_style.pointerEvents;
setTimeout(() => {
// End of the zoom: the ghost has landed on the
// real icon's slot — swap them back in the same
// frame, hide the window, and put every touched
// inline property back while nothing is visible.
// If the user re-opened the window mid-animation
// (tile click → showWindow cleared the flags),
// restore but leave it visible.
ghost.remove();
icon.style.visibility = icon_orig_visibility;
$win.attr('data-window_morphing', '0');
const minimized = $win.attr('data-is_minimized');
if ( minimized === '1' || minimized === 'true' ) $win.hide();
el_window.style.transition = 'none';
el_window.style.transform = orig_style.transform;
el_window.style.transformOrigin = orig_style.transformOrigin;
el_window.style.opacity = orig_style.opacity;
el_window.style.borderRadius = orig_style.borderRadius;
el_window.style.overflow = orig_style.overflow;
el_window.style.willChange = orig_style.willChange;
el_window.style.pointerEvents = orig_style.pointerEvents;
setTimeout(() => {
el_window.style.transition = orig_style.transition;
}, 50);
}, 480);
return;
}
el_window.style.transition = orig_style.transition;
}, 50);
}, 480);
return;
}
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
});
$win.fadeOut(150);
return;
}
// Calculate animation target based on taskbar position
let animationTarget = {};
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
// taskbar position is center of window minus half of taskbar item width
taskbar_item_pos.left = taskbar_item_pos.left + ($(window).width() / 2) - ($('.taskbar').width() / 2);
animationTarget = {
top: 'calc(100% - 60px)',
left: taskbar_item_pos.left + 14.5,
};
} else if ( taskbarPosition === 'left' ) {
animationTarget = {
top: taskbar_item_pos.top + ($(window).height() / 2) - ($('.taskbar').height() / 2) + 14.5,
left: '5px',
};
} else if ( taskbarPosition === 'right' ) {
animationTarget = {
top: taskbar_item_pos.top + ($(window).height() / 2) - ($('.taskbar').height() / 2) + 14.5,
left: 'calc(100% - 60px)',
};
}
$(this).attr({
'data-orig-width': $(this).width(),
'data-orig-height': $(this).height(),
'data-orig-top': $(this).position().top,
'data-orig-left': $(this).position().left,
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
});
$(this).css({
...(!isMobile.phone ? {
'transition': 'top 0.2s, left 0.2s, bottom 0.2s, right 0.2s, width 0.2s, height 0.2s',
} : {}),
width: '0',
height: '0',
...animationTarget,
});
// remove transitions a good while after setting css to make sure
// it doesn't interfere with an ongoing animation
setTimeout(() => {
$(this).css({
'transition': 'none',
'transform': 'none',
});
}, 250);
// reset the tab title (the URL is not touched — apps never
// rewrite it on the desktop) — only if this window owned it
const update_window_url = $(this).attr('data-update_window_url');
if ( ! window.is_dashboard_mode && (update_window_url === 'true' || update_window_url === null) ) {
document.title = i18n('window_title_puter');
}
$win.fadeOut(150);
}
});
return this;
@@ -5589,6 +5423,26 @@ async function saveSidebarOrder (order) {
}
}
/**
* Fill the page with a fullpage window (the dashboard): no head, no rounding.
* @param {HTMLElement} el_window
*/
function enter_fullpage_mode (el_window) {
$(el_window).find('.window-head').hide();
$('body').addClass('fullpage-mode');
$(el_window).css({
width: '100%',
height: '100%',
top: `${window.toolbar_height}px`,
left: 0,
'border-radius': 0,
});
}
function exit_fullpage_mode () {
$('body').removeClass('fullpage-mode');
}
// Function to update maximized window positioning based on taskbar position
window.update_maximized_window_for_taskbar = function (el_window) {
const position = window.taskbar_position || 'bottom';
-1
View File
@@ -782,7 +782,6 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
width: Math.min(480, window.innerWidth - 24),
height: 'auto',
dominant: true,
show_in_taskbar: false,
is_draggable: false,
backdrop: true,
on_before_exit: async () => {
+127 -408
View File
@@ -17,13 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { isPlausibleHostname, normalizeHostnameInput } from '../helpers/customDomainHostname.js';
let attach_modal_seq = 0;
const SUCCESS_AUTOCLOSE_MS = 1800;
const COPIED_RESET_MS = 1400;
/** API error code → message key; anything unlisted gets the generic error. */
const ERROR_KEYS = {
invalid_hostname: 'custom_domain_error_invalid',
@@ -36,25 +31,6 @@ const ERROR_KEYS = {
custom_domains_permission_required: 'custom_domain_error_generic',
};
/** Domain status → the badge shown for it; anything else reads as pending. */
const STATUS_BADGE_KEYS = {
active: 'custom_domain_badge_active',
pending: 'custom_domain_badge_pending',
failed: 'custom_domain_badge_failed',
};
const statusOf = (domain) => (domain?.status in STATUS_BADGE_KEYS ? domain.status : 'pending');
const svg = (body, strokeWidth = 2) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const ICON_GLOBE = svg('<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/>', 1.8);
const ICON_TRASH = svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4h6v3"/>', 1.8);
const ICON_COPY = svg('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>', 1.8);
const ICON_CHECK = svg('<path d="M20 6 9 17l-5-5"/>', 2.4);
const ICON_CLOCK = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>');
const ICON_ALERT = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/>', 2.2);
const ICON_PLUS = svg('<path d="M12 5v14M5 12h14"/>', 2.2);
const ICON_X = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>', 2.2);
const format_price = (pricing) => {
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
try {
@@ -74,14 +50,13 @@ const format_price = (pricing) => {
* from another site if need be) or adds one; the caller learns only the
* domain that ends up chosen.
*
* Built on the app feedback modal's shell (`.app-feedback-*`). Talks to the
* Built on the app feedback modal's shell (`.app-feedback-*`) so it renders
* the same on the desktop and in the standalone popup. Talks to the
* `/custom-domains` API with the GUI's own session: the requesting app never
* holds a token that can manage domains.
*
* @param {{ subdomain: string, requester?: string, fullpage?: boolean }} options
* `requester` is the app title or opener origin shown to the user, as the
* GUI resolved it. `fullpage` fills the viewport instead of floating over
* it, for a popup window that exists only to host this dialog.
* @param {{ subdomain: string, requester?: string }} options `requester` is
* the app title or opener origin shown to the user, as the GUI resolved it.
* @returns {Promise<object|null>} The chosen domain as the API returns it, or
* null when the user closes the dialog first. Never rejects.
*/
@@ -100,28 +75,22 @@ async function UIWindowAttachCustomDomain (options) {
let chosen = null;
const titleId = `${modal_id}-title`;
const closeLabel = html_encode(i18n('close'));
const h = `
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' custom-domain-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-overlay custom-domain-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-modal custom-domain-modal" tabindex="-1">
<div class="app-feedback-head custom-domain-head">
<div class="custom-domain-head-icon">${ICON_GLOBE}</div>
<div class="custom-domain-head-text">
<h2 class="app-feedback-title" id="${titleId}">${i18n('custom_domain_title')}</h2>
<p class="custom-domain-intro">${i18n('custom_domain_intro', [site_host])}</p>
${options.requester ? `<p class="custom-domain-requester">${i18n('custom_domain_requested_by', [options.requester])}</p>` : ''}
</div>
<button type="button" class="app-feedback-x" aria-label="${closeLabel}" title="${closeLabel}">${ICON_X}</button>
<div class="app-feedback-head">
<h2 class="app-feedback-title" id="${titleId}">${i18n('custom_domain_title')}</h2>
<button type="button" class="app-feedback-x" aria-label="${html_encode(i18n('close'))}" title="${html_encode(i18n('close'))}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
<div class="app-feedback-body custom-domain-body">
<div class="custom-domain-pane" data-pane="loading" aria-busy="true">
<span class="custom-domain-sr">${i18n('loading')}…</span>
<div class="custom-domain-skeleton"><span></span><span></span></div>
<div class="custom-domain-skeleton"><span></span><span></span></div>
</div>
<div class="app-feedback-body">
<p class="custom-domain-intro">${i18n('custom_domain_intro', [site_host])}</p>
${options.requester ? `<p class="custom-domain-requester">${i18n('custom_domain_requested_by', [options.requester])}</p>` : ''}
<div class="custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
<div class="custom-domain-state-icon">${ICON_ALERT}</div>
<div class="app-feedback-loading custom-domain-pane" data-pane="loading">${i18n('loading')}…</div>
<div class="custom-domain-pane" data-pane="unavailable" style="display:none;">
<p class="app-feedback-unavailable-message"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-close">${i18n('close')}</button>
@@ -130,30 +99,28 @@ async function UIWindowAttachCustomDomain (options) {
<div class="custom-domain-pane" data-pane="list" style="display:none;">
<ul class="custom-domain-list"></ul>
<div class="custom-domain-state custom-domain-empty" style="display:none;">
<div class="custom-domain-state-icon">${ICON_GLOBE}</div>
<p class="custom-domain-state-title">${i18n('custom_domain_none_yet')}</p>
<p class="custom-domain-state-text">${i18n('custom_domain_empty_hint')}</p>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add custom-domain-empty-add">${ICON_PLUS}<span>${i18n('custom_domain_add_new')}</span></button>
</div>
<p class="custom-domain-empty" style="display:none;">${i18n('custom_domain_none_yet')}</p>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn custom-domain-close">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add custom-domain-list-add">${ICON_PLUS}<span>${i18n('custom_domain_add_new')}</span></button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add">${i18n('custom_domain_add_new')}</button>
</div>
</div>
<form class="custom-domain-pane" data-pane="add" style="display:none;" novalidate>
<form class="custom-domain-pane" data-pane="add" style="display:none;">
<label class="custom-domain-label" for="${modal_id}-hostname">${i18n('custom_domain_hostname_label')}</label>
<div class="custom-domain-field">
${ICON_GLOBE}
<input type="text" class="custom-domain-input" id="${modal_id}-hostname" placeholder="www.example.com" autocomplete="off" autocapitalize="off" spellcheck="false" inputmode="url" enterkeyhint="go">
</div>
<p class="app-feedback-note custom-domain-hint">${i18n('custom_domain_hostname_hint')}</p>
<p class="custom-domain-callout custom-domain-price" style="display:none;"></p>
<input type="text" class="custom-domain-input" id="${modal_id}-hostname" placeholder="www.example.com" autocomplete="off" autocapitalize="off" spellcheck="false">
<p class="app-feedback-note custom-domain-price" style="display:none;"></p>
<div class="custom-domain-claim" style="display:none;">
<p class="custom-domain-callout custom-domain-callout-warn">${i18n('custom_domain_claim_c2a')}</p>
<div class="custom-domain-records"></div>
<p class="app-feedback-note">${i18n('custom_domain_claim_c2a')}</p>
<table class="custom-domain-records">
<thead><tr><th>${i18n('custom_domain_record_type')}</th><th>${i18n('name')}</th><th>${i18n('custom_domain_record_value')}</th></tr></thead>
<tbody><tr>
<td>TXT</td>
<td><code class="custom-domain-copy custom-domain-claim-name"></code></td>
<td><code class="custom-domain-copy custom-domain-claim-value"></code></td>
</tr></tbody>
</table>
</div>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
@@ -163,32 +130,21 @@ async function UIWindowAttachCustomDomain (options) {
</form>
<div class="custom-domain-pane" data-pane="records" style="display:none;">
<div class="custom-domain-status-card">
<div class="custom-domain-status-icon"></div>
<div class="custom-domain-status-main">
<p class="custom-domain-status"></p>
<p class="custom-domain-status-detail" style="display:none;"></p>
</div>
</div>
<p class="custom-domain-records-c2a">${i18n('custom_domain_records_c2a')}</p>
<div class="custom-domain-records"></div>
<p class="custom-domain-status"></p>
<p class="app-feedback-note custom-domain-records-c2a">${i18n('custom_domain_records_c2a')}</p>
<table class="custom-domain-records">
<thead><tr><th>${i18n('custom_domain_record_type')}</th><th>${i18n('name')}</th><th>${i18n('custom_domain_record_value')}</th></tr></thead>
<tbody></tbody>
</table>
<p class="app-feedback-note custom-domain-redirect" style="display:none;"></p>
<p class="app-feedback-note custom-domain-apex" style="display:none;">${i18n('custom_domain_apex_hint')}</p>
<button type="button" class="custom-domain-link-btn custom-domain-use-www" style="display:none;">${i18n('custom_domain_no_root_alias')}</button>
<button type="button" class="app-feedback-btn custom-domain-use-www" style="display:none;">${i18n('custom_domain_no_root_alias')}</button>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<p class="custom-domain-callout custom-domain-callout-danger custom-domain-remove-note" style="display:none;">${i18n('custom_domain_remove_confirm')}</p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn custom-domain-btn-danger-text custom-domain-remove-chosen">${i18n('custom_domain_remove')}</button>
<button type="button" class="app-feedback-btn custom-domain-check">${i18n('custom_domain_check_status')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-done">${i18n('done')}</button>
</div>
</div>
<div class="custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
<div class="app-feedback-success-check">${ICON_CHECK}</div>
<p class="app-feedback-success-text custom-domain-success-text"></p>
<p class="app-feedback-note custom-domain-success-note"></p>
</div>
</div>
</div>
</div>
@@ -197,9 +153,6 @@ async function UIWindowAttachCustomDomain (options) {
const $overlay = $(h);
$('body').append($overlay);
requestAnimationFrame(() => $overlay.addClass('app-feedback-open'));
const modalEl = $overlay.find('.custom-domain-modal').get(0);
const focusOn = (el) => el?.focus({ preventScroll: true });
focusOn(modalEl);
const close = () => {
if ( closed ) return;
@@ -209,25 +162,19 @@ async function UIWindowAttachCustomDomain (options) {
setTimeout(() => $overlay.remove(), 160);
resolve(chosen);
};
const finish = (domain) => {
chosen = domain;
close();
};
let currentPane = 'loading';
const paneOf = (el) => $(el).closest('.custom-domain-pane').attr('data-pane');
// A focused control that gets hidden or disabled hands focus to the
// page behind the dialog; keep it in the dialog instead.
const focusLost = () => ! document.activeElement || document.activeElement === document.body;
const showPane = (pane) => {
const from = paneOf(document.activeElement);
$overlay.find('.custom-domain-pane').hide();
$overlay.find('.app-feedback-error').hide();
$overlay.find(`.custom-domain-pane[data-pane="${pane}"]`).show();
currentPane = pane;
// The modal scrolls when it floats, the body when it fills the page.
$overlay.find('.custom-domain-modal, .custom-domain-body').scrollTop(0);
if ( focusLost() || (from && from !== pane) ) focusOn(modalEl);
};
// Plain strings into `.text()`: i18n's HTML encoding would show up
// literally.
const text = (key, args = []) => i18n(key, args, false);
const text = (key) => i18n(key, [], false);
const showUnavailable = (key) => {
$overlay.find('.app-feedback-unavailable-message').text(text(key));
showPane('unavailable');
@@ -236,22 +183,10 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find(`.custom-domain-pane[data-pane="${pane}"] .app-feedback-error`)
.text(message).show();
};
/** `$trigger` shows the spinner; every other control just waits. */
let focusedBeforeBusy = null;
const setBusy = (value, $trigger) => {
const setBusy = (value) => {
busy = value;
if ( value ) focusedBeforeBusy = document.activeElement;
$overlay.find('button').prop('disabled', value);
$overlay.find('.custom-domain-input').prop('readonly', value);
$overlay.find('.is-loading').removeClass('is-loading');
if ( value && $trigger ) $trigger.addClass('is-loading');
if ( value ) return;
updateAddButton();
if ( focusLost() && focusedBeforeBusy?.isConnected && paneOf(focusedBeforeBusy) === currentPane ) {
focusOn(focusedBeforeBusy);
}
if ( focusLost() ) focusOn(modalEl);
focusedBeforeBusy = null;
if ( ! value ) updateAddButton();
};
const api = async (method, path = '', body) => {
@@ -284,21 +219,11 @@ async function UIWindowAttachCustomDomain (options) {
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
};
// Escape backs out of a pending confirmation first. Escape, the
// backdrop and the close buttons all dismiss, except while a request
// is in flight: closing then would report no domain for one that may
// still land server-side.
// Escape, the backdrop and the close buttons all dismiss, except
// while a request is in flight: closing then would report no domain
// for one that may still land server-side.
$(document).on(`keydown.${modal_id}`, (e) => {
if ( e.key !== 'Escape' || busy ) return;
if ( $overlay.find('.custom-domain-row[data-confirming]').length ) {
resetConfirming();
return;
}
if ( $overlay.find('.custom-domain-remove-chosen.custom-domain-confirming').length ) {
resetRemoveChosen();
return;
}
close();
if ( e.key === 'Escape' && ! busy ) close();
});
$overlay.on('mousedown', (e) => {
// Same reason as the feedback modal: keep the press from focusing
@@ -310,203 +235,57 @@ async function UIWindowAttachCustomDomain (options) {
if ( ! busy ) close();
});
/**
* True once `el`'s text is on the clipboard. Without clipboard access
* (an insecure origin has no `navigator.clipboard` at all), the text
* is selected for the legacy copy and stays selected to copy by hand.
*/
const copyText = async (el) => {
try {
await navigator.clipboard.writeText(el.textContent);
return true;
} catch {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
return document.execCommand('copy');
} catch {
return false;
}
}
};
// Copy buttons flip to a check for a moment once the value is copied.
$overlay.on('click', '.custom-domain-copy', async function () {
const $btn = $(this);
if ( ! await copyText($btn.siblings('code').get(0)) ) return;
clearTimeout($btn.data('reset'));
$btn.addClass('is-copied').attr('aria-label', text('copied')).attr('title', text('copied'));
$btn.data('reset', setTimeout(() => {
$btn.removeClass('is-copied').attr('aria-label', text('copy')).attr('title', text('copy'));
}, COPIED_RESET_MS));
});
/**
* One DNS record as a card: its type, then the name and value, each
* with a copy button.
*/
const recordCard = (record, typeLabel) => {
const $card = $(`
<div class="custom-domain-record">
<div class="custom-domain-record-head">
<span class="custom-domain-record-type"></span>
<span class="custom-domain-record-optional" style="display:none;">${i18n('custom_domain_optional')}</span>
</div>
<div class="custom-domain-record-field">
<span class="custom-domain-record-label">${i18n('name')}</span>
<code></code>
<button type="button" class="custom-domain-copy">${ICON_COPY}${ICON_CHECK}</button>
</div>
<div class="custom-domain-record-field">
<span class="custom-domain-record-label">${i18n('custom_domain_record_value')}</span>
<code></code>
<button type="button" class="custom-domain-copy">${ICON_COPY}${ICON_CHECK}</button>
</div>
</div>
`);
$card.find('.custom-domain-record-type').text(typeLabel ?? record.type);
$card.find('.custom-domain-record-optional').toggle(record.required === false);
const [$name, $value] = $card.find('code').toArray().map((el) => $(el));
$name.text(record.name);
$value.text(record.value);
$card.find('.custom-domain-copy').attr({ 'aria-label': text('copy'), title: text('copy') });
return $card;
};
// -- Success --------------------------------------------------------
// The check is the last thing the user sees: the domain serves this
// site, so there's nothing left to do here.
const showSuccess = (domain) => {
chosen = domain;
$overlay.find('.custom-domain-success-text').html(i18n('custom_domain_status_active', [domain.hostname]));
$overlay.find('.custom-domain-success-note').html(i18n('custom_domain_success_note', [domain.hostname, site_host]));
showPane('success');
setTimeout(close, SUCCESS_AUTOCLOSE_MS);
};
/** Where to go once `domain` serves this site. */
const settleOn = (domain) => {
if ( statusOf(domain) === 'active' ) {
showSuccess(domain);
return;
}
chosen = domain;
renderRecords(domain);
showPane('records');
};
// -- List ---------------------------------------------------------
let listed = [];
const metaFor = (item) => item.site !== subdomain
? text('custom_domain_serves', [`${item.site}.${window.hosting_domain}`])
: text('custom_domain_serves_this');
// With no domains, adding one is the only way on, so the button sits
// in the empty state where the eye lands instead of the footer.
const toggleEmpty = () => {
$overlay.find('.custom-domain-empty').toggle(listed.length === 0);
$overlay.find('.custom-domain-list-add').toggle(listed.length > 0);
};
// One row asks for confirmation at a time; asking on another puts the
// first back.
const resetConfirming = () => {
$overlay.find('.custom-domain-row[data-confirming]').each(function () {
const $row = $(this);
const kind = $row.attr('data-confirming');
const hadFocus = $.contains($row.find('.custom-domain-row-confirm').get(0), document.activeElement);
$row.removeAttr('data-confirming');
$row.find('.custom-domain-row-meta').text(metaFor($row.data('item')));
// Back to the button that asked, rather than one now hidden.
if ( hadFocus ) {
focusOn($row.find(kind === 'move' ? '.custom-domain-use' : '.custom-domain-remove').get(0));
}
});
};
const confirmOn = ($row, kind, message) => {
resetConfirming();
$row.attr('data-confirming', kind);
$row.find('.custom-domain-row-meta').text(message);
$row.find('.custom-domain-confirm-go')
.text(text(kind === 'move' ? 'custom_domain_move' : 'custom_domain_remove'))
.get(0)?.focus({ preventScroll: true });
};
const renderList = (items) => {
listed = items;
const $list = $overlay.find('.custom-domain-list').empty();
toggleEmpty();
$overlay.find('.custom-domain-empty').toggle(items.length === 0);
for ( const item of items ) {
const status = statusOf(item);
const elsewhere = item.site !== subdomain;
const $row = $(`
<li class="custom-domain-row">
<span class="custom-domain-row-icon">${ICON_GLOBE}</span>
<div class="custom-domain-row-main">
<div class="custom-domain-row-title">
<span class="custom-domain-row-host"></span>
<span class="custom-domain-badge" data-status="${status}">${i18n(STATUS_BADGE_KEYS[status])}</span>
</div>
<div class="custom-domain-row-host"></div>
<div class="custom-domain-row-meta"></div>
</div>
<div class="custom-domain-row-actions">
<button type="button" class="custom-domain-icon-btn custom-domain-remove">${ICON_TRASH}</button>
<button type="button" class="app-feedback-btn custom-domain-use">${i18n('custom_domain_use')}</button>
</div>
<div class="custom-domain-row-confirm">
<button type="button" class="app-feedback-btn custom-domain-confirm-cancel">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn custom-domain-confirm-go"></button>
</div>
<button type="button" class="app-feedback-btn custom-domain-use">${i18n('custom_domain_use')}</button>
</li>
`);
$row.data('item', item);
$row.toggleClass('is-current', item.site === subdomain);
$row.find('.custom-domain-row-host').text(item.hostname);
$row.find('.custom-domain-row-meta').text(metaFor(item));
$row.find('.custom-domain-remove').attr({
'aria-label': text('custom_domain_remove_named', [item.hostname]),
title: text('custom_domain_remove'),
});
$row.find('.custom-domain-row-meta').text(elsewhere
? i18n('custom_domain_serves', [`${item.site}.${window.hosting_domain}`], false)
: i18n('custom_domain_serves_this', [], false));
$row.find('.custom-domain-use').on('click', () => use(item, $row));
$row.find('.custom-domain-remove').on('click', () => {
if ( ! busy ) confirmOn($row, 'remove', text('custom_domain_remove_confirm'));
});
$row.find('.custom-domain-confirm-cancel').on('click', () => {
if ( ! busy ) resetConfirming();
});
$row.find('.custom-domain-confirm-go').on('click', (e) => {
const $btn = $(e.currentTarget);
if ( $row.attr('data-confirming') === 'move' ) move(item, $btn);
else remove(item, $row, $btn);
});
$list.append($row);
}
};
const use = (item, $row) => {
const use = async (item, $row) => {
if ( busy ) return;
if ( item.site === subdomain ) {
settleOn(item);
finish(item);
return;
}
// Moving takes the domain off another site, so say so first.
confirmOn($row, 'move', text('custom_domain_move_confirm', [
`${item.site}.${window.hosting_domain}`,
site_host,
]));
};
const move = async (item, $btn) => {
if ( busy ) return;
setBusy(true, $btn);
if ( ! $row.hasClass('custom-domain-confirming') ) {
$overlay.find('.custom-domain-confirming').removeClass('custom-domain-confirming')
.find('.custom-domain-use').text(text('custom_domain_use'));
$row.addClass('custom-domain-confirming');
$row.find('.custom-domain-row-meta').text(i18n('custom_domain_move_confirm', [
`${item.site}.${window.hosting_domain}`,
site_host,
], false));
$row.find('.custom-domain-use').text(text('custom_domain_move'));
return;
}
setBusy(true);
try {
const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain });
if ( ! res.ok ) {
showError('list', errorFor(res));
return;
}
settleOn(res.data);
finish(res.data);
} catch ( e ) {
console.error('custom-domain: move failed', e);
showError('list', text('custom_domain_error_generic'));
@@ -515,57 +294,11 @@ async function UIWindowAttachCustomDomain (options) {
}
};
/**
* Removes the domain from the account, which also stops its rent.
* True once it's gone, including when it already was.
*/
const deleteDomain = async (item, pane, $btn) => {
setBusy(true, $btn);
try {
const res = await api('DELETE', `/${encodeURIComponent(item.uuid)}`);
if ( ! res.ok && res.status !== 404 ) {
showError(pane, errorFor(res));
return false;
}
// A removed domain is no answer to hand the caller.
if ( chosen?.uuid === item.uuid ) chosen = null;
return true;
} catch ( e ) {
console.error('custom-domain: remove failed', e);
showError(pane, text('custom_domain_error_generic'));
return false;
} finally {
setBusy(false);
}
};
const remove = async (item, $row, $btn) => {
if ( busy ) return;
if ( ! await deleteDomain(item, 'list', $btn) ) return;
listed = listed.filter((i) => i.uuid !== item.uuid);
// Focus moves to the next row, else the previous one, else to
// adding a domain once the empty state shows.
const hadFocus = $.contains($row.get(0), document.activeElement);
const $next = $row.nextAll('.custom-domain-row:not(.is-leaving)').first();
const $other = $next.length ? $next : $row.prevAll('.custom-domain-row:not(.is-leaving)').first();
if ( hadFocus ) focusOn($other.find('.custom-domain-remove').get(0));
// Gone server-side: its buttons can't act on it again as it fades.
$row.addClass('is-leaving').attr('inert', '');
setTimeout(() => {
$row.remove();
toggleEmpty();
if ( hadFocus && focusLost() ) {
focusOn($overlay.find(listed.length ? '.custom-domain-list-add' : '.custom-domain-empty-add').get(0));
}
}, 180);
};
// -- Add ----------------------------------------------------------
const $input = $overlay.find('.custom-domain-input');
const typedHostname = () => normalizeHostnameInput($input.val());
const updateAddButton = () => {
$overlay.find('.custom-domain-add').prop('disabled', busy || ! isPlausibleHostname(typedHostname()));
$overlay.find('.custom-domain-add').prop('disabled', busy || String($input.val() || '').trim() === '');
};
// A claim is for one hostname; editing it starts over.
const hideClaim = () => {
@@ -573,20 +306,22 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('.custom-domain-add').text(text('custom_domain_add'));
};
const showClaim = (record) => {
$overlay.find('.custom-domain-claim .custom-domain-records')
.empty()
.append(recordCard({ type: 'TXT', name: record.name, value: record.value, required: true }));
$overlay.find('.custom-domain-claim-name').text(record.name);
$overlay.find('.custom-domain-claim-value').text(record.value);
$overlay.find('.custom-domain-claim').show();
$overlay.find('.custom-domain-add').text(text('custom_domain_verify_and_add'));
};
$input.on('input', () => {
hideClaim();
$overlay.find('[data-pane="add"] .app-feedback-error').hide();
updateAddButton();
});
$overlay.find('.custom-domain-claim .custom-domain-copy')
.attr('title', text('copy'))
.on('click', function () {
navigator.clipboard?.writeText($(this).text()).catch(() => {});
});
$overlay.find('.custom-domain-to-add').on('click', () => {
resetConfirming();
showPane('add');
$input.get(0)?.focus({ preventScroll: true });
});
@@ -596,10 +331,9 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('form[data-pane="add"]').on('submit', async (e) => {
e.preventDefault();
const hostname = typedHostname();
if ( ! isPlausibleHostname(hostname) || busy ) return;
$input.val(hostname);
setBusy(true, $overlay.find('.custom-domain-add'));
const hostname = String($input.val() || '').trim();
if ( ! hostname || busy ) return;
setBusy(true);
try {
const res = await api('POST', '', { site: subdomain, hostname });
if ( res.data?.code === 'custom_domain_claim_required' && res.data.verification ) {
@@ -615,7 +349,9 @@ async function UIWindowAttachCustomDomain (options) {
return;
}
hideClaim();
settleOn(res.data);
chosen = res.data;
renderRecords(res.data);
showPane('records');
} catch ( err ) {
console.error('custom-domain: add failed', err);
showError('add', text('custom_domain_error_generic'));
@@ -626,47 +362,52 @@ async function UIWindowAttachCustomDomain (options) {
// -- Records --------------------------------------------------------
const STATUS_ICONS = { active: ICON_CHECK, pending: ICON_CLOCK, failed: ICON_ALERT };
const STATUS_TEXT_KEYS = {
active: 'custom_domain_status_active',
pending: 'custom_domain_status_pending',
failed: 'custom_domain_status_failed',
};
const resetRemoveChosen = () => {
$overlay.find('.custom-domain-remove-chosen')
.removeClass('custom-domain-confirming').text(text('custom_domain_remove'));
$overlay.find('.custom-domain-remove-note').hide();
};
const renderRecords = (domain) => {
resetRemoveChosen();
const status = statusOf(domain);
$overlay.find('.custom-domain-status-card').attr('data-status', status);
$overlay.find('.custom-domain-status-icon').html(STATUS_ICONS[status]);
$overlay.find('.custom-domain-status').html(i18n(STATUS_TEXT_KEYS[status], [domain.hostname]));
$overlay.find('.custom-domain-status-detail')
.text(domain.statusDetail ?? '')
.toggle(!!domain.statusDetail && status !== 'active');
const key = domain.status === 'active'
? 'custom_domain_status_active'
: domain.status === 'failed'
? 'custom_domain_status_failed'
: 'custom_domain_status_pending';
const $status = $overlay.find('.custom-domain-status')
.html(i18n(key, [domain.hostname]));
if ( domain.statusDetail && domain.status !== 'active' ) {
$status.append($('<span class="custom-domain-status-detail"></span>').text(domain.statusDetail));
}
const $redirect = $overlay.find('.custom-domain-redirect').toggle(!!domain.redirectFrom);
if ( domain.redirectFrom ) {
$redirect.html(i18n('custom_domain_redirect_note', [domain.redirectFrom, domain.hostname]));
}
$overlay.find('.custom-domain-apex').toggle(domain.isApex === true);
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && status !== 'active');
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && domain.status !== 'active');
const $records = $overlay.find('[data-pane="records"] .custom-domain-records').empty();
const $tbody = $overlay.find('.custom-domain-records tbody').empty();
for ( const record of domain.records ?? [] ) {
const $tr = $(`
<tr>
<td class="custom-domain-record-type"></td>
<td><code class="custom-domain-copy"></code></td>
<td><code class="custom-domain-copy"></code></td>
</tr>
`);
const type = domain.isApex && record.purpose === 'routing'
? text('custom_domain_record_type_apex')
: record.type;
$records.append(recordCard(record, type));
$tr.find('.custom-domain-record-type').text(record.required
? type
: `${type} ${text('custom_domain_optional')}`);
const [$name, $value] = $tr.find('code').toArray().map((el) => $(el));
$name.text(record.name);
$value.text(record.value);
$tbody.append($tr);
}
$tbody.find('.custom-domain-copy').attr('title', i18n('copy', [], false)).on('click', function () {
navigator.clipboard?.writeText($(this).text()).catch(() => {});
});
};
$overlay.find('.custom-domain-check').on('click', async (e) => {
$overlay.find('.custom-domain-check').on('click', async () => {
if ( busy || ! chosen ) return;
setBusy(true, $(e.currentTarget));
setBusy(true);
try {
const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`);
if ( ! res.ok ) {
@@ -676,33 +417,27 @@ async function UIWindowAttachCustomDomain (options) {
// A domain that is no longer pending comes back with only its
// routing record, and a GET never repeats the provider or the
// redirect; keep what was shown when it was added.
const known = chosen.records ?? [];
const redirect = known.filter((r) => r.purpose === 'redirect');
const fresh = (res.data.records?.length > 1 ? res.data.records : known)
const redirect = chosen.records.filter((r) => r.purpose === 'redirect');
const fresh = (res.data.records?.length > 1 ? res.data.records : chosen.records)
.filter((r) => r.purpose !== 'redirect');
const next = {
chosen = {
...res.data,
dnsProvider: chosen.dnsProvider,
redirectFrom: chosen.redirectFrom,
records: [...fresh.slice(0, 1), ...redirect, ...fresh.slice(1)],
};
if ( statusOf(next) === 'active' ) {
showSuccess(next);
return;
}
chosen = next;
renderRecords(chosen);
} catch ( err ) {
console.error('custom-domain: status check failed', err);
} catch ( e ) {
console.error('custom-domain: status check failed', e);
showError('records', text('custom_domain_error_generic'));
} finally {
setBusy(false);
}
});
// Same row, so the month already paid covers the www hostname.
$overlay.find('.custom-domain-use-www').on('click', async (e) => {
$overlay.find('.custom-domain-use-www').on('click', async () => {
if ( busy || ! chosen ) return;
setBusy(true, $(e.currentTarget));
setBusy(true);
try {
const res = await api('POST', `/${encodeURIComponent(chosen.uuid)}/use-www`);
if ( ! res.ok ) {
@@ -711,35 +446,20 @@ async function UIWindowAttachCustomDomain (options) {
}
chosen = res.data;
renderRecords(chosen);
} catch ( err ) {
console.error('custom-domain: switch to www failed', err);
} catch ( e ) {
console.error('custom-domain: switch to www failed', e);
showError('records', text('custom_domain_error_generic'));
} finally {
setBusy(false);
}
});
// Undoes an add made by mistake, or drops a domain the user is done
// with, without leaving the dialog.
$overlay.find('.custom-domain-remove-chosen').on('click', async function () {
if ( busy || ! chosen ) return;
const $btn = $(this);
if ( ! $btn.hasClass('custom-domain-confirming') ) {
$btn.addClass('custom-domain-confirming').text(text('confirm'));
$overlay.find('.custom-domain-remove-note').show();
return;
}
if ( ! await deleteDomain(chosen, 'records', $btn) ) return;
resetRemoveChosen();
showPane('loading');
await loadList();
});
$overlay.find('.custom-domain-done').on('click', () => {
if ( ! busy ) close();
});
// -- Load -------------------------------------------------------------
const loadList = async () => {
(async () => {
try {
const res = await api('GET');
if ( closed ) return;
@@ -760,15 +480,14 @@ async function UIWindowAttachCustomDomain (options) {
renderList(res.data?.items ?? []);
const price = format_price(res.data?.pricing);
if ( price ) {
$overlay.find('.custom-domain-price').text(text('custom_domain_price_note', [price])).show();
$overlay.find('.custom-domain-price').text(i18n('custom_domain_price_note', [price], false)).show();
}
showPane('list');
} catch ( e ) {
console.error('custom-domain: list failed', e);
if ( ! closed ) showUnavailable('custom_domain_error_generic');
}
};
loadList();
})();
});
}
@@ -1,257 +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/>.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const { default: UIWindowAttachCustomDomain } = await import('./UIWindowAttachCustomDomain.js');
const pendingDomain = (overrides = {}) => ({
uuid: 'd1',
hostname: 'www.example.com',
site: 'mysite',
status: 'pending',
isApex: false,
records: [{ type: 'CNAME', name: 'www.example.com', value: 'mysite.puter.site', purpose: 'routing', required: true }],
...overrides,
});
const json = (status, data) => new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** Answers the list call with `items`; anything else 200s with `{}`. */
const serve = (items) => vi.fn(async (url, opts = {}) => {
const path = String(url).split('/custom-domains')[1];
if ( (opts.method ?? 'GET') === 'GET' && ! path ) return json(200, { items });
return json(200, {});
});
const flush = () => new Promise((r) => setTimeout(r, 0));
const $ = (sel) => jQuery(sel);
const visiblePane = () => $('.custom-domain-pane').filter((_, el) => el.style.display !== 'none').attr('data-pane');
const pressEscape = () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
/** Opens the dialog and waits for the list to load. */
const open = async (items = [pendingDomain()]) => {
globalThis.fetch = serve(items);
let result = 'open';
UIWindowAttachCustomDomain({ subdomain: 'mysite' }).then((r) => {
result = r;
});
await flush();
await flush();
return { result: () => result };
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
globalThis.puter = { authToken: 'token' };
window.api_origin = 'https://api.puter.test';
window.hosting_domain = 'puter.site';
});
afterEach(() => {
$(document).off('keydown');
vi.useRealTimers();
});
describe('UIWindowAttachCustomDomain', () => {
it('backs out of the records pane remove confirmation on Escape', async () => {
const dialog = await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
expect(visiblePane()).toBe('records');
$('.custom-domain-remove-chosen').trigger('click');
expect($('.custom-domain-remove-chosen').hasClass('custom-domain-confirming')).toBe(true);
pressEscape();
expect($('.custom-domain-remove-chosen').hasClass('custom-domain-confirming')).toBe(false);
expect($('.custom-domain-remove-note').css('display')).toBe('none');
expect(dialog.result()).toBe('open');
pressEscape();
await flush();
expect(dialog.result()).toMatchObject({ uuid: 'd1' });
});
it('starts each step scrolled to the top of the floating modal', async () => {
await open();
// jsdom has no layout, so stand in for a modal scrolled down the list.
const modal = document.querySelector('.custom-domain-modal');
let scrollTop = 240;
Object.defineProperty(modal, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value;
},
});
$('.custom-domain-row .custom-domain-use').trigger('click');
expect(visiblePane()).toBe('records');
expect(scrollTop).toBe(0);
});
describe('keyboard focus', () => {
const otherSite = (uuid, hostname) => pendingDomain({ uuid, hostname, site: 'other' });
it('moves into the dialog when the focused control\'s step is hidden', async () => {
await open();
const use = document.querySelector('.custom-domain-use');
use.focus();
$(use).trigger('click');
expect(visiblePane()).toBe('records');
expect(document.activeElement).toBe(document.querySelector('.custom-domain-modal'));
});
it('returns to the button that asked when a row confirmation is backed out', async () => {
await open([otherSite('a', 'a.example.com')]);
const $row = $('.custom-domain-row');
$row.find('.custom-domain-use').trigger('click');
expect(document.activeElement).toBe($row.find('.custom-domain-confirm-go').get(0));
pressEscape();
expect(document.activeElement).toBe($row.find('.custom-domain-use').get(0));
$row.find('.custom-domain-remove').trigger('click');
$row.find('.custom-domain-confirm-cancel').trigger('click');
expect(document.activeElement).toBe($row.find('.custom-domain-remove').get(0));
});
it('comes back to the clicked button once a request finishes', async () => {
await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
const check = document.querySelector('.custom-domain-check');
check.focus();
// A browser drops focus to the page once the focused control is
// disabled; jsdom doesn't, so drop it by hand.
globalThis.fetch = vi.fn(async () => {
const elsewhere = document.createElement('input');
document.body.append(elsewhere);
elsewhere.focus();
elsewhere.remove();
return json(200, pendingDomain());
});
$(check).trigger('click');
await flush();
await flush();
expect(check.disabled).toBe(false);
expect(document.activeElement).toBe(check);
});
it('moves to the next row when a row is removed', async () => {
await open([otherSite('a', 'a.example.com'), otherSite('b', 'b.example.com')]);
const [rowA, rowB] = $('.custom-domain-row').toArray();
$(rowA).find('.custom-domain-remove').trigger('click');
$(rowA).find('.custom-domain-confirm-go').trigger('click');
await flush();
await flush();
expect(document.activeElement).toBe($(rowB).find('.custom-domain-remove').get(0));
});
});
describe('copy buttons', () => {
afterEach(() => {
delete navigator.clipboard;
delete document.execCommand;
});
const copyCnameValue = async () => {
await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
const $btn = $('[data-pane="records"] .custom-domain-copy').eq(1);
$btn.trigger('click');
await flush();
return $btn;
};
it('copy with the clipboard API', async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const $btn = await copyCnameValue();
expect(writeText).toHaveBeenCalledWith('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(true);
});
it('fall back to selecting and copying when there is no clipboard API', async () => {
document.execCommand = vi.fn(() => true);
const $btn = await copyCnameValue();
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(window.getSelection().toString()).toBe('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(true);
});
it('leave the value selected when nothing can copy it', async () => {
const $btn = await copyCnameValue();
expect(window.getSelection().toString()).toBe('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(false);
});
});
describe('empty state', () => {
const shown = (sel) => $(sel).get(0).style.display !== 'none';
it('puts the add button in the empty state instead of the footer', async () => {
await open([]);
expect(shown('.custom-domain-empty')).toBe(true);
expect(shown('.custom-domain-list-add')).toBe(false);
$('.custom-domain-empty-add').trigger('click');
expect(visiblePane()).toBe('add');
});
it('keeps the add button in the footer while there are domains', async () => {
await open();
expect(shown('.custom-domain-empty')).toBe(false);
expect(shown('.custom-domain-list-add')).toBe(true);
});
it('moves the button and focus to the empty state when the last domain is removed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] });
const ready = open([pendingDomain({ site: 'other' })]);
await vi.runAllTimersAsync();
await ready;
const row = document.querySelector('.custom-domain-row');
$(row).find('.custom-domain-remove').trigger('click');
$(row).find('.custom-domain-confirm-go').trigger('click');
await vi.runAllTimersAsync();
expect(row.isConnected).toBe(false);
expect(shown('.custom-domain-empty')).toBe(true);
expect(shown('.custom-domain-list-add')).toBe(false);
expect(document.activeElement).toBe($('.custom-domain-empty-add').get(0));
});
});
it('takes no more input on a removed row while it fades out', async () => {
await open([pendingDomain({ site: 'other' })]);
const row = document.querySelector('.custom-domain-row');
$(row).find('.custom-domain-remove').trigger('click');
$(row).find('.custom-domain-confirm-go').trigger('click');
await flush();
await flush();
expect(row.isConnected).toBe(true);
expect(row.hasAttribute('inert')).toBe(true);
});
});
-1
View File
@@ -215,7 +215,6 @@ async function UIWindowAuthMe (options = {}) {
width: 400,
height: 'auto',
dominant: true,
show_in_taskbar: false,
window_class: 'window-authme',
body_css: {
width: 'initial',
-1
View File
@@ -87,7 +87,6 @@ async function UIWindowBlockedSenders (options) {
width: 420,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.blocked-username').get(0)?.focus({ preventScroll: true });
},
-1
View File
@@ -68,7 +68,6 @@ async function UIWindowChangeUsername (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.new-username').get(0)?.focus({ preventScroll: true });
const oidc_only = !!(window.user && window.user.oidc_only);
-1
View File
@@ -114,7 +114,6 @@ function UIWindowCopyToken (options = {}) {
width: 450,
height: 'auto',
dominant: true,
show_in_taskbar: false,
window_class: 'window-copy-auth-token',
body_css: {
width: 'initial',
-217
View File
@@ -1,217 +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';
async function UIWindowDesktopBGSettings (options) {
options = options ?? {};
return new Promise(async (resolve) => {
let h = '';
const original_background_css = $('body').attr('style');
let bg_url = window.desktop_bg_url,
bg_color = window.desktop_bg_color,
bg_fit = window.desktop_bg_fit;
h += '<div style="padding: 10px; border-bottom: 1px solid #ced7e1;">';
// type
h += `<label>${i18n('background')}:</label>`;
h += '<select class="desktop-bg-type" style="width: 150px; margin-bottom: 20px;">';
h += `<option value="default">${i18n('default')}</option>`;
h += `<option value="picture">${i18n('picture')}</option>`;
h += `<option value="color">${i18n('color')}</option>`;
h += '</select>';
// Picture
h += '<div class="desktop-bg-settings-wrapper desktop-bg-settings-picture">';
h += `<label>${i18n('image')}:</label>`;
h += `<button class="button button-default button-small browse">${i18n('browse')}</button>`;
h += `<label style="margin-top: 20px;">${i18n('fit')}:</label>`;
h += '<select class="desktop-bg-fit" style="width: 150px;">';
h += `<option value="cover">${i18n('cover')}</option>`;
h += `<option value="center">${i18n('center')}</option>`;
h += `<option value="contain">${i18n('contain')}</option>`;
h += `<option value="repeat">${i18n('repeat')}</option>`;
h += '</select>';
h += '</div>';
// Color
h += '<div class="desktop-bg-settings-wrapper desktop-bg-settings-color">';
h += `<label>${i18n('color')}:</label>`;
h += '<div class="desktop-bg-color-blocks">';
h += '<div class="desktop-bg-color-block" data-color="#4F7BB5" style="background-color: #4F7BB5"></div>';
h += '<div class="desktop-bg-color-block" data-color="#545554" style="background-color: #545554"></div>';
h += '<div class="desktop-bg-color-block" data-color="#F5D3CE" style="background-color: #F5D3CE"></div>';
h += '<div class="desktop-bg-color-block" data-color="#52A758" style="background-color: #52A758"></div>';
h += '<div class="desktop-bg-color-block" data-color="#ad3983" style="background-color: #ad3983"></div>';
h += '<div class="desktop-bg-color-block" data-color="#ffffff" style="background-color: #ffffff"></div>';
h += '<div class="desktop-bg-color-block" data-color="#000000" style="background-color: #000000"></div>';
h += '<div class="desktop-bg-color-block" data-color="#454545" style="background-color: #454545"></div>';
h += `<div class="desktop-bg-color-block desktop-bg-color-block-palette" data-color="" style="background-image: url(${window.icons['palette.svg']});
background-repeat: no-repeat;
background-size: contain;
background-position: center;"><input type="color" style="width:25px; height: 25px; opacity:0;"></div>`;
h += '</div>';
h += '</div>';
h += '<div style="padding-top: 5px; overflow:hidden; margin-top: 25px; border-top: 1px solid #CCC;">';
h += `<button class="button button-primary apply" style="float:right;">${i18n('apply')}</button>`;
h += `<button class="button button-default cancel" style="float:right; margin-right: 10px;">${i18n('cancel')}</button>`;
h += '</div>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('change_desktop_background'),
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,
onAppend: function (this_window) {
$(this_window).find('.access-recipient').focus();
},
window_class: 'window-give-access',
width: 350,
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
},
...options.window_options,
});
const default_wallpaper = (window.gui_env === 'prod') ? 'https://puter-assets.b-cdn.net/wallpaper.webp' : '/images/wallpaper.webp';
$(el_window).find('.desktop-bg-settings-wrapper').hide();
if ( window.desktop_bg_url === default_wallpaper ) {
$(el_window).find('.desktop-bg-type').val('default');
} else if ( window.desktop_bg_url !== undefined && window.desktop_bg_url !== null ) {
$(el_window).find('.desktop-bg-settings-picture').show();
$(el_window).find('.desktop-bg-type').val('picture');
} else if ( window.desktop_bg_color !== undefined && window.desktop_bg_color !== null ) {
$(el_window).find('.desktop-bg-settings-color').show();
$(el_window).find('.desktop-bg-type').val('color');
} else {
// Default fallback if no specific wallpaper settings are detected
$(el_window).find('.desktop-bg-type').val('default');
}
$(el_window).find('.desktop-bg-color-block:not(.desktop-bg-color-block-palette').on('click', async function (e) {
window.set_desktop_background({ color: $(this).attr('data-color') });
});
$(el_window).find('.desktop-bg-color-block-palette input').on('change', async function (e) {
window.set_desktop_background({ color: $(this).val() });
});
$(el_window).on('file_opened', function (e) {
let selected_file = Array.isArray(e.detail) ? e.detail[0] : e.detail;
const fit = $(el_window).find('.desktop-bg-fit').val();
bg_url = selected_file.read_url;
bg_fit = fit;
bg_color = undefined;
window.set_desktop_background({ url: bg_url, fit: bg_fit });
});
$(el_window).find('.desktop-bg-fit').on('change', function (e) {
const fit = $(this).val();
bg_fit = fit;
window.set_desktop_background({ fit: fit });
});
$(el_window).find('.desktop-bg-type').on('change', function (e) {
const type = $(this).val();
$(el_window).find('.desktop-bg-settings-wrapper').hide();
if ( type === 'picture' ) {
$(el_window).find('.desktop-bg-settings-picture').show();
} else if ( type === 'color' ) {
$(el_window).find('.desktop-bg-settings-color').show();
} else if ( type === 'default' ) {
bg_color = undefined;
bg_fit = 'cover';
window.set_desktop_background({ url: default_wallpaper, fit: bg_fit });
}
});
$(el_window).find('.apply').on('click', async function (e) {
// /set-desktop-bg
try {
$.ajax({
url: `${window.api_origin }/set-desktop-bg`,
type: 'POST',
data: JSON.stringify({
url: window.desktop_bg_url,
color: window.desktop_bg_color,
fit: window.desktop_bg_fit,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
$(el_window).close();
resolve(true);
} catch ( err ) {
// Ignore
}
});
$(el_window).find('.browse').on('click', function () {
// open dialog
UIWindow({
path: `/${ window.user.username }/Desktop`,
// this is the uuid of the window to which this dialog will return
parent_uuid: $(el_window).attr('data-element_uuid'),
allowed_file_types: ['image/*'],
show_maximize_button: false,
show_minimize_button: false,
title: i18n('window_title_open'),
is_dir: true,
is_openFileDialog: true,
selectable_body: false,
});
});
$(el_window).find('.cancel').on('click', function () {
$('body').attr('style', original_background_css);
$(el_window).close();
resolve(true);
});
});
}
export default UIWindowDesktopBGSettings;
-1
View File
@@ -91,7 +91,6 @@ async function UIWindowFeedback (options) {
width: 380,
height: 'auto',
dominant: true,
show_in_taskbar: false,
...options.window_options,
onAppend: function (this_window) {
$(this_window).find('.feedback-message').get(0).focus({ preventScroll: true });
+1 -2
View File
@@ -167,8 +167,7 @@ async function UIWindowLogin (options) {
if ( logo_clickable ) {
$(el_window).find('.auth-logo').on('click', function () {
// keep desktop users on the desktop; everyone else goes to the root dashboard
window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/';
window.location.href = '/';
});
}
-1
View File
@@ -306,7 +306,6 @@ export default async function UIWindowLogin2FA (challengeToken) {
width: Math.min(400, window.innerWidth - 24),
height: 'auto',
dominant: true,
show_in_taskbar: false,
is_draggable: false,
backdrop: true,
stay_on_top: true,
@@ -57,7 +57,6 @@ async function UIWindowLoginInProgress (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
backdrop: true,
stay_on_top: true,
window_class: 'window-login-progress',
-1
View File
@@ -100,7 +100,6 @@ async function UIWindowNewPassword (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.new-password').get(0)?.focus({ preventScroll: true });
},
@@ -71,7 +71,6 @@ function UIWindowPasswordChangeRequired (options) {
width: 390,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.pcr-current').get(0)?.focus({ preventScroll: true });
},
-88
View File
@@ -1,88 +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 Placeholder from '../util/Placeholder.js';
import Flexer from './Components/Flexer.js';
import UIQRCode from './UIQRCode.js';
import UIWindow from './UIWindow.js';
async function UIWindowQR (options) {
options = options ?? {};
const placeholder_qr = Placeholder();
let h = '';
// close button containing the multiplication sign
h += '<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>';
h += '<div class="otp-qr-code">';
h += `<h1 style="text-align: center; font-size: 16px; padding: 10px; font-weight: 400; margin: -10px 10px 20px 10px; -webkit-font-smoothing: antialiased; color: #5f626d;">${
i18n(options.message_i18n_key || 'scan_qr_generic')
}</h1>`;
h += '</div>';
h += placeholder_qr.html;
const el_window = await UIWindow({
title: i18n('window_title_instant_login'),
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
backdrop: true,
width: 450,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
window_class: 'window-qr',
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '50px 20px',
},
});
const el_qr = UIQRCode({
value: options.text,
size: 250,
});
const component_flexer = new Flexer({
children: [
el_qr,
],
});
component_flexer.attach(placeholder_qr);
}
export default UIWindowQR;
-1
View File
@@ -91,7 +91,6 @@ async function UIWindowSaveAccount (options) {
allow_user_select: true,
width: 350,
dominant: true,
show_in_taskbar: false,
...options.window_options,
onAppend: function (this_window) {
if ( options.default_username )
-308
View File
@@ -1,308 +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 path from '../lib/path.js';
import UIAlert from './UIAlert.js';
import launch_app from '../helpers/launchApp.js';
import item_icon from '../helpers/itemIcon.js';
import UIContextMenu from './UIContextMenu.js';
async function UIWindowSearch (options) {
let h = '';
h += '<div class="search-input-wrapper">';
h += `<input type="text" class="search-input" placeholder="Search" style="background-image:url('${window.icons['magnifier-outline.svg']}');">`;
h += '</div>';
h += '<div class="search-results" style="overflow-y: auto; max-height: 300px;">';
const el_window = await UIWindow({
icon: null,
single_instance: true,
app: 'search',
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
draggable_body: true,
allow_context_menu: false,
is_draggable: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: true,
allow_user_select: true,
window_class: 'window-search',
backdrop: true,
center: isMobile.phone,
width: 500,
dominant: true,
window_css: {
height: 'initial',
padding: '0',
},
body_css: {
width: 'initial',
'max-height': 'calc(100vh - 200px)',
'background-color': 'rgb(241 246 251)',
'backdrop-filter': 'blur(3px)',
'padding': '0',
'height': 'initial',
'overflow': 'hidden',
'min-height': '65px',
'padding-bottom': '10px',
},
});
$(el_window).find('.search-input').focus();
// Debounce function to limit rate of API calls
function debounce (func, wait) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
// State for managing loading indicator
let isSearching = false;
// Debounced search function
const performSearch = debounce(async function (searchInput, resultsContainer) {
// Don't search if input is empty
if ( searchInput.val() === '' ) {
resultsContainer.html('');
resultsContainer.hide();
return;
}
// Set loading state
if ( ! isSearching ) {
isSearching = true;
}
try {
// Perform the search
let results = await fetch(`${window.api_origin }/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${puter.authToken}`,
},
body: JSON.stringify({ text: searchInput.val() }),
});
results = await results.json();
// Hide results if there are none
if ( results.length === 0 )
{
resultsContainer.hide();
}
else
{
resultsContainer.show();
}
// Build results HTML
let h = '';
for ( let i = 0; i < results.length; i++ ) {
const result = results[i];
h += `<div
class="search-result"
data-path="${html_encode(result.path)}"
data-uid="${html_encode(result.uid)}"
data-is_dir="${html_encode(result.is_dir)}"
>`;
// icon
h += `<img src="${html_encode((await item_icon(result)).image)}" style="width: 20px; height: 20px; margin-right: 6px;">`;
h += html_encode(result.name);
h += '</div>';
}
resultsContainer.html(h);
} catch ( error ) {
resultsContainer.html('<div class="search-error">Search failed. Please try again.</div>');
console.error('Search error:', error);
} finally {
isSearching = false;
}
}, 300); // Wait 300ms after last keystroke before searching
// Event binding
$(el_window).find('.search-input').on('input', function (e) {
const searchInput = $(this);
const resultsContainer = $(el_window).find('.search-results');
performSearch(searchInput, resultsContainer);
});
}
$(document).on('click', '.search-result', async function (e) {
const fspath = $(this).data('path');
const fsuid = $(this).data('uid');
const is_dir = $(this).attr('data-is_dir') === 'true' || $(this).data('is_dir') === '1';
let open_item_meta;
if ( is_dir ) {
UIWindow({
path: fspath,
title: path.basename(fspath),
icon: await item_icon({ is_dir: true, path: fspath }),
uid: fsuid,
is_dir: is_dir,
app: 'explorer',
});
// close search window
$(this).closest('.window').close();
return;
}
// get all info needed to open an item
try {
open_item_meta = await $.ajax({
url: `${window.api_origin }/open_item`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
uid: fsuid ?? undefined,
path: fspath ?? undefined,
}),
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
} catch ( err ) {
// Ignored
}
// get a list of suggested apps for this file type.
let suggested_apps = open_item_meta?.suggested_apps ?? await window.suggest_apps_for_fsentry({ uid: fsuid, path: fspath });
//---------------------------------------------
// No suitable apps, ask if user would like to
// download
//---------------------------------------------
if ( suggested_apps.length === 0 ) {
//---------------------------------------------
// If .zip file, unzip it
//---------------------------------------------
if ( path.extname(fspath) === '.zip' ) {
window.unzipItem(fspath);
return;
}
const alert_resp = await UIAlert('Found no suitable apps to open this file with. Would you like to download it instead?',
[
{
label: i18n('download_file'),
value: 'download_file',
type: 'primary',
},
{
label: i18n('cancel'),
},
]);
if ( alert_resp === 'download_file' ) {
window.trigger_download([fspath]);
}
return;
}
//---------------------------------------------
// First suggested app is default app to open this item
//---------------------------------------------
else {
launch_app({
name: suggested_apps[0].name,
token: open_item_meta.token,
file_path: fspath,
app_obj: suggested_apps[0],
window_title: path.basename(fspath),
file_uid: fsuid,
// maximized: options.maximized,
file_signature: open_item_meta.signature,
});
}
// close
$(this).closest('.window').close();
});
// Context menu for search results
$(document).on('contextmenu', '.search-result', async function (e) {
e.preventDefault();
e.stopPropagation();
const fspath = $(this).data('path');
const fsuid = $(this).data('uid');
const is_dir = $(this).attr('data-is_dir') === 'true' || $(this).data('is_dir') === '1';
// Get the parent directory path
const parent_path = path.dirname(fspath);
// Build context menu items
const menuItems = [
{
html: i18n('open'),
onClick: async function () {
// Trigger the same logic as clicking on the search result
$(e.target).trigger('click');
},
},
];
// Only add "Open enclosing folder" if we're not already at root
if ( parent_path && parent_path !== fspath && parent_path !== '/' ) {
menuItems.push('-'); // divider
menuItems.push({
html: i18n('open_containing_folder'),
onClick: async function () {
// Open the enclosing folder
UIWindow({
path: parent_path,
title: path.basename(parent_path) || window.root_dirname,
icon: window.icons['folder.svg'],
is_dir: true,
app: 'explorer',
});
// Close search window
$(e.target).closest('.window').close();
},
});
}
UIContextMenu({
items: menuItems,
});
});
export default UIWindowSearch;
-1
View File
@@ -82,7 +82,6 @@ async function UIWindowSessionList (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
update_window_url: false,
cover_page: options.cover_page ?? false,
window_class: 'window-session-list',
-1
View File
@@ -127,7 +127,6 @@ async function UIWindowShare (options) {
width: 420,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.share-recipient').get(0)?.focus({ preventScroll: true });
},
+1 -31
View File
@@ -68,15 +68,6 @@ function UIWindowSignup(options) {
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
let offersProviders = false;
// Form signups are closed, so the providers are the only way in.
const showOidcOnly = (el_window) => {
$(el_window).find('.signup-form').hide();
$(el_window).find('.signup-with-email-wrapper').hide();
$(el_window).find('.oidc-providers-wrapper').show();
$(el_window).find('.signup-oidc-only-notice').show();
};
const logo_clickable =
!!options.window_options?.cover_page && !window.embedded_in_popup;
@@ -158,7 +149,6 @@ function UIWindowSignup(options) {
h += '</form>';
h +=
'<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
h += `<p class="signup-oidc-only-notice" style="display:none;">${i18n('signup_oidc_only')}</p>`;
h += `<button type="button" class="oidc-google-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
h += `<button type="button" class="oidc-microsoft-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
h += `<button type="button" class="oidc-apple-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
@@ -217,11 +207,7 @@ function UIWindowSignup(options) {
$(el_window)
.find('.auth-logo')
.on('click', function () {
// keep desktop users on the desktop; everyone else goes to the root dashboard
window.location.href =
window.location.pathname === '/desktop'
? '/desktop'
: '/';
window.location.href = '/';
});
}
@@ -382,7 +368,6 @@ function UIWindowSignup(options) {
});
if (hasProvider) {
offersProviders = true;
$(el_window).find('.oidc-providers-wrapper').show();
// Hide the signup form and show the "Sign up with email" button
$(el_window).find('.signup-form').hide();
@@ -406,10 +391,6 @@ function UIWindowSignup(options) {
?.focus({ preventScroll: true });
});
}
if (data.signupOidcOnly === true && hasProvider) {
showOidcOnly(el_window);
}
} catch (_) {}
})();
},
@@ -758,17 +739,6 @@ function UIWindowSignup(options) {
return;
}
// Form signups closed after this window loaded.
// Without provider buttons (some popups), the
// server's message below says it instead.
if (
errorJson?.code === 'signup_oidc_only' &&
offersProviders
) {
showOidcOnly(el_window);
return;
}
// Drop the code so the next attempt signs up without it.
if (errorJson?.code === 'bonus_code_invalid') {
window.signup_bonus_code = null;
@@ -1,100 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const state = vi.hoisted(() => ({ el: null }));
vi.mock('./UIWindowLogin.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/authLogoHeader.js', () => ({
authLogoHeader: () => '',
wireAuthLogoHeader: vi.fn(),
}));
vi.mock('../helpers/checkPasswordStrength.js', () => ({
default: () => ({ overallPass: true }),
}));
vi.mock('./UIWindow.js', () => ({
default: async (options) => {
state.el = document.createElement('div');
state.el.innerHTML = options.body_content;
document.body.append(state.el);
options.onAppend?.(state.el);
return state.el;
},
}));
globalThis.def = globalThis.def ?? ((value) => value);
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
const providers = (body) => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body)));
};
const find = (selector) => jQuery(state.el).find(selector);
const shown = (selector) => find(selector).css('display') !== 'none';
const open = async () => {
void UIWindowSignup({ reload_on_success: false, redirect_url: '/' });
await vi.waitFor(() => expect(find('.oidc-google-btn').css('display')).toBe('flex'));
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
jQuery.fn.close = vi.fn();
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
window.gui_origin = 'https://puter.test';
window.api_origin = 'https://api.puter.test';
window.gui_params = {};
window.icons = {};
window.uuidv4 = () => 'oidc-only-test';
window.is_email = () => true;
window.embedded_in_popup = false;
window.url_query_params = new URLSearchParams('');
state.el = null;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('signup window when only OIDC signups are open', () => {
it('offers the email form behind a button normally', async () => {
providers({ providers: ['google'], signupOidcOnly: false });
await open();
expect(shown('.signup-with-email-wrapper')).toBe(true);
expect(shown('.signup-oidc-only-notice')).toBe(false);
});
it('drops the email form and says why', async () => {
providers({ providers: ['google'], signupOidcOnly: true });
await open();
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
expect(shown('.signup-form')).toBe(false);
expect(shown('.signup-with-email-wrapper')).toBe(false);
});
it('switches to the providers when a form signup is refused for it', async () => {
providers({ providers: ['google'], signupOidcOnly: false });
await open();
jQuery.ajax = vi.fn((opts) =>
opts.error({
responseText: JSON.stringify({
code: 'signup_oidc_only',
message: 'closed',
}),
}));
find('.signup-with-email-btn').trigger('click');
find('.username').val('someone');
find('.email').val('[email protected]');
find('.password').val('a-Strong-pass-1');
find('.confirm-password').val('a-Strong-pass-1');
find('.signup-btn').trigger('click');
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
expect(shown('.signup-form')).toBe(false);
expect(shown('.signup-with-email-wrapper')).toBe(false);
});
});
-244
View File
@@ -1,244 +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 { END_HARD, END_SOFT } from '../definitions.js';
import UIAlert from './UIAlert.js';
import UIContextMenu from './UIContextMenu.js';
import UIWindow from './UIWindow.js';
const end_process = async (uuid, force) => {
const svc_process = globalThis.services.get('process');
const process = svc_process.get_by_uuid(uuid);
if ( ! process ) {
console.warn(`Can't end process with uuid='${uuid}': does not exist`);
return;
}
let confirmation;
if ( process.is_init() ) {
if ( ! force ) {
confirmation = i18n('close_all_windows_confirm');
} else {
confirmation = i18n('restart_puter_confirm');
}
} else if ( force ) {
confirmation = i18n('end_process_force_confirm');
}
if ( confirmation ) {
const alert_resp = await UIAlert({
message: confirmation,
buttons: [
{
label: i18n('yes'),
value: true,
type: 'primary',
},
{
label: i18n('no'),
value: false,
},
],
});
if ( ! alert_resp ) return;
}
process.signal(force ? END_HARD : END_SOFT);
};
const calculate_indent_string = (indent_level, is_last_item_stack, is_last_item) => {
// Returns a string of '| ├└'
let result = '';
for ( let i = 0; i < indent_level; i++ ) {
const last_cell = i === indent_level - 1;
const has_trunk = (last_cell && ( !is_last_item )) ||
(!last_cell && !is_last_item_stack[i + 1]);
const has_branch = last_cell;
if ( has_trunk && has_branch ) {
result += '├';
} else if ( has_trunk ) {
result += '|';
} else if ( has_branch ) {
result += '└';
} else {
result += ' ';
}
}
return result;
};
const generate_task_rows = (items, { indent_level, is_last_item_stack }) => {
const svc_process = globalThis.services.get('process');
let rows_html = '';
for ( let i = 0; i < items.length; i++ ) {
const item = items[i];
const is_last_item = i === items.length - 1;
const indentation = calculate_indent_string(indent_level, is_last_item_stack, is_last_item);
// Generate indentation HTML
let indentation_html = '';
for ( const c of indentation ) {
indentation_html += '<div class="indentcell">';
switch (c) {
case ' ':
break;
case '|':
indentation_html += '<div class="indentcell-trunk"></div>';
break;
case '└':
indentation_html += '<div class="indentcell-branch"></div>';
break;
case '├':
indentation_html += '<div class="indentcell-trunk"></div>';
indentation_html += '<div class="indentcell-branch"></div>';
break;
}
indentation_html += '</div>';
}
rows_html += `
<tr class="task-row" data-uuid="${item.uuid}">
<td>
<div class="task">
<div class="task-indentation">${indentation_html}</div>
<div class="task-name">${item.name}</div>
</div>
</td>
<td><span class="process-type">${i18n(`process_type_${ item.type}`)}</span></td>
<td><span class="process-status">${i18n(`process_status_${ item.status.i18n_key}`)}</span></td>
</tr>
`;
const children = svc_process.get_children_of(item.uuid);
if ( children ) {
rows_html += generate_task_rows(children, {
indent_level: indent_level + 1,
is_last_item_stack: [ ...is_last_item_stack, is_last_item ],
});
}
}
return rows_html;
};
const UIWindowTaskManager = async function UIWindowTaskManager () {
const svc_process = globalThis.services.get('process');
let h = '';
h += '<div class="task-manager-container">';
h += '<table>';
h += '<thead>';
h += '<tr>';
h += `<th>${i18n('taskmgr_header_name')}</th>`;
h += `<th>${i18n('taskmgr_header_type')}</th>`;
h += `<th>${i18n('taskmgr_header_status')}</th>`;
h += '</tr>';
h += '</thead>';
h += '<tbody class="taskmgr-taskarea">';
h += '</tbody>';
h += '</table>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('task_manager'),
icon: globalThis.icons['cog.svg'],
uid: null,
is_dir: false,
single_instance: true,
app: 'taskmgr',
is_resizable: true,
is_droppable: false,
has_head: true,
selectable_body: true,
draggable_body: false,
allow_context_menu: false,
show_in_taskbar: true,
dominant: true,
body_content: h,
width: 350,
window_class: 'window-task-manager',
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
padding: '20px',
'background-color': `hsla(
var(--primary-hue),
var(--primary-saturation),
var(--primary-lightness),
var(--primary-alpha))`,
'backdrop-filter': 'blur(3px)',
'box-sizing': 'border-box',
height: 'calc(100% - 30px)',
display: 'flex',
'flex-direction': 'column',
'--scale': '2pt',
'--line-color': '#6e6e6ebd',
padding: '0',
},
});
const update_tasks = () => {
const processes = [svc_process.get_init()];
const rows_html = generate_task_rows(processes, { indent_level: 0, is_last_item_stack: [] });
$(el_window).find('.taskmgr-taskarea').html(rows_html);
};
// Set up context menu for task rows
$(el_window).on('contextmenu', '.task-row', function (e) {
e.preventDefault();
const uuid = $(this).data('uuid');
UIContextMenu({
items: [
{
html: i18n('close'),
onClick: () => {
end_process(uuid);
},
},
{
html: i18n('force_quit'),
onClick: () => {
end_process(uuid, true);
},
},
],
});
});
// Initial task update
update_tasks();
// Set up interval to refresh tasks
const interval = setInterval(update_tasks, 500);
// Clean up interval when window is closed
$(el_window).on('close', () => {
clearInterval(interval);
});
return el_window;
};
export default UIWindowTaskManager;
-1
View File
@@ -52,7 +52,6 @@ const UIWindowThemeDialog = async function UIWindowThemeDialog (options) {
selectable_body: false,
draggable_body: false,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
width: 350,
-90
View File
@@ -1,90 +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';
async function UIWindowWelcome (options) {
options = options ?? {};
let h = '';
// close button containing the multiplication sign
h += '<div class="generic-close-window-button welcome-window-close-button"> &times; </div>';
h += '<div style="display:flex; flex-direction: colum;">';
h += '<div style="overflow: hidden; width: 200px; max-width: 200px; min-width: 200px; background: linear-gradient(45deg, #3d476b, #838eb7); min-height: 400px; padding: 20px; box-sizing: border-box;">';
h += '<img style="display: block; margin: 45px auto 0; width: 270px; opacity: 0.5;" src="data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%3F%3E%3Csvg%20width%3D%2248%22%20height%3D%2248%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20class%3D%22layer%22%3E%3Ctitle%3ELayer%201%3C%2Ftitle%3E%3Cg%20id%3D%22svg_1%22%20stroke-width%3D%221%22%20transform%3D%22rotate(90%2024%2023.9997)%22%3E%3Cpolyline%20fill%3D%22none%22%20id%3D%22svg_2%22%20points%3D%2239%2024%2025%2024%2025%2028%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpolyline%20fill%3D%22none%22%20id%3D%22svg_3%22%20points%3D%2235.879%2010.121%2032%2014%2025%2014%2025%2018%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpath%20d%3D%22m13%2C26a10.29%2C10.29%200%200%201%20-7.2%2C-3%22%20fill%3D%22none%22%20id%3D%22svg_4%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpath%20d%3D%22m17%2C31.6a5.83%2C5.83%200%200%201%20-4%2C-5.6a5.73%2C5.73%200%200%201%202%2C-4.4%22%20fill%3D%22none%22%20id%3D%22svg_5%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpath%20d%3D%22m35.88%2C37.88l-3.88%2C-3.88l-7%2C0l0%2C2a9.9%2C9.9%200%200%201%20-10%2C10a9.9%2C9.9%200%200%201%20-10%2C-10a9.06%2C9.06%200%200%201%200.6%2C-3.2a5.63%2C5.63%200%200%201%20-2.6%2C-4.8a5.89%2C5.89%200%200%201%202.8%2C-5a9.99%2C9.99%200%200%201%20-2.8%2C-7a9.9%2C9.9%200%200%201%2010%2C-10l0.4%2C0a5.83%2C5.83%200%200%201%205.6%2C-4a5.89%2C5.89%200%200%201%206%2C6%22%20fill%3D%22none%22%20id%3D%22svg_6%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Ccircle%20cx%3D%2238%22%20cy%3D%228%22%20data-color%3D%22color-2%22%20fill%3D%22none%22%20id%3D%22svg_7%22%20r%3D%223%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Ccircle%20cx%3D%2242%22%20cy%3D%2224%22%20data-color%3D%22color-2%22%20fill%3D%22none%22%20id%3D%22svg_8%22%20r%3D%223%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Ccircle%20cx%3D%2238%22%20cy%3D%2240%22%20data-color%3D%22color-2%22%20fill%3D%22none%22%20id%3D%22svg_9%22%20r%3D%223%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E">';
h += '</div>';
h += '<div style="flex-grow: 1; padding-left: 50px; padding-top: 70px; padding-right: 50px;">';
h += `<h1 style="font-size: 25px; font-weight: 300; -webkit-font-smoothing: antialiased; color: #545763;">${i18n('welcome_title')}</h1>`;
h += `<p style="margin-top: 25px; font-size: 16px; font-weight: 300; -webkit-font-smoothing: antialiased; color: #3e4251;">${i18n('welcome_description')}</p>`;
h += `<button class="welcome-window-get-started" style="font-size: 15px; font-weight: 300; -webkit-font-smoothing: antialiased; cursor: pointer; padding: 8px 20px; border-radius: 5px; text-decoration: none; margin-right: 20px; border: 1px solid #656565 !important; background: none; margin-top: 10px;">${i18n('welcome_get_started')}</button>`;
h += '<div class="welcome-window-footer">';
h += `<a href="/terms" target="_blank">${i18n('welcome_terms')}</a>`;
h += `<a href="/privacy" style="margin-left: 20px;" target="_blank">${i18n('welcome_privacy')}</a>`;
h += `<a href="https://developer.puter.com" style="margin-left: 20px;" target="_blank">${i18n('welcome_developers')}</a>`;
h += `<a href="https://github.com/heyputer/puter" style="margin-left: 20px;" target="_blank">${i18n('welcome_open_source')}</a>`;
h += '</div>';
h += '</div>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('welcome_instant_login_title'),
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
backdrop: true,
close_on_backdrop_click: false,
backdrop_covers_toolbar: true,
width: 650,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
fadeIn: 1000,
window_class: 'window-welcome',
on_close: function () {
// save the fact that the user has seen the welcome window
puter.kv.set('has_seen_welcome_window', true).catch(err => console.warn('Could not save has_seen_welcome_window:', err));
},
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '0',
},
});
$(document).on('click', '.welcome-window-get-started', function () {
$(el_window).close();
});
}
export default UIWindowWelcome;
+53 -764
View File
@@ -117,11 +117,6 @@
--dashboard-bento-usage-surface: #eef2ff;
--dashboard-bento-usage-header-bg: #e0e7ff;
--dashboard-bento-usage-bar-fill: #ea580c;
/* Emerald (matches the green icon accent used elsewhere) */
--dashboard-bento-desktop-icon-bg: #059669;
--dashboard-bento-desktop-icon-shadow: rgba(5, 150, 105, 0.22);
--dashboard-bento-desktop-surface: #ecfdf5;
--dashboard-bento-desktop-header-bg: #d1fae5;
--dashboard-shadow-subtle: rgba(0, 0, 0, 0.04);
--dashboard-shadow-light: rgba(0, 0, 0, 0.06);
@@ -244,10 +239,6 @@ body {
--dashboard-bento-usage-surface: #1c1d2e;
--dashboard-bento-usage-header-bg: #252a42;
--dashboard-bento-usage-bar-fill: #fb923c;
--dashboard-bento-desktop-icon-bg: #10b981;
--dashboard-bento-desktop-icon-shadow: rgba(16, 185, 129, 0.32);
--dashboard-bento-desktop-surface: #16241f;
--dashboard-bento-desktop-header-bg: #172e26;
--dashboard-shadow-subtle: rgba(0, 0, 0, 0.2);
--dashboard-shadow-light: rgba(0, 0, 0, 0.3);
@@ -2007,174 +1998,28 @@ input.myapps-group-name:focus {
}
/* Custom domain modal (puter.hosting.attach), on the app feedback shell. */
.custom-domain-overlay {
--cd-accent: var(--dashboard-button-primary, #4a67f5);
--cd-subtle: rgba(15, 23, 42, 0.035);
--cd-success: #16a34a;
--cd-success-bg: rgba(34, 197, 94, 0.14);
--cd-warn: #b45309;
--cd-warn-bg: rgba(245, 158, 11, 0.14);
--cd-danger: #dc2626;
/* Under white text, so it stays this dark in both themes (4.8:1). */
--cd-danger-fill: #dc2626;
--cd-danger-bg: rgba(220, 38, 38, 0.08);
--cd-skeleton: rgba(15, 23, 42, 0.07);
}
@media (prefers-color-scheme: dark) {
.custom-domain-overlay {
--cd-subtle: rgba(255, 255, 255, 0.04);
--cd-success: #4ade80;
--cd-success-bg: rgba(74, 222, 128, 0.14);
--cd-warn: #fbbf24;
--cd-warn-bg: rgba(251, 191, 36, 0.14);
--cd-danger: #f87171;
--cd-danger-bg: rgba(248, 113, 113, 0.12);
--cd-skeleton: rgba(255, 255, 255, 0.08);
}
}
.custom-domain-modal {
width: min(520px, 100%);
display: flex;
flex-direction: column;
}
.custom-domain-modal svg {
flex: none;
}
/* A hostname moves to the next line whole, and breaks only when it's wider
than the line. */
.custom-domain-modal strong {
font-weight: 600;
overflow-wrap: anywhere;
}
.custom-domain-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* -- Header -- */
.custom-domain-head {
align-items: flex-start;
gap: 12px;
padding: 20px 16px 4px 20px;
}
.custom-domain-head-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 11px;
background: color-mix(in srgb, var(--cd-accent) 12%, transparent);
color: var(--cd-accent);
}
.custom-domain-head-icon svg {
width: 22px;
height: 22px;
}
.custom-domain-head-text {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.custom-domain-intro,
.custom-domain-requester {
margin: 3px 0 0;
font-size: 13px;
margin: 0 0 12px;
font-size: 14px;
line-height: 1.45;
color: var(--afb-muted);
word-break: break-word;
}
.custom-domain-intro strong {
color: var(--afb-fg);
}
.custom-domain-requester {
margin-top: 2px;
margin-top: -6px;
font-size: 12px;
}
.custom-domain-body {
padding-top: 16px;
}
/* -- Loading -- */
.custom-domain-skeleton {
padding: 14px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
}
.custom-domain-skeleton + .custom-domain-skeleton {
margin-top: 8px;
}
.custom-domain-skeleton span {
display: block;
height: 10px;
width: 46%;
border-radius: 5px;
background: var(--cd-skeleton);
animation: custom-domain-pulse 1.2s ease-in-out infinite;
}
.custom-domain-skeleton span + span {
width: 30%;
margin-top: 8px;
height: 8px;
}
@keyframes custom-domain-pulse {
50% { opacity: 0.45; }
}
/* -- Empty, unavailable -- */
.custom-domain-state {
text-align: center;
padding: 18px 12px 6px;
}
.custom-domain-state-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
margin: 0 auto 12px;
border-radius: 50%;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-state-icon svg {
width: 24px;
height: 24px;
}
.custom-domain-state-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.custom-domain-state-text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.45;
color: var(--afb-muted);
}
.custom-domain-empty {
border: 1px dashed var(--afb-field-border);
border-radius: 12px;
padding: 24px 16px;
}
.custom-domain-empty-add {
margin-top: 16px;
}
/* -- Domain list -- */
.custom-domain-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--afb-border);
border-radius: 10px;
overflow: hidden;
}
.custom-domain-list:empty {
display: none;
@@ -2183,624 +2028,113 @@ input.myapps-group-name:focus {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 10px 10px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
transition: border-color 0.12s ease, background-color 0.12s ease, opacity 0.18s ease, transform 0.18s ease;
padding: 10px 12px;
}
@media (hover: hover) {
.custom-domain-row:hover {
border-color: var(--afb-field-border);
background: var(--cd-subtle);
}
}
.custom-domain-row.is-current {
border-color: color-mix(in srgb, var(--cd-accent) 45%, transparent);
}
.custom-domain-row.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.custom-domain-row-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex: none;
border-radius: 9px;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-row-icon svg {
width: 18px;
height: 18px;
.custom-domain-row + .custom-domain-row {
border-top: 1px solid var(--afb-border);
}
.custom-domain-row-main {
flex: 1;
min-width: 0;
}
.custom-domain-row-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
}
.custom-domain-row-host {
min-width: 0;
font-size: 14px;
font-weight: 600;
word-break: break-all;
}
.custom-domain-row-meta {
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
margin-top: 2px;
word-break: break-word;
}
.custom-domain-row-actions,
.custom-domain-row-confirm {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.custom-domain-row-confirm,
.custom-domain-row[data-confirming] .custom-domain-row-actions {
display: none;
}
.custom-domain-row[data-confirming] .custom-domain-row-confirm {
display: flex;
}
.custom-domain-row[data-confirming] .custom-domain-row-meta {
.custom-domain-confirming .custom-domain-row-meta {
color: var(--afb-fg);
}
.custom-domain-row[data-confirming="remove"] {
border-color: color-mix(in srgb, var(--cd-danger) 40%, transparent);
background: var(--cd-danger-bg);
}
.custom-domain-row[data-confirming="move"] {
border-color: color-mix(in srgb, var(--cd-accent) 45%, transparent);
background: color-mix(in srgb, var(--cd-accent) 6%, transparent);
}
.custom-domain-row .app-feedback-btn {
flex: none;
padding: 6px 14px;
font-size: 13px;
}
.custom-domain-row[data-confirming="move"] .custom-domain-confirm-go {
border-color: var(--cd-accent);
background: var(--cd-accent);
color: #fff;
}
.custom-domain-row[data-confirming="remove"] .custom-domain-confirm-go {
border-color: var(--cd-danger-fill);
background: var(--cd-danger-fill);
color: #fff;
}
@media (hover: hover) {
.custom-domain-row[data-confirming] .custom-domain-confirm-go:hover:not(:disabled) {
filter: brightness(0.92);
}
}
/* Status pill next to a hostname. */
.custom-domain-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 8px 1px 7px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
border-radius: 999px;
white-space: nowrap;
color: var(--cd-warn);
background: var(--cd-warn-bg);
}
.custom-domain-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.custom-domain-badge[data-status="active"] {
color: var(--cd-success);
background: var(--cd-success-bg);
}
.custom-domain-badge[data-status="failed"] {
color: var(--cd-danger);
background: var(--cd-danger-bg);
}
/* Icon-only button: trash on a row. */
.custom-domain-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
.custom-domain-empty {
margin: 4px 0 0;
font-size: 14px;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
.custom-domain-icon-btn svg {
width: 16px;
height: 16px;
}
@media (hover: hover) {
.custom-domain-icon-btn:hover:not(:disabled) {
background: var(--cd-danger-bg);
color: var(--cd-danger);
}
}
.custom-domain-icon-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* -- Buttons -- */
.custom-domain-modal .app-feedback-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.custom-domain-modal .app-feedback-btn svg {
width: 15px;
height: 15px;
}
.custom-domain-modal .app-feedback-btn:focus-visible,
.custom-domain-icon-btn:focus-visible,
.custom-domain-copy:focus-visible,
.custom-domain-link-btn:focus-visible {
outline: 2px solid var(--cd-accent);
outline-offset: 2px;
}
/* The clicked button holds its size and shows a spinner while it works. */
.custom-domain-modal .app-feedback-btn.is-loading {
color: transparent !important;
opacity: 1;
}
.custom-domain-modal .app-feedback-btn.is-loading::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 14px;
height: 14px;
margin: -9px 0 0 -9px;
border: 2px solid var(--afb-muted);
border-right-color: transparent;
border-radius: 50%;
animation: share-modal-spin 0.7s linear infinite;
}
.custom-domain-modal .app-feedback-btn-primary.is-loading::after,
.custom-domain-row[data-confirming] .custom-domain-confirm-go.is-loading::after {
border-color: #fff;
border-right-color: transparent;
}
.custom-domain-modal .app-feedback-btn.is-loading svg {
visibility: hidden;
}
.custom-domain-btn-danger-text {
margin-right: auto;
border-color: transparent;
background: none;
color: var(--cd-danger);
padding-left: 10px;
padding-right: 10px;
}
@media (hover: hover) {
.custom-domain-modal .app-feedback-btn.custom-domain-btn-danger-text:hover:not(:disabled) {
background: var(--cd-danger-bg);
}
}
.custom-domain-btn-danger-text.custom-domain-confirming {
border-color: var(--cd-danger-fill);
background: var(--cd-danger-fill);
color: #fff;
}
@media (hover: hover) {
.custom-domain-modal .app-feedback-btn.custom-domain-btn-danger-text.custom-domain-confirming:hover:not(:disabled) {
background: var(--cd-danger-fill);
filter: brightness(0.92);
}
}
.custom-domain-link-btn {
display: block;
margin: 10px 0 0;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 12px;
text-align: left;
color: var(--cd-accent);
cursor: pointer;
}
@media (hover: hover) {
.custom-domain-link-btn:hover:not(:disabled) {
text-decoration: underline;
}
}
.custom-domain-link-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Working: full strength, with a spinner after the label. */
.custom-domain-link-btn.is-loading {
opacity: 1;
}
.custom-domain-link-btn.is-loading::after {
content: '';
display: inline-block;
width: 8px;
height: 8px;
margin-left: 6px;
vertical-align: -1px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: share-modal-spin 0.7s linear infinite;
}
/* -- Add form -- */
.custom-domain-label {
display: block;
font-size: 13px;
font-weight: 600;
font-weight: 500;
margin-bottom: 6px;
}
.custom-domain-field {
position: relative;
display: flex;
align-items: center;
}
.custom-domain-field svg {
position: absolute;
left: 12px;
width: 16px;
height: 16px;
color: var(--afb-muted);
pointer-events: none;
}
.custom-domain-field .custom-domain-input {
.custom-domain-input {
display: block;
width: 100%;
box-sizing: border-box;
height: 42px;
padding: 0 12px 0 36px;
padding: 9px 12px;
font: inherit;
font-size: 15px;
font-size: 14px;
color: var(--afb-fg);
background: var(--afb-field-bg);
border: 1px solid var(--afb-field-border);
border-radius: 10px;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
border-radius: 8px;
}
.custom-domain-field .custom-domain-input::placeholder {
color: var(--afb-muted);
opacity: 0.7;
}
.custom-domain-field .custom-domain-input:focus {
.custom-domain-input:focus {
outline: none;
padding: 0 12px 0 36px;
border: 1px solid var(--cd-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cd-accent) 20%, transparent);
}
.custom-domain-hint {
margin-top: 6px;
}
.custom-domain-callout {
margin: 12px 0 0;
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.45;
border-radius: 10px;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-callout-warn {
color: var(--afb-fg);
background: var(--cd-warn-bg);
border-color: color-mix(in srgb, var(--cd-warn) 30%, transparent);
}
.custom-domain-callout-danger {
color: var(--afb-fg);
background: var(--cd-danger-bg);
border-color: color-mix(in srgb, var(--cd-danger) 30%, transparent);
}
/* -- Status and DNS records -- */
.custom-domain-status-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: var(--cd-warn-bg);
border: 1px solid color-mix(in srgb, var(--cd-warn) 25%, transparent);
}
.custom-domain-status-card[data-status="active"] {
background: var(--cd-success-bg);
border-color: color-mix(in srgb, var(--cd-success) 25%, transparent);
}
.custom-domain-status-card[data-status="failed"] {
background: var(--cd-danger-bg);
border-color: color-mix(in srgb, var(--cd-danger) 25%, transparent);
}
.custom-domain-status-icon {
display: flex;
color: var(--cd-warn);
padding-top: 1px;
}
.custom-domain-status-card[data-status="active"] .custom-domain-status-icon {
color: var(--cd-success);
}
.custom-domain-status-card[data-status="failed"] .custom-domain-status-icon {
color: var(--cd-danger);
}
.custom-domain-status-icon svg {
width: 20px;
height: 20px;
}
.custom-domain-status-main {
flex: 1;
min-width: 0;
border-color: #088ef0;
box-shadow: 0 0 0 3px rgba(8, 142, 240, 0.18);
}
.custom-domain-status {
margin: 0;
margin: 0 0 8px;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
}
.custom-domain-status-detail {
margin: 3px 0 0;
display: block;
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
word-break: break-word;
}
.custom-domain-records-c2a {
margin: 16px 0 0;
font-size: 13px;
line-height: 1.45;
}
.custom-domain-records {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 10px;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.custom-domain-records:empty {
display: none;
}
.custom-domain-record {
padding: 10px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
background: var(--cd-subtle);
}
.custom-domain-record-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.custom-domain-record-type {
padding: 1px 7px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 600;
line-height: 18px;
border-radius: 6px;
color: var(--cd-accent);
background: color-mix(in srgb, var(--cd-accent) 12%, transparent);
}
.custom-domain-record-optional {
font-size: 11px;
color: var(--afb-muted);
}
.custom-domain-record-field {
display: grid;
grid-template-columns: 48px 1fr auto;
align-items: center;
gap: 8px;
min-height: 30px;
}
.custom-domain-record-label {
font-size: 11px;
.custom-domain-records th {
text-align: left;
font-weight: 500;
color: var(--afb-muted);
padding: 0 6px 6px 0;
}
.custom-domain-record-field code {
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
.custom-domain-records th:first-child {
width: 86px;
}
.custom-domain-records td {
padding: 6px 6px 6px 0;
border-top: 1px solid var(--afb-border);
vertical-align: top;
}
.custom-domain-redirect strong {
color: var(--afb-fg);
word-break: break-all;
user-select: all;
}
.custom-domain-use-www {
display: block;
width: 100%;
margin-top: 10px;
font-size: 13px;
}
.custom-domain-copy {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: none;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.custom-domain-copy svg {
width: 15px;
height: 15px;
}
.custom-domain-copy svg + svg,
.custom-domain-copy.is-copied svg:first-child {
display: none;
}
.custom-domain-copy.is-copied svg + svg {
display: block;
animation: custom-domain-pop 0.2s ease-out;
}
.custom-domain-copy.is-copied {
color: var(--cd-success);
word-break: break-all;
cursor: copy;
border-radius: 4px;
}
@media (hover: hover) {
.custom-domain-copy:hover:not(:disabled) {
border-color: var(--afb-border);
background: var(--afb-bg);
color: var(--afb-fg);
.custom-domain-copy:hover {
background: var(--afb-hover);
}
.custom-domain-copy.is-copied:hover:not(:disabled) {
color: var(--cd-success);
}
}
.custom-domain-claim .custom-domain-records {
margin-top: 8px;
}
/* -- Success -- */
.custom-domain-success {
padding: 28px 10px 16px;
}
.custom-domain-success .app-feedback-success-check {
width: 60px;
height: 60px;
background: var(--cd-success-bg);
color: var(--cd-success);
animation: custom-domain-pop 0.32s cubic-bezier(0.2, 0.9, 0.3, 1.3);
}
.custom-domain-success .app-feedback-success-check svg {
width: 30px;
height: 30px;
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: custom-domain-draw 0.32s 0.14s ease-out forwards;
}
.custom-domain-success-note {
margin-top: 6px;
font-size: 13px;
word-break: break-word;
}
@keyframes custom-domain-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes custom-domain-draw {
to { stroke-dashoffset: 0; }
}
/* -- Panes -- */
.custom-domain-pane {
animation: custom-domain-fade 0.16s ease-out;
}
@keyframes custom-domain-fade {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.custom-domain-pane,
.custom-domain-success .app-feedback-success-check,
.custom-domain-copy.is-copied svg + svg {
animation: none;
}
.custom-domain-success .app-feedback-success-check svg {
animation: none;
stroke-dashoffset: 0;
}
.custom-domain-skeleton span {
animation: none;
}
}
@media (max-width: 500px) {
/* The full-width actions wrap below; the name fills the line beside
the icon. */
.custom-domain-row {
flex-wrap: wrap;
}
.custom-domain-row-actions,
.custom-domain-row-confirm {
width: 100%;
justify-content: flex-end;
}
}
/* In a popup that exists only for this dialog, the dialog is the page: a
centered column, with the actions pinned to the bottom edge. */
.custom-domain-fullpage {
--cd-column: min(552px, 100% - 48px);
padding: 0;
align-items: stretch;
background: var(--afb-bg);
}
.custom-domain-fullpage .custom-domain-modal {
width: 100%;
height: 100dvh;
max-height: none;
border: none;
border-radius: 0;
box-shadow: none;
transform: none;
padding-bottom: 0;
overflow: hidden;
}
.custom-domain-fullpage .app-feedback-x {
display: none;
}
.custom-domain-fullpage .custom-domain-head {
width: var(--cd-column);
margin: 0 auto;
padding: 28px 0 4px;
}
.custom-domain-fullpage .custom-domain-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px 0 0;
}
.custom-domain-fullpage .custom-domain-pane {
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
.custom-domain-fullpage .custom-domain-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
box-sizing: border-box;
width: var(--cd-column);
margin-left: auto;
margin-right: auto;
}
/* Keeps the actions off the content when it fills the page. */
.custom-domain-fullpage .custom-domain-pane::after {
content: '';
flex: 1 0 20px;
order: 1;
}
.custom-domain-fullpage .app-feedback-actions {
order: 2;
position: sticky;
bottom: 0;
margin: 0;
padding: 14px max(24px, calc((100% - 552px) / 2)) calc(14px + env(safe-area-inset-bottom, 0px));
background: var(--afb-bg);
border-top: 1px solid var(--afb-border);
}
.custom-domain-fullpage .custom-domain-state .app-feedback-actions {
justify-content: center;
}
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
@@ -7373,11 +6707,6 @@ body.myapps-reordering .myapps-tile {
color: #fff;
}
.dashboard .bento-card-fancy-icon-desktop {
background-color: var(--dashboard-bento-desktop-icon-bg);
color: #fff;
}
.dashboard .bento-card-fancy-text {
display: flex;
flex-direction: column;
@@ -7540,41 +6869,6 @@ body.myapps-reordering .myapps-tile {
color: var(--dashboard-text-tertiary);
}
/* Open Desktop bento card (full width; links to the desktop interface) */
.dashboard .bento-desktop {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 24px;
background-color: var(--dashboard-bento-desktop-header-bg);
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease;
}
@media (hover: hover) {
.dashboard .bento-desktop:hover {
box-shadow:
0 2px 4px var(--dashboard-shadow-subtle),
0 10px 28px var(--dashboard-bento-desktop-icon-shadow);
}
.dashboard .bento-desktop:hover .bento-desktop-arrow {
transform: translateX(3px);
color: var(--dashboard-text-primary);
}
}
.dashboard .bento-desktop-arrow {
margin-left: auto;
font-size: 22px;
font-weight: 300;
line-height: 1;
color: var(--dashboard-text-hint);
transition: transform 0.15s ease, color 0.15s ease;
}
/* Usage bento card (cool indigo surfaces; orange bars stay as progress accent) */
.dashboard .bento-usage {
grid-column: 1 / -1;
@@ -7897,11 +7191,6 @@ body.myapps-reordering .myapps-tile {
gap: 12px;
}
.dashboard .bento-desktop {
padding: 16px 20px;
gap: 12px;
}
.dashboard .bento-card-fancy-icon {
width: 40px;
height: 40px;
+4 -1001
View File
File diff suppressed because it is too large. Load diff
@@ -1,54 +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/>.
*/
console.debug('[puter] modify-user-options-menu loaded');
$(window).on('ctxmenu-will-open', (event) => {
if ( event.detail.options?.id === 'user-options-menu' ) {
// Define array of new menu items
const newMenuItems = [
// Separator
'-',
// 'Developer', opens developer site in new tab
{
id: 'go_to_developer_site',
html: 'Developer<svg style="width: 11px; height: 11px; margin-left:2px;" height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><path d="m26 28h-20a2.0027 2.0027 0 0 1 -2-2v-20a2.0027 2.0027 0 0 1 2-2h10v2h-10v20h20v-10h2v10a2.0027 2.0027 0 0 1 -2 2z"/><path d="m20 2v2h6.586l-8.586 8.586 1.414 1.414 8.586-8.586v6.586h2v-10z"/><path d="m0 0h32v32h-32z" fill="none"/></svg>',
html_active: 'Developer<svg style="width: 11px; height: 11px; margin-left:2px;" height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"> <path d="m26 28h-20a2.0027 2.0027 0 0 1 -2-2v-20a2.0027 2.0027 0 0 1 2-2h10v2h-10v20h20v-10h2v10a2.0027 2.0027 0 0 1 -2 2z" style="fill: rgb(255, 255, 255);"/> <path d="m20 2v2h6.586l-8.586 8.586 1.414 1.414 8.586-8.586v6.586h2v-10z" style="fill: rgb(255, 255, 255);"/> <path d="m0 0h32v32h-32z" fill="none"/> </svg>',
action: function () {
window.open('https://developer.puter.com', '_blank');
},
},
];
// Find the position of 'contact_us'
const items = event.detail.options.items;
const insertBeforeIndex = items.findIndex(item => item.id === 'contact_us');
// 'contact_us' not found, append new items at the end
if ( insertBeforeIndex === -1 ) {
event.detail.options.items = [...items, ...newMenuItems];
return;
}
// 'contact_us' found, insert new items before it
const firstHalf = items.slice(0, insertBeforeIndex);
const secondHalf = items.slice(insertBeforeIndex);
event.detail.options.items = [...firstHalf, ...newMenuItems, ...secondHalf];
}
});
-3
View File
@@ -220,9 +220,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 = [];
+13 -94
View File
@@ -684,15 +684,9 @@ window.update_auth_data = async (auth_token, user) => {
// ----------------------------------------------------
const to_storable_user = user => {
const storable_user = { ...user };
delete storable_user.taskbar_items;
return storable_user;
};
// update this session's user data
window.user = user;
localStorage.setItem('user', JSON.stringify(to_storable_user(user)));
localStorage.setItem('user', JSON.stringify(user));
// re-initialize the Puter.js objects with the new auth token
puter.setAuthToken(auth_token, window.api_origin);
@@ -715,7 +709,7 @@ window.update_auth_data = async (auth_token, user) => {
window.logged_in_users.push(userobj);
}
// update local storage
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users.map(to_storable_user)));
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users));
}
window.desktop_path = `/${ window.user.username }/Desktop`;
@@ -729,11 +723,6 @@ window.update_auth_data = async (auth_token, user) => {
window.public_path = `/${ window.user.username }/Public`;
window.shared_path = 'puter://shared';
if ( window.user !== null && !window.user.is_temp ) {
$('.user-options-login-btn, .user-options-create-account-btn').hide();
$('.user-options-menu-btn').show();
}
// Search and store user templates (non-blocking)
window.available_templates();
};
@@ -1084,7 +1073,6 @@ window.show_save_account_notice_if_needed = function (message) {
reload_on_success: true,
send_confirmation_code: false,
window_options: {
show_in_taskbar: false,
backdrop: true,
close_on_backdrop_click: false,
},
@@ -1706,14 +1694,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.
* 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);
@@ -2168,34 +2155,17 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
};
/**
* Refreshes the desktop background based on the user's settings.
* If the user has set a custom desktop background URL or color, it will use that.
* If not, it defaults to a specific wallpaper image.
*
* @global
* @function
* @fires set_desktop_background - Calls this global function to set the desktop background.
*
* @example
* // This will refresh the desktop background according to the user's preference or defaults.
* window.refresh_desktop_background();
* Paints the default wallpaper behind the pages that show before the
* dashboard: the saved-session list and `/action/<name>` dialogs.
*/
window.refresh_desktop_background = function () {
if ( window.user && (window.user.desktop_bg_url !== null || window.user.desktop_bg_color !== null) ) {
window.set_desktop_background({
url: window.user.desktop_bg_url,
fit: window.user.desktop_bg_fit,
color: window.user.desktop_bg_color,
});
}
// default background
else {
let wallpaper = (window.gui_env === 'prod') ? 'https://puter-assets.b-cdn.net/wallpaper.webp' : '/src/images/wallpaper.webp';
window.set_desktop_background({
url: wallpaper,
fit: 'cover',
});
}
const wallpaper = (window.gui_env === 'prod') ? 'https://puter-assets.b-cdn.net/wallpaper.webp' : '/src/images/wallpaper.webp';
$('body').css({
'background-image': `url(${wallpaper})`,
'background-size': 'cover',
'background-repeat': 'no-repeat',
'background-position': 'center center',
});
};
window.determine_website_url = function (fsentry_path) {
@@ -3427,57 +3397,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);
+5 -6
View File
@@ -63,11 +63,11 @@ export const get_auth_redirect_url = () => {
*/
const oidc_return_path = () => {
const pathname = window.location.pathname;
if ( pathname === '/' || pathname === '/desktop' || pathname === '/dashboard' ) {
if ( pathname === '/' || pathname === '/dashboard' ) {
return pathname;
}
// app landing: normalize away a trailing slash to match the backend whitelist
if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) {
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
return pathname.replace(/\/$/, '');
}
return null;
@@ -76,10 +76,9 @@ const oidc_return_path = () => {
/**
* The `return_to` to send along when starting an OIDC flow, or null if the
* current page isn't one the backend will return to. The backend strictly
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
* and direct app landings (`/app/<name>`, plus the desktop-booted
* `/desktop/app/<name>`), so OIDC login started from an app landing comes back
* to the app — and to the same interface it was opened in.
* whitelists these (never a client-supplied URL): `/dashboard` and direct app
* landings (`/app/<name>`), so OIDC login started from an app landing comes
* back to the app.
*
* A share link (`?shared=`, from an email) is carried along with the path: the
* recipient usually has to sign in before they can see what was shared, and an
+7 -12
View File
@@ -42,7 +42,6 @@ afterEach(() => {
describe('get_oidc_return_to', () => {
it('returns the interface pages the backend whitelists', () => {
expect(at('/desktop')).toBe('/desktop');
expect(at('/dashboard')).toBe('/dashboard');
});
@@ -51,17 +50,12 @@ describe('get_oidc_return_to', () => {
expect(at('/app/editor/')).toBe('/app/editor');
});
it('keeps a desktop app landing on the desktop', () => {
expect(at('/desktop/app/editor')).toBe('/desktop/app/editor');
expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
});
it('carries a share link so the item survives the round trip', () => {
expect(at('/', share_search(shared_path('Report.pdf')))).toBe(
`/${share_search(shared_path('Report.pdf'))}`,
);
expect(at('/desktop', share_search(shared_path('Report.pdf')))).toBe(
`/desktop${share_search(shared_path('Report.pdf'))}`,
expect(at('/dashboard', share_search(shared_path('Report.pdf')))).toBe(
`/dashboard${share_search(shared_path('Report.pdf'))}`,
);
expect(
at('/', share_search(shared_path('a.txt'), shared_path('b.txt'))),
@@ -83,10 +77,10 @@ describe('get_oidc_return_to', () => {
expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
expect(at('/', '?shared=')).toBe(null);
// other parameters are not ours to carry
expect(at('/desktop', '?app=editor')).toBe('/desktop');
expect(at('/dashboard', '?app=editor')).toBe('/dashboard');
expect(
at('/desktop', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/desktop${share_search(shared_path('a.txt'))}`);
at('/dashboard', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/dashboard${share_search(shared_path('a.txt'))}`);
});
it('returns null for anything the backend would reject', () => {
@@ -94,7 +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/app/editor/extra')).toBe(null);
expect(at('/desktop')).toBe(null);
expect(at('/desktop/app/editor')).toBe(null);
expect(at('/dashboard/app/editor')).toBe(null);
});
});
@@ -1,45 +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/>.
*/
/**
* Turns what a user typed or pasted into a bare hostname: a URL like
* `https://www.example.com/about` becomes `www.example.com`. The server
* still validates the result.
*
* @param {unknown} raw
* @returns {string} The hostname, or '' when nothing is left.
*/
export const normalizeHostnameInput = (raw) => String(raw ?? '')
.trim()
.toLowerCase()
.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
.replace(/^[^@/?#]*@/, '')
.replace(/[/?#].*$/, '')
.replace(/:\d*$/, '')
.replace(/\.+$/, '');
/**
* Whether a normalized hostname is worth sending: something with a dot and
* no whitespace. Anything subtler is the server's call.
*
* @param {string} hostname
* @returns {boolean}
*/
export const isPlausibleHostname = (hostname) =>
/^[^\s.]+(\.[^\s.]+)+$/.test(hostname);
@@ -1,59 +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 { describe, it, expect } from 'vitest';
import { isPlausibleHostname, normalizeHostnameInput } from './customDomainHostname.js';
describe('normalizeHostnameInput', () => {
it('keeps a bare hostname, lowercased and trimmed', () => {
expect(normalizeHostnameInput(' WWW.Example.com ')).toBe('www.example.com');
});
it('strips a pasted URL down to its hostname', () => {
expect(normalizeHostnameInput('https://www.example.com/about?x=1#top')).toBe('www.example.com');
expect(normalizeHostnameInput('http://example.com:8080')).toBe('example.com');
expect(normalizeHostnameInput('https://user:[email protected]/')).toBe('example.com');
});
it('drops a trailing slash or dot', () => {
expect(normalizeHostnameInput('example.com/')).toBe('example.com');
expect(normalizeHostnameInput('example.com.')).toBe('example.com');
});
it('returns an empty string for nothing', () => {
expect(normalizeHostnameInput(undefined)).toBe('');
expect(normalizeHostnameInput(' ')).toBe('');
expect(normalizeHostnameInput('https://')).toBe('');
});
});
describe('isPlausibleHostname', () => {
it('accepts names with at least one dot', () => {
expect(isPlausibleHostname('example.com')).toBe(true);
expect(isPlausibleHostname('www.example.co.uk')).toBe(true);
});
it('rejects names without a dot, with spaces or with empty labels', () => {
expect(isPlausibleHostname('')).toBe(false);
expect(isPlausibleHostname('localhost')).toBe(false);
expect(isPlausibleHostname('www.exa mple.com')).toBe(false);
expect(isPlausibleHostname('www..example.com')).toBe(false);
expect(isPlausibleHostname('.example.com')).toBe(false);
});
});
+123
View File
@@ -0,0 +1,123 @@
/*
* 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 UIAlert from '../UI/UIAlert.js';
import UIWindowProgress from '../UI/UIWindowProgress.js';
/**
* Save the file at `url` into the user's Desktop folder once they confirm,
* with a progress window that can cancel it. Backs `/?download=<url>` links.
*
* @param {string} url
* @returns {Promise<void>} settles once the user has answered the prompt;
* the download itself carries on in the background
*/
const download_url_to_desktop = async (url) => {
const file_name = url.split('/').pop().split('?')[0];
const confirmed = 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 ( ! confirmed ) return;
let cancelled = false;
let upload_xhr = null;
const abort_controller = new AbortController();
const 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 {
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 ) {
chunks.push(value);
received += value.length;
const progress = Number.isFinite(total) && total > 0
? received / total
: 0;
progwin?.set_progress(Math.floor(progress * 100));
}
}
if ( cancelled ) {
progwin?.close();
return;
}
const blob = new Blob(chunks, {
type: response.headers.get('content-type') ?? 'application/octet-stream',
});
progwin?.set_progress(0);
progwin?.set_status(i18n('uploading_file', file_name));
await puter.fs.write(`~/Desktop/${file_name}`, blob, {
dedupeName: true,
progress: (_, percent) => {
progwin?.set_progress(percent);
},
init: (_, xhr) => {
upload_xhr = xhr;
},
});
} catch (e) {
// A cancel aborts the fetch; only a real failure is worth reporting.
if ( !cancelled && e.name !== 'AbortError' ) {
await UIAlert({
message: `${i18n('error_download_failed')}: ${e.message}`,
type: 'error',
});
}
}
progwin?.close();
})();
};
export default download_url_to_desktop;
-16
View File
@@ -139,9 +139,6 @@ const launch_app = 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],
@@ -679,18 +676,6 @@ const launch_app = 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 )
@@ -749,7 +734,6 @@ const launch_app = 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,
});
+76
View File
@@ -0,0 +1,76 @@
/*
* 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/>.
*/
/**
* @typedef {Object} LegacyLanding
* @property {{ tab: string, path: string|null }} [route] dashboard route to open on
* @property {string} [open_path] a path to open as double-clicking it would
* @property {boolean} [invalid_username] `/@<name>` named no valid username
*/
const decode_segment = (segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
};
/**
* Where a URL that used to open on the desktop lands in the dashboard:
* `/@<user>/<path>` and `/show/<path>` open that path, `/settings` opens Home,
* and `?app=explorer[&path=]` opens Files. The URL is spent once read: the
* caller swaps it for the dashboard's own.
*
* @param {string[]} url_paths route segments (see parse_url_paths)
* @param {URLSearchParams} query_params
* @returns {LegacyLanding|null} null for any other URL
*/
export const legacy_landing = (url_paths, query_params) => {
const first = url_paths[0] ?? '';
if ( first.startsWith('@') ) {
const username = first.substring(1);
if ( ! /^[a-z0-9_]+$/i.test(username) ) {
return { invalid_username: true };
}
const rest = url_paths.slice(1).map(decode_segment);
return {
open_path: [`/${username}/Public`, ...rest].join('/'),
};
}
if ( first.toLowerCase() === 'show' && url_paths.length > 1 ) {
return {
open_path: `/${url_paths.slice(1).map(decode_segment).join('/')}`,
};
}
if ( first.toLowerCase() === 'settings' ) {
return { route: { tab: 'home', path: null } };
}
if ( query_params.get('app') === 'explorer' ) {
return {
route: { tab: 'files', path: query_params.get('path') || null },
};
}
return null;
};
+75
View File
@@ -0,0 +1,75 @@
/*
* 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 { legacy_landing } from './legacyLanding.js';
import { parse_url_paths } from './urlPaths.js';
const at = (pathname, search = '') =>
legacy_landing(parse_url_paths(pathname), new URLSearchParams(search));
describe('legacy_landing', () => {
it('opens a user\'s public folder from /@<user>', () => {
expect(at('/@alice')).toEqual({ open_path: '/alice/Public' });
expect(at('/@alice/site/index.html')).toEqual({
open_path: '/alice/Public/site/index.html',
});
});
it('decodes the path segments it opens', () => {
expect(at('/@alice/My%20Site')).toEqual({ open_path: '/alice/Public/My Site' });
expect(at('/show/alice/Documents/a%20b.txt')).toEqual({
open_path: '/alice/Documents/a b.txt',
});
// a malformed escape is kept as written rather than throwing
expect(at('/show/alice/100%')).toEqual({ open_path: '/alice/100%' });
});
it('refuses a /@ path that names no valid username', () => {
expect(at('/@al.ice')).toEqual({ invalid_username: true });
expect(at('/@')).toEqual({ invalid_username: true });
});
it('opens /show/<path>', () => {
expect(at('/show/alice/Documents')).toEqual({ open_path: '/alice/Documents' });
// /show alone names nothing to open
expect(at('/show')).toBe(null);
});
it('sends /settings to Home', () => {
expect(at('/settings')).toEqual({ route: { tab: 'home', path: null } });
expect(at('/settings/account')).toEqual({ route: { tab: 'home', path: null } });
});
it('sends ?app=explorer to Files, at its path if it names one', () => {
expect(at('/', '?app=explorer')).toEqual({ route: { tab: 'files', path: null } });
expect(at('/', '?app=explorer&path=/alice/Documents')).toEqual({
route: { tab: 'files', path: '/alice/Documents' },
});
});
it('leaves every other URL to the dashboard', () => {
expect(at('/')).toBe(null);
expect(at('/dashboard')).toBe(null);
expect(at('/app/editor')).toBe(null);
expect(at('/action/login')).toBe(null);
expect(at('/', '?app=editor')).toBe(null);
expect(at('/', '?shared=/alice/x/y')).toBe(null);
});
});
+98
View File
@@ -0,0 +1,98 @@
/*
* 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 path from '../lib/path.js';
import UIAlert from '../UI/UIAlert.js';
import { resolve_default_app } from './defaultApp.js';
import launch_app from './launchApp.js';
/**
* The dashboard hash that opens `dir_path` in Files. Each segment is encoded
* because parseDashboardRoute decodes the hash as a whole before splitting it.
*
* @param {string} dir_path
* @returns {string}
*/
export const files_route_hash = (dir_path) =>
`#files/${dir_path.split('/').filter(Boolean).map(encodeURIComponent).join('/')}`;
/**
* Open a path as double-clicking it would: a file in its app, a folder in the
* dashboard's Files tab. A file no app can open falls back to its folder.
*
* TODO: DRY with open_item, which is coupled to an item element and can't
* operate on a path alone.
*
* @param {string} item_path
* @param {{ is_dir: boolean, uid?: string, associated_app?: { name: string } }} stat
* @returns {Promise<void>}
*/
export const open_path_target = async (item_path, stat) => {
if ( ! stat.is_dir ) {
if ( stat.associated_app ) {
launch_app({ name: stat.associated_app.name });
return;
}
const default_app = await resolve_default_app({ uid: stat.uid, path: item_path });
if ( default_app ) {
launch_app({
name: default_app.name,
app_obj: default_app,
file_path: item_path,
});
return;
}
const open_item_meta = await $.ajax({
url: `${window.api_origin}/open_item`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ path: item_path }),
headers: { 'Authorization': `Bearer ${window.auth_token}` },
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
const suggested_apps = open_item_meta?.suggested_apps
?? await window.suggest_apps_for_fsentry({ path: item_path });
if ( suggested_apps.length !== 0 ) {
launch_app({
name: suggested_apps[0].name,
token: open_item_meta.token,
file_path: item_path,
app_obj: suggested_apps[0],
window_title: path.basename(item_path),
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
});
return;
}
await UIAlert({
message: 'Cannot find an app to open this file; opening directory instead.',
});
item_path = path.dirname(item_path);
}
window.location.hash = files_route_hash(item_path);
};
@@ -0,0 +1,94 @@
/*
* 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';
const launch_app = vi.fn();
const resolve_default_app = vi.fn();
const UIAlert = vi.fn();
vi.mock('./launchApp.js', () => ({ default: (...args) => launch_app(...args) }));
vi.mock('./defaultApp.js', () => ({ resolve_default_app: (...args) => resolve_default_app(...args) }));
vi.mock('../UI/UIAlert.js', () => ({ default: (...args) => UIAlert(...args) }));
const { files_route_hash, open_path_target } = await import('./openPathTarget.js');
// The hash the dashboard's route parser reads back into a Files path.
const parsed_path = (hash) => `/${decodeURIComponent(hash.slice(1)).split('/').filter(Boolean).slice(1).join('/')}`;
describe('files_route_hash', () => {
it('round-trips a folder through the dashboard route parser', () => {
for ( const dir of ['/alice/Public', '/alice/My Site', '/alice/50% off', '/alice/a#b'] ) {
const hash = files_route_hash(dir);
expect(hash.startsWith('#files/')).toBe(true);
expect(parsed_path(hash)).toBe(dir);
}
});
});
describe('open_path_target', () => {
let ajax;
beforeEach(() => {
launch_app.mockReset();
resolve_default_app.mockReset();
UIAlert.mockReset();
ajax = vi.fn();
globalThis.$ = { ajax };
globalThis.window = { location: { hash: '', pathname: '/' }, api_origin: 'https://api.test' };
});
afterEach(() => {
delete globalThis.$;
delete globalThis.window;
});
it('opens a folder in the Files tab', async () => {
await open_path_target('/alice/Public', { is_dir: true });
expect(window.location.hash).toBe('#files/alice/Public');
expect(launch_app).not.toHaveBeenCalled();
});
it('opens a file in the app it is associated with', async () => {
await open_path_target('/alice/Public/site.html', {
is_dir: false,
associated_app: { name: 'viewer' },
});
expect(launch_app).toHaveBeenCalledWith({ name: 'viewer' });
});
it('opens a file with the user\'s default app for it', async () => {
const app = { name: 'editor' };
resolve_default_app.mockResolvedValue(app);
await open_path_target('/alice/notes.txt', { is_dir: false, uid: 'u1' });
expect(launch_app).toHaveBeenCalledWith({
name: 'editor',
app_obj: app,
file_path: '/alice/notes.txt',
});
});
it('falls back to the folder when no app can open the file', async () => {
resolve_default_app.mockResolvedValue(null);
ajax.mockResolvedValue({ suggested_apps: [] });
await open_path_target('/alice/Public/blob.bin', { is_dir: false, uid: 'u1' });
expect(UIAlert).toHaveBeenCalled();
expect(launch_app).not.toHaveBeenCalled();
expect(window.location.hash).toBe('#files/alice/Public');
});
});
@@ -220,7 +220,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,
@@ -243,7 +242,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,
@@ -254,28 +252,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' });
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,
@@ -42,7 +42,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: () => {},
+15 -17
View File
@@ -21,22 +21,20 @@
* 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);
/**
* The pathname with a leading `/desktop` segment dropped. `/desktop` used to
* pick the desktop interface, and what follows it is the same route at the
* root: `/desktop/app/<name>` is `/app/<name>`.
*
* @param {string} pathname - e.g. `/desktop/app/editor`
* @returns {string} e.g. `/app/editor`; `/` when nothing follows the prefix
*/
export const drop_desktop_prefix = (pathname) =>
String(pathname ?? '').replace(/^\/desktop(?=\/|$)/i, '') || '/';
+11 -21
View File
@@ -18,7 +18,7 @@
*/
import { describe, it, expect } from 'vitest';
import { parse_url_paths } from './urlPaths.js';
import { drop_desktop_prefix, parse_url_paths } from './urlPaths.js';
describe('parse_url_paths', () => {
it('splits a pathname into non-empty segments', () => {
@@ -27,30 +27,20 @@ describe('parse_url_paths', () => {
expect(parse_url_paths('/')).toEqual([]);
expect(parse_url_paths('')).toEqual([]);
});
});
describe('drop_desktop_prefix', () => {
it('drops a leading desktop segment so the rest reads as a root route', () => {
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([]);
expect(drop_desktop_prefix('/desktop/app/editor')).toBe('/app/editor');
expect(drop_desktop_prefix('/DeskTop/app/editor')).toBe('/app/editor');
expect(drop_desktop_prefix('/desktop')).toBe('/');
expect(drop_desktop_prefix('/desktop/')).toBe('/');
});
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',
]);
expect(drop_desktop_prefix('/')).toBe('/');
expect(drop_desktop_prefix('/app/desktop')).toBe('/app/desktop');
expect(drop_desktop_prefix('/desktops/app/editor')).toBe('/desktops/app/editor');
expect(drop_desktop_prefix('/desktop/desktop/app')).toBe('/desktop/app');
});
});
-40
View File
@@ -1,40 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
async function changeLanguage (lang) {
if ( !lang || window.locale === lang ) return;
// Shell strings are rendered once. Persist first, then reload.
try {
await puter.kv.set('user_preferences.language', lang);
} catch (err) {
console.warn('Could not save user_preferences.language:', err);
return;
}
window.locale = lang;
try {
window.mutate_user_preferences({
language: lang,
});
} catch (err) {
console.warn('Could not apply user_preferences.language:', err);
}
location.reload();
}
export default changeLanguage;
-1
View File
@@ -235,7 +235,6 @@ const cs = {
"open_in_new_tab": "Otevřít na nové kartě",
"open_in_new_window": "Otevřít v novém okně",
"new_email": "Nový e-mail",
"open_desktop": "Otevřít plochu",
"new_username": "Nové uživatelské jméno",
"open_with_ai": "Otevřít pomocí AI",
"os": "Operační systém",
+4 -15
View File
@@ -161,13 +161,9 @@ const en = {
custom_domain_add: 'Add Domain',
custom_domain_add_new: 'Add a New Domain',
custom_domain_apex_hint: 'This is a root domain, so your DNS provider needs to support a CNAME, ALIAS or ANAME record at the root (sometimes called CNAME flattening).',
custom_domain_badge_active: 'Connected',
custom_domain_badge_failed: 'Failed',
custom_domain_badge_pending: 'Pending',
custom_domain_claim_c2a: 'This domain is on another Puter account. If it\'s yours, add this record at your DNS provider, then verify. The domain moves to your account.',
custom_domain_claim_c2a: 'Someone else added this domain but never connected it. If it\'s yours, add this record at your DNS provider, then verify. The domain moves to your account.',
custom_domain_claim_not_found: 'The record isn\'t visible yet. DNS changes can take a few minutes; try again shortly.',
custom_domain_check_status: 'Check Status',
custom_domain_empty_hint: 'Add a domain you own and point it at this website.',
custom_domain_error_generic: 'Something went wrong. Please try again.',
custom_domain_error_invalid: 'Enter a domain name like www.example.com.',
custom_domain_error_limit: 'Your account has reached the maximum number of custom domains.',
@@ -178,29 +174,25 @@ const en = {
custom_domain_error_site: 'This website isn\'t on your account.',
custom_domain_error_taken: 'That domain is already connected to a Puter account.',
custom_domain_hostname_label: 'Domain',
custom_domain_hostname_hint: 'Use a domain you own, like www.example.com or example.com.',
custom_domain_intro: 'Choose a domain for %strong%.',
custom_domain_move: 'Move',
custom_domain_move_confirm: 'Moves it from %% to %%.',
custom_domain_not_enabled: 'Custom domains aren\'t available on your account yet.',
custom_domain_none_yet: 'You don\'t have any custom domains yet.',
custom_domain_optional: 'Optional',
custom_domain_optional: '(optional)',
custom_domain_price_note: 'Each domain costs %% per month. example.com and www.example.com count as two domains.',
custom_domain_no_root_alias: 'My DNS provider doesn\'t support CNAME, ALIAS or ANAME at the root',
custom_domain_record_type: 'Type',
custom_domain_record_type_apex: 'CNAME / ALIAS / ANAME',
custom_domain_redirect_note: 'Your website is served on %strong%. The A records forward visitors from %strong% to it.',
custom_domain_redirect_note: 'Your website is served on %strong%. The A record forwards visitors from %strong% to it.',
custom_domain_record_value: 'Value',
custom_domain_remove: 'Remove',
custom_domain_remove_confirm: 'The domain stops working right away and is removed from your account. This month isn\'t refunded.',
custom_domain_records_c2a: 'Add these records at your DNS provider. The domain starts working a few minutes after they\'re in place.',
custom_domain_remove_named: 'Remove %%',
custom_domain_requested_by: 'Requested by %strong%',
custom_domain_serves: 'Serving %%',
custom_domain_serves_this: 'Serving this website',
custom_domain_status_active: '%strong% is connected.',
custom_domain_status_failed: '%strong% couldn\'t be verified.',
custom_domain_status_pending: '%strong% is waiting for its DNS records.',
custom_domain_success_note: 'Visitors to %strong% now see %strong%.',
custom_domain_title: 'Connect a Custom Domain',
custom_domain_unavailable: 'Custom domains aren\'t available on this Puter.',
custom_domain_use: 'Use',
@@ -361,7 +353,6 @@ const en = {
or: 'or',
open: 'Open',
new_window: 'New Window',
open_desktop: 'Open Desktop',
open_with_ai: 'Open with AI',
open_in_new_tab: 'Open in New Tab',
app_pages: 'App pages',
@@ -758,7 +749,6 @@ const en = {
no_recent_apps_hint: 'Apps you use will appear here',
recently_used: 'Recently used',
monthly_overview: 'Monthly overview',
open_desktop_hint: 'Switch to the desktop interface',
taking_longer_than_usual: 'Taking a little longer than usual. Please wait...',
email_needed_to_create_token: 'This account has no email address, which tokens require.',
payments_not_configured: 'Payments are not configured on this deployment.',
@@ -995,7 +985,6 @@ const en = {
sign_in_with_provider: 'Sign in with %%',
sign_up_with_provider: 'Sign up with %%',
sign_up_with_email: 'Sign up using email',
signup_oidc_only: 'New accounts can only be created with one of the options below.',
popup_opener_uses_puter: '%strong% is powered by Puter for its AI and cloud features.\nUse your Puter account to continue.',
popup_opener_origin_unsupported: 'Puter cannot sign you in to this page because the browser gives it no identifiable address. Pages opened straight from a file (file://) and iframes sandboxed without allow-same-origin are not supported — serve the page over http://localhost or a real domain and try again.',
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
-59
View File
@@ -1,59 +0,0 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<title>folders-svg</title>
<defs>
<linearGradient id="g1" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,-48.959,190.941,0,-38.837,49.923)">
<stop offset="0" stop-color="#000000" stop-opacity=".318"/>
<stop offset="1" stop-color="#000000" stop-opacity=".24"/>
</linearGradient>
<linearGradient id="g2" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,48.248,-203.712,0,24.927,1.266)">
<stop offset=".001" stop-color="#f59e0b"/>
<stop offset="1" stop-color="#9f7000"/>
</linearGradient>
<linearGradient id="g3" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,41.867,-91.585,0,11.791,2.755)">
<stop offset="0" stop-color="#ffffff"/>
<stop offset=".079" stop-color="#ffffff" stop-opacity=".235"/>
<stop offset=".947" stop-color="#ffffff" stop-opacity=".157"/>
<stop offset="1" stop-color="#ffffff" stop-opacity=".392"/>
</linearGradient>
<linearGradient id="g4" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,37.589,-146.712,0,37.715,6.155)">
<stop offset="0" stop-color="#ffffff"/>
<stop offset=".073" stop-color="#ffffff" stop-opacity=".235"/>
<stop offset=".947" stop-color="#ffffff" stop-opacity=".157"/>
<stop offset="1" stop-color="#ffffff" stop-opacity=".392"/>
</linearGradient>
<linearGradient id="g5" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,49.575,-60.42,0,25.267,.507)">
<stop offset="0" stop-color="#fcd34d"/>
<stop offset="1" stop-color="#e0a720"/>
</linearGradient>
<linearGradient id="g6" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,33.308,-41.08,0,41.143,10.606)">
<stop offset="0" stop-color="#ffffff"/>
<stop offset=".068" stop-color="#ffffff" stop-opacity=".235"/>
<stop offset=".947" stop-color="#ffffff" stop-opacity=".157"/>
<stop offset="1" stop-color="#ffffff" stop-opacity=".392"/>
</linearGradient>
</defs>
<style>
.s0 { fill: none;stroke: url(#g1);stroke-linejoin: round;stroke-width: 1 }
.s1 { fill: url(#g2) }
.s2 { fill: none;stroke: url(#g3);stroke-linecap: round;stroke-linejoin: round;stroke-width: 1 }
.s3 { fill: none;stroke: url(#g4);stroke-linecap: round;stroke-linejoin: round;stroke-width: 1 }
.s4 { opacity: .1;fill: #002e99 }
.s5 { fill: url(#g5);stroke: #fcd34d }
.s6 { opacity: .3;fill: none;stroke: url(#g6);stroke-linecap: round;stroke-linejoin: round }
.s7 { fill: #fafafa }
</style>
<g id="Layer 1">
<path id="rect5505-21-1-2-8-6" fill-rule="evenodd" class="s0" d="m46.3 12.4v-3.4c0-2.5-2.1-4.6-4.6-4.6h-18.8c-1.6 0-3.8-1.7-4.5-2.3-0.8-0.7-1.9-1.1-3-1.1h-9.1c-2.5 0-4.6 2-4.6 4.6v6.8"/>
<path id="rect5505-21-1-2-8-6-1" fill-rule="evenodd" class="s1" d="m6.3 1.6c-2.2 0-4 1.7-4 4v6.3h43.4v-2.9c0-2.2-1.8-4-4-4h-18.8c-1 0-2-0.5-2.9-1-0.9-0.6-1.6-1.2-2-1.5-0.7-0.6-1.6-0.9-2.6-0.9z"/>
<path id="rect5505-21-1-2-8-6-1-9" fill-rule="evenodd" class="s2" d="m22.9 5.6c-1.2 0-2.3-0.6-3.2-1.2-0.9-0.5-1.7-1.1-2.1-1.5-0.6-0.5-1.3-0.8-2.2-0.8h-9.1c-1.9 0-3.4 1.6-3.4 3.5v5.7"/>
<path id="rect5505-21-1-2-8-6-1-9-9" fill-rule="evenodd" class="s3" d="m45.1 11.3v-2.3c0-1.9-1.5-3.4-3.4-3.4h-18.8"/>
<path id="rect5505-21-1-9-7" fill-rule="evenodd" class="s4" d="m6.2 6.1c-3 0-5.7 2.7-5.6 5.8 0 9.3-0.1 18.6 0 28 0.2 2.9 3 5.3 5.9 5.1 11.9 0 23.8 0 35.6 0 3-0.1 5.5-3 5.3-5.9 0-9.3 0.1-18.6 0-27.8-0.2-2.9-3-5.3-5.9-5.2q-17.6 0-35.3 0z"/>
<path id="rect5505-21-1-9" fill-rule="evenodd" class="s4" d="m1.7 11.3c0-2.2 1.8-4 4-4h36.6c2.2 0 4 1.8 4 4v28.6c0 2.2-1.8 4-4 4h-36.6c-2.2 0-4-1.8-4-4z"/>
<path id="rect5505-21-1" fill-rule="evenodd" class="s5" d="m1.7 13c0-2.2 1.8-4 4-4h36.6c2.2 0 4 1.8 4 4v28.6c0 2.2-1.8 4-4 4h-36.6c-2.2 0-4-1.8-4-4z"/>
<path id="rect6741-2" fill-rule="evenodd" class="s6" d="m2.9 13.1c0-1.6 1.3-3 3-3h36.2c1.7 0 3 1.4 3 3v28.3c0 1.7-1.3 3-3 3h-36.2c-1.7 0-3-1.3-3-3z"/>
<path id="path13635-4-7-8-4" fill-rule="evenodd" class="s4" d="m22.3 18.4c-4.1 0.1-7.8 3.4-8.4 7.5-0.7 3.9 1.7 8.1 5.4 9.6 2.4 1 5.3 0.8 7.6-0.4 1.6 1.5 3.2 3.3 4.9 4.8 1.1 0.9 3 0.5 3.7-0.8 0.6-1 0.3-2.5-0.6-3.3q-2.2-2.1-4.4-4.3c1.8-3.3 1.2-7.7-1.4-10.4-1.8-1.8-4.3-2.9-6.8-2.7zm1.3 4.1c2.5 0.5 4.3 3.1 3.9 5.5-0.4 2.8-3.5 4.8-6.2 3.9-2.6-0.7-4.1-3.9-3.1-6.3 0.8-2.2 3.2-3.5 5.4-3.1z"/>
<path id="path13635-4-7-8" fill-rule="evenodd" class="s4" d="m22.7 19.5c-4.3 0-7.8 3.5-7.8 7.8 0 4.2 3.5 7.7 7.8 7.7q0.6 0 1.2-0.1 0.5-0.1 1.1-0.3 0.5-0.1 1.1-0.4 0.5-0.2 1-0.6l5.2 5.2c0.2 0.3 0.6 0.4 1 0.4 0.3 0 0.7-0.1 1-0.4 0.2-0.2 0.4-0.6 0.4-1 0-0.4-0.2-0.7-0.5-1l-5.1-5.2q0.3-0.4 0.6-1 0.2-0.5 0.4-1 0.2-0.6 0.2-1.2 0.1-0.6 0.1-1.1c0-4.3-3.5-7.8-7.7-7.8zm0 1.8q1.2 0 2.3 0.4 1.1 0.5 1.9 1.3 0.9 0.9 1.3 2 0.5 1.1 0.5 2.3 0 1.1-0.5 2.2-0.4 1.1-1.3 2-0.8 0.8-1.9 1.3-1.1 0.4-2.3 0.4-1.2 0-2.3-0.4-1.1-0.5-1.9-1.3-0.9-0.9-1.3-2-0.5-1.1-0.5-2.2 0-1.2 0.5-2.3 0.4-1.1 1.3-2 0.8-0.8 1.9-1.3 1.1-0.4 2.3-0.4z"/>
<path id="path13635-4-7" fill-rule="evenodd" class="s7" d="m22.7 18.4c-4.3 0-7.8 3.5-7.8 7.7 0 4.3 3.5 7.8 7.8 7.8q0.6 0 1.2-0.1 0.5-0.1 1.1-0.3 0.5-0.2 1.1-0.4 0.5-0.3 1-0.6l5.2 5.2c0.2 0.2 0.6 0.4 1 0.4 0.3 0 0.7-0.1 1-0.4 0.2-0.3 0.4-0.6 0.4-1 0-0.4-0.2-0.8-0.5-1l-5.1-5.2q0.3-0.5 0.6-1 0.2-0.5 0.4-1.1 0.2-0.5 0.2-1.1 0.1-0.6 0.1-1.2c0-4.2-3.5-7.7-7.7-7.7zm0 1.7q1.2 0 2.3 0.5 1.1 0.4 1.9 1.3 0.9 0.8 1.3 1.9 0.5 1.1 0.5 2.3 0 1.2-0.5 2.3-0.4 1.1-1.3 1.9-0.8 0.9-1.9 1.3-1.1 0.5-2.3 0.5-1.2 0-2.3-0.5-1.1-0.4-1.9-1.3-0.9-0.8-1.3-1.9-0.5-1.1-0.5-2.3 0-1.2 0.5-2.3 0.4-1.1 1.3-1.9 0.8-0.9 1.9-1.3 1.1-0.5 2.3-0.5z"/>
<path id="path13635-4-7-5" fill-rule="evenodd" class="s4" d="m22.7 19q-1.2 0-2.3 0.4-1.1 0.5-1.9 1.3-0.9 0.9-1.3 2-0.5 1.1-0.5 2.3 0 0 0 0.1 0 0.1 0 0.2 0 0 0 0.1 0 0.1 0.1 0.1 0.1-1.1 0.6-2.1 0.5-1 1.3-1.7 0.8-0.8 1.8-1.2 1.1-0.4 2.2-0.4 1.1 0 2.2 0.4 1 0.5 1.8 1.2 0.9 0.8 1.4 1.8 0.4 1 0.5 2.1 0 0 0-0.1 0.1-0.1 0.1-0.2 0-0.1 0-0.2 0 0 0-0.1 0-1.2-0.5-2.3-0.4-1.1-1.3-2-0.8-0.8-1.9-1.3-1.1-0.4-2.3-0.4zm7.7 6.4q0 0.5-0.1 1.1-0.1 0.5-0.3 1-0.2 0.5-0.4 0.9-0.2 0.5-0.5 1l0.4 0.4q0.2-0.4 0.4-0.9 0.2-0.4 0.3-0.9 0.1-0.5 0.2-0.9 0-0.5 0-1 0-0.3 0-0.7zm-15.4 0.1q-0.1 0.3-0.1 0.6c0 4.3 3.5 7.8 7.8 7.8q0.6 0 1.2-0.1 0.5-0.1 1.1-0.3 0.5-0.2 1.1-0.4 0.5-0.3 1-0.6l5.2 5.2q0.3 0.3 0.8 0.4 0.5 0 0.9-0.3 0.5-0.2 0.6-0.7 0.1-0.5-0.1-1-0.1 0.3-0.4 0.5-0.2 0.3-0.6 0.3-0.3 0.1-0.7 0-0.3-0.1-0.5-0.4l-5.2-5.2q-0.5 0.4-1 0.6-0.6 0.3-1.1 0.5-0.6 0.1-1.1 0.2-0.6 0.1-1.2 0.1c-4.1 0-7.4-3.2-7.7-7.2z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="59px" height="59px" viewBox="0 0 24 24" stroke-width="1.9" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M17 17L21 21" stroke="#000000" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z" stroke="#000000" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path></svg>

Before

Width:  |  Height:  |  Size: 551 B

-15
View File
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" version="1">
<rect style="fill:#713a9d" width="40" height="40" x="4" y="4" rx="2" ry="2"/>
<path fill="#fff" opacity=".1" d="m6 4c-1.108 0-2 0.892-2 2v1c0-1.108 0.892-2 2-2h36c1.108 0 2 0.892 2 2v-1c0-1.108-0.892-2-2-2h-36z"/>
<path opacity=".2" d="m6 45c-1.108 0-2-0.892-2-2v-1c0 1.108 0.892 2 2 2h36c1.108 0 2-0.892 2-2v1c0 1.108-0.892 2-2 2h-36z"/>
<path style="opacity:0.5;fill:#ffffff" d="m 22,23 v 4 h 12 c 1.108,0 2,-0.892 2,-2 0,-1.108 -0.892,-2 -2,-2 z m 4,10 v 4 h 8 c 1.108,0 2,-0.892 2,-2 0,-1.108 -0.892,-2 -2,-2 z"/>
<path style="fill:#f44336" d="M 29,13 H 14 C 12.892,13 12,13.892 12,15 12,16.108 12.892,17 14,17 H 29 Z"/>
<path style="fill:#03a9f4" d="M 27,33 H 14 C 12.892,33 12,33.892 12,35 12,36.108 12.892,37 14,37 H 27 Z"/>
<path style="fill:#4caf50" d="M 21,23 H 14 C 12.892,23 12,23.892 12,25 12,26.108 12.892,27 14,27 H 21 Z"/>
<path style="opacity:0.2" d="m 36,16 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4 z"/>
<path style="opacity:0.2" d="m 30,36 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4 z"/>
<path style="opacity:0.2" d="m 26,26 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4 z"/>
<path style="fill:#ffffff" d="m 36,15 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4 z"/>
<path style="fill:#ffffff" d="m 30,35 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4 z"/>
<path style="fill:#ffffff" d="m 26,25 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4 z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more