mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a1a24dfe | ||
|
|
620a94c7fc |
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { PuterServer } from '../server.ts';
|
||||
import { setupTestServer } from '../testUtil.ts';
|
||||
import { getTaskbarItems } from './taskbarItems.ts';
|
||||
|
||||
describe('getTaskbarItems', () => {
|
||||
let server: PuterServer;
|
||||
let deps: Parameters<typeof getTaskbarItems>[1];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
deps = {
|
||||
clients: server.clients,
|
||||
stores: server.stores,
|
||||
apiBaseUrl: 'https://api.puter.com',
|
||||
config: { static_hosting_domain: 'puter.site' },
|
||||
} as unknown as Parameters<typeof getTaskbarItems>[1];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (taskbarItems?: unknown) => {
|
||||
const username = `tb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
} as never);
|
||||
if (taskbarItems !== undefined) {
|
||||
await server.stores.user.update(created.id, {
|
||||
taskbar_items:
|
||||
typeof taskbarItems === 'string'
|
||||
? taskbarItems
|
||||
: JSON.stringify(taskbarItems),
|
||||
});
|
||||
}
|
||||
return (await server.stores.user.getById(created.id))!;
|
||||
};
|
||||
|
||||
const makeApp = async (ownerUserId: number, overrides = {}) => {
|
||||
const name = `tb-app-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: `Title ${name}`,
|
||||
index_url: `https://${name}.example.com/`,
|
||||
description: 'a test app',
|
||||
...overrides,
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
};
|
||||
|
||||
it('seeds the default taskbar for a user that has none, and persists it', async () => {
|
||||
const user = await makeUser();
|
||||
expect(user.taskbar_items).toBeFalsy();
|
||||
|
||||
await getTaskbarItems(user as never, deps);
|
||||
|
||||
const [row] = await server.clients.db.read(
|
||||
'SELECT `taskbar_items` FROM `user` WHERE `id` = ?',
|
||||
[user.id],
|
||||
);
|
||||
const stored = JSON.parse(String(row.taskbar_items));
|
||||
expect(stored.map((e: { name: string }) => e.name)).toContain(
|
||||
'app-center',
|
||||
);
|
||||
// The cached row must not keep serving the pre-seed value.
|
||||
expect(
|
||||
(await server.stores.user.getById(user.id))?.taskbar_items,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resolves entries by name, uid and id', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const byName = await makeApp(owner.id);
|
||||
const byUid = await makeApp(owner.id);
|
||||
const byId = await makeApp(owner.id);
|
||||
|
||||
const user = await makeUser([
|
||||
{ name: byName.name, type: 'app' },
|
||||
{ uid: byUid.uid, type: 'app' },
|
||||
{ id: byId.id, type: 'app' },
|
||||
]);
|
||||
|
||||
const items = await getTaskbarItems(user as never, deps);
|
||||
expect(items.map((i) => i.uid)).toEqual([
|
||||
byName.uid,
|
||||
byUid.uid,
|
||||
byId.uid,
|
||||
]);
|
||||
expect(items[0]).toMatchObject({
|
||||
uuid: byName.uid,
|
||||
name: byName.name,
|
||||
title: byName.title,
|
||||
godmode: false,
|
||||
maximize_on_start: false,
|
||||
description: 'a test app',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the whole taskbar with one batched lookup per key, not one per entry', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const apps = await Promise.all([0, 1, 2].map(() => makeApp(owner.id)));
|
||||
const byUid = await makeApp(owner.id);
|
||||
const user = await makeUser([
|
||||
...apps.map((app) => ({ name: app.name, type: 'app' })),
|
||||
{ uid: byUid.uid, type: 'app' },
|
||||
]);
|
||||
const appStore = server.stores.app;
|
||||
const spies = {
|
||||
getByName: vi.spyOn(appStore, 'getByName'),
|
||||
getByUid: vi.spyOn(appStore, 'getByUid'),
|
||||
getById: vi.spyOn(appStore, 'getById'),
|
||||
getByNames: vi.spyOn(appStore, 'getByNames'),
|
||||
getByUids: vi.spyOn(appStore, 'getByUids'),
|
||||
};
|
||||
try {
|
||||
const items = await getTaskbarItems(user as never, deps);
|
||||
|
||||
expect(items.map((i) => i.uid)).toEqual([
|
||||
...apps.map((app) => app.uid),
|
||||
byUid.uid,
|
||||
]);
|
||||
expect(spies.getByName).not.toHaveBeenCalled();
|
||||
expect(spies.getByUid).not.toHaveBeenCalled();
|
||||
expect(spies.getById).not.toHaveBeenCalled();
|
||||
expect(spies.getByNames).toHaveBeenCalledTimes(1);
|
||||
expect(spies.getByUids).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
for (const spy of Object.values(spies)) spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips non-app entries, the explorer pin, and unresolvable apps', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id);
|
||||
const user = await makeUser([
|
||||
{ name: 'explorer', type: 'app' },
|
||||
{ name: 'nothing-here', type: 'app' },
|
||||
{ name: app.name, type: 'separator' },
|
||||
{ type: 'app' },
|
||||
{ name: app.name, type: 'app' },
|
||||
]);
|
||||
|
||||
const items = await getTaskbarItems(user as never, deps);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe(app.name);
|
||||
});
|
||||
|
||||
it('falls back to an empty taskbar when the stored JSON is corrupt', async () => {
|
||||
const user = await makeUser('{not json');
|
||||
expect(await getTaskbarItems(user as never, deps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts an already-parsed taskbar array on the user row', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id);
|
||||
const user = await makeUser([]);
|
||||
|
||||
const items = await getTaskbarItems(
|
||||
{ ...user, taskbar_items: [{ name: app.name, type: 'app' }] },
|
||||
deps,
|
||||
);
|
||||
expect(items.map((i) => i.name)).toEqual([app.name]);
|
||||
});
|
||||
|
||||
it('builds a sized icon URL by default and drops the icon on request', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id);
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const withIcon = await getTaskbarItems(user as never, deps, {
|
||||
iconSize: 64,
|
||||
});
|
||||
expect(withIcon[0].icon).toBe(
|
||||
`https://api.puter.com/app-icon/${app.uid}/64`,
|
||||
);
|
||||
|
||||
const withoutIcon = await getTaskbarItems(user as never, deps, {
|
||||
noIcons: true,
|
||||
});
|
||||
expect('icon' in withoutIcon[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('pairs the icon URL with the direct subdomain URL when one exists', async () => {
|
||||
const owner = await makeUser([]);
|
||||
// An http(s) icon column is what the resize pipeline leaves behind, so
|
||||
// the sized PNG is on the subdomain.
|
||||
const app = await makeApp(owner.id, {
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
});
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const items = await getTaskbarItems(user as never, deps, {
|
||||
iconSize: 64,
|
||||
});
|
||||
expect(items[0].icon).toBe(
|
||||
`https://api.puter.com/app-icon/${app.uid}/64`,
|
||||
);
|
||||
expect(items[0].iconCdnUrl).toBe(
|
||||
`https://puter-app-icons.puter.site/${app.uid}-64.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no subdomain URL for an app whose icon was never generated', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id);
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const items = await getTaskbarItems(user as never, deps, {
|
||||
iconSize: 64,
|
||||
});
|
||||
expect(items[0].iconCdnUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the raw icon column when no API base URL is configured', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id, {
|
||||
icon: 'https://cdn.example.com/i.png',
|
||||
});
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const items = await getTaskbarItems(
|
||||
user as never,
|
||||
{
|
||||
...deps,
|
||||
apiBaseUrl: undefined,
|
||||
} as never,
|
||||
);
|
||||
expect(items[0].icon).toBe('https://cdn.example.com/i.png');
|
||||
});
|
||||
|
||||
it('reports null for an app with no icon and no API base URL', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id);
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const items = await getTaskbarItems(
|
||||
user as never,
|
||||
{
|
||||
...deps,
|
||||
apiBaseUrl: undefined,
|
||||
} as never,
|
||||
);
|
||||
expect(items[0].icon).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { AppIconHostConfig } from './appIcon.js';
|
||||
import { toAppSummary, type AppRow } from './appView.js';
|
||||
|
||||
interface TaskbarEntry {
|
||||
name?: string;
|
||||
id?: number;
|
||||
uid?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface TaskbarOptions {
|
||||
iconSize?: number;
|
||||
noIcons?: boolean;
|
||||
}
|
||||
|
||||
interface TaskbarDeps {
|
||||
apiBaseUrl?: string;
|
||||
config?: AppIconHostConfig;
|
||||
stores: {
|
||||
app: {
|
||||
getByNames: (names: string[]) => Promise<Map<string, AppRow>>;
|
||||
getByUids: (uids: string[]) => Promise<Map<string, AppRow>>;
|
||||
getByIds: (ids: number[]) => Promise<Map<number, AppRow>>;
|
||||
};
|
||||
user: {
|
||||
update: (
|
||||
id: number,
|
||||
patch: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TASKBAR_ITEMS: TaskbarEntry[] = [
|
||||
{ name: 'app-center', type: 'app' },
|
||||
{ name: 'builder', type: 'app' },
|
||||
{ name: 'dev-center', type: 'app' },
|
||||
{ name: 'editor', type: 'app' },
|
||||
{ name: 'code', type: 'app' },
|
||||
{ name: 'camera', type: 'app' },
|
||||
{ name: 'recorder', type: 'app' },
|
||||
{ name: 'browser', type: 'app' },
|
||||
];
|
||||
|
||||
export async function getTaskbarItems(
|
||||
user: Record<string, unknown>,
|
||||
deps: TaskbarDeps,
|
||||
options: TaskbarOptions = {},
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
let raw: TaskbarEntry[];
|
||||
|
||||
if (!user.taskbar_items) {
|
||||
raw = DEFAULT_TASKBAR_ITEMS;
|
||||
await deps.stores.user.update(user.id as number, {
|
||||
taskbar_items: JSON.stringify(raw),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
raw =
|
||||
typeof user.taskbar_items === 'string'
|
||||
? JSON.parse(user.taskbar_items as string)
|
||||
: (user.taskbar_items as TaskbarEntry[]);
|
||||
} catch {
|
||||
raw = [];
|
||||
}
|
||||
}
|
||||
|
||||
const entries = raw.filter(
|
||||
(entry) => entry.type === 'app' && entry.name !== 'explorer',
|
||||
);
|
||||
const [byName, byUid, byId] = await Promise.all([
|
||||
deps.stores.app.getByNames(
|
||||
entries.flatMap((entry) => (entry.name ? [entry.name] : [])),
|
||||
),
|
||||
deps.stores.app.getByUids(
|
||||
entries.flatMap((entry) =>
|
||||
!entry.name && entry.uid ? [entry.uid] : [],
|
||||
),
|
||||
),
|
||||
deps.stores.app.getByIds(
|
||||
entries.flatMap((entry) =>
|
||||
!entry.name && !entry.uid && entry.id ? [entry.id] : [],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
for (const entry of entries) {
|
||||
const app = entry.name
|
||||
? byName.get(entry.name)
|
||||
: entry.uid
|
||||
? byUid.get(entry.uid)
|
||||
: entry.id
|
||||
? byId.get(entry.id)
|
||||
: undefined;
|
||||
if (!app) continue;
|
||||
|
||||
const item: Record<string, unknown> = {
|
||||
uid: app.uid,
|
||||
...toAppSummary(app, {
|
||||
apiBaseUrl: deps.apiBaseUrl,
|
||||
config: deps.config,
|
||||
iconSize: options.iconSize,
|
||||
}),
|
||||
description: app.description,
|
||||
};
|
||||
if (options.noIcons) {
|
||||
delete item.icon;
|
||||
delete item.iconCdnUrl;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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})`));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,6 @@ async function UIWindowAuthMe (options = {}) {
|
||||
width: 400,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
window_class: 'window-authme',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
|
||||
@@ -87,7 +87,6 @@ async function UIWindowBlockedSenders (options) {
|
||||
width: 420,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.blocked-username').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
|
||||
@@ -68,7 +68,6 @@ async function UIWindowChangeUsername (options) {
|
||||
width: 350,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.new-username').get(0)?.focus({ preventScroll: true });
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
|
||||
@@ -114,7 +114,6 @@ function UIWindowCopyToken (options = {}) {
|
||||
width: 450,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
window_class: 'window-copy-auth-token',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = '/';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -100,7 +100,6 @@ async function UIWindowNewPassword (options) {
|
||||
width: 350,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.new-password').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
|
||||
@@ -71,7 +71,6 @@ function UIWindowPasswordChangeRequired (options) {
|
||||
width: 390,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.pcr-current').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
|
||||
@@ -1,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"> × </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;
|
||||
@@ -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 )
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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"> × </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
@@ -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
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];
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -63,11 +63,11 @@ export const get_auth_redirect_url = () => {
|
||||
*/
|
||||
const oidc_return_path = () => {
|
||||
const pathname = window.location.pathname;
|
||||
if ( pathname === '/' || pathname === '/desktop' || pathname === '/dashboard' ) {
|
||||
if ( pathname === '/' || pathname === '/dashboard' ) {
|
||||
return pathname;
|
||||
}
|
||||
// app landing: normalize away a trailing slash to match the backend whitelist
|
||||
if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) {
|
||||
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
|
||||
return pathname.replace(/\/$/, '');
|
||||
}
|
||||
return null;
|
||||
@@ -76,10 +76,9 @@ const oidc_return_path = () => {
|
||||
/**
|
||||
* The `return_to` to send along when starting an OIDC flow, or null if the
|
||||
* current page isn't one the backend will return to. The backend strictly
|
||||
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
|
||||
* and direct app landings (`/app/<name>`, plus the desktop-booted
|
||||
* `/desktop/app/<name>`), so OIDC login started from an app landing comes back
|
||||
* to the app — and to the same interface it was opened in.
|
||||
* whitelists these (never a client-supplied URL): `/dashboard` and direct app
|
||||
* landings (`/app/<name>`), so OIDC login started from an app landing comes
|
||||
* back to the app.
|
||||
*
|
||||
* A share link (`?shared=`, from an email) is carried along with the path: the
|
||||
* recipient usually has to sign in before they can see what was shared, and an
|
||||
|
||||
@@ -42,7 +42,6 @@ afterEach(() => {
|
||||
|
||||
describe('get_oidc_return_to', () => {
|
||||
it('returns the interface pages the backend whitelists', () => {
|
||||
expect(at('/desktop')).toBe('/desktop');
|
||||
expect(at('/dashboard')).toBe('/dashboard');
|
||||
});
|
||||
|
||||
@@ -51,17 +50,12 @@ describe('get_oidc_return_to', () => {
|
||||
expect(at('/app/editor/')).toBe('/app/editor');
|
||||
});
|
||||
|
||||
it('keeps a desktop app landing on the desktop', () => {
|
||||
expect(at('/desktop/app/editor')).toBe('/desktop/app/editor');
|
||||
expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
|
||||
});
|
||||
|
||||
it('carries a share link so the item survives the round trip', () => {
|
||||
expect(at('/', share_search(shared_path('Report.pdf')))).toBe(
|
||||
`/${share_search(shared_path('Report.pdf'))}`,
|
||||
);
|
||||
expect(at('/desktop', share_search(shared_path('Report.pdf')))).toBe(
|
||||
`/desktop${share_search(shared_path('Report.pdf'))}`,
|
||||
expect(at('/dashboard', share_search(shared_path('Report.pdf')))).toBe(
|
||||
`/dashboard${share_search(shared_path('Report.pdf'))}`,
|
||||
);
|
||||
expect(
|
||||
at('/', share_search(shared_path('a.txt'), shared_path('b.txt'))),
|
||||
@@ -83,10 +77,10 @@ describe('get_oidc_return_to', () => {
|
||||
expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
|
||||
expect(at('/', '?shared=')).toBe(null);
|
||||
// other parameters are not ours to carry
|
||||
expect(at('/desktop', '?app=editor')).toBe('/desktop');
|
||||
expect(at('/dashboard', '?app=editor')).toBe('/dashboard');
|
||||
expect(
|
||||
at('/desktop', `${share_search(shared_path('a.txt'))}&app=editor`),
|
||||
).toBe(`/desktop${share_search(shared_path('a.txt'))}`);
|
||||
at('/dashboard', `${share_search(shared_path('a.txt'))}&app=editor`),
|
||||
).toBe(`/dashboard${share_search(shared_path('a.txt'))}`);
|
||||
});
|
||||
|
||||
it('returns null for anything the backend would reject', () => {
|
||||
@@ -94,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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, '') || '/';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 +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 |
@@ -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
Reference in new issue
Block a user