Compare commits

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

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

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

No files matched your search

+5 -10
View File
@@ -257,8 +257,6 @@ describe('whoami extension — handleWhoami', () => {
expect(body.created_ts).toBeGreaterThan(0);
// `directories` is only sent to user actors — confirm it’s present.
expect(body.directories).toBeDefined();
// taskbar_items is only sent to user actors.
expect(body).toHaveProperty('taskbar_items');
});
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
@@ -322,7 +320,7 @@ describe('whoami extension — handleWhoami', () => {
expect(flags.payment_bypass).toBeUndefined();
});
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
it('strips created_ts and human_readable_age fields for app actors', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
@@ -340,9 +338,6 @@ describe('whoami extension — handleWhoami', () => {
expect(body.app_name).toBe('app-test-actor');
// is_user_token is stripped for app actors.
expect(body.is_user_token).toBeUndefined();
expect(body.desktop_bg_url).toBeUndefined();
expect(body.desktop_bg_color).toBeUndefined();
expect(body.desktop_bg_fit).toBeUndefined();
expect(body.human_readable_age).toBeUndefined();
// Account age, in either form, is not exposed to apps.
expect(body.created_ts).toBeUndefined();
@@ -432,7 +427,7 @@ describe('whoami extension — handleWhoami', () => {
});
});
it('cannot add taskbar_items for an app actor, which core omits', async () => {
it('cannot add directories for an app actor, which core omits', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({
@@ -440,11 +435,11 @@ describe('whoami extension — handleWhoami', () => {
app: { uid: 'app-test-actor' },
}),
(_key, event) => {
event.details.taskbar_items = [{ name: 'injected' }];
event.details.directories = { '/injected': 'x' };
},
);
expect(body).not.toHaveProperty('taskbar_items');
expect(body).not.toHaveProperty('directories');
});
});
@@ -525,7 +520,7 @@ describe('whoami extension — handleWhoami', () => {
expect(body.otp_secret).toBeUndefined();
expect(body.signup_ip).toBeUndefined();
// Not just absent as a key — the values must not appear anywhere
// in the payload (nested under metadata, taskbar items, …).
// in the payload (nested under metadata, …).
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('5551234567');
expect(serialized).not.toContain('fp_ABC123');
+3 -17
View File
@@ -1,6 +1,5 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import {
buildUserDetails,
scrubSensitive,
@@ -26,7 +25,7 @@ const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
]);
export const handleWhoami = async (
req: Request,
_req: Request,
res: Response,
): Promise<void> => {
const actor = Context.get('actor');
@@ -54,19 +53,10 @@ export const handleWhoami = async (
return;
}
const rawIconSize =
typeof req.query?.icon_size === 'string'
? Number(req.query.icon_size)
: undefined;
const iconSize =
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
? rawIconSize
: undefined;
const details = await buildUserDetails(
user,
{ config: extension.config, clients, stores, services },
{ isUser, iconSize, noIcons: !iconSize },
{ isUser },
);
details.human_readable_age = user.timestamp
? timeago.format(new Date(user.timestamp as string))
@@ -81,9 +71,6 @@ export const handleWhoami = async (
delete details.email;
delete details.unconfirmed_email;
}
delete details.desktop_bg_url;
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.created_ts;
delete details.is_user_token;
@@ -121,8 +108,7 @@ export const handleWhoami = async (
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
{ offering?: Record<string, unknown> } | undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
@@ -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 --
+1 -1
View File
@@ -2864,7 +2864,7 @@ export class AuthService extends PuterService {
#actorUserFromRow(user: UserRow) {
// Strip the password hash; pass everything else through so callers
// can read metadata, desktop_bg_*, otp_enabled, etc. without
// can read metadata, otp_enabled, etc. without
// re-fetching the user. Mirrors what /whoami exposes off of UserRow.
const { password: _password, ...rest } = user;
return {
-271
View File
@@ -1,271 +0,0 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PuterServer } from '../server.ts';
import { setupTestServer } from '../testUtil.ts';
import { getTaskbarItems } from './taskbarItems.ts';
describe('getTaskbarItems', () => {
let server: PuterServer;
let deps: Parameters<typeof getTaskbarItems>[1];
beforeAll(async () => {
server = await setupTestServer();
deps = {
clients: server.clients,
stores: server.stores,
apiBaseUrl: 'https://api.puter.com',
config: { static_hosting_domain: 'puter.site' },
} as unknown as Parameters<typeof getTaskbarItems>[1];
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (taskbarItems?: unknown) => {
const username = `tb-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
} as never);
if (taskbarItems !== undefined) {
await server.stores.user.update(created.id, {
taskbar_items:
typeof taskbarItems === 'string'
? taskbarItems
: JSON.stringify(taskbarItems),
});
}
return (await server.stores.user.getById(created.id))!;
};
const makeApp = async (ownerUserId: number, overrides = {}) => {
const name = `tb-app-${Math.random().toString(36).slice(2, 10)}`;
return server.stores.app.create(
{
name,
title: `Title ${name}`,
index_url: `https://${name}.example.com/`,
description: 'a test app',
...overrides,
},
{ ownerUserId },
);
};
it('seeds the default taskbar for a user that has none, and persists it', async () => {
const user = await makeUser();
expect(user.taskbar_items).toBeFalsy();
await getTaskbarItems(user as never, deps);
const [row] = await server.clients.db.read(
'SELECT `taskbar_items` FROM `user` WHERE `id` = ?',
[user.id],
);
const stored = JSON.parse(String(row.taskbar_items));
expect(stored.map((e: { name: string }) => e.name)).toContain(
'app-center',
);
// The cached row must not keep serving the pre-seed value.
expect(
(await server.stores.user.getById(user.id))?.taskbar_items,
).toBeTruthy();
});
it('resolves entries by name, uid and id', async () => {
const owner = await makeUser([]);
const byName = await makeApp(owner.id);
const byUid = await makeApp(owner.id);
const byId = await makeApp(owner.id);
const user = await makeUser([
{ name: byName.name, type: 'app' },
{ uid: byUid.uid, type: 'app' },
{ id: byId.id, type: 'app' },
]);
const items = await getTaskbarItems(user as never, deps);
expect(items.map((i) => i.uid)).toEqual([
byName.uid,
byUid.uid,
byId.uid,
]);
expect(items[0]).toMatchObject({
uuid: byName.uid,
name: byName.name,
title: byName.title,
godmode: false,
maximize_on_start: false,
description: 'a test app',
});
});
it('resolves the whole taskbar with one batched lookup per key, not one per entry', async () => {
const owner = await makeUser([]);
const apps = await Promise.all([0, 1, 2].map(() => makeApp(owner.id)));
const byUid = await makeApp(owner.id);
const user = await makeUser([
...apps.map((app) => ({ name: app.name, type: 'app' })),
{ uid: byUid.uid, type: 'app' },
]);
const appStore = server.stores.app;
const spies = {
getByName: vi.spyOn(appStore, 'getByName'),
getByUid: vi.spyOn(appStore, 'getByUid'),
getById: vi.spyOn(appStore, 'getById'),
getByNames: vi.spyOn(appStore, 'getByNames'),
getByUids: vi.spyOn(appStore, 'getByUids'),
};
try {
const items = await getTaskbarItems(user as never, deps);
expect(items.map((i) => i.uid)).toEqual([
...apps.map((app) => app.uid),
byUid.uid,
]);
expect(spies.getByName).not.toHaveBeenCalled();
expect(spies.getByUid).not.toHaveBeenCalled();
expect(spies.getById).not.toHaveBeenCalled();
expect(spies.getByNames).toHaveBeenCalledTimes(1);
expect(spies.getByUids).toHaveBeenCalledTimes(1);
} finally {
for (const spy of Object.values(spies)) spy.mockRestore();
}
});
it('skips non-app entries, the explorer pin, and unresolvable apps', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([
{ name: 'explorer', type: 'app' },
{ name: 'nothing-here', type: 'app' },
{ name: app.name, type: 'separator' },
{ type: 'app' },
{ name: app.name, type: 'app' },
]);
const items = await getTaskbarItems(user as never, deps);
expect(items).toHaveLength(1);
expect(items[0].name).toBe(app.name);
});
it('falls back to an empty taskbar when the stored JSON is corrupt', async () => {
const user = await makeUser('{not json');
expect(await getTaskbarItems(user as never, deps)).toEqual([]);
});
it('accepts an already-parsed taskbar array on the user row', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([]);
const items = await getTaskbarItems(
{ ...user, taskbar_items: [{ name: app.name, type: 'app' }] },
deps,
);
expect(items.map((i) => i.name)).toEqual([app.name]);
});
it('builds a sized icon URL by default and drops the icon on request', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const withIcon = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(withIcon[0].icon).toBe(
`https://api.puter.com/app-icon/${app.uid}/64`,
);
const withoutIcon = await getTaskbarItems(user as never, deps, {
noIcons: true,
});
expect('icon' in withoutIcon[0]).toBe(false);
});
it('pairs the icon URL with the direct subdomain URL when one exists', async () => {
const owner = await makeUser([]);
// An http(s) icon column is what the resize pipeline leaves behind, so
// the sized PNG is on the subdomain.
const app = await makeApp(owner.id, {
icon: 'https://api.puter.com/app-icon/x',
});
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(items[0].icon).toBe(
`https://api.puter.com/app-icon/${app.uid}/64`,
);
expect(items[0].iconCdnUrl).toBe(
`https://puter-app-icons.puter.site/${app.uid}-64.png`,
);
});
it('reports no subdomain URL for an app whose icon was never generated', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(user as never, deps, {
iconSize: 64,
});
expect(items[0].iconCdnUrl).toBeNull();
});
it('falls back to the raw icon column when no API base URL is configured', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id, {
icon: 'https://cdn.example.com/i.png',
});
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(
user as never,
{
...deps,
apiBaseUrl: undefined,
} as never,
);
expect(items[0].icon).toBe('https://cdn.example.com/i.png');
});
it('reports null for an app with no icon and no API base URL', async () => {
const owner = await makeUser([]);
const app = await makeApp(owner.id);
const user = await makeUser([{ name: app.name, type: 'app' }]);
const items = await getTaskbarItems(
user as never,
{
...deps,
apiBaseUrl: undefined,
} as never,
);
expect(items[0].icon).toBeNull();
});
});
-134
View File
@@ -1,134 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { AppIconHostConfig } from './appIcon.js';
import { toAppSummary, type AppRow } from './appView.js';
interface TaskbarEntry {
name?: string;
id?: number;
uid?: string;
type?: string;
}
interface TaskbarOptions {
iconSize?: number;
noIcons?: boolean;
}
interface TaskbarDeps {
apiBaseUrl?: string;
config?: AppIconHostConfig;
stores: {
app: {
getByNames: (names: string[]) => Promise<Map<string, AppRow>>;
getByUids: (uids: string[]) => Promise<Map<string, AppRow>>;
getByIds: (ids: number[]) => Promise<Map<number, AppRow>>;
};
user: {
update: (
id: number,
patch: Record<string, unknown>,
) => Promise<unknown>;
};
};
}
const DEFAULT_TASKBAR_ITEMS: TaskbarEntry[] = [
{ name: 'app-center', type: 'app' },
{ name: 'builder', type: 'app' },
{ name: 'dev-center', type: 'app' },
{ name: 'editor', type: 'app' },
{ name: 'code', type: 'app' },
{ name: 'camera', type: 'app' },
{ name: 'recorder', type: 'app' },
{ name: 'browser', type: 'app' },
];
export async function getTaskbarItems(
user: Record<string, unknown>,
deps: TaskbarDeps,
options: TaskbarOptions = {},
): Promise<Array<Record<string, unknown>>> {
let raw: TaskbarEntry[];
if (!user.taskbar_items) {
raw = DEFAULT_TASKBAR_ITEMS;
await deps.stores.user.update(user.id as number, {
taskbar_items: JSON.stringify(raw),
});
} else {
try {
raw =
typeof user.taskbar_items === 'string'
? JSON.parse(user.taskbar_items as string)
: (user.taskbar_items as TaskbarEntry[]);
} catch {
raw = [];
}
}
const entries = raw.filter(
(entry) => entry.type === 'app' && entry.name !== 'explorer',
);
const [byName, byUid, byId] = await Promise.all([
deps.stores.app.getByNames(
entries.flatMap((entry) => (entry.name ? [entry.name] : [])),
),
deps.stores.app.getByUids(
entries.flatMap((entry) =>
!entry.name && entry.uid ? [entry.uid] : [],
),
),
deps.stores.app.getByIds(
entries.flatMap((entry) =>
!entry.name && !entry.uid && entry.id ? [entry.id] : [],
),
),
]);
const items: Array<Record<string, unknown>> = [];
for (const entry of entries) {
const app = entry.name
? byName.get(entry.name)
: entry.uid
? byUid.get(entry.uid)
: entry.id
? byId.get(entry.id)
: undefined;
if (!app) continue;
const item: Record<string, unknown> = {
uid: app.uid,
...toAppSummary(app, {
apiBaseUrl: deps.apiBaseUrl,
config: deps.config,
iconSize: options.iconSize,
}),
description: app.description,
};
if (options.noIcons) {
delete item.icon;
delete item.iconCdnUrl;
}
items.push(item);
}
return items;
}
+9 -23
View File
@@ -64,7 +64,7 @@ const makeUser = async () => {
};
describe('buildUserDetails', () => {
it('builds the desktop payload for a user actor', async () => {
it('builds the GUI payload for a user actor', async () => {
const user = await makeUser();
const details = await buildUserDetails(user, deps(), {
isUser: true,
@@ -85,15 +85,18 @@ describe('buildUserDetails', () => {
[`/${user.username}/Desktop`]: user.desktop_uuid,
[`/${user.username}/Trash`]: user.trash_uuid,
});
expect(Array.isArray(details.taskbar_items)).toBe(true);
// The desktop's taskbar and wallpaper are gone with it.
expect(details).not.toHaveProperty('taskbar_items');
expect(details).not.toHaveProperty('desktop_bg_url');
expect(details).not.toHaveProperty('desktop_bg_color');
expect(details).not.toHaveProperty('desktop_bg_fit');
});
it('leaves the desktop-only parts out for an app actor', async () => {
it('leaves the GUI-only parts out for an app actor', async () => {
const user = await makeUser();
const details = await buildUserDetails(user, deps(), {
isUser: false,
});
expect(details).not.toHaveProperty('taskbar_items');
expect(details).not.toHaveProperty('directories');
expect(details.card_fallback_available).toBe(false);
});
@@ -124,7 +127,7 @@ describe('buildUserDetails lookups', () => {
return keys.slice(keys.indexOf('hasDevAccountAccess') + 1);
};
it('reads the linked provider, taskbar and seat together, listed in a fixed order', async () => {
it('reads the linked provider and seat together, listed in a fixed order', async () => {
const user = {
...(await makeUser()),
password: null,
@@ -162,7 +165,6 @@ describe('buildUserDetails lookups', () => {
);
expect(tailKeys(details)).toEqual([
'oidc_revalidate_url',
'taskbar_items',
'directories',
'team',
'last_activity_ts',
@@ -177,7 +179,6 @@ describe('buildUserDetails lookups', () => {
const user = {
...(await makeUser()),
password: null,
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
last_activity_ts: '2026-01-02 03:04:05',
};
const events: string[] = [];
@@ -199,15 +200,6 @@ describe('buildUserDetails lookups', () => {
},
},
stores: Object.assign(Object.create(d.stores), {
app: {
getByNames: () =>
timed(events, 'taskbar', 20, () => {
throw new Error('taskbar down');
}),
getByUids: async () => new Map(),
getByIds: async () => new Map(),
},
// Fails first, but is reported after the taskbar.
team: {
getOrgSeat: () =>
timed(events, 'seat', 5, () => {
@@ -219,18 +211,12 @@ describe('buildUserDetails lookups', () => {
{ isUser: true },
);
expect(warn.mock.calls).toEqual([
['[user-details] taskbar_items failed:', expect.any(Error)],
['[user-details] team lookup failed:', expect.any(Error)],
]);
} finally {
warn.mockRestore();
}
expect(tailKeys(details)).toEqual([
'taskbar_items',
'directories',
'last_activity_ts',
]);
expect(details.taskbar_items).toEqual([]);
expect(tailKeys(details)).toEqual(['directories', 'last_activity_ts']);
});
});
+8 -50
View File
@@ -27,18 +27,12 @@ import {
isCardFallbackEligible,
type CardFallbackClients,
} from './cardFallback.js';
import { getTaskbarItems } from './taskbarItems.js';
import { isEmailGateLast } from './verificationOrder.js';
type TaskbarDeps = Parameters<typeof getTaskbarItems>[1];
export interface UserDetailsDeps {
config: IConfig;
// `db.write` backs the taskbar's first-read default row.
clients: CardFallbackClients & {
db: { write: (query: string, params?: unknown[]) => Promise<unknown> };
};
stores: TaskbarDeps['stores'] & {
clients: CardFallbackClients;
stores: {
kv: Pick<SystemKVStore, 'get'>;
team?: Pick<TeamStoreContract, 'getOrgSeat'>;
};
@@ -78,8 +72,8 @@ const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
]);
/**
* Delete every sensitive key at any depth (`metadata` and `taskbar_items` are
* nested). Depth-limited and cycle-safe. Runs last over a whole payload.
* Delete every sensitive key at any depth (`metadata` is nested). Depth-limited
* and cycle-safe. Runs last over a whole payload.
*/
export const scrubSensitive = (
value: unknown,
@@ -118,13 +112,13 @@ export const toUnixSeconds = (value: unknown): number | undefined => {
/**
* The account as the client keeps it in `window.user`: what `/whoami` answers
* and what a sign-in hands back. `isUser` false (an app acting for the user)
* leaves out the desktop-only parts; trimming fields an app may not see is the
* leaves out the GUI-only parts; trimming fields an app may not see is the
* caller's job. Not scrubbed: run `scrubSensitive` on the final payload.
*/
export async function buildUserDetails(
user: UserRow,
deps: UserDetailsDeps,
opts: { isUser: boolean; iconSize?: number; noIcons?: boolean },
opts: { isUser: boolean },
): Promise<Record<string, unknown>> {
const { config, clients, stores, services } = deps;
const { isUser } = opts;
@@ -170,9 +164,6 @@ export async function buildUserDetails(
cardFallbackDepsFrom(clients),
)
: false,
desktop_bg_url: user.desktop_bg_url,
desktop_bg_color: user.desktop_bg_color,
desktop_bg_fit: user.desktop_bg_fit,
is_temp: user.password === null && user.email === null,
is_user_token: true,
// Null until the account asks for a code; never minted here.
@@ -185,14 +176,6 @@ export async function buildUserDetails(
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
// Not an inline literal, so a `TaskbarDeps` without `clients` still
// accepts it.
const taskbarDeps = {
clients,
stores,
apiBaseUrl: String(config.api_base_url ?? ''),
config,
};
const linkedProvider = async () => {
if (!oidcOnly) return null;
try {
@@ -202,19 +185,6 @@ export async function buildUserDetails(
return null;
}
};
const taskbarItems = async () => {
if (!isUser) return null;
try {
return {
items: await getTaskbarItems(user, taskbarDeps, {
iconSize: opts.iconSize,
noIcons: opts.noIcons,
}),
};
} catch (error) {
return { error };
}
};
// The team that pays for the account; every seat restriction keys on it.
const orgSeat = async () => {
if (!isUser || config.teams_enabled !== true) return null;
@@ -226,26 +196,14 @@ export async function buildUserDetails(
};
// Independent and each best-effort, so read together; the results land
// below in the order the payload has always listed them.
const [provider, taskbar, team] = await Promise.all([
linkedProvider(),
taskbarItems(),
orgSeat(),
]);
const [provider, team] = await Promise.all([linkedProvider(), orgSeat()]);
if (provider) {
const origin = (config.origin ?? '').replace(/\/$/, '');
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
}
if (taskbar) {
// Best-effort: the account still works without them.
if ('error' in taskbar) {
console.warn('[user-details] taskbar_items failed:', taskbar.error);
details.taskbar_items = [];
} else {
details.taskbar_items = taskbar.items;
}
if (isUser) {
const directories: Record<string, unknown> = {};
const nameToProp: Record<string, string> = {
desktop_uuid: `/${user.username}/Desktop`,
+1 -2
View File
@@ -23,7 +23,7 @@ A set of key/value pairs that configure the window.
* `has_head` (Boolean): if set to `true`, window will have a head which contains the icon and close, minimize, and maximize buttons.
* `height` (Float): height of window in pixels.
* `is_resizable` (Boolean): if set to `true`, user will be able to resize the window.
* `show_in_taskbar` (Boolean): if set to `true`, window will be represented in the taskbar.
* `show_in_taskbar` (Boolean): deprecated, and has no effect. Puter no longer has a taskbar.
* `title` (String): title of the window.
* `width` (Float): width of window in pixels.
@@ -46,7 +46,6 @@ A `Promise` that resolves to a window handle object with an `id` (String) proper
is_resizable: false,
has_head: true,
center: true,
show_in_taskbar: false,
})
</script>
</body>
+6 -6
View File
@@ -46,12 +46,12 @@ the window before the app's own code runs, so a service app cannot avoid briefly
appearing on screen.
The instance stays private to your app for as long as it is hidden: it has no
taskbar item and no running mark on its icon, and opening the app from the
taskbar or from Puter's app list starts a separate, ordinary instance for the
user rather than handing them the one you are talking to. It can show itself at
any time with [`puter.ui.showWindow()`](/UI/showWindow), and from that moment it
is an ordinary window — it takes its place in the taskbar, and the user can
return to it, hide it, or close it like any other. Defaults to `false`.
running mark on its icon, and opening the app from Puter's app list starts a
separate, ordinary instance for the user rather than handing them the one you
are talking to. It can show itself at any time with
[`puter.ui.showWindow()`](/UI/showWindow), and from that moment it is an
ordinary window — the user can return to it, hide it, or close it like any
other. Defaults to `false`.
A background app closes when the app that launched it closes: it was launched to
serve that app, and the user never saw it. Once it has shown itself it keeps
+1 -1
View File
@@ -398,7 +398,7 @@ For long-lived mounts, sign in with a `-token` username and an API token as the
| ------------------------------------------------------------------ | ------------------------------- | ----------- |
| `nameAvailable` | 60/min | |
| App icon | 12,000/min | Per network |
| App landing page (`/app/<name>`, `/desktop/app/<name>`) | 600/min | Per network |
| App landing page (`/app/<name>`) | 600/min | Per network |
| Names per batch app lookup (`GET /apps/:names`, `POST /query/app`) | 200, up to 200 characters each | |
| `metadata` | 16 KiB, measured as JSON | |
| `filetypeAssociations` | 200 entries, 60 characters each | |
+1 -1
View File
@@ -118,7 +118,7 @@ const startServer = (attempt, useAnyFreePort = false) => {
if ( process.env.PUTER_NO_BROWSER ) return;
// Open the GUI rather than the sign-in link directly: it's idempotent.
// An already-signed-in GUI loads straight to the desktop instead of
// An already-signed-in GUI loads straight to the dashboard instead of
// re-prompting for consent on every restart.
open(guiUrl).catch((e) => {
console.log(chalk.dim(` (could not open a browser: ${e.message})`));
-1
View File
@@ -522,7 +522,6 @@ const ipc_listener = async (event, handled) => {
is_resizable: event.data.options.is_resizable,
has_head: event.data.options.has_head,
center: event.data.options.center,
show_in_taskbar: event.data.options.show_in_taskbar,
iframe_srcdoc: event.data.options.content,
parent_uuid: event.data.appInstanceID,
});
-19
View File
@@ -291,25 +291,6 @@ const TabHome = {
h += '</div>';
h += '</div>';
// Open Desktop card (spans full width, links to the desktop interface)
h +=
'<a href="/desktop" target="_blank" rel="noopener" class="bento-card bento-desktop allow-native-ctxmenu">';
h +=
'<div class="bento-card-fancy-icon bento-card-fancy-icon-desktop">';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += `<h2>${i18n('open_desktop')}</h2>`;
h += '<span class="bento-card-fancy-subtitle">';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>';
h += i18n('open_desktop_hint');
h += '</span>';
h += '</div>';
h += '<span class="bento-desktop-arrow">›</span>';
h += '</a>';
h += '</div>';
return h;
},
+5 -6
View File
@@ -205,8 +205,8 @@ async function UIDashboard (options) {
$item.addClass('active').attr('aria-current', 'page');
};
// Remove `?ref=...` from the URL, keeping the path and tab hash (mirrors
// UIDesktop). Replaced, not pushed, so Back leaves the dashboard.
// Remove `?ref=...` from the URL, keeping the path and tab hash.
// Replaced, not pushed, so Back leaves the dashboard.
if ( window.url_query_params?.has('ref') ) {
window.history.replaceState(null, document.title, window.location.pathname + window.location.hash);
}
@@ -273,7 +273,6 @@ async function UIDashboard (options) {
// =========================================================================
// Socket initialization
// In dashboard mode, UIDesktop is never loaded, so we create the socket here.
// This runs inside the function (not at module level) to ensure window.gui_origin
// and window.auth_token are already set.
// =========================================================================
@@ -368,8 +367,8 @@ async function UIDashboard (options) {
// =========================================================================
// Item event handlers
// Incremental DOM updates using UIDashboardFileItem for item creation and
// direct jQuery manipulation for removals/updates. Mirrors UIDesktop's
// approach but adapted for Dashboard's list-view structure.
// direct jQuery manipulation for removals/updates, adapted for the
// dashboard's list-view structure.
// =========================================================================
// A row taken away from elsewhere: keep the footer's count and size in
@@ -601,7 +600,7 @@ async function UIDashboard (options) {
window.addEventListener('popstate', handleRouteChange);
// Sidebar item click handler — only tab items ([data-section]); plain links
// like the "Open Desktop" button navigate natively
// navigate natively
$el_window.on('click', '.dashboard-sidebar-item[data-section]', function (e) {
// A modified click opens the tab's link in a new browser tab, natively.
if ( e.metaKey || e.ctrlKey || e.shiftKey ) return;
@@ -79,7 +79,6 @@ async function UIWindowDisable2FA (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
on_before_exit: async () => {
if ( ! disabled_successfully ) {
promise.resolve(false);
-1
View File
@@ -136,7 +136,6 @@ function UIAlert (options) {
selectable_body: false,
draggable_body: options.draggable_body ?? true,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
body_content: h,
-3
View File
@@ -801,9 +801,6 @@ function UIContextMenu (options) {
$(options.parent_element).css('overflow', 'scroll');
$(options.parent_element).removeClass('has-open-contextmenu');
if ( $(options.parent_element).hasClass('taskbar-item') ) {
window.make_taskbar_sortable();
}
}
});
File diff suppressed because it is too large. Load diff
+1 -75
View File
@@ -30,7 +30,6 @@ import truncate_filename from '../helpers/truncateFilename.js';
import launch_app from '../helpers/launchApp.js';
import open_item from '../helpers/openItem.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
import mime from '../lib/mime.js';
import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js';
import { is_owned_by_me } from '../helpers/pathOwner.js';
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
@@ -237,17 +236,6 @@ async function UIItem (options) {
window.update_explorer_footer_item_count(el_window);
}
// manual positioning
if ( !window.is_auto_arrange_enabled &&
options.position &&
// item is on the desktop (must be desktop itself and not a window, hence the '.desktop' class check)
$(el_item).closest('.item-container.desktop').attr('data-path') === window.desktop_path
) {
el_item.style.position = 'absolute';
el_item.style.left = `${options.position.left }px`;
el_item.style.top = `${options.position.top }px`;
}
// --------------------------------------------------------
// Dragster
// allow dragging of local files on this window, if it's is_dir
@@ -327,7 +315,6 @@ async function UIItem (options) {
last_window_dragged_over = null;
window.an_item_is_being_dragged = true;
$('.toolbar').css('pointer-events', 'none');
},
drag: function (event, ui) {
// Constrain item within desktop bounds
@@ -413,20 +400,6 @@ async function UIItem (options) {
}
},
stop: function (event, ui) {
// Allow rearranging only if item is on desktop, not trash container, auto arrange is disabled and item is not dropped into another item
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path &&
!window.is_auto_arrange_enabled && $(el_item).attr('data-path') !== window.trash_path && !ui.helper.data('dropped') &&
// Item must be dropped on the Desktop and not on the taskbar
window.mouseover_window === undefined && ui.position.top <= window.desktop_height - window.taskbar_height - 15 ) {
el_item.style.position = 'absolute';
el_item.style.left = `${ui.position.left }px`;
el_item.style.top = `${ui.position.top }px`;
$('.ui-draggable-dragging').remove();
window.desktop_item_positions[$(el_item).attr('data-uid')] = ui.position;
window.save_desktop_item_positions();
}
$('.item-selected-clone').remove();
$('.draggable-count-badge').remove();
// re-enable all droppable UIItems that are not a dir
@@ -437,7 +410,6 @@ async function UIItem (options) {
clearTimeout(longer_hover_timeout);
last_window_dragged_over = null;
window.an_item_is_being_dragged = false;
$('.toolbar').css('pointer-events', 'auto');
},
});
@@ -546,10 +518,6 @@ async function UIItem (options) {
}
// Otherwise, move items
else if ( options.is_dir ) {
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
delete window.desktop_item_positions[$(el_item).attr('data-uid')];
window.save_desktop_item_positions();
}
window.move_items(items_to_move, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
}
}
@@ -1342,48 +1310,6 @@ async function UIItem (options) {
});
}
// -------------------------------------------
// Set as Wallpaper
// -------------------------------------------
const mime_type = mime.getType($(el_item).attr('data-name')) ??
'application/octet-stream';
if ( !is_trashed && !is_trash && !options.is_dir && mime_type.startsWith('image/') ) {
menu_items.push({
html: i18n('set_as_background'),
onClick: async function () {
const read_url = await puter.fs.sign(undefined, { uid: $(el_item).attr('data-uid'), action: 'read' });
window.set_desktop_background({
url: read_url.items.read_url,
fit: window.desktop_bg_fit,
});
try {
$.ajax({
url: `${window.api_origin }/set-desktop-bg`,
type: 'POST',
data: JSON.stringify({
url: window.desktop_bg_url,
color: window.desktop_bg_color,
fit: window.desktop_bg_fit,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
$(el_window).close();
resolve(true);
} catch ( err ) {
// Ignore
}
},
});
}
// -------------------------------------------
// Zip
// -------------------------------------------
if ( !is_trash && !is_trashed && !$(el_item).attr('data-path').endsWith('.zip') ) {
@@ -1638,7 +1564,7 @@ async function UIItem (options) {
// Create ContextMenu
UIContextMenu({
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
parent_element: options.appendTo,
items: menu_items,
});
-1
View File
@@ -71,7 +71,6 @@ function UIPrompt (options) {
selectable_body: false,
draggable_body: true,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
body_content: h,
-769
View File
@@ -1,769 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createUploadThumbnailGenerator } from '../services/pdfThumbnails/index.js';
import UITaskbarItem from './UITaskbarItem.js';
import UIPopover from './UIPopover.js';
import launch_app from '../helpers/launchApp.js';
import UIContextMenu from './UIContextMenu.js';
import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js';
import { filterLaunchableApps } from '../helpers/launchAppsList.js';
const TASKBAR_POSITIONS = ['left', 'bottom', 'right'];
async function UITaskbar (options) {
window.global_element_id++;
options = options ?? {};
options.content = options.content ?? '';
let taskbar_position;
// if first visit ever, set taskbar position to left
if ( window.first_visit_ever ) {
puter.kv.set('taskbar_position', 'left').catch(err => console.warn('Could not save taskbar_position:', err));
taskbar_position = 'left';
} else {
taskbar_position = await puter.kv.get('taskbar_position').catch(() => null);
// if this is not first visit, set taskbar position to bottom since it's from a user that
// used puter before customizing taskbar position was added and the taskbar position was set to bottom
// Unknown values are discarded too: this ends up in markup and stored values may be poisoned.
if ( ! TASKBAR_POSITIONS.includes(taskbar_position) ) {
taskbar_position = 'bottom'; // default position
puter.kv.set('taskbar_position', taskbar_position).catch(err => console.warn('Could not save taskbar_position:', err));
}
}
// Force bottom position on mobile devices
if ( isMobile.phone || isMobile.tablet ) {
taskbar_position = 'bottom';
}
// Set global taskbar position
window.taskbar_position = taskbar_position;
let launchAppsRequest = 0;
let pendingLaunchApps;
const fetchLaunchApps = () => {
pendingLaunchApps?.abort();
pendingLaunchApps = $.ajax({
url: `${window.api_origin}/get-launch-apps?icon_size=64`,
type: 'GET',
contentType: 'application/json',
timeout: 30000,
headers: { 'Authorization': `Bearer ${window.auth_token}` },
});
return pendingLaunchApps;
};
const logRefreshFailure = (xhr, status) => {
if ( status !== 'abort' ) console.error('Failed to refresh launch apps:', status);
};
fetchLaunchApps().done(apps => {
if ( launchAppsRequest === 0 ) window.launch_apps = apps;
}).fail(logRefreshFailure);
let h = '';
h += `<div id="ui-taskbar_${window.global_element_id}" class="taskbar taskbar-position-${taskbar_position}" style="height:${window.taskbar_height}px;">`;
h += '<div class="taskbar-sortable" style="display: flex; justify-content: center; z-index: 99999;"></div>';
h += '</div>';
if ( taskbar_position === 'left' || taskbar_position === 'right' ) {
$('.desktop').addClass(`desktop-taskbar-position-${taskbar_position}`);
}
$('.desktop').append(h);
//---------------------------------------------
// add `Start` to taskbar
//---------------------------------------------
UITaskbarItem({
icon: window.icons['start.svg'],
name: i18n('start'),
sortable: false,
keep_in_taskbar: true,
disable_context_menu: true,
onClick: async function (item) {
// skip if popover already open
if ( $(item).hasClass('has-open-popover') )
{
$('.popover-launcher').remove();
return;
}
// show popover
let popover = UIPopover({
content: '<div class="launch-popover hide-scrollbar"></div>',
snapToElement: item,
parent_element: item,
width: 500,
height: 500,
class: 'popover-launcher',
center_horizontally: true,
});
$(popover).on('remove', () => pendingLaunchApps?.abort());
const request = ++launchAppsRequest;
if ( ! window.launch_apps?.recent ) {
window.launch_apps = { recent: [], recommended: [] };
}
$(popover).find('.launch-popover').append(`
<div class="launch-search-wrapper">
<input style="background-image:url('${window.icons['magnifier-outline.svg']}');" class="launch-search">
<img class="launch-search-clear" src="${window.icons['close.svg']}">
</div>
`);
const renderApps = () => {
const recentApps = filterLaunchableApps(window.launch_apps.recent);
const recommendedApps = filterLaunchableApps(window.launch_apps.recommended);
let apps_str = '';
// -------------------------------------------
// Recent apps
// -------------------------------------------
if ( recentApps.length > 0 ) {
// heading
apps_str += `<h1 class="start-section-heading start-section-heading-recent">${i18n('recent')}</h1>`;
// apps
apps_str += '<div class="launch-apps-recent">';
for ( let index = 0; index < window.launch_recent_apps_count && index < recentApps.length; index++ ) {
const app_info = recentApps[index];
apps_str += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
const icon = appIconSrc(app_info);
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
apps_str += '</div>';
apps_str += '</div>';
}
apps_str += '</div>';
}
// -------------------------------------------
// Recommended apps
// -------------------------------------------
if ( recommendedApps.length > 0 ) {
// heading
apps_str += `<h1 class="start-section-heading start-section-heading-recommended" style="${recentApps.length > 0 ? 'padding-top: 30px;' : ''}">${i18n('recommended')}</h1>`;
// apps
apps_str += '<div class="launch-apps-recommended">';
for ( let index = 0; index < recommendedApps.length; index++ ) {
const app_info = recommendedApps[index];
apps_str += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
const icon = appIconSrc(app_info);
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
apps_str += '</div>';
apps_str += '</div>';
}
apps_str += '</div>';
}
// Replace tiles only, so the search input keeps its text and focus.
$(popover).find('.start-section-heading, .launch-apps-recent, .launch-apps-recommended').remove();
$(popover).find('.launch-popover').append(apps_str);
// make apps draggable
$(popover).find('.start-app').draggable({
appendTo: 'body',
revert: 'invalid',
connectToSortable: '.taskbar-sortable',
zIndex: parseInt($(popover).css('z-index')) + 1,
scroll: false,
distance: 5,
revertDuration: 100,
helper: 'clone',
cursorAt: { left: 18, top: 20 },
start: function (event, ui) {
},
drag: function (event, ui) {
},
stop: function () {
},
});
};
renderApps();
if ( ! isMobile.phone ) $(popover).find('.launch-search').focus();
fetchLaunchApps().done(apps => {
if ( request !== launchAppsRequest || !popover.isConnected ) return;
window.launch_apps = apps;
renderApps();
$(popover).find('.launch-search').trigger('change');
}).fail(logRefreshFailure);
$(popover).on('click', function () {
// close other context menus
$('.context-menu').fadeOut(200, function () {
$(this).remove();
$('.launch-app-selected').removeClass('launch-app-selected');
});
});
$(popover).on('contextmenu taphold', function (e) {
if ( ! e.target.closest('.launch-search') ) {
e.preventDefault();
}
});
$(document).on('contextmenu taphold', '.start-app', function (e) {
if ( e.type === 'taphold' && !isMobile.phone && !isMobile.tablet )
{
return;
}
e.stopImmediatePropagation();
e.preventDefault();
// close other context menus
$('.context-menu').fadeOut(200, function () {
$(this).remove();
});
let items = [{
html: i18n('open'),
onClick: function () {
$(e.currentTarget).trigger('click');
},
}];
$('.launch-app-selected').removeClass('launch-app-selected');
$(e.currentTarget).parent().addClass('launch-app-selected');
// Determine pin state
const $existingTaskbarItem = $(`.taskbar-item[data-app="${e.currentTarget.dataset.appName}"]`);
const isPinned = $existingTaskbarItem.length > 0 && $existingTaskbarItem.attr('data-keep-in-taskbar') === 'true';
items.push({
html: i18n('add_to_desktop'),
onClick: async function () {
try {
const fileName = `${e.currentTarget.dataset.appTitle}.app`;
const content = JSON.stringify({
app: e.currentTarget.dataset.appName,
title: e.currentTarget.dataset.appTitle,
icon: e.currentTarget.dataset.appIcon,
});
await puter.fs.upload(new File([content], fileName), window.desktop_path, {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
});
} catch ( err ) {
console.error('Failed to add shortcut to desktop:', err);
// Consider showing a user-facing error notification
}
},
});
if ( ! isPinned ) {
items.push({
html: i18n('keep_in_taskbar'),
onClick: function () {
const $taskbarItem = $(`.taskbar-item[data-app="${e.currentTarget.dataset.appName}"]`);
if ( $taskbarItem.length === 0 ) {
// No taskbar item yet: create a new pinned one
UITaskbarItem({
icon: e.currentTarget.dataset.appIcon,
iconFallback: e.currentTarget.dataset.appIconFallback,
app: e.currentTarget.dataset.appName,
name: e.currentTarget.dataset.appTitle,
keep_in_taskbar: true,
});
} else if ( $taskbarItem.attr('data-keep-in-taskbar') !== 'true' ) {
// mark as pinned
$taskbarItem.attr('data-keep-in-taskbar', 'true');
}
// Persist
window.update_taskbar();
},
});
} else {
items.push({
html: i18n('remove_from_taskbar'),
onClick: function () {
const $taskbarItem = $(`.taskbar-item[data-app="${e.currentTarget.dataset.appName}"]`);
if ( $taskbarItem.length === 0 ) return; // nothing to do
// Unpin
$taskbarItem.attr('data-keep-in-taskbar', 'false');
// If no open windows for this app, remove the item
if ( $taskbarItem.attr('data-open-windows') === '0' ) {
if ( window.remove_taskbar_item ) {
window.remove_taskbar_item($taskbarItem.get(0));
} else {
$taskbarItem.remove();
}
}
window.update_taskbar();
},
});
}
UIContextMenu({
items: items,
});
return false;
});
},
});
//---------------------------------------------
// add `Explorer` to the taskbar
//---------------------------------------------
UITaskbarItem({
icon: window.icons['folders.svg'],
app: 'explorer',
name: 'Explorer',
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
onClick: function () {
let open_window_count = parseInt($('.taskbar-item[data-app="explorer"]').attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({ name: 'explorer', path: window.home_path });
} else {
return false;
}
},
});
//---------------------------------------------
// add separator before trash
//---------------------------------------------
UITaskbarItem({
icon: '', // No icon for separator
name: 'separator',
app: 'separator',
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
disable_context_menu: true,
style: 'pointer-events: none;', // Make it non-interactive
onClick: function () {
// Separator is non-interactive
return false;
},
});
//---------------------------------------------
// Add other useful apps to the taskbar
//---------------------------------------------
if ( window.user.taskbar_items && window.user.taskbar_items.length > 0 ) {
for ( let index = 0; index < window.user.taskbar_items.length; index++ ) {
const app_info = window.user.taskbar_items[index];
const icon = appIconSrc(app_info);
// add taskbar item for each app
UITaskbarItem({
icon: icon.src,
iconFallback: icon.fallback,
app: app_info.name,
name: app_info.title,
keep_in_taskbar: true,
onClick: function () {
let open_window_count = parseInt($(`.taskbar-item[data-app="${app_info.name}"]`).attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({
name: app_info.name,
});
} else {
return false;
}
},
});
}
}
//---------------------------------------------
// add `Trash` to the taskbar
//---------------------------------------------
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
UITaskbarItem({
icon: trash.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'],
app: 'trash',
name: `${i18n('trash')}`,
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
onClick: function () {
let open_windows = $(`.window[data-path="${html_encode(window.trash_path)}"]`);
if ( open_windows.length === 0 ) {
launch_app({ name: 'explorer', path: window.trash_path });
} else {
open_windows.focusWindow();
}
},
onItemsDrop: function (items) {
window.move_items(items, window.trash_path);
},
});
//---------------------------------------------
// add separator before trash
//---------------------------------------------
UITaskbarItem({
icon: '', // No icon for separator
name: 'separator',
app: 'separator',
sortable: false,
keep_in_taskbar: true,
lock_keep_in_taskbar: true,
disable_context_menu: true,
style: 'pointer-events: none;', // Make it non-interactive
onClick: function () {
// Separator is non-interactive
return false;
},
});
window.make_taskbar_sortable();
}
//-------------------------------------------
// Taskbar is sortable
//-------------------------------------------
window.make_taskbar_sortable = function () {
const position = window.taskbar_position || 'bottom';
const axis = position === 'bottom' ? 'x' : 'y';
$('.taskbar-sortable').sortable({
axis: axis,
items: '.taskbar-item-sortable:not(.has-open-contextmenu):not([data-app="separator"])',
cancel: '.has-open-contextmenu',
placeholder: 'taskbar-item-sortable-placeholder',
helper: 'clone',
distance: 5,
revert: 10,
receive: function (event, ui) {
if ( ! $(ui.item).hasClass('taskbar-item') ) {
// if app is already in taskbar, cancel
if ( $(`.taskbar-item[data-app="${$(ui.item).attr('data-app-name')}"]`).length !== 0 ) {
$(this).sortable('cancel');
$('.taskbar .start-app').remove();
return;
}
}
},
update: function (event, ui) {
if ( ! $(ui.item).hasClass('taskbar-item') ) {
// if app is already in taskbar, cancel
if ( $(`.taskbar-item[data-app="${$(ui.item).attr('data-app-name')}"]`).length !== 0 ) {
$(this).sortable('cancel');
$('.taskbar .start-app').remove();
return;
}
let item = UITaskbarItem({
icon: $(ui.item).attr('data-app-icon'),
iconFallback: $(ui.item).attr('data-app-icon-fallback'),
app: $(ui.item).attr('data-app-name'),
name: $(ui.item).attr('data-app-title'),
append_to_taskbar: false,
keep_in_taskbar: true,
onClick: function () {
let open_window_count = parseInt($(`.taskbar-item[data-app="${$(ui.item).attr('data-app-name')}"]`).attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({
name: $(ui.item).attr('data-app-name'),
});
} else {
return false;
}
},
});
let el = ($(item).detach());
$(el).insertAfter(ui.item);
$(el).show();
$(ui.item).removeItems();
window.update_taskbar();
}
// only proceed to update DB if the item sorted was a pinned item otherwise no point in updating the taskbar in DB
else if ( $(ui.item).attr('data-keep-in-taskbar') === 'true' ) {
window.update_taskbar();
}
},
});
};
// Function to update taskbar position
window.update_taskbar_position = async function (new_position) {
// Prevent position changes on mobile devices - always keep bottom
if ( isMobile.phone || isMobile.tablet ) {
return;
}
if ( ! TASKBAR_POSITIONS.includes(new_position) ) {
return;
}
// Store the new position
puter.kv.set('taskbar_position', new_position).catch(err => console.warn('Could not save taskbar_position:', err));
window.taskbar_position = new_position;
// Remove old position classes and add new one
$('.taskbar').removeClass('taskbar-position-left taskbar-position-bottom taskbar-position-right');
$('.taskbar').addClass(`taskbar-position-${new_position}`);
// update desktop class, if left or right, add `desktop-taskbar-position-left` or `desktop-taskbar-position-right`
$('.desktop').removeClass('desktop-taskbar-position-left');
$('.desktop').removeClass('desktop-taskbar-position-right');
$('.desktop').addClass(`desktop-taskbar-position-${new_position}`);
// Update desktop height/width calculations based on new position
window.update_desktop_dimensions_for_taskbar();
// Update window positions if needed (for maximized windows)
$('.window[data-is_maximized="1"]').each(function () {
const el_window = this;
window.update_maximized_window_for_taskbar(el_window);
});
// Re-initialize sortable with correct axis
$('.taskbar-sortable').sortable('destroy');
window.make_taskbar_sortable();
// Adjust taskbar item sizes for the new position
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
// Reinitialize all taskbar item tooltips with new position
$('.taskbar-item').each(function () {
const $item = $(this);
// Destroy existing tooltip
if ( $item.data('ui-tooltip') ) {
$item.tooltip('destroy');
}
// Helper function to get tooltip position based on taskbar position
function getTooltipPosition () {
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
return {
my: 'center bottom-20',
at: 'center top',
};
} else if ( taskbarPosition === 'top' ) {
return {
my: 'center top+20',
at: 'center bottom',
};
} else if ( taskbarPosition === 'left' ) {
return {
my: 'left+20 center',
at: 'right center',
};
} else if ( taskbarPosition === 'right' ) {
return {
my: 'right-20 center',
at: 'left center',
};
}
return {
my: 'center bottom-20',
at: 'center top',
}; // fallback
}
const tooltipPosition = getTooltipPosition();
// Reinitialize tooltip with new position
$item.tooltip({
items: ".taskbar:not(.children-have-open-contextmenu) .taskbar-item:not([data-app='separator'])",
position: {
my: tooltipPosition.my,
at: tooltipPosition.at,
using: function ( position, feedback ) {
$(this).css( position);
$('<div>')
.addClass( 'arrow')
.addClass( feedback.vertical)
.addClass( feedback.horizontal)
.appendTo( this);
},
},
});
});
};
// Function to update desktop dimensions based on taskbar position
window.update_desktop_dimensions_for_taskbar = function () {
const position = window.taskbar_position || 'bottom';
if ( position === 'bottom' ) {
$('.desktop').css({
'height': `calc(100vh - ${window.taskbar_height + window.toolbar_height}px)`,
'width': '100%',
'left': '0',
'top': `${window.toolbar_height}px`,
});
} else if ( position === 'left' ) {
$('.desktop').css({
'height': `calc(100vh - ${window.toolbar_height}px)`,
'width': `calc(100% - ${window.taskbar_height}px)`,
'left': `${window.taskbar_height}px`,
'top': `${window.toolbar_height}px`,
});
} else if ( position === 'right' ) {
$('.desktop').css({
'height': `calc(100vh - ${window.toolbar_height}px)`,
'width': `calc(100% - ${window.taskbar_height}px)`,
'left': '0',
'top': `${window.toolbar_height}px`,
});
}
};
//-------------------------------------------
// Dynamic taskbar item resizing for left/right positions
//-------------------------------------------
window.adjust_taskbar_item_sizes = function () {
const position = window.taskbar_position || 'bottom';
// Only apply to left and right positions
if ( position !== 'left' && position !== 'right' ) {
// Reset to default sizes for bottom position
$('.taskbar .taskbar-item').css({
'width': '40px',
'height': '40px',
'min-width': '40px',
'min-height': '40px',
});
$('.taskbar-icon').css('height', '40px');
return;
}
const taskbar = $('.taskbar')[0];
const taskbarItems = $('.taskbar .taskbar-item:visible');
if ( !taskbar || taskbarItems.length === 0 ) return;
// Get available height (minus padding)
const totalItemsNeeded = taskbarItems.length;
const taskbarHeight = taskbar.clientHeight;
const paddingTop = 20; // from CSS
const paddingBottom = 20; // from CSS
const availableHeight = taskbarHeight - paddingTop - paddingBottom - 180;
// Calculate space needed with default sizes
const defaultItemSize = 40;
const defaultMargin = 5;
const spaceNeededDefault = (totalItemsNeeded * defaultItemSize) + ((totalItemsNeeded - 1) * defaultMargin);
if ( spaceNeededDefault <= availableHeight ) {
// No overflow, use default sizes
taskbarItems.css({
'width': '40px',
'height': '40px',
'min-width': '40px',
'min-height': '40px',
'padding': '6px 5px 10px 5px', // default padding
});
$('.taskbar-icon').css('height', `${defaultItemSize }px`);
$('.taskbar-icon').css('width', '40px');
$('.taskbar-icon > img').css('width', 'auto');
$('.taskbar-icon > img').css('margin', 'auto');
$('.taskbar-icon > img').css('display', 'block');
// Reset margins to default
taskbarItems.css('margin-bottom', '5px');
taskbarItems.last().css('margin-bottom', '0px');
} else {
// Overflow detected, calculate smaller sizes
// Reserve some margin space (minimum 2px between items)
const minMargin = 2;
const marginSpace = (totalItemsNeeded - 1) * minMargin;
const availableForItems = availableHeight - marginSpace;
const newItemSize = Math.floor(availableForItems / totalItemsNeeded);
// Ensure minimum size of 20px
const finalItemSize = Math.max(20, newItemSize);
// Calculate proportional padding based on size ratio
const sizeRatio = finalItemSize / defaultItemSize;
const paddingTop = Math.max(1, Math.floor(6 * sizeRatio));
const paddingRight = Math.max(1, Math.floor(5 * sizeRatio));
const paddingBottom = Math.max(1, Math.floor(10 * sizeRatio));
const paddingLeft = Math.max(1, Math.floor(5 * sizeRatio));
// Apply new sizes and padding
taskbarItems.css({
'width': '40px',
'height': `${finalItemSize }px`,
'min-width': '40px',
'min-height': `${finalItemSize }px`,
'padding': `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px`,
});
$('.taskbar-icon').css('height', `${finalItemSize }px`);
$('.taskbar-icon').css('width', '40px');
$('.taskbar-icon > img').css('width', 'auto');
$('.taskbar-icon > img').css('margin', 'auto');
$('.taskbar-icon > img').css('display', 'block');
// Adjust margins
taskbarItems.css('margin-bottom', `${minMargin }px`);
taskbarItems.last().css('margin-bottom', '0px');
}
};
// Hook into existing taskbar functionality
$(document).ready(function () {
// Watch for taskbar item changes
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if ( mutation.type === 'childList' || mutation.type === 'attributes' ) {
// Delay to ensure DOM updates are complete
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
}
});
});
// Start observing when taskbar is available
const checkTaskbar = setInterval(() => {
const taskbar = document.querySelector('.taskbar-sortable');
if ( taskbar ) {
observer.observe(taskbar, {
childList: true,
attributes: true,
subtree: true,
});
clearInterval(checkTaskbar);
// Initial call
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 100);
}
}, 100);
// Also watch for window resize events
window.addEventListener('resize', () => {
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 10);
});
});
export default UITaskbar;
-181
View File
@@ -1,181 +0,0 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
vi.mock('./UIContextMenu.js', () => ({ default: vi.fn() }));
globalThis.$ = globalThis.jQuery = jQuery;
globalThis.i18n = (key) => (key === 'start' ? 'Start' : key);
globalThis.html_encode = (value) => String(value ?? '');
globalThis.isMobile = { phone: false, tablet: false };
await import('../lib/jquery-ui-1.13.2/jquery-ui.js');
const { default: UITaskbar } = await import('./UITaskbar.js');
const app = { name: 'test-app', uuid: 'test-uid', title: 'Test app' };
let launchApps;
let start;
let requests;
afterEach(() => {
$('.popover-launcher, .taskbar').remove();
$(document).off();
$(window).off();
document.body.innerHTML = '';
delete window.launch_apps;
vi.unstubAllGlobals();
vi.useRealTimers();
vi.restoreAllMocks();
});
beforeEach(async () => {
document.body.innerHTML = '<div class="desktop"></div>';
Object.assign(window, {
global_element_id: 0,
taskbar_height: 50,
toolbar_height: 0,
api_origin: 'http://api.test',
auth_token: 'test-token',
icons: {},
user: {},
launch_recent_apps_count: 10,
});
vi.stubGlobal('puter', {
kv: { get: async () => 'bottom' },
fs: { stat: async () => ({ is_empty: true }) },
});
launchApps = { recent: [app], recommended: [] };
requests = [];
$.ajaxTransport('+*', () => {
let complete;
const request = {
send(headers, done) {
complete = done;
},
abort: vi.fn(),
resolve(data) {
complete(
200,
'OK',
{ text: JSON.stringify(data) },
'Content-Type: application/json',
);
},
};
requests.push(request);
return request;
});
await UITaskbar();
requests[0].resolve(launchApps);
start = $('.taskbar-item[data-name="Start"]');
});
it.each([true, false])(
'keeps cached search usable after a timeout, cached=%s',
async (cached) => {
if (!cached) window.launch_apps = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.useFakeTimers();
start.trigger('click');
await vi.advanceTimersByTimeAsync(30000);
expect(requests.at(-1).abort).toHaveBeenCalledOnce();
expect($('.launch-search')).toHaveLength(1);
expect($('.start-app')).toHaveLength(cached ? 1 : 0);
},
);
function deferRefresh() {
return (data) => requests.at(-1).resolve(data);
}
it('refreshes reopened Start without losing cached tiles or search input', async () => {
const survivor = {
name: 'survivor',
uuid: 'survivor-uid',
title: 'Survivor',
};
launchApps.recent.push(survivor);
start.trigger('click');
requests.at(-1).resolve(launchApps);
start.trigger('click');
const resolve = deferRefresh();
start.trigger('click');
const input = $('.launch-search').get(0);
expect(input).toBeDefined();
expect($('.start-app[data-app-name="test-app"]')).toHaveLength(1);
input.value = 'notes';
const onChange = vi.fn();
$(input).on('change', onChange);
resolve({
recent: [survivor],
recommended: [{ name: 'notes', uuid: 'notes-uid', title: 'Notes' }],
});
await vi.waitFor(() =>
expect($('.start-app[data-app-name="notes"]')).toHaveLength(1),
);
expect($('.launch-search').get(0)).toBe(input);
expect(input.value).toBe('notes');
expect(document.activeElement).toBe(input);
expect(onChange).toHaveBeenCalledOnce();
expect($('.start-app[data-app-name="survivor"]')).toHaveLength(1);
expect($('.start-app[data-app-name="test-app"]')).toHaveLength(0);
});
it('cancels closed menus and ignores their late responses', () => {
start.trigger('click');
const old = requests.at(-1);
start.trigger('click');
expect(old.abort).toHaveBeenCalledOnce();
start.trigger('click');
requests.at(-1).resolve({ recent: [], recommended: [] });
old.resolve({ recent: [app], recommended: [] });
expect(window.launch_apps.recent).toHaveLength(0);
expect($('.start-app')).toHaveLength(0);
});
it('cancels the startup request when Start opens', async () => {
requests = [];
document.body.innerHTML = '<div class="desktop"></div>';
await UITaskbar();
const startup = requests[0];
$('.taskbar-item[data-name="Start"]').trigger('click');
expect(startup.abort).toHaveBeenCalledOnce();
});
it('hides apps the backend marks unlaunchable', () => {
start.trigger('click');
requests.at(-1).resolve({
recent: [
app,
{
name: 'reclaimed',
uuid: 'reclaimed-uid',
title: 'Reclaimed',
index_url: null,
privateAccess: { hasAccess: false },
},
],
recommended: [],
});
expect($('.start-app[data-app-name="test-app"]')).toHaveLength(1);
expect($('.start-app[data-app-name="reclaimed"]')).toHaveLength(0);
});
-543
View File
@@ -1,543 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIContextMenu from './UIContextMenu.js';
import path from '../lib/path.js';
import launch_app from '../helpers/launchApp.js';
import { user_facing_windows } from '../helpers/windowVisibility.js';
import { appIconFallbackAttr } from '../helpers/appIcon.js';
let tray_item_id = 1;
function UITaskbarItem (options) {
let h = '';
tray_item_id++;
options.sortable = options.sortable ?? true;
options.open_windows_count = options.open_windows_count ?? 0;
options.lock_keep_in_taskbar = options.lock_keep_in_taskbar ?? false;
options.append_to_taskbar = options.append_to_taskbar ?? true;
options.before_trash = options.before_trash ?? false;
// The windows this item stands for: the user's own. An instance another
// app launched in the background is not counted in data-open-windows and
// has no item of its own (see UIWindow's taskbar block) — it must not be
// shown, hidden, or closed through the item its app's OTHER windows own.
const item_windows = () => $(user_facing_windows($(`.window[data-app="${options.app}"]`)));
const element_id = window.global_element_id++;
h += `<div class = "taskbar-item ${options.sortable ? 'taskbar-item-sortable' : ''} disable-user-select"
id = "taskbar-item-${tray_item_id}"
data-taskbar-item-id = "${tray_item_id}"
data-element-id = "${html_encode(element_id)}"
data-name = "${html_encode(options.name)}"
data-app = "${html_encode(options.app)}"
data-keep-in-taskbar = "${html_encode(options.keep_in_taskbar ?? 'false')}"
data-open-windows="${(options.open_windows_count)}"
title = "${html_encode(options.name)}"
style= "${options.style ? html_encode(options.style) : ''}"
>`;
let icon = options.icon ? options.icon : window.icons['app.svg'];
// App icons carry a retry URL (see helpers/appIcon.js); the bundled icons
// below never need one.
let icon_fallback = options.iconFallback ?? '';
if ( options.app === 'explorer' )
{
icon = window.icons['folders.svg'];
icon_fallback = '';
}
// taskbar icon
h += '<div class="taskbar-icon">';
// Don't add img tag for separator
if ( options.app !== 'separator' ) {
h += `<img src="${html_encode(icon)}"${appIconFallbackAttr(icon_fallback)} style="${options.group === 'apps' ? 'filter:none;' : ''}">`;
}
h += '</div>';
// active indicator
if ( options.app !== 'apps' )
{
h += '<span class="active-taskbar-indicator"></span>';
}
h += '</div>';
if ( options.append_to_taskbar ) {
if ( options.before_trash ) {
$('.taskbar-sortable').append(h);
} else {
if ( options.sortable )
{
$('.taskbar-sortable').append(h);
}
else {
// if taskbar-sortable is empty then append before it
if ( $('.taskbar-sortable').children().length === 0 )
{
$('.taskbar').find('.taskbar-sortable').before(h);
}
else
{
$('.taskbar').find('.taskbar-sortable').after(h);
}
}
}
} else {
$('body').prepend(h);
}
const el_taskbar_item = document.querySelector(`#taskbar-item-${tray_item_id}`);
// fade in the taskbar item
$(el_taskbar_item).show(50);
// Adjust taskbar item sizes after adding new item
if ( window.adjust_taskbar_item_sizes ) {
setTimeout(() => {
window.adjust_taskbar_item_sizes();
}, 100);
}
$(el_taskbar_item).on('click', function (e) {
e.preventDefault();
e.stopPropagation();
// Don't handle clicks for separators
if ( options.app === 'separator' ) {
return;
}
// if this is for the launcher popover, and it's mobile, and has-open-popover, close the popover
if ( $(el_taskbar_item).attr('data-name') === 'Start'
&& (isMobile.phone || isMobile.tablet) && $(el_taskbar_item).hasClass('has-open-popover') ) {
$('.popover').remove();
return;
}
// If this item has an open context menu, don't do anything
if ( $(el_taskbar_item).hasClass('has-open-contextmenu') )
{
return;
}
if ( options.onClick === undefined || options.onClick(el_taskbar_item) === false ) {
// re-show each window in this app group
item_windows().showWindow();
}
});
$(el_taskbar_item).on('contextmenu taphold', function (e) {
// seems like the only way to stop sortable is to destroy it
if ( options.sortable ) {
$('.taskbar-sortable').sortable('destroy');
}
e.preventDefault();
e.stopPropagation();
// Don't show context menu for separators
if ( options.app === 'separator' ) {
return;
}
// If context menu is disabled on this item, return
if ( options.disable_context_menu )
{
return;
}
// don't allow context menu to open if it's already open
if ( $(el_taskbar_item).hasClass('has-open-contextmenu') )
{
return;
}
const menu_items = [];
const open_windows = parseInt($(el_taskbar_item).attr('data-open-windows'));
// -------------------------------------------
// List of open windows belonging to this app
// -------------------------------------------
item_windows().each(function () {
menu_items.push({
html: $(this).find('.window-head-title').html(),
val: $(this).attr('data-id'),
onClick: function (e) {
$(`.window[data-id="${e.value}"]`).showWindow();
},
});
});
// -------------------------------------------
// divider
// -------------------------------------------
if ( menu_items.length > 0 )
{
menu_items.push('-');
}
//------------------------------------------
// New Window
//------------------------------------------
if ( options.app && options.app !== 'trash' ) {
menu_items.push({
html: i18n('new_window'),
val: $(this).attr('data-id'),
onClick: function () {
// is trash?
launch_app({
name: options.app,
maximized: (isMobile.phone || isMobile.tablet),
});
},
});
}
//------------------------------------------
// Open Trash
//------------------------------------------
else if ( options.app && options.app === 'trash' ) {
menu_items.push({
html: i18n('open_trash'),
val: $(this).attr('data-id'),
onClick: function () {
launch_app({
name: options.app,
path: window.trash_path,
maximized: (isMobile.phone || isMobile.tablet),
});
},
});
}
//------------------------------------------
// Empty Trash
//------------------------------------------
if ( options.app && options.app === 'trash' ) {
// divider
menu_items.push('-');
// Empty Trash menu item
menu_items.push({
html: i18n('empty_trash'),
val: $(this).attr('data-id'),
onClick: async function () {
window.empty_trash();
},
});
}
//------------------------------------------
// Remove from Taskbar
//------------------------------------------
if ( options.keep_in_taskbar && !options.lock_keep_in_taskbar ) {
menu_items.push({
html: i18n('remove_from_taskbar'),
val: $(this).attr('data-id'),
onClick: function () {
$(el_taskbar_item).attr('data-keep-in-taskbar', 'false');
if ( $(el_taskbar_item).attr('data-open-windows') === '0' ) {
window.remove_taskbar_item(el_taskbar_item);
}
window.update_taskbar();
options.keep_in_taskbar = false;
},
});
}
//------------------------------------------
// Keep in Taskbar
//------------------------------------------
else if ( ! options.keep_in_taskbar ) {
menu_items.push({
html: i18n('keep_in_taskbar'),
val: $(this).attr('data-id'),
onClick: function () {
$(el_taskbar_item).attr('data-keep-in-taskbar', 'true');
window.update_taskbar();
options.keep_in_taskbar = true;
},
});
}
if ( open_windows > 0 ) {
// -------------------------------------------
// divider
// -------------------------------------------
menu_items.push('-');
// -------------------------------------------
// Show All Windows
// -------------------------------------------
menu_items.push({
html: i18n('show_all_windows'),
onClick: function () {
item_windows().showWindow();
},
});
// -------------------------------------------
// Hide All Windows
// -------------------------------------------
menu_items.push({
html: i18n('hide_all_windows'),
onClick: function () {
if ( open_windows > 0 )
{
item_windows().hideWindow();
}
},
});
// -------------------------------------------
// Close All Windows
// -------------------------------------------
menu_items.push({
html: i18n('close_all_windows'),
onClick: function () {
// "All" means the windows this item lists — a background
// helper belongs to the app running it, and goes when
// that app does (UIWindow's close path).
item_windows().close();
},
});
}
const pos = el_taskbar_item.getBoundingClientRect();
UIContextMenu({
parent_element: el_taskbar_item,
position: getContextMenuPosition(pos),
items: menu_items,
});
return false;
});
// Helper function to get tooltip position based on taskbar position
function getTooltipPosition () {
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
return {
my: 'center bottom-20',
at: 'center top',
};
} else if ( taskbarPosition === 'top' ) {
return {
my: 'center top+20',
at: 'center bottom',
};
} else if ( taskbarPosition === 'left' ) {
return {
my: 'left+20 center',
at: 'right center',
};
} else if ( taskbarPosition === 'right' ) {
return {
my: 'right-20 center',
at: 'left center',
};
}
return {
my: 'center bottom-20',
at: 'center top',
}; // fallback
}
// Helper function to get context menu position based on taskbar position
function getContextMenuPosition (pos) {
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
return {
top: pos.top - 15,
left: pos.left + 5,
};
} else if ( taskbarPosition === 'top' ) {
return {
top: pos.bottom + 15,
left: pos.left + 5,
};
} else if ( taskbarPosition === 'left' ) {
return {
top: pos.top + 5,
left: pos.right + 5,
};
} else if ( taskbarPosition === 'right' ) {
return {
top: pos.top + 5,
left: pos.left - 20,
};
}
return {
top: pos.top - 15,
left: pos.left + 5,
}; // fallback
}
const tooltipPosition = getTooltipPosition();
$(el_taskbar_item).tooltip({
// only show tooltip if desktop is not selectable active
items: ".desktop:not(.desktop-selectable-active) .taskbar:not(.children-have-open-contextmenu) .taskbar-item:not([data-app='separator'])",
position: {
my: tooltipPosition.my,
at: tooltipPosition.at,
using: function ( position, feedback ) {
$(this).css( position);
$('<div>')
.addClass( 'arrow')
.addClass( feedback.vertical)
.addClass( feedback.horizontal)
.appendTo( this);
},
},
});
// --------------------------------------------------------
// Droppable
// --------------------------------------------------------
// Don't make separators droppable
if ( options.app !== 'separator' ) {
$(el_taskbar_item).droppable({
accept: '.item',
// 'pointer' is very important because of active window tracking is based on the position of cursor.
tolerance: 'pointer',
drop: async function ( event, ui ) {
// Check if hovering over an item that is VISIBILE
if ( $(event.target).closest('.window').attr('data-id') !== $(window.mouseover_window).attr('data-id') )
{
return;
}
// If ctrl is pressed and source is Trashed, cancel whole operation
if ( event.ctrlKey && path.dirname($(ui.draggable).attr('data-path')) === window.trash_path )
{
return;
}
const items_to_move = [];
// First item
items_to_move.push(ui.draggable);
// All subsequent items
const cloned_items = document.getElementsByClassName('item-selected-clone');
for ( let i = 0; i < cloned_items.length; i++ ) {
const source_item = document.getElementById(`item-${ $(cloned_items[i]).attr('data-id')}`);
if ( source_item !== null )
{
items_to_move.push(source_item);
}
}
// --------------------------------------------------------
// If `options.onItemsDrop` is set, call it with the items to move
//--------------------------------------------------------
if ( options.onItemsDrop && typeof options.onItemsDrop === 'function' ) {
options.onItemsDrop(items_to_move);
return;
}
// --------------------------------------------------------
// If dropped on an app, open the app with the dropped item as an argument
//--------------------------------------------------------
else if ( options.app ) {
// an array that hold the items to sign
const items_to_sign = [];
// prepare items to sign
for ( let i = 0; i < items_to_move.length; i++ ) {
items_to_sign.push({
name: $(items_to_move[i]).attr('data-name'),
uid: $(items_to_move[i]).attr('data-uid'),
action: 'write',
path: $(items_to_move[i]).attr('data-path'),
});
}
// open each item
for ( let i = 0; i < items_to_sign.length; i++ ) {
const item = items_to_sign[i];
launch_app({
name: options.app,
file_path: item.path,
// app_obj: open_item_meta.suggested_apps[0],
window_title: item.name,
file_uid: item.uid,
// file_signature: item,
});
}
// deselect dragged item
for ( let i = 0; i < items_to_move.length; i++ )
{
$(items_to_move[i]).removeClass('item-selected');
}
}
// Unselect directory/app if item is dropped
if ( options.is_dir || options.app ) {
$(el_taskbar_item).removeClass('active');
$(el_taskbar_item).tooltip('close');
$('.ui-draggable-dragging .item-name, .item-selected-clone .item-name').css('opacity', 'initial');
$('.item-container').removeClass('item-container-transparent-border');
}
// Re-enable droppable on all item-container
$('.item-container').droppable('enable');
return false;
},
over: function (event, ui) {
// Check hovering over an item that is VISIBILE
const $event_parent_win = $(event.target).closest('.window');
if ( $event_parent_win.length > 0 && $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
{
return;
}
// Don't do anything if the dragged item is NOT a UIItem
if ( ! $(ui.draggable).hasClass('item') )
{
return;
}
// If this is a directory or an app, and an item was dragged over it, highlight it.
if ( options.is_dir || options.app ) {
$(el_taskbar_item).addClass('active');
// show tooltip of this item
$(el_taskbar_item).tooltip().mouseover();
// make item name partially transparent
$('.ui-draggable-dragging .item-name, .item-selected-clone .item-name').css('opacity', 0.1);
// remove all item-container active borders
$('.item-container').addClass('item-container-transparent-border');
}
// Disable all window bodies
$('.item-container').droppable( 'disable');
},
out: function (event, ui) {
// Don't do anything if the dragged item is NOT a UIItem
if ( ! $(ui.draggable).hasClass('item') )
{
return;
}
// Unselect directory/app if item is dragged out
if ( options.is_dir || options.app ) {
$(el_taskbar_item).removeClass('active');
$(el_taskbar_item).tooltip('close');
$('.ui-draggable-dragging .item-name, .item-selected-clone .item-name').css('opacity', 'initial');
$('.item-container').removeClass('item-container-transparent-border');
}
$('.item-container').droppable( 'enable');
},
});
}
return el_taskbar_item;
}
export default UITaskbarItem;
+177 -323
View File
@@ -20,7 +20,6 @@
import UIAlert from './UIAlert.js';
import UIContextMenu from './UIContextMenu.js';
import path from '../lib/path.js';
import UITaskbarItem from './UITaskbarItem.js';
import UIWindowLogin from './UIWindowLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
@@ -117,7 +116,6 @@ async function UIWindow (options) {
options.on_close = options.on_close ?? undefined;
options.parent_uuid = options.parent_uuid ?? null;
options.selectable_body = (options.selectable_body === undefined || options.selectable_body === true) ? true : false;
options.show_in_taskbar = options.show_in_taskbar ?? true;
options.show_maximize_button = options.show_maximize_button ?? true;
options.single_instance = options.single_instance ?? false;
options.sort_by = options.sort_by ?? 'name';
@@ -526,17 +524,7 @@ async function UIWindow (options) {
// backdrop
if ( options.backdrop ) {
let backdrop_zindex;
// backdrop should also cover over taskbar
let taskbar_zindex = $('.taskbar').css('z-index');
if ( taskbar_zindex === null || taskbar_zindex === undefined )
{
backdrop_zindex = zindex;
}
else {
taskbar_zindex = parseInt(taskbar_zindex);
backdrop_zindex = taskbar_zindex > zindex ? taskbar_zindex : zindex;
}
let backdrop_zindex = zindex;
// dominant backdrop will cover over toolbar as well
if ( options.backdrop_covers_toolbar )
@@ -586,56 +574,6 @@ async function UIWindow (options) {
const el_directorypicker_select_btn = document.querySelector(`#window-${win_id} .directorypicker-select-btn`);
const el_window_filedialog_upload_here = document.querySelector(`#window-${win_id} .window-filedialog-upload-here`);
// Add Taskbar Item
// data-in_taskbar records that this window is one of the item's
// data-open-windows, so the close path only decrements a count this
// window actually added to (see the close handler) — the two must stay
// in step, and a window that never took an item must not take one away.
const add_taskbar_item = () => {
$(el_window).attr('data-in_taskbar', '1');
// add icon if there is no similar app already open
if ( $(`.taskbar-item[data-app="${options.app}"]`).length === 0 ) {
UITaskbarItem({
icon: options.icon,
name: options.title,
app: options.app,
open_windows_count: 1,
before_trash: true,
onClick: function () {
let open_window_count = parseInt($(`.taskbar-item[data-app="${options.app}"]`).attr('data-open-windows'));
if ( open_window_count === 0 ) {
launch_app({
name: options.app,
});
} else {
return false;
}
},
});
if ( options.app )
{
$(`.taskbar-item[data-app="${options.app}"] .active-taskbar-indicator`).show();
}
} else {
if ( options.app ) {
$(`.taskbar-item[data-app="${options.app}"]`).attr('data-open-windows', parseInt($(`.taskbar-item[data-app="${options.app}"]`).attr('data-open-windows')) + 1);
$(`.taskbar-item[data-app="${options.app}"] .active-taskbar-indicator`).show();
}
}
};
if ( !options.is_openFileDialog && !options.is_saveFileDialog && !options.is_directoryPicker && options.show_in_taskbar ) {
// A window started hidden by a background launch gets no taskbar item
// yet: it runs for the app that launched it, and an item would both
// advertise and hand out an instance the user never asked for. The
// item is what the window earns the moment it first becomes visible
// — makeWindowVisible calls this, whether the app showed itself with
// puter.ui.showWindow() or something else revealed it. An app that is
// ALWAYS windowless never reaches here: launch_app leaves
// show_in_taskbar false for it, so it keeps having no item at all.
if ( options.launched_hidden ) el_window._add_taskbar_item = add_taskbar_item;
else add_taskbar_item();
}
if ( el_window_filedialog_upload_here ) {
el_window_filedialog_upload_here.addEventListener('click', function () {
window.init_upload_using_dialog(el_window_body, `${$(el_window).attr('data-path') }/`);
@@ -1923,7 +1861,6 @@ async function UIWindow (options) {
snap_trigger_timeout = undefined;
}
hideSnapPlaceholder();
$('.toolbar').css('pointer-events', 'none');
// if window is snapped, unsnap it and reset its position to where it was before snapping
if ( options.is_resizable && window_is_snapped ) {
window_is_snapped = false;
@@ -2018,7 +1955,6 @@ async function UIWindow (options) {
$(el_window_app_iframe).css('pointer-events', 'all');
$('.window').css('pointer-events', 'initial');
$('.toolbar').css('pointer-events', 'auto');
if ( options.is_resizable && snap_placeholder_active && !window_is_snapped ) {
window_will_snap = true;
@@ -2859,7 +2795,7 @@ async function UIWindow (options) {
if ( options.is_fullpage ) {
$(el_window).hide();
setTimeout(function () {
window.enter_fullpage_mode(el_window);
enter_fullpage_mode(el_window);
$(el_window).show();
}, 50);
}
@@ -3651,32 +3587,8 @@ $.fn.close = async function (options) {
let window_uuid = $(this).attr('data-element_uuid');
// remove all instances of win_id from window.window_stack
window.window_stack = window.window_stack.filter(id => id !== win_id);
// taskbar update — only for a window that IS one of the item's
// open windows (data-in_taskbar, stamped when it took its place
// in the count). A window that never counted must not decrement:
// an unseen background instance closing alongside the user's own
// window would otherwise zero the count and take the item away
// while that window is still open.
if ( $(this).attr('data-in_taskbar') === '1' ) {
let open_window_count = parseInt($(`.taskbar-item[data-app="${$(this).attr('data-app')}"]`).attr('data-open-windows'));
// update open window count of corresponding taskbar item
if ( open_window_count > 0 ) {
$(`.taskbar-item[data-app="${$(this).attr('data-app')}"]`).attr('data-open-windows', open_window_count - 1);
}
// decide whether to remove taskbar item
if ( open_window_count === 1 ) {
$(`.taskbar-item[data-app="${$(this).attr('data-app')}"] .active-taskbar-indicator`).hide();
window.remove_taskbar_item($(`.taskbar-item[data-app="${$(this).attr('data-app')}"][data-keep-in-taskbar="false"]`));
}
// if no more windows of this app are open, remove taskbar item
if ( open_window_count - 1 === 0 )
{
$(`.taskbar-item[data-app="${$(this).attr('data-app')}"] .active-taskbar-indicator`).hide();
}
}
// if a fullpage window is closed, show desktop and taskbar
if ( $(this).attr('data-is_fullpage') === '1' ) {
window.exit_fullpage_mode();
exit_fullpage_mode();
}
// FileDialog closed
@@ -3797,9 +3709,6 @@ $.fn.close = async function (options) {
document.activeElement.blur();
$('input').blur();
}
// focus back to desktop
$('.desktop').find('.item-blurred').removeClass('item-blurred');
window.active_item_container = $('.desktop.item-container').get(0);
}
});
@@ -3824,11 +3733,6 @@ window.scale_window = (el_window) => {
// Use taskbar position-aware window positioning
window.update_maximized_window_for_taskbar(el_window);
// hide toolbar
if ( !isMobile.phone && !isMobile.tablet ) {
window.hide_toolbar();
}
}
//shrink
else {
@@ -3953,15 +3857,11 @@ $.fn.makeWindowVisible = function (options) {
// Seen by the user, so no longer a window that exists purely to
// serve whoever launched it: it outlives its launcher from here on
// (see the close path's cleanup of background children), and it
// becomes a window the user can act on — hence the taskbar item
// its launch deferred, and the dashboard tile's running dot.
// Both happen BEFORE the window shows: focusWindow() below marks
// the app's taskbar item active, which needs the item to exist.
// becomes a window the user can act on — hence the dashboard
// tile's running dot.
const was_launched_hidden = $(this).attr('data-launched_hidden') === '1';
$(this).removeAttr('data-launched_hidden');
if ( was_launched_hidden ) {
this._add_taskbar_item?.();
delete this._add_taskbar_item;
if ( window.is_dashboard_mode && $(this).attr('data-app') ) {
document.dispatchEvent(new CustomEvent('dashboard-app-windows-changed'));
}
@@ -4233,8 +4133,7 @@ $.fn.focusWindow = function (event) {
// remove blurred class from items on this window
$(window.active_item_container).find('.item-blurred').removeClass('item-blurred');
// Update the tab title to the focused window. The URL itself is
// deliberately NOT touched here: on the desktop it stays /desktop
// (apps don't rewrite it), and in dashboard mode the open app owns
// deliberately NOT touched here: in dashboard mode the open app owns
// it through real history entries (push_dashboard_app_url).
if ( ! window.is_dashboard_mode ) {
const update_window_url = $(this).attr('data-update_window_url');
@@ -4242,10 +4141,8 @@ $.fn.focusWindow = function (event) {
document.title = $(this).attr('data-name');
}
}
$(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).addClass('taskbar-item-active');
} else {
$('.window').find('.item-selected').addClass('item-blurred');
$('.desktop').find('.item-blurred').removeClass('item-blurred');
}
return this;
@@ -5224,230 +5121,167 @@ function morph_window_from_tile (el_window, tile, morph_options = {}) {
$.fn.hideWindow = async function (options) {
$(this).each(async function () {
if ( $(this).hasClass('window') ) {
// get taskbar item location
let taskbar_item_pos = $(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).position();
// If the app's tile is visible on the Apps tab's current page,
// shrink the window into it so the user sees where the app went;
// otherwise simply hide the window in place. Either way the
// geometry is untouched afterwards and showWindow() un-hides via
// the data-minimized_in_place flag.
const el_window = this;
const $win = $(this);
const reduce_motion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// (A morph already in flight — either direction — owns the
// window's inline transform/opacity and will restore them at
// its end, so don't start another on top; fall back to the
// plain fade below, exactly as before the morphs existed.)
const tile = (reduce_motion
|| $win.attr('data-window_morphing') === '1')
? null
: dashboard_tile_in_view($win.attr('data-app'));
// No taskbar item to animate toward (e.g. dashboard mode, which
// has no taskbar): if the app's tile is visible on the Apps tab's
// current page, shrink the window into it so the user sees where
// the app went; otherwise simply hide the window in place. Either
// way the geometry is untouched afterwards and showWindow()
// un-hides via the data-minimized_in_place flag.
if ( ! taskbar_item_pos ) {
const el_window = this;
const $win = $(this);
const reduce_motion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// (A morph already in flight — either direction — owns the
// window's inline transform/opacity and will restore them at
// its end, so don't start another on top; fall back to the
// plain fade below, exactly as before the morphs existed.)
const tile = (reduce_motion
|| $win.attr('data-window_morphing') === '1')
? null
: dashboard_tile_in_view($win.attr('data-app'));
if ( tile ) {
// iOS-style zoom-to-icon morph, two congruent layers on
// the same 450ms decelerating path so it reads as ONE
// object transforming:
// 1. the LIVE window scales onto the tile's icon box,
// its corner radius growing toward the squircle,
// fading OUT once it has mostly shrunk;
// 2. an enlarged clone of the icon ("ghost") starts
// covering the window and flies/shrinks with it,
// fading IN as the window fades out, landing exactly
// on the real icon's slot (the real icon is hidden
// until the ghost lands, so nothing doubles).
// Transform + opacity animate on the compositor (no
// per-frame reflow of the app's iframe); the window's
// geometry (top/left/width/height) is never touched, so
// restore is trivial.
const icon = tile.querySelector('.myapps-tile-icon') || tile;
const win_rect = el_window.getBoundingClientRect();
const icon_rect = icon.getBoundingClientRect();
if ( win_rect.width > 0 && win_rect.height > 0
&& icon_rect.width > 0 && icon_rect.height > 0 ) {
// Everything touched is restored from these exact
// inline values once the animation ends.
const orig_style = {
transform: el_window.style.transform,
transformOrigin: el_window.style.transformOrigin,
opacity: el_window.style.opacity,
borderRadius: el_window.style.borderRadius,
overflow: el_window.style.overflow,
willChange: el_window.style.willChange,
pointerEvents: el_window.style.pointerEvents,
transition: el_window.style.transition,
};
const icon_orig_visibility = icon.style.visibility;
// Map the window rect exactly onto the icon rect (and
// the ghost the exact inverse: icon rect onto window
// rect), so the two layers stay congruent throughout.
const scale_x = icon_rect.width / win_rect.width;
const scale_y = icon_rect.height / win_rect.height;
const dx = icon_rect.left - win_rect.left;
const dy = icon_rect.top - win_rect.top;
if ( tile ) {
// iOS-style zoom-to-icon morph, two congruent layers on
// the same 450ms decelerating path so it reads as ONE
// object transforming:
// 1. the LIVE window scales onto the tile's icon box,
// its corner radius growing toward the squircle,
// fading OUT once it has mostly shrunk;
// 2. an enlarged clone of the icon ("ghost") starts
// covering the window and flies/shrinks with it,
// fading IN as the window fades out, landing exactly
// on the real icon's slot (the real icon is hidden
// until the ghost lands, so nothing doubles).
// Transform + opacity animate on the compositor (no
// per-frame reflow of the app's iframe); the window's
// geometry (top/left/width/height) is never touched, so
// restore is trivial.
const icon = tile.querySelector('.myapps-tile-icon') || tile;
const win_rect = el_window.getBoundingClientRect();
const icon_rect = icon.getBoundingClientRect();
if ( win_rect.width > 0 && win_rect.height > 0
&& icon_rect.width > 0 && icon_rect.height > 0 ) {
// Everything touched is restored from these exact
// inline values once the animation ends.
const orig_style = {
transform: el_window.style.transform,
transformOrigin: el_window.style.transformOrigin,
opacity: el_window.style.opacity,
borderRadius: el_window.style.borderRadius,
overflow: el_window.style.overflow,
willChange: el_window.style.willChange,
pointerEvents: el_window.style.pointerEvents,
transition: el_window.style.transition,
};
const icon_orig_visibility = icon.style.visibility;
// Map the window rect exactly onto the icon rect (and
// the ghost the exact inverse: icon rect onto window
// rect), so the two layers stay congruent throughout.
const scale_x = icon_rect.width / win_rect.width;
const scale_y = icon_rect.height / win_rect.height;
const dx = icon_rect.left - win_rect.left;
const dy = icon_rect.top - win_rect.top;
const ghost = icon.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${icon_rect.left}px`;
ghost.style.top = `${icon_rect.top}px`;
ghost.style.width = `${icon_rect.width}px`;
ghost.style.height = `${icon_rect.height}px`;
ghost.style.margin = '0';
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 1;
ghost.style.pointerEvents = 'none';
ghost.style.transformOrigin = 'top left';
ghost.style.willChange = 'transform, opacity';
ghost.style.opacity = '0';
// UNIFORM start scale so the icon stays square (a
// rect-onto-rect mapping stretches it), sized to fit
// INSIDE the window (contain, centered) — a covering
// square would protrude past the card's short edge
// and the protruding halo pops in mid-crossfade
// (visible jitter). Contained, the ghost's side
// tracks the card's short edge exactly for the whole
// flight (both interpolate linearly between the same
// start and end sizes), so the card's rectangular
// flanks simply melt away around a steady square.
const ghost_scale = Math.min(
win_rect.width / icon_rect.width,
win_rect.height / icon_rect.height,
);
const ghost_left = win_rect.left + (win_rect.width - icon_rect.width * ghost_scale) / 2;
const ghost_top = win_rect.top + (win_rect.height - icon_rect.height * ghost_scale) / 2;
ghost.style.transform = `translate(${ghost_left - icon_rect.left}px, ${ghost_top - icon_rect.top}px) scale(${ghost_scale})`;
document.body.appendChild(ghost);
icon.style.visibility = 'hidden';
const ghost = icon.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${icon_rect.left}px`;
ghost.style.top = `${icon_rect.top}px`;
ghost.style.width = `${icon_rect.width}px`;
ghost.style.height = `${icon_rect.height}px`;
ghost.style.margin = '0';
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 1;
ghost.style.pointerEvents = 'none';
ghost.style.transformOrigin = 'top left';
ghost.style.willChange = 'transform, opacity';
ghost.style.opacity = '0';
// UNIFORM start scale so the icon stays square (a
// rect-onto-rect mapping stretches it), sized to fit
// INSIDE the window (contain, centered) — a covering
// square would protrude past the card's short edge
// and the protruding halo pops in mid-crossfade
// (visible jitter). Contained, the ghost's side
// tracks the card's short edge exactly for the whole
// flight (both interpolate linearly between the same
// start and end sizes), so the card's rectangular
// flanks simply melt away around a steady square.
const ghost_scale = Math.min(
win_rect.width / icon_rect.width,
win_rect.height / icon_rect.height,
);
const ghost_left = win_rect.left + (win_rect.width - icon_rect.width * ghost_scale) / 2;
const ghost_top = win_rect.top + (win_rect.height - icon_rect.height * ghost_scale) / 2;
ghost.style.transform = `translate(${ghost_left - icon_rect.left}px, ${ghost_top - icon_rect.top}px) scale(${ghost_scale})`;
document.body.appendChild(ghost);
icon.style.visibility = 'hidden';
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
'data-window_morphing': '1',
});
el_window.style.transformOrigin = 'top left';
el_window.style.overflow = 'hidden'; // let the radius clip the content
el_window.style.willChange = 'transform, opacity';
el_window.style.pointerEvents = 'none';
// Flush the starting state (both layers) so the
// transitions animate from it rather than snapping.
void el_window.offsetWidth;
// The handoff: by ~130ms the decelerating curve has
// done ~80% of the shrink — the ghost fades in just
// before (80→240ms) and the window fades out just
// after (120→320ms). The overlap means there is never
// a hole, and since both ride the same path the swap
// is invisible: the window "becomes" the icon.
el_window.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.2s ease-out 0.12s',
].join(', ');
el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`;
// Percentage radius stays proportional under the
// scale — ~22% is the icon-squircle look.
el_window.style.borderRadius = '22%';
el_window.style.opacity = '0';
ghost.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.16s ease-in 0.08s',
].join(', ');
ghost.style.transform = 'none';
ghost.style.opacity = '1';
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
'data-window_morphing': '1',
});
el_window.style.transformOrigin = 'top left';
el_window.style.overflow = 'hidden'; // let the radius clip the content
el_window.style.willChange = 'transform, opacity';
el_window.style.pointerEvents = 'none';
// Flush the starting state (both layers) so the
// transitions animate from it rather than snapping.
void el_window.offsetWidth;
// The handoff: by ~130ms the decelerating curve has
// done ~80% of the shrink — the ghost fades in just
// before (80→240ms) and the window fades out just
// after (120→320ms). The overlap means there is never
// a hole, and since both ride the same path the swap
// is invisible: the window "becomes" the icon.
el_window.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.2s ease-out 0.12s',
].join(', ');
el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`;
// Percentage radius stays proportional under the
// scale — ~22% is the icon-squircle look.
el_window.style.borderRadius = '22%';
el_window.style.opacity = '0';
ghost.style.transition = [
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
'opacity 0.16s ease-in 0.08s',
].join(', ');
ghost.style.transform = 'none';
ghost.style.opacity = '1';
setTimeout(() => {
// End of the zoom: the ghost has landed on the
// real icon's slot — swap them back in the same
// frame, hide the window, and put every touched
// inline property back while nothing is visible.
// If the user re-opened the window mid-animation
// (tile click → showWindow cleared the flags),
// restore but leave it visible.
ghost.remove();
icon.style.visibility = icon_orig_visibility;
$win.attr('data-window_morphing', '0');
const minimized = $win.attr('data-is_minimized');
if ( minimized === '1' || minimized === 'true' ) $win.hide();
el_window.style.transition = 'none';
el_window.style.transform = orig_style.transform;
el_window.style.transformOrigin = orig_style.transformOrigin;
el_window.style.opacity = orig_style.opacity;
el_window.style.borderRadius = orig_style.borderRadius;
el_window.style.overflow = orig_style.overflow;
el_window.style.willChange = orig_style.willChange;
el_window.style.pointerEvents = orig_style.pointerEvents;
setTimeout(() => {
// End of the zoom: the ghost has landed on the
// real icon's slot — swap them back in the same
// frame, hide the window, and put every touched
// inline property back while nothing is visible.
// If the user re-opened the window mid-animation
// (tile click → showWindow cleared the flags),
// restore but leave it visible.
ghost.remove();
icon.style.visibility = icon_orig_visibility;
$win.attr('data-window_morphing', '0');
const minimized = $win.attr('data-is_minimized');
if ( minimized === '1' || minimized === 'true' ) $win.hide();
el_window.style.transition = 'none';
el_window.style.transform = orig_style.transform;
el_window.style.transformOrigin = orig_style.transformOrigin;
el_window.style.opacity = orig_style.opacity;
el_window.style.borderRadius = orig_style.borderRadius;
el_window.style.overflow = orig_style.overflow;
el_window.style.willChange = orig_style.willChange;
el_window.style.pointerEvents = orig_style.pointerEvents;
setTimeout(() => {
el_window.style.transition = orig_style.transition;
}, 50);
}, 480);
return;
}
el_window.style.transition = orig_style.transition;
}, 50);
}, 480);
return;
}
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
});
$win.fadeOut(150);
return;
}
// Calculate animation target based on taskbar position
let animationTarget = {};
const taskbarPosition = window.taskbar_position || 'bottom';
if ( taskbarPosition === 'bottom' ) {
// taskbar position is center of window minus half of taskbar item width
taskbar_item_pos.left = taskbar_item_pos.left + ($(window).width() / 2) - ($('.taskbar').width() / 2);
animationTarget = {
top: 'calc(100% - 60px)',
left: taskbar_item_pos.left + 14.5,
};
} else if ( taskbarPosition === 'left' ) {
animationTarget = {
top: taskbar_item_pos.top + ($(window).height() / 2) - ($('.taskbar').height() / 2) + 14.5,
left: '5px',
};
} else if ( taskbarPosition === 'right' ) {
animationTarget = {
top: taskbar_item_pos.top + ($(window).height() / 2) - ($('.taskbar').height() / 2) + 14.5,
left: 'calc(100% - 60px)',
};
}
$(this).attr({
'data-orig-width': $(this).width(),
'data-orig-height': $(this).height(),
'data-orig-top': $(this).position().top,
'data-orig-left': $(this).position().left,
$win.attr({
'data-is_minimized': true,
'data-minimized_in_place': '1',
});
$(this).css({
...(!isMobile.phone ? {
'transition': 'top 0.2s, left 0.2s, bottom 0.2s, right 0.2s, width 0.2s, height 0.2s',
} : {}),
width: '0',
height: '0',
...animationTarget,
});
// remove transitions a good while after setting css to make sure
// it doesn't interfere with an ongoing animation
setTimeout(() => {
$(this).css({
'transition': 'none',
'transform': 'none',
});
}, 250);
// reset the tab title (the URL is not touched — apps never
// rewrite it on the desktop) — only if this window owned it
const update_window_url = $(this).attr('data-update_window_url');
if ( ! window.is_dashboard_mode && (update_window_url === 'true' || update_window_url === null) ) {
document.title = i18n('window_title_puter');
}
$win.fadeOut(150);
}
});
return this;
@@ -5589,6 +5423,26 @@ async function saveSidebarOrder (order) {
}
}
/**
* Fill the page with a fullpage window (the dashboard): no head, no rounding.
* @param {HTMLElement} el_window
*/
function enter_fullpage_mode (el_window) {
$(el_window).find('.window-head').hide();
$('body').addClass('fullpage-mode');
$(el_window).css({
width: '100%',
height: '100%',
top: `${window.toolbar_height}px`,
left: 0,
'border-radius': 0,
});
}
function exit_fullpage_mode () {
$('body').removeClass('fullpage-mode');
}
// Function to update maximized window positioning based on taskbar position
window.update_maximized_window_for_taskbar = function (el_window) {
const position = window.taskbar_position || 'bottom';
-1
View File
@@ -782,7 +782,6 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
width: Math.min(480, window.innerWidth - 24),
height: 'auto',
dominant: true,
show_in_taskbar: false,
is_draggable: false,
backdrop: true,
on_before_exit: async () => {
-1
View File
@@ -215,7 +215,6 @@ async function UIWindowAuthMe (options = {}) {
width: 400,
height: 'auto',
dominant: true,
show_in_taskbar: false,
window_class: 'window-authme',
body_css: {
width: 'initial',
-1
View File
@@ -87,7 +87,6 @@ async function UIWindowBlockedSenders (options) {
width: 420,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.blocked-username').get(0)?.focus({ preventScroll: true });
},
-1
View File
@@ -68,7 +68,6 @@ async function UIWindowChangeUsername (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.new-username').get(0)?.focus({ preventScroll: true });
const oidc_only = !!(window.user && window.user.oidc_only);
-1
View File
@@ -114,7 +114,6 @@ function UIWindowCopyToken (options = {}) {
width: 450,
height: 'auto',
dominant: true,
show_in_taskbar: false,
window_class: 'window-copy-auth-token',
body_css: {
width: 'initial',
-217
View File
@@ -1,217 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindow from './UIWindow.js';
async function UIWindowDesktopBGSettings (options) {
options = options ?? {};
return new Promise(async (resolve) => {
let h = '';
const original_background_css = $('body').attr('style');
let bg_url = window.desktop_bg_url,
bg_color = window.desktop_bg_color,
bg_fit = window.desktop_bg_fit;
h += '<div style="padding: 10px; border-bottom: 1px solid #ced7e1;">';
// type
h += `<label>${i18n('background')}:</label>`;
h += '<select class="desktop-bg-type" style="width: 150px; margin-bottom: 20px;">';
h += `<option value="default">${i18n('default')}</option>`;
h += `<option value="picture">${i18n('picture')}</option>`;
h += `<option value="color">${i18n('color')}</option>`;
h += '</select>';
// Picture
h += '<div class="desktop-bg-settings-wrapper desktop-bg-settings-picture">';
h += `<label>${i18n('image')}:</label>`;
h += `<button class="button button-default button-small browse">${i18n('browse')}</button>`;
h += `<label style="margin-top: 20px;">${i18n('fit')}:</label>`;
h += '<select class="desktop-bg-fit" style="width: 150px;">';
h += `<option value="cover">${i18n('cover')}</option>`;
h += `<option value="center">${i18n('center')}</option>`;
h += `<option value="contain">${i18n('contain')}</option>`;
h += `<option value="repeat">${i18n('repeat')}</option>`;
h += '</select>';
h += '</div>';
// Color
h += '<div class="desktop-bg-settings-wrapper desktop-bg-settings-color">';
h += `<label>${i18n('color')}:</label>`;
h += '<div class="desktop-bg-color-blocks">';
h += '<div class="desktop-bg-color-block" data-color="#4F7BB5" style="background-color: #4F7BB5"></div>';
h += '<div class="desktop-bg-color-block" data-color="#545554" style="background-color: #545554"></div>';
h += '<div class="desktop-bg-color-block" data-color="#F5D3CE" style="background-color: #F5D3CE"></div>';
h += '<div class="desktop-bg-color-block" data-color="#52A758" style="background-color: #52A758"></div>';
h += '<div class="desktop-bg-color-block" data-color="#ad3983" style="background-color: #ad3983"></div>';
h += '<div class="desktop-bg-color-block" data-color="#ffffff" style="background-color: #ffffff"></div>';
h += '<div class="desktop-bg-color-block" data-color="#000000" style="background-color: #000000"></div>';
h += '<div class="desktop-bg-color-block" data-color="#454545" style="background-color: #454545"></div>';
h += `<div class="desktop-bg-color-block desktop-bg-color-block-palette" data-color="" style="background-image: url(${window.icons['palette.svg']});
background-repeat: no-repeat;
background-size: contain;
background-position: center;"><input type="color" style="width:25px; height: 25px; opacity:0;"></div>`;
h += '</div>';
h += '</div>';
h += '<div style="padding-top: 5px; overflow:hidden; margin-top: 25px; border-top: 1px solid #CCC;">';
h += `<button class="button button-primary apply" style="float:right;">${i18n('apply')}</button>`;
h += `<button class="button button-default cancel" style="float:right; margin-right: 10px;">${i18n('cancel')}</button>`;
h += '</div>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('change_desktop_background'),
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: true,
selectable_body: false,
draggable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: true,
allow_user_select: true,
onAppend: function (this_window) {
$(this_window).find('.access-recipient').focus();
},
window_class: 'window-give-access',
width: 350,
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
},
...options.window_options,
});
const default_wallpaper = (window.gui_env === 'prod') ? 'https://puter-assets.b-cdn.net/wallpaper.webp' : '/images/wallpaper.webp';
$(el_window).find('.desktop-bg-settings-wrapper').hide();
if ( window.desktop_bg_url === default_wallpaper ) {
$(el_window).find('.desktop-bg-type').val('default');
} else if ( window.desktop_bg_url !== undefined && window.desktop_bg_url !== null ) {
$(el_window).find('.desktop-bg-settings-picture').show();
$(el_window).find('.desktop-bg-type').val('picture');
} else if ( window.desktop_bg_color !== undefined && window.desktop_bg_color !== null ) {
$(el_window).find('.desktop-bg-settings-color').show();
$(el_window).find('.desktop-bg-type').val('color');
} else {
// Default fallback if no specific wallpaper settings are detected
$(el_window).find('.desktop-bg-type').val('default');
}
$(el_window).find('.desktop-bg-color-block:not(.desktop-bg-color-block-palette').on('click', async function (e) {
window.set_desktop_background({ color: $(this).attr('data-color') });
});
$(el_window).find('.desktop-bg-color-block-palette input').on('change', async function (e) {
window.set_desktop_background({ color: $(this).val() });
});
$(el_window).on('file_opened', function (e) {
let selected_file = Array.isArray(e.detail) ? e.detail[0] : e.detail;
const fit = $(el_window).find('.desktop-bg-fit').val();
bg_url = selected_file.read_url;
bg_fit = fit;
bg_color = undefined;
window.set_desktop_background({ url: bg_url, fit: bg_fit });
});
$(el_window).find('.desktop-bg-fit').on('change', function (e) {
const fit = $(this).val();
bg_fit = fit;
window.set_desktop_background({ fit: fit });
});
$(el_window).find('.desktop-bg-type').on('change', function (e) {
const type = $(this).val();
$(el_window).find('.desktop-bg-settings-wrapper').hide();
if ( type === 'picture' ) {
$(el_window).find('.desktop-bg-settings-picture').show();
} else if ( type === 'color' ) {
$(el_window).find('.desktop-bg-settings-color').show();
} else if ( type === 'default' ) {
bg_color = undefined;
bg_fit = 'cover';
window.set_desktop_background({ url: default_wallpaper, fit: bg_fit });
}
});
$(el_window).find('.apply').on('click', async function (e) {
// /set-desktop-bg
try {
$.ajax({
url: `${window.api_origin }/set-desktop-bg`,
type: 'POST',
data: JSON.stringify({
url: window.desktop_bg_url,
color: window.desktop_bg_color,
fit: window.desktop_bg_fit,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
$(el_window).close();
resolve(true);
} catch ( err ) {
// Ignore
}
});
$(el_window).find('.browse').on('click', function () {
// open dialog
UIWindow({
path: `/${ window.user.username }/Desktop`,
// this is the uuid of the window to which this dialog will return
parent_uuid: $(el_window).attr('data-element_uuid'),
allowed_file_types: ['image/*'],
show_maximize_button: false,
show_minimize_button: false,
title: i18n('window_title_open'),
is_dir: true,
is_openFileDialog: true,
selectable_body: false,
});
});
$(el_window).find('.cancel').on('click', function () {
$('body').attr('style', original_background_css);
$(el_window).close();
resolve(true);
});
});
}
export default UIWindowDesktopBGSettings;
-1
View File
@@ -91,7 +91,6 @@ async function UIWindowFeedback (options) {
width: 380,
height: 'auto',
dominant: true,
show_in_taskbar: false,
...options.window_options,
onAppend: function (this_window) {
$(this_window).find('.feedback-message').get(0).focus({ preventScroll: true });
+1 -2
View File
@@ -167,8 +167,7 @@ async function UIWindowLogin (options) {
if ( logo_clickable ) {
$(el_window).find('.auth-logo').on('click', function () {
// keep desktop users on the desktop; everyone else goes to the root dashboard
window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/';
window.location.href = '/';
});
}
-1
View File
@@ -306,7 +306,6 @@ export default async function UIWindowLogin2FA (challengeToken) {
width: Math.min(400, window.innerWidth - 24),
height: 'auto',
dominant: true,
show_in_taskbar: false,
is_draggable: false,
backdrop: true,
stay_on_top: true,
@@ -57,7 +57,6 @@ async function UIWindowLoginInProgress (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
backdrop: true,
stay_on_top: true,
window_class: 'window-login-progress',
-1
View File
@@ -100,7 +100,6 @@ async function UIWindowNewPassword (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.new-password').get(0)?.focus({ preventScroll: true });
},
@@ -71,7 +71,6 @@ function UIWindowPasswordChangeRequired (options) {
width: 390,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.pcr-current').get(0)?.focus({ preventScroll: true });
},
-88
View File
@@ -1,88 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Placeholder from '../util/Placeholder.js';
import Flexer from './Components/Flexer.js';
import UIQRCode from './UIQRCode.js';
import UIWindow from './UIWindow.js';
async function UIWindowQR (options) {
options = options ?? {};
const placeholder_qr = Placeholder();
let h = '';
// close button containing the multiplication sign
h += '<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>';
h += '<div class="otp-qr-code">';
h += `<h1 style="text-align: center; font-size: 16px; padding: 10px; font-weight: 400; margin: -10px 10px 20px 10px; -webkit-font-smoothing: antialiased; color: #5f626d;">${
i18n(options.message_i18n_key || 'scan_qr_generic')
}</h1>`;
h += '</div>';
h += placeholder_qr.html;
const el_window = await UIWindow({
title: i18n('window_title_instant_login'),
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
backdrop: true,
width: 450,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
window_class: 'window-qr',
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '50px 20px',
},
});
const el_qr = UIQRCode({
value: options.text,
size: 250,
});
const component_flexer = new Flexer({
children: [
el_qr,
],
});
component_flexer.attach(placeholder_qr);
}
export default UIWindowQR;
-1
View File
@@ -91,7 +91,6 @@ async function UIWindowSaveAccount (options) {
allow_user_select: true,
width: 350,
dominant: true,
show_in_taskbar: false,
...options.window_options,
onAppend: function (this_window) {
if ( options.default_username )
-308
View File
@@ -1,308 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindow from './UIWindow.js';
import path from '../lib/path.js';
import UIAlert from './UIAlert.js';
import launch_app from '../helpers/launchApp.js';
import item_icon from '../helpers/itemIcon.js';
import UIContextMenu from './UIContextMenu.js';
async function UIWindowSearch (options) {
let h = '';
h += '<div class="search-input-wrapper">';
h += `<input type="text" class="search-input" placeholder="Search" style="background-image:url('${window.icons['magnifier-outline.svg']}');">`;
h += '</div>';
h += '<div class="search-results" style="overflow-y: auto; max-height: 300px;">';
const el_window = await UIWindow({
icon: null,
single_instance: true,
app: 'search',
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
draggable_body: true,
allow_context_menu: false,
is_draggable: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: true,
allow_user_select: true,
window_class: 'window-search',
backdrop: true,
center: isMobile.phone,
width: 500,
dominant: true,
window_css: {
height: 'initial',
padding: '0',
},
body_css: {
width: 'initial',
'max-height': 'calc(100vh - 200px)',
'background-color': 'rgb(241 246 251)',
'backdrop-filter': 'blur(3px)',
'padding': '0',
'height': 'initial',
'overflow': 'hidden',
'min-height': '65px',
'padding-bottom': '10px',
},
});
$(el_window).find('.search-input').focus();
// Debounce function to limit rate of API calls
function debounce (func, wait) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
// State for managing loading indicator
let isSearching = false;
// Debounced search function
const performSearch = debounce(async function (searchInput, resultsContainer) {
// Don't search if input is empty
if ( searchInput.val() === '' ) {
resultsContainer.html('');
resultsContainer.hide();
return;
}
// Set loading state
if ( ! isSearching ) {
isSearching = true;
}
try {
// Perform the search
let results = await fetch(`${window.api_origin }/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${puter.authToken}`,
},
body: JSON.stringify({ text: searchInput.val() }),
});
results = await results.json();
// Hide results if there are none
if ( results.length === 0 )
{
resultsContainer.hide();
}
else
{
resultsContainer.show();
}
// Build results HTML
let h = '';
for ( let i = 0; i < results.length; i++ ) {
const result = results[i];
h += `<div
class="search-result"
data-path="${html_encode(result.path)}"
data-uid="${html_encode(result.uid)}"
data-is_dir="${html_encode(result.is_dir)}"
>`;
// icon
h += `<img src="${html_encode((await item_icon(result)).image)}" style="width: 20px; height: 20px; margin-right: 6px;">`;
h += html_encode(result.name);
h += '</div>';
}
resultsContainer.html(h);
} catch ( error ) {
resultsContainer.html('<div class="search-error">Search failed. Please try again.</div>');
console.error('Search error:', error);
} finally {
isSearching = false;
}
}, 300); // Wait 300ms after last keystroke before searching
// Event binding
$(el_window).find('.search-input').on('input', function (e) {
const searchInput = $(this);
const resultsContainer = $(el_window).find('.search-results');
performSearch(searchInput, resultsContainer);
});
}
$(document).on('click', '.search-result', async function (e) {
const fspath = $(this).data('path');
const fsuid = $(this).data('uid');
const is_dir = $(this).attr('data-is_dir') === 'true' || $(this).data('is_dir') === '1';
let open_item_meta;
if ( is_dir ) {
UIWindow({
path: fspath,
title: path.basename(fspath),
icon: await item_icon({ is_dir: true, path: fspath }),
uid: fsuid,
is_dir: is_dir,
app: 'explorer',
});
// close search window
$(this).closest('.window').close();
return;
}
// get all info needed to open an item
try {
open_item_meta = await $.ajax({
url: `${window.api_origin }/open_item`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
uid: fsuid ?? undefined,
path: fspath ?? undefined,
}),
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
} catch ( err ) {
// Ignored
}
// get a list of suggested apps for this file type.
let suggested_apps = open_item_meta?.suggested_apps ?? await window.suggest_apps_for_fsentry({ uid: fsuid, path: fspath });
//---------------------------------------------
// No suitable apps, ask if user would like to
// download
//---------------------------------------------
if ( suggested_apps.length === 0 ) {
//---------------------------------------------
// If .zip file, unzip it
//---------------------------------------------
if ( path.extname(fspath) === '.zip' ) {
window.unzipItem(fspath);
return;
}
const alert_resp = await UIAlert('Found no suitable apps to open this file with. Would you like to download it instead?',
[
{
label: i18n('download_file'),
value: 'download_file',
type: 'primary',
},
{
label: i18n('cancel'),
},
]);
if ( alert_resp === 'download_file' ) {
window.trigger_download([fspath]);
}
return;
}
//---------------------------------------------
// First suggested app is default app to open this item
//---------------------------------------------
else {
launch_app({
name: suggested_apps[0].name,
token: open_item_meta.token,
file_path: fspath,
app_obj: suggested_apps[0],
window_title: path.basename(fspath),
file_uid: fsuid,
// maximized: options.maximized,
file_signature: open_item_meta.signature,
});
}
// close
$(this).closest('.window').close();
});
// Context menu for search results
$(document).on('contextmenu', '.search-result', async function (e) {
e.preventDefault();
e.stopPropagation();
const fspath = $(this).data('path');
const fsuid = $(this).data('uid');
const is_dir = $(this).attr('data-is_dir') === 'true' || $(this).data('is_dir') === '1';
// Get the parent directory path
const parent_path = path.dirname(fspath);
// Build context menu items
const menuItems = [
{
html: i18n('open'),
onClick: async function () {
// Trigger the same logic as clicking on the search result
$(e.target).trigger('click');
},
},
];
// Only add "Open enclosing folder" if we're not already at root
if ( parent_path && parent_path !== fspath && parent_path !== '/' ) {
menuItems.push('-'); // divider
menuItems.push({
html: i18n('open_containing_folder'),
onClick: async function () {
// Open the enclosing folder
UIWindow({
path: parent_path,
title: path.basename(parent_path) || window.root_dirname,
icon: window.icons['folder.svg'],
is_dir: true,
app: 'explorer',
});
// Close search window
$(e.target).closest('.window').close();
},
});
}
UIContextMenu({
items: menuItems,
});
});
export default UIWindowSearch;
-1
View File
@@ -82,7 +82,6 @@ async function UIWindowSessionList (options) {
width: 350,
height: 'auto',
dominant: true,
show_in_taskbar: false,
update_window_url: false,
cover_page: options.cover_page ?? false,
window_class: 'window-session-list',
-1
View File
@@ -127,7 +127,6 @@ async function UIWindowShare (options) {
width: 420,
height: 'auto',
dominant: true,
show_in_taskbar: false,
onAppend: function (this_window) {
$(this_window).find('.share-recipient').get(0)?.focus({ preventScroll: true });
},
+1 -5
View File
@@ -207,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 = '/';
});
}
-244
View File
@@ -1,244 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { END_HARD, END_SOFT } from '../definitions.js';
import UIAlert from './UIAlert.js';
import UIContextMenu from './UIContextMenu.js';
import UIWindow from './UIWindow.js';
const end_process = async (uuid, force) => {
const svc_process = globalThis.services.get('process');
const process = svc_process.get_by_uuid(uuid);
if ( ! process ) {
console.warn(`Can't end process with uuid='${uuid}': does not exist`);
return;
}
let confirmation;
if ( process.is_init() ) {
if ( ! force ) {
confirmation = i18n('close_all_windows_confirm');
} else {
confirmation = i18n('restart_puter_confirm');
}
} else if ( force ) {
confirmation = i18n('end_process_force_confirm');
}
if ( confirmation ) {
const alert_resp = await UIAlert({
message: confirmation,
buttons: [
{
label: i18n('yes'),
value: true,
type: 'primary',
},
{
label: i18n('no'),
value: false,
},
],
});
if ( ! alert_resp ) return;
}
process.signal(force ? END_HARD : END_SOFT);
};
const calculate_indent_string = (indent_level, is_last_item_stack, is_last_item) => {
// Returns a string of '| ├└'
let result = '';
for ( let i = 0; i < indent_level; i++ ) {
const last_cell = i === indent_level - 1;
const has_trunk = (last_cell && ( !is_last_item )) ||
(!last_cell && !is_last_item_stack[i + 1]);
const has_branch = last_cell;
if ( has_trunk && has_branch ) {
result += '├';
} else if ( has_trunk ) {
result += '|';
} else if ( has_branch ) {
result += '└';
} else {
result += ' ';
}
}
return result;
};
const generate_task_rows = (items, { indent_level, is_last_item_stack }) => {
const svc_process = globalThis.services.get('process');
let rows_html = '';
for ( let i = 0; i < items.length; i++ ) {
const item = items[i];
const is_last_item = i === items.length - 1;
const indentation = calculate_indent_string(indent_level, is_last_item_stack, is_last_item);
// Generate indentation HTML
let indentation_html = '';
for ( const c of indentation ) {
indentation_html += '<div class="indentcell">';
switch (c) {
case ' ':
break;
case '|':
indentation_html += '<div class="indentcell-trunk"></div>';
break;
case '└':
indentation_html += '<div class="indentcell-branch"></div>';
break;
case '├':
indentation_html += '<div class="indentcell-trunk"></div>';
indentation_html += '<div class="indentcell-branch"></div>';
break;
}
indentation_html += '</div>';
}
rows_html += `
<tr class="task-row" data-uuid="${item.uuid}">
<td>
<div class="task">
<div class="task-indentation">${indentation_html}</div>
<div class="task-name">${item.name}</div>
</div>
</td>
<td><span class="process-type">${i18n(`process_type_${ item.type}`)}</span></td>
<td><span class="process-status">${i18n(`process_status_${ item.status.i18n_key}`)}</span></td>
</tr>
`;
const children = svc_process.get_children_of(item.uuid);
if ( children ) {
rows_html += generate_task_rows(children, {
indent_level: indent_level + 1,
is_last_item_stack: [ ...is_last_item_stack, is_last_item ],
});
}
}
return rows_html;
};
const UIWindowTaskManager = async function UIWindowTaskManager () {
const svc_process = globalThis.services.get('process');
let h = '';
h += '<div class="task-manager-container">';
h += '<table>';
h += '<thead>';
h += '<tr>';
h += `<th>${i18n('taskmgr_header_name')}</th>`;
h += `<th>${i18n('taskmgr_header_type')}</th>`;
h += `<th>${i18n('taskmgr_header_status')}</th>`;
h += '</tr>';
h += '</thead>';
h += '<tbody class="taskmgr-taskarea">';
h += '</tbody>';
h += '</table>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('task_manager'),
icon: globalThis.icons['cog.svg'],
uid: null,
is_dir: false,
single_instance: true,
app: 'taskmgr',
is_resizable: true,
is_droppable: false,
has_head: true,
selectable_body: true,
draggable_body: false,
allow_context_menu: false,
show_in_taskbar: true,
dominant: true,
body_content: h,
width: 350,
window_class: 'window-task-manager',
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
padding: '20px',
'background-color': `hsla(
var(--primary-hue),
var(--primary-saturation),
var(--primary-lightness),
var(--primary-alpha))`,
'backdrop-filter': 'blur(3px)',
'box-sizing': 'border-box',
height: 'calc(100% - 30px)',
display: 'flex',
'flex-direction': 'column',
'--scale': '2pt',
'--line-color': '#6e6e6ebd',
padding: '0',
},
});
const update_tasks = () => {
const processes = [svc_process.get_init()];
const rows_html = generate_task_rows(processes, { indent_level: 0, is_last_item_stack: [] });
$(el_window).find('.taskmgr-taskarea').html(rows_html);
};
// Set up context menu for task rows
$(el_window).on('contextmenu', '.task-row', function (e) {
e.preventDefault();
const uuid = $(this).data('uuid');
UIContextMenu({
items: [
{
html: i18n('close'),
onClick: () => {
end_process(uuid);
},
},
{
html: i18n('force_quit'),
onClick: () => {
end_process(uuid, true);
},
},
],
});
});
// Initial task update
update_tasks();
// Set up interval to refresh tasks
const interval = setInterval(update_tasks, 500);
// Clean up interval when window is closed
$(el_window).on('close', () => {
clearInterval(interval);
});
return el_window;
};
export default UIWindowTaskManager;
-1
View File
@@ -52,7 +52,6 @@ const UIWindowThemeDialog = async function UIWindowThemeDialog (options) {
selectable_body: false,
draggable_body: false,
allow_context_menu: false,
show_in_taskbar: false,
window_class: 'window-alert',
dominant: true,
width: 350,
-90
View File
@@ -1,90 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindow from './UIWindow.js';
async function UIWindowWelcome (options) {
options = options ?? {};
let h = '';
// close button containing the multiplication sign
h += '<div class="generic-close-window-button welcome-window-close-button"> &times; </div>';
h += '<div style="display:flex; flex-direction: colum;">';
h += '<div style="overflow: hidden; width: 200px; max-width: 200px; min-width: 200px; background: linear-gradient(45deg, #3d476b, #838eb7); min-height: 400px; padding: 20px; box-sizing: border-box;">';
h += '<img style="display: block; margin: 45px auto 0; width: 270px; opacity: 0.5;" src="data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%3F%3E%3Csvg%20width%3D%2248%22%20height%3D%2248%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20class%3D%22layer%22%3E%3Ctitle%3ELayer%201%3C%2Ftitle%3E%3Cg%20id%3D%22svg_1%22%20stroke-width%3D%221%22%20transform%3D%22rotate(90%2024%2023.9997)%22%3E%3Cpolyline%20fill%3D%22none%22%20id%3D%22svg_2%22%20points%3D%2239%2024%2025%2024%2025%2028%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpolyline%20fill%3D%22none%22%20id%3D%22svg_3%22%20points%3D%2235.879%2010.121%2032%2014%2025%2014%2025%2018%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpath%20d%3D%22m13%2C26a10.29%2C10.29%200%200%201%20-7.2%2C-3%22%20fill%3D%22none%22%20id%3D%22svg_4%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpath%20d%3D%22m17%2C31.6a5.83%2C5.83%200%200%201%20-4%2C-5.6a5.73%2C5.73%200%200%201%202%2C-4.4%22%20fill%3D%22none%22%20id%3D%22svg_5%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Cpath%20d%3D%22m35.88%2C37.88l-3.88%2C-3.88l-7%2C0l0%2C2a9.9%2C9.9%200%200%201%20-10%2C10a9.9%2C9.9%200%200%201%20-10%2C-10a9.06%2C9.06%200%200%201%200.6%2C-3.2a5.63%2C5.63%200%200%201%20-2.6%2C-4.8a5.89%2C5.89%200%200%201%202.8%2C-5a9.99%2C9.99%200%200%201%20-2.8%2C-7a9.9%2C9.9%200%200%201%2010%2C-10l0.4%2C0a5.83%2C5.83%200%200%201%205.6%2C-4a5.89%2C5.89%200%200%201%206%2C6%22%20fill%3D%22none%22%20id%3D%22svg_6%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Ccircle%20cx%3D%2238%22%20cy%3D%228%22%20data-color%3D%22color-2%22%20fill%3D%22none%22%20id%3D%22svg_7%22%20r%3D%223%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Ccircle%20cx%3D%2242%22%20cy%3D%2224%22%20data-color%3D%22color-2%22%20fill%3D%22none%22%20id%3D%22svg_8%22%20r%3D%223%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3Ccircle%20cx%3D%2238%22%20cy%3D%2240%22%20data-color%3D%22color-2%22%20fill%3D%22none%22%20id%3D%22svg_9%22%20r%3D%223%22%20stroke%3D%22%23ffffff%22%20stroke-linecap%3D%22square%22%20stroke-miterlimit%3D%2210%22%20stroke-width%3D%221%22%2F%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E">';
h += '</div>';
h += '<div style="flex-grow: 1; padding-left: 50px; padding-top: 70px; padding-right: 50px;">';
h += `<h1 style="font-size: 25px; font-weight: 300; -webkit-font-smoothing: antialiased; color: #545763;">${i18n('welcome_title')}</h1>`;
h += `<p style="margin-top: 25px; font-size: 16px; font-weight: 300; -webkit-font-smoothing: antialiased; color: #3e4251;">${i18n('welcome_description')}</p>`;
h += `<button class="welcome-window-get-started" style="font-size: 15px; font-weight: 300; -webkit-font-smoothing: antialiased; cursor: pointer; padding: 8px 20px; border-radius: 5px; text-decoration: none; margin-right: 20px; border: 1px solid #656565 !important; background: none; margin-top: 10px;">${i18n('welcome_get_started')}</button>`;
h += '<div class="welcome-window-footer">';
h += `<a href="/terms" target="_blank">${i18n('welcome_terms')}</a>`;
h += `<a href="/privacy" style="margin-left: 20px;" target="_blank">${i18n('welcome_privacy')}</a>`;
h += `<a href="https://developer.puter.com" style="margin-left: 20px;" target="_blank">${i18n('welcome_developers')}</a>`;
h += `<a href="https://github.com/heyputer/puter" style="margin-left: 20px;" target="_blank">${i18n('welcome_open_source')}</a>`;
h += '</div>';
h += '</div>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('welcome_instant_login_title'),
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
backdrop: true,
close_on_backdrop_click: false,
backdrop_covers_toolbar: true,
width: 650,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
fadeIn: 1000,
window_class: 'window-welcome',
on_close: function () {
// save the fact that the user has seen the welcome window
puter.kv.set('has_seen_welcome_window', true).catch(err => console.warn('Could not save has_seen_welcome_window:', err));
},
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '0',
},
});
$(document).on('click', '.welcome-window-get-started', function () {
$(el_window).close();
});
}
export default UIWindowWelcome;
-54
View File
@@ -117,11 +117,6 @@
--dashboard-bento-usage-surface: #eef2ff;
--dashboard-bento-usage-header-bg: #e0e7ff;
--dashboard-bento-usage-bar-fill: #ea580c;
/* Emerald (matches the green icon accent used elsewhere) */
--dashboard-bento-desktop-icon-bg: #059669;
--dashboard-bento-desktop-icon-shadow: rgba(5, 150, 105, 0.22);
--dashboard-bento-desktop-surface: #ecfdf5;
--dashboard-bento-desktop-header-bg: #d1fae5;
--dashboard-shadow-subtle: rgba(0, 0, 0, 0.04);
--dashboard-shadow-light: rgba(0, 0, 0, 0.06);
@@ -244,10 +239,6 @@ body {
--dashboard-bento-usage-surface: #1c1d2e;
--dashboard-bento-usage-header-bg: #252a42;
--dashboard-bento-usage-bar-fill: #fb923c;
--dashboard-bento-desktop-icon-bg: #10b981;
--dashboard-bento-desktop-icon-shadow: rgba(16, 185, 129, 0.32);
--dashboard-bento-desktop-surface: #16241f;
--dashboard-bento-desktop-header-bg: #172e26;
--dashboard-shadow-subtle: rgba(0, 0, 0, 0.2);
--dashboard-shadow-light: rgba(0, 0, 0, 0.3);
@@ -6716,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;
@@ -6883,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;
@@ -7240,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;
File diff suppressed because it is too large. Load diff
@@ -1,54 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
console.debug('[puter] modify-user-options-menu loaded');
$(window).on('ctxmenu-will-open', (event) => {
if ( event.detail.options?.id === 'user-options-menu' ) {
// Define array of new menu items
const newMenuItems = [
// Separator
'-',
// 'Developer', opens developer site in new tab
{
id: 'go_to_developer_site',
html: 'Developer<svg style="width: 11px; height: 11px; margin-left:2px;" height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><path d="m26 28h-20a2.0027 2.0027 0 0 1 -2-2v-20a2.0027 2.0027 0 0 1 2-2h10v2h-10v20h20v-10h2v10a2.0027 2.0027 0 0 1 -2 2z"/><path d="m20 2v2h6.586l-8.586 8.586 1.414 1.414 8.586-8.586v6.586h2v-10z"/><path d="m0 0h32v32h-32z" fill="none"/></svg>',
html_active: 'Developer<svg style="width: 11px; height: 11px; margin-left:2px;" height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"> <path d="m26 28h-20a2.0027 2.0027 0 0 1 -2-2v-20a2.0027 2.0027 0 0 1 2-2h10v2h-10v20h20v-10h2v10a2.0027 2.0027 0 0 1 -2 2z" style="fill: rgb(255, 255, 255);"/> <path d="m20 2v2h6.586l-8.586 8.586 1.414 1.414 8.586-8.586v6.586h2v-10z" style="fill: rgb(255, 255, 255);"/> <path d="m0 0h32v32h-32z" fill="none"/> </svg>',
action: function () {
window.open('https://developer.puter.com', '_blank');
},
},
];
// Find the position of 'contact_us'
const items = event.detail.options.items;
const insertBeforeIndex = items.findIndex(item => item.id === 'contact_us');
// 'contact_us' not found, append new items at the end
if ( insertBeforeIndex === -1 ) {
event.detail.options.items = [...items, ...newMenuItems];
return;
}
// 'contact_us' found, insert new items before it
const firstHalf = items.slice(0, insertBeforeIndex);
const secondHalf = items.slice(insertBeforeIndex);
event.detail.options.items = [...firstHalf, ...newMenuItems, ...secondHalf];
}
});
-3
View File
@@ -220,9 +220,6 @@ window.feature_flags = {
download_directory: true,
};
window.is_auto_arrange_enabled = true;
window.desktop_item_positions = {};
window.reset_item_positions = true; // The variable decides if the item positions should be reset when the user enabled auto arrange
window.file_templates = [];
+13 -94
View File
@@ -684,15 +684,9 @@ window.update_auth_data = async (auth_token, user) => {
// ----------------------------------------------------
const to_storable_user = user => {
const storable_user = { ...user };
delete storable_user.taskbar_items;
return storable_user;
};
// update this session's user data
window.user = user;
localStorage.setItem('user', JSON.stringify(to_storable_user(user)));
localStorage.setItem('user', JSON.stringify(user));
// re-initialize the Puter.js objects with the new auth token
puter.setAuthToken(auth_token, window.api_origin);
@@ -715,7 +709,7 @@ window.update_auth_data = async (auth_token, user) => {
window.logged_in_users.push(userobj);
}
// update local storage
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users.map(to_storable_user)));
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users));
}
window.desktop_path = `/${ window.user.username }/Desktop`;
@@ -729,11 +723,6 @@ window.update_auth_data = async (auth_token, user) => {
window.public_path = `/${ window.user.username }/Public`;
window.shared_path = 'puter://shared';
if ( window.user !== null && !window.user.is_temp ) {
$('.user-options-login-btn, .user-options-create-account-btn').hide();
$('.user-options-menu-btn').show();
}
// Search and store user templates (non-blocking)
window.available_templates();
};
@@ -1084,7 +1073,6 @@ window.show_save_account_notice_if_needed = function (message) {
reload_on_success: true,
send_confirmation_code: false,
window_options: {
show_in_taskbar: false,
backdrop: true,
close_on_backdrop_click: false,
},
@@ -1706,14 +1694,13 @@ window.trigger_download = (paths) => {
/**
* Updates every Trash icon in the UI to reflect whether the trash is empty:
* the taskbar item, desktop items (and shortcuts to Trash), open explorer
* window head icons, and the Dashboard's Files-tab sidebar.
* items (and shortcuts to Trash), open explorer window head icons, and the
* Dashboard's Files-tab sidebar.
*
* @param {boolean} is_empty - Whether the trash is empty
*/
window.update_trash_icons = function (is_empty) {
const icon = is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
$('[data-app="trash"]').find('.taskbar-icon > img').attr('src', icon);
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', icon);
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.window-head-icon').attr('src', icon);
$('.directories [data-folder="Trash"] img').attr('src', icon);
@@ -2168,34 +2155,17 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
};
/**
* Refreshes the desktop background based on the user's settings.
* If the user has set a custom desktop background URL or color, it will use that.
* If not, it defaults to a specific wallpaper image.
*
* @global
* @function
* @fires set_desktop_background - Calls this global function to set the desktop background.
*
* @example
* // This will refresh the desktop background according to the user's preference or defaults.
* window.refresh_desktop_background();
* Paints the default wallpaper behind the pages that show before the
* dashboard: the saved-session list and `/action/<name>` dialogs.
*/
window.refresh_desktop_background = function () {
if ( window.user && (window.user.desktop_bg_url !== null || window.user.desktop_bg_color !== null) ) {
window.set_desktop_background({
url: window.user.desktop_bg_url,
fit: window.user.desktop_bg_fit,
color: window.user.desktop_bg_color,
});
}
// default background
else {
let wallpaper = (window.gui_env === 'prod') ? 'https://puter-assets.b-cdn.net/wallpaper.webp' : '/src/images/wallpaper.webp';
window.set_desktop_background({
url: wallpaper,
fit: 'cover',
});
}
const wallpaper = (window.gui_env === 'prod') ? 'https://puter-assets.b-cdn.net/wallpaper.webp' : '/src/images/wallpaper.webp';
$('body').css({
'background-image': `url(${wallpaper})`,
'background-size': 'cover',
'background-repeat': 'no-repeat',
'background-position': 'center center',
});
};
window.determine_website_url = function (fsentry_path) {
@@ -3427,57 +3397,6 @@ window.undo_delete = async (items) => {
}
};
window.store_auto_arrange_preference = (preference) => {
// localStorage still carries it for this device either way.
puter.kv.set('user_preferences.auto_arrange_desktop', preference)
.catch(err => console.warn('Could not save auto_arrange_desktop:', err));
localStorage.setItem('auto_arrange', preference);
};
window.get_auto_arrange_data = async () => {
// Best-effort, like every other preference read: falling back to the
// default keeps the desktop arranging itself rather than not appearing.
const preferenceValue = await puter.kv.get('user_preferences.auto_arrange_desktop').catch(() => null);
window.is_auto_arrange_enabled = preferenceValue === null ? true : preferenceValue;
const positions = await puter.kv.get('desktop_item_positions').catch(() => null);
window.desktop_item_positions = (!positions || typeof positions !== 'object' || Array.isArray(positions)) ? {} : positions;
};
window.clear_desktop_item_positions = async (el_desktop) => {
$(el_desktop).find('.item').each(function () {
const el_item = $(this)[0];
$(el_item).css('position', '');
$(el_item).css('left', '');
$(el_item).css('top', '');
});
if ( window.reset_item_positions ) {
window.delete_desktop_item_positions();
}
};
window.set_desktop_item_positions = async (el_desktop) => {
$(el_desktop).find('.item').each(async function () {
const position = window.desktop_item_positions[$(this).attr('data-uid')];
const el_item = $(this)[0];
if ( position ) {
$(el_item).css('position', 'absolute');
$(el_item).css('left', `${position.left }px`);
$(el_item).css('top', `${position.top }px`);
}
});
};
window.save_desktop_item_positions = () => {
puter.kv.set('desktop_item_positions', window.desktop_item_positions)
.catch(err => console.warn('Could not save desktop_item_positions:', err));
};
window.delete_desktop_item_positions = () => {
window.desktop_item_positions = {};
puter.kv.del('desktop_item_positions')
.catch(err => console.warn('Could not clear desktop_item_positions:', err));
};
// Finds the `.window` element for the given app instance ID
window.window_for_app_instance = (instance_id) => {
return $(`.window[data-element_uuid="${instance_id}"]`).get(0);
+5 -6
View File
@@ -63,11 +63,11 @@ export const get_auth_redirect_url = () => {
*/
const oidc_return_path = () => {
const pathname = window.location.pathname;
if ( pathname === '/' || pathname === '/desktop' || pathname === '/dashboard' ) {
if ( pathname === '/' || pathname === '/dashboard' ) {
return pathname;
}
// app landing: normalize away a trailing slash to match the backend whitelist
if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) {
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
return pathname.replace(/\/$/, '');
}
return null;
@@ -76,10 +76,9 @@ const oidc_return_path = () => {
/**
* The `return_to` to send along when starting an OIDC flow, or null if the
* current page isn't one the backend will return to. The backend strictly
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
* and direct app landings (`/app/<name>`, plus the desktop-booted
* `/desktop/app/<name>`), so OIDC login started from an app landing comes back
* to the app — and to the same interface it was opened in.
* whitelists these (never a client-supplied URL): `/dashboard` and direct app
* landings (`/app/<name>`), so OIDC login started from an app landing comes
* back to the app.
*
* A share link (`?shared=`, from an email) is carried along with the path: the
* recipient usually has to sign in before they can see what was shared, and an
+7 -12
View File
@@ -42,7 +42,6 @@ afterEach(() => {
describe('get_oidc_return_to', () => {
it('returns the interface pages the backend whitelists', () => {
expect(at('/desktop')).toBe('/desktop');
expect(at('/dashboard')).toBe('/dashboard');
});
@@ -51,17 +50,12 @@ describe('get_oidc_return_to', () => {
expect(at('/app/editor/')).toBe('/app/editor');
});
it('keeps a desktop app landing on the desktop', () => {
expect(at('/desktop/app/editor')).toBe('/desktop/app/editor');
expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
});
it('carries a share link so the item survives the round trip', () => {
expect(at('/', share_search(shared_path('Report.pdf')))).toBe(
`/${share_search(shared_path('Report.pdf'))}`,
);
expect(at('/desktop', share_search(shared_path('Report.pdf')))).toBe(
`/desktop${share_search(shared_path('Report.pdf'))}`,
expect(at('/dashboard', share_search(shared_path('Report.pdf')))).toBe(
`/dashboard${share_search(shared_path('Report.pdf'))}`,
);
expect(
at('/', share_search(shared_path('a.txt'), shared_path('b.txt'))),
@@ -83,10 +77,10 @@ describe('get_oidc_return_to', () => {
expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
expect(at('/', '?shared=')).toBe(null);
// other parameters are not ours to carry
expect(at('/desktop', '?app=editor')).toBe('/desktop');
expect(at('/dashboard', '?app=editor')).toBe('/dashboard');
expect(
at('/desktop', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/desktop${share_search(shared_path('a.txt'))}`);
at('/dashboard', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/dashboard${share_search(shared_path('a.txt'))}`);
});
it('returns null for anything the backend would reject', () => {
@@ -94,7 +88,8 @@ describe('get_oidc_return_to', () => {
expect(at('/settings')).toBe(null);
expect(at('/action/login')).toBe(null);
expect(at('/app/editor/extra')).toBe(null);
expect(at('/desktop/app/editor/extra')).toBe(null);
expect(at('/desktop')).toBe(null);
expect(at('/desktop/app/editor')).toBe(null);
expect(at('/dashboard/app/editor')).toBe(null);
});
});
+123
View File
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIAlert from '../UI/UIAlert.js';
import UIWindowProgress from '../UI/UIWindowProgress.js';
/**
* Save the file at `url` into the user's Desktop folder once they confirm,
* with a progress window that can cancel it. Backs `/?download=<url>` links.
*
* @param {string} url
* @returns {Promise<void>} settles once the user has answered the prompt;
* the download itself carries on in the background
*/
const download_url_to_desktop = async (url) => {
const file_name = url.split('/').pop().split('?')[0];
const confirmed = await UIAlert({
message: i18n('confirm_download_file_to_desktop', file_name),
type: 'confirm',
buttons: [
{ label: i18n('alert_yes'), value: true, type: 'primary' },
{ label: i18n('alert_no'), value: false, type: 'secondary' },
],
});
if ( ! confirmed ) return;
let cancelled = false;
let upload_xhr = null;
const abort_controller = new AbortController();
const progwin = await UIWindowProgress({
title: i18n('downloading'),
icon: window.icons['app-icon-uploader.svg'],
operation_id: window.uuidv4(),
show_progress: true,
on_cancel: () => {
cancelled = true;
abort_controller.abort();
if ( upload_xhr ) {
upload_xhr.abort();
}
},
});
progwin?.set_status(i18n('downloading_file', file_name));
(async () => {
try {
const response = await puter.net.fetch(url, {
signal: abort_controller.signal,
});
const total = Number(response.headers.get('content-length'));
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while ( true ) {
const { done, value } = await reader.read();
if ( done || cancelled ) break;
if ( value ) {
chunks.push(value);
received += value.length;
const progress = Number.isFinite(total) && total > 0
? received / total
: 0;
progwin?.set_progress(Math.floor(progress * 100));
}
}
if ( cancelled ) {
progwin?.close();
return;
}
const blob = new Blob(chunks, {
type: response.headers.get('content-type') ?? 'application/octet-stream',
});
progwin?.set_progress(0);
progwin?.set_status(i18n('uploading_file', file_name));
await puter.fs.write(`~/Desktop/${file_name}`, blob, {
dedupeName: true,
progress: (_, percent) => {
progwin?.set_progress(percent);
},
init: (_, xhr) => {
upload_xhr = xhr;
},
});
} catch (e) {
// A cancel aborts the fetch; only a real failure is worth reporting.
if ( !cancelled && e.name !== 'AbortError' ) {
await UIAlert({
message: `${i18n('error_download_failed')}: ${e.message}`,
type: 'error',
});
}
}
progwin?.close();
})();
};
export default download_url_to_desktop;
-16
View File
@@ -139,9 +139,6 @@ const launch_app = async (options) => {
const launchAttributes = {
'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown',
'launch.dashboard_mode': !! window.is_dashboard_mode,
// url_paths has the `/desktop` prefix stripped, so this counts the
// desktop-booted landing (`/desktop/app/<name>`) as the app URL
// it is.
'launch.from_app_url':
window.url_paths?.[0]?.toLocaleLowerCase() === 'app'
&& !! window.url_paths?.[1],
@@ -679,18 +676,6 @@ const launch_app = async (options) => {
title = options.file_path ? path.basename(options.file_path) : title;
}
// show_in_taskbar
// Deliberately keyed on the app's own `background`, not on this launch's:
// an app that always runs windowless has nothing to put in the taskbar,
// ever. A background *launch* is a normal app that happens to start
// hidden, so it keeps asking for an item — UIWindow just holds it back
// until the window is first shown (see its taskbar block).
let show_in_taskbar = app_info.background ? false : window_options?.show_in_taskbar;
if ( window_options?.show_in_taskbar !== undefined )
{
show_in_taskbar = window_options.show_in_taskbar;
}
// has_head
let has_head = app_info.metadata?.has_head !== undefined ? app_info.metadata.has_head : window_options?.has_head;
if ( app_info.metadata?.hide_titlebar !== undefined && typeof app_info.metadata.hide_titlebar === 'boolean' && app_info.metadata.hide_titlebar === true )
@@ -749,7 +734,6 @@ const launch_app = async (options) => {
...window_options,
is_resizable: window_resizable,
has_head: has_head,
show_in_taskbar: show_in_taskbar,
update_window_url: update_window_url,
custom_path: custom_path,
});
+76
View File
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* @typedef {Object} LegacyLanding
* @property {{ tab: string, path: string|null }} [route] dashboard route to open on
* @property {string} [open_path] a path to open as double-clicking it would
* @property {boolean} [invalid_username] `/@<name>` named no valid username
*/
const decode_segment = (segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
};
/**
* Where a URL that used to open on the desktop lands in the dashboard:
* `/@<user>/<path>` and `/show/<path>` open that path, `/settings` opens Home,
* and `?app=explorer[&path=]` opens Files. The URL is spent once read: the
* caller swaps it for the dashboard's own.
*
* @param {string[]} url_paths route segments (see parse_url_paths)
* @param {URLSearchParams} query_params
* @returns {LegacyLanding|null} null for any other URL
*/
export const legacy_landing = (url_paths, query_params) => {
const first = url_paths[0] ?? '';
if ( first.startsWith('@') ) {
const username = first.substring(1);
if ( ! /^[a-z0-9_]+$/i.test(username) ) {
return { invalid_username: true };
}
const rest = url_paths.slice(1).map(decode_segment);
return {
open_path: [`/${username}/Public`, ...rest].join('/'),
};
}
if ( first.toLowerCase() === 'show' && url_paths.length > 1 ) {
return {
open_path: `/${url_paths.slice(1).map(decode_segment).join('/')}`,
};
}
if ( first.toLowerCase() === 'settings' ) {
return { route: { tab: 'home', path: null } };
}
if ( query_params.get('app') === 'explorer' ) {
return {
route: { tab: 'files', path: query_params.get('path') || null },
};
}
return null;
};
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect } from 'vitest';
import { legacy_landing } from './legacyLanding.js';
import { parse_url_paths } from './urlPaths.js';
const at = (pathname, search = '') =>
legacy_landing(parse_url_paths(pathname), new URLSearchParams(search));
describe('legacy_landing', () => {
it('opens a user\'s public folder from /@<user>', () => {
expect(at('/@alice')).toEqual({ open_path: '/alice/Public' });
expect(at('/@alice/site/index.html')).toEqual({
open_path: '/alice/Public/site/index.html',
});
});
it('decodes the path segments it opens', () => {
expect(at('/@alice/My%20Site')).toEqual({ open_path: '/alice/Public/My Site' });
expect(at('/show/alice/Documents/a%20b.txt')).toEqual({
open_path: '/alice/Documents/a b.txt',
});
// a malformed escape is kept as written rather than throwing
expect(at('/show/alice/100%')).toEqual({ open_path: '/alice/100%' });
});
it('refuses a /@ path that names no valid username', () => {
expect(at('/@al.ice')).toEqual({ invalid_username: true });
expect(at('/@')).toEqual({ invalid_username: true });
});
it('opens /show/<path>', () => {
expect(at('/show/alice/Documents')).toEqual({ open_path: '/alice/Documents' });
// /show alone names nothing to open
expect(at('/show')).toBe(null);
});
it('sends /settings to Home', () => {
expect(at('/settings')).toEqual({ route: { tab: 'home', path: null } });
expect(at('/settings/account')).toEqual({ route: { tab: 'home', path: null } });
});
it('sends ?app=explorer to Files, at its path if it names one', () => {
expect(at('/', '?app=explorer')).toEqual({ route: { tab: 'files', path: null } });
expect(at('/', '?app=explorer&path=/alice/Documents')).toEqual({
route: { tab: 'files', path: '/alice/Documents' },
});
});
it('leaves every other URL to the dashboard', () => {
expect(at('/')).toBe(null);
expect(at('/dashboard')).toBe(null);
expect(at('/app/editor')).toBe(null);
expect(at('/action/login')).toBe(null);
expect(at('/', '?app=editor')).toBe(null);
expect(at('/', '?shared=/alice/x/y')).toBe(null);
});
});
+98
View File
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import path from '../lib/path.js';
import UIAlert from '../UI/UIAlert.js';
import { resolve_default_app } from './defaultApp.js';
import launch_app from './launchApp.js';
/**
* The dashboard hash that opens `dir_path` in Files. Each segment is encoded
* because parseDashboardRoute decodes the hash as a whole before splitting it.
*
* @param {string} dir_path
* @returns {string}
*/
export const files_route_hash = (dir_path) =>
`#files/${dir_path.split('/').filter(Boolean).map(encodeURIComponent).join('/')}`;
/**
* Open a path as double-clicking it would: a file in its app, a folder in the
* dashboard's Files tab. A file no app can open falls back to its folder.
*
* TODO: DRY with open_item, which is coupled to an item element and can't
* operate on a path alone.
*
* @param {string} item_path
* @param {{ is_dir: boolean, uid?: string, associated_app?: { name: string } }} stat
* @returns {Promise<void>}
*/
export const open_path_target = async (item_path, stat) => {
if ( ! stat.is_dir ) {
if ( stat.associated_app ) {
launch_app({ name: stat.associated_app.name });
return;
}
const default_app = await resolve_default_app({ uid: stat.uid, path: item_path });
if ( default_app ) {
launch_app({
name: default_app.name,
app_obj: default_app,
file_path: item_path,
});
return;
}
const open_item_meta = await $.ajax({
url: `${window.api_origin}/open_item`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ path: item_path }),
headers: { 'Authorization': `Bearer ${window.auth_token}` },
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
const suggested_apps = open_item_meta?.suggested_apps
?? await window.suggest_apps_for_fsentry({ path: item_path });
if ( suggested_apps.length !== 0 ) {
launch_app({
name: suggested_apps[0].name,
token: open_item_meta.token,
file_path: item_path,
app_obj: suggested_apps[0],
window_title: path.basename(item_path),
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
});
return;
}
await UIAlert({
message: 'Cannot find an app to open this file; opening directory instead.',
});
item_path = path.dirname(item_path);
}
window.location.hash = files_route_hash(item_path);
};
@@ -0,0 +1,94 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const launch_app = vi.fn();
const resolve_default_app = vi.fn();
const UIAlert = vi.fn();
vi.mock('./launchApp.js', () => ({ default: (...args) => launch_app(...args) }));
vi.mock('./defaultApp.js', () => ({ resolve_default_app: (...args) => resolve_default_app(...args) }));
vi.mock('../UI/UIAlert.js', () => ({ default: (...args) => UIAlert(...args) }));
const { files_route_hash, open_path_target } = await import('./openPathTarget.js');
// The hash the dashboard's route parser reads back into a Files path.
const parsed_path = (hash) => `/${decodeURIComponent(hash.slice(1)).split('/').filter(Boolean).slice(1).join('/')}`;
describe('files_route_hash', () => {
it('round-trips a folder through the dashboard route parser', () => {
for ( const dir of ['/alice/Public', '/alice/My Site', '/alice/50% off', '/alice/a#b'] ) {
const hash = files_route_hash(dir);
expect(hash.startsWith('#files/')).toBe(true);
expect(parsed_path(hash)).toBe(dir);
}
});
});
describe('open_path_target', () => {
let ajax;
beforeEach(() => {
launch_app.mockReset();
resolve_default_app.mockReset();
UIAlert.mockReset();
ajax = vi.fn();
globalThis.$ = { ajax };
globalThis.window = { location: { hash: '', pathname: '/' }, api_origin: 'https://api.test' };
});
afterEach(() => {
delete globalThis.$;
delete globalThis.window;
});
it('opens a folder in the Files tab', async () => {
await open_path_target('/alice/Public', { is_dir: true });
expect(window.location.hash).toBe('#files/alice/Public');
expect(launch_app).not.toHaveBeenCalled();
});
it('opens a file in the app it is associated with', async () => {
await open_path_target('/alice/Public/site.html', {
is_dir: false,
associated_app: { name: 'viewer' },
});
expect(launch_app).toHaveBeenCalledWith({ name: 'viewer' });
});
it('opens a file with the user\'s default app for it', async () => {
const app = { name: 'editor' };
resolve_default_app.mockResolvedValue(app);
await open_path_target('/alice/notes.txt', { is_dir: false, uid: 'u1' });
expect(launch_app).toHaveBeenCalledWith({
name: 'editor',
app_obj: app,
file_path: '/alice/notes.txt',
});
});
it('falls back to the folder when no app can open the file', async () => {
resolve_default_app.mockResolvedValue(null);
ajax.mockResolvedValue({ suggested_apps: [] });
await open_path_target('/alice/Public/blob.bin', { is_dir: false, uid: 'u1' });
expect(UIAlert).toHaveBeenCalled();
expect(launch_app).not.toHaveBeenCalled();
expect(window.location.hash).toBe('#files/alice/Public');
});
});
@@ -220,7 +220,6 @@ const refresh_item_container = function (el_item_container, options) {
if ( item_path !== window.trash_path && item_path !== window.appdata_path ) {
// if this is trash, get original name from item metadata
fsentry.name = metadata.original_name ?? fsentry.name;
const position = window.desktop_item_positions[fsentry.uid] ?? undefined;
UIItem({
appendTo: el_item_container,
uid: fsentry.uid,
@@ -243,7 +242,6 @@ const refresh_item_container = function (el_item_container, options) {
suggested_apps: fsentry.suggested_apps,
disabled: is_disabled,
visible: visible,
position: position,
shared_with_me: fsentry.shared_with_me,
share_mode: fsentry.share_mode,
shared_by: fsentry.shared_by,
@@ -254,28 +252,6 @@ const refresh_item_container = function (el_item_container, options) {
}
}
// if this is desktop, add Trash
if ( $(el_item_container).hasClass('desktop') ) {
try {
const trash = await puter.fs.stat({ path: window.trash_path, consistency: options.consistency ?? 'eventual' });
UIItem({
appendTo: el_item_container,
uid: trash.id,
immutable: trash.immutable,
path: window.trash_path,
icon: { image: (trash.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']), type: 'icon' },
name: trash.name,
is_dir: trash.is_dir,
sort_by: trash.sort_by,
type: trash.type,
is_trash: true,
sortable: false,
});
window.sort_items(el_item_container, $(el_item_container).attr('data-sort_by'), $(el_item_container).attr('data-sort_order'));
} catch (e) {
// Ignored
}
}
// sort items
window.sort_items(
el_item_container,
@@ -42,7 +42,6 @@ Object.assign(window, {
shared_path: '/alice/Shared',
trash_path: '/alice/Trash',
user_preferences: {},
desktop_item_positions: {},
check_fsentry_against_allowed_file_types_string: () => true,
sort_items: () => {},
update_explorer_footer_item_count: () => {},
+15 -17
View File
@@ -21,22 +21,20 @@
* Splits a GUI pathname into the route segments the boot code matches on
* (`window.url_paths`), dropping empty segments.
*
* A leading `desktop` segment is dropped with them: `/desktop` only picks the
* interface, and everything after it is the same route it would be at the
* root. So `/desktop/app/<name>` yields `['app', '<name>']` — the app landing
* every route check downstream already understands — and the only difference
* from `/app/<name>` is that the dashboard-mode check (see initgui.js) doesn't
* claim the path, leaving the app to open on the desktop.
*
* @param {string} pathname - the pathname to parse, e.g. `/desktop/app/editor`
* @param {string} pathname - the pathname to parse, e.g. `/app/editor`
* @returns {string[]} route segments
*/
export const parse_url_paths = (pathname) => {
const paths = String(pathname ?? '')
.split('/')
.filter((element) => element);
if ( paths[0]?.toLocaleLowerCase() === 'desktop' ) {
paths.shift();
}
return paths;
};
export const parse_url_paths = (pathname) => String(pathname ?? '')
.split('/')
.filter((element) => element);
/**
* The pathname with a leading `/desktop` segment dropped. `/desktop` used to
* pick the desktop interface, and what follows it is the same route at the
* root: `/desktop/app/<name>` is `/app/<name>`.
*
* @param {string} pathname - e.g. `/desktop/app/editor`
* @returns {string} e.g. `/app/editor`; `/` when nothing follows the prefix
*/
export const drop_desktop_prefix = (pathname) =>
String(pathname ?? '').replace(/^\/desktop(?=\/|$)/i, '') || '/';
+11 -21
View File
@@ -18,7 +18,7 @@
*/
import { describe, it, expect } from 'vitest';
import { parse_url_paths } from './urlPaths.js';
import { drop_desktop_prefix, parse_url_paths } from './urlPaths.js';
describe('parse_url_paths', () => {
it('splits a pathname into non-empty segments', () => {
@@ -27,30 +27,20 @@ describe('parse_url_paths', () => {
expect(parse_url_paths('/')).toEqual([]);
expect(parse_url_paths('')).toEqual([]);
});
});
describe('drop_desktop_prefix', () => {
it('drops a leading desktop segment so the rest reads as a root route', () => {
expect(parse_url_paths('/desktop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/DeskTop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/desktop')).toEqual([]);
expect(parse_url_paths('/desktop/')).toEqual([]);
expect(drop_desktop_prefix('/desktop/app/editor')).toBe('/app/editor');
expect(drop_desktop_prefix('/DeskTop/app/editor')).toBe('/app/editor');
expect(drop_desktop_prefix('/desktop')).toBe('/');
expect(drop_desktop_prefix('/desktop/')).toBe('/');
});
it('only drops the prefix, not a later or lookalike segment', () => {
expect(parse_url_paths('/app/desktop')).toEqual(['app', 'desktop']);
expect(parse_url_paths('/desktops/app/editor')).toEqual([
'desktops',
'app',
'editor',
]);
expect(parse_url_paths('/desktop/desktop/app')).toEqual([
'desktop',
'app',
]);
expect(drop_desktop_prefix('/')).toBe('/');
expect(drop_desktop_prefix('/app/desktop')).toBe('/app/desktop');
expect(drop_desktop_prefix('/desktops/app/editor')).toBe('/desktops/app/editor');
expect(drop_desktop_prefix('/desktop/desktop/app')).toBe('/desktop/app');
});
});
-40
View File
@@ -1,40 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
async function changeLanguage (lang) {
if ( !lang || window.locale === lang ) return;
// Shell strings are rendered once. Persist first, then reload.
try {
await puter.kv.set('user_preferences.language', lang);
} catch (err) {
console.warn('Could not save user_preferences.language:', err);
return;
}
window.locale = lang;
try {
window.mutate_user_preferences({
language: lang,
});
} catch (err) {
console.warn('Could not apply user_preferences.language:', err);
}
location.reload();
}
export default changeLanguage;
-1
View File
@@ -235,7 +235,6 @@ const cs = {
"open_in_new_tab": "Otevřít na nové kartě",
"open_in_new_window": "Otevřít v novém okně",
"new_email": "Nový e-mail",
"open_desktop": "Otevřít plochu",
"new_username": "Nové uživatelské jméno",
"open_with_ai": "Otevřít pomocí AI",
"os": "Operační systém",
-2
View File
@@ -353,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',
@@ -750,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.',
-59
View File
@@ -1,59 +0,0 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<title>folders-svg</title>
<defs>
<linearGradient id="g1" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,-48.959,190.941,0,-38.837,49.923)">
<stop offset="0" stop-color="#000000" stop-opacity=".318"/>
<stop offset="1" stop-color="#000000" stop-opacity=".24"/>
</linearGradient>
<linearGradient id="g2" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,48.248,-203.712,0,24.927,1.266)">
<stop offset=".001" stop-color="#f59e0b"/>
<stop offset="1" stop-color="#9f7000"/>
</linearGradient>
<linearGradient id="g3" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,41.867,-91.585,0,11.791,2.755)">
<stop offset="0" stop-color="#ffffff"/>
<stop offset=".079" stop-color="#ffffff" stop-opacity=".235"/>
<stop offset=".947" stop-color="#ffffff" stop-opacity=".157"/>
<stop offset="1" stop-color="#ffffff" stop-opacity=".392"/>
</linearGradient>
<linearGradient id="g4" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,37.589,-146.712,0,37.715,6.155)">
<stop offset="0" stop-color="#ffffff"/>
<stop offset=".073" stop-color="#ffffff" stop-opacity=".235"/>
<stop offset=".947" stop-color="#ffffff" stop-opacity=".157"/>
<stop offset="1" stop-color="#ffffff" stop-opacity=".392"/>
</linearGradient>
<linearGradient id="g5" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,49.575,-60.42,0,25.267,.507)">
<stop offset="0" stop-color="#fcd34d"/>
<stop offset="1" stop-color="#e0a720"/>
</linearGradient>
<linearGradient id="g6" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,33.308,-41.08,0,41.143,10.606)">
<stop offset="0" stop-color="#ffffff"/>
<stop offset=".068" stop-color="#ffffff" stop-opacity=".235"/>
<stop offset=".947" stop-color="#ffffff" stop-opacity=".157"/>
<stop offset="1" stop-color="#ffffff" stop-opacity=".392"/>
</linearGradient>
</defs>
<style>
.s0 { fill: none;stroke: url(#g1);stroke-linejoin: round;stroke-width: 1 }
.s1 { fill: url(#g2) }
.s2 { fill: none;stroke: url(#g3);stroke-linecap: round;stroke-linejoin: round;stroke-width: 1 }
.s3 { fill: none;stroke: url(#g4);stroke-linecap: round;stroke-linejoin: round;stroke-width: 1 }
.s4 { opacity: .1;fill: #002e99 }
.s5 { fill: url(#g5);stroke: #fcd34d }
.s6 { opacity: .3;fill: none;stroke: url(#g6);stroke-linecap: round;stroke-linejoin: round }
.s7 { fill: #fafafa }
</style>
<g id="Layer 1">
<path id="rect5505-21-1-2-8-6" fill-rule="evenodd" class="s0" d="m46.3 12.4v-3.4c0-2.5-2.1-4.6-4.6-4.6h-18.8c-1.6 0-3.8-1.7-4.5-2.3-0.8-0.7-1.9-1.1-3-1.1h-9.1c-2.5 0-4.6 2-4.6 4.6v6.8"/>
<path id="rect5505-21-1-2-8-6-1" fill-rule="evenodd" class="s1" d="m6.3 1.6c-2.2 0-4 1.7-4 4v6.3h43.4v-2.9c0-2.2-1.8-4-4-4h-18.8c-1 0-2-0.5-2.9-1-0.9-0.6-1.6-1.2-2-1.5-0.7-0.6-1.6-0.9-2.6-0.9z"/>
<path id="rect5505-21-1-2-8-6-1-9" fill-rule="evenodd" class="s2" d="m22.9 5.6c-1.2 0-2.3-0.6-3.2-1.2-0.9-0.5-1.7-1.1-2.1-1.5-0.6-0.5-1.3-0.8-2.2-0.8h-9.1c-1.9 0-3.4 1.6-3.4 3.5v5.7"/>
<path id="rect5505-21-1-2-8-6-1-9-9" fill-rule="evenodd" class="s3" d="m45.1 11.3v-2.3c0-1.9-1.5-3.4-3.4-3.4h-18.8"/>
<path id="rect5505-21-1-9-7" fill-rule="evenodd" class="s4" d="m6.2 6.1c-3 0-5.7 2.7-5.6 5.8 0 9.3-0.1 18.6 0 28 0.2 2.9 3 5.3 5.9 5.1 11.9 0 23.8 0 35.6 0 3-0.1 5.5-3 5.3-5.9 0-9.3 0.1-18.6 0-27.8-0.2-2.9-3-5.3-5.9-5.2q-17.6 0-35.3 0z"/>
<path id="rect5505-21-1-9" fill-rule="evenodd" class="s4" d="m1.7 11.3c0-2.2 1.8-4 4-4h36.6c2.2 0 4 1.8 4 4v28.6c0 2.2-1.8 4-4 4h-36.6c-2.2 0-4-1.8-4-4z"/>
<path id="rect5505-21-1" fill-rule="evenodd" class="s5" d="m1.7 13c0-2.2 1.8-4 4-4h36.6c2.2 0 4 1.8 4 4v28.6c0 2.2-1.8 4-4 4h-36.6c-2.2 0-4-1.8-4-4z"/>
<path id="rect6741-2" fill-rule="evenodd" class="s6" d="m2.9 13.1c0-1.6 1.3-3 3-3h36.2c1.7 0 3 1.4 3 3v28.3c0 1.7-1.3 3-3 3h-36.2c-1.7 0-3-1.3-3-3z"/>
<path id="path13635-4-7-8-4" fill-rule="evenodd" class="s4" d="m22.3 18.4c-4.1 0.1-7.8 3.4-8.4 7.5-0.7 3.9 1.7 8.1 5.4 9.6 2.4 1 5.3 0.8 7.6-0.4 1.6 1.5 3.2 3.3 4.9 4.8 1.1 0.9 3 0.5 3.7-0.8 0.6-1 0.3-2.5-0.6-3.3q-2.2-2.1-4.4-4.3c1.8-3.3 1.2-7.7-1.4-10.4-1.8-1.8-4.3-2.9-6.8-2.7zm1.3 4.1c2.5 0.5 4.3 3.1 3.9 5.5-0.4 2.8-3.5 4.8-6.2 3.9-2.6-0.7-4.1-3.9-3.1-6.3 0.8-2.2 3.2-3.5 5.4-3.1z"/>
<path id="path13635-4-7-8" fill-rule="evenodd" class="s4" d="m22.7 19.5c-4.3 0-7.8 3.5-7.8 7.8 0 4.2 3.5 7.7 7.8 7.7q0.6 0 1.2-0.1 0.5-0.1 1.1-0.3 0.5-0.1 1.1-0.4 0.5-0.2 1-0.6l5.2 5.2c0.2 0.3 0.6 0.4 1 0.4 0.3 0 0.7-0.1 1-0.4 0.2-0.2 0.4-0.6 0.4-1 0-0.4-0.2-0.7-0.5-1l-5.1-5.2q0.3-0.4 0.6-1 0.2-0.5 0.4-1 0.2-0.6 0.2-1.2 0.1-0.6 0.1-1.1c0-4.3-3.5-7.8-7.7-7.8zm0 1.8q1.2 0 2.3 0.4 1.1 0.5 1.9 1.3 0.9 0.9 1.3 2 0.5 1.1 0.5 2.3 0 1.1-0.5 2.2-0.4 1.1-1.3 2-0.8 0.8-1.9 1.3-1.1 0.4-2.3 0.4-1.2 0-2.3-0.4-1.1-0.5-1.9-1.3-0.9-0.9-1.3-2-0.5-1.1-0.5-2.2 0-1.2 0.5-2.3 0.4-1.1 1.3-2 0.8-0.8 1.9-1.3 1.1-0.4 2.3-0.4z"/>
<path id="path13635-4-7" fill-rule="evenodd" class="s7" d="m22.7 18.4c-4.3 0-7.8 3.5-7.8 7.7 0 4.3 3.5 7.8 7.8 7.8q0.6 0 1.2-0.1 0.5-0.1 1.1-0.3 0.5-0.2 1.1-0.4 0.5-0.3 1-0.6l5.2 5.2c0.2 0.2 0.6 0.4 1 0.4 0.3 0 0.7-0.1 1-0.4 0.2-0.3 0.4-0.6 0.4-1 0-0.4-0.2-0.8-0.5-1l-5.1-5.2q0.3-0.5 0.6-1 0.2-0.5 0.4-1.1 0.2-0.5 0.2-1.1 0.1-0.6 0.1-1.2c0-4.2-3.5-7.7-7.7-7.7zm0 1.7q1.2 0 2.3 0.5 1.1 0.4 1.9 1.3 0.9 0.8 1.3 1.9 0.5 1.1 0.5 2.3 0 1.2-0.5 2.3-0.4 1.1-1.3 1.9-0.8 0.9-1.9 1.3-1.1 0.5-2.3 0.5-1.2 0-2.3-0.5-1.1-0.4-1.9-1.3-0.9-0.8-1.3-1.9-0.5-1.1-0.5-2.3 0-1.2 0.5-2.3 0.4-1.1 1.3-1.9 0.8-0.9 1.9-1.3 1.1-0.5 2.3-0.5z"/>
<path id="path13635-4-7-5" fill-rule="evenodd" class="s4" d="m22.7 19q-1.2 0-2.3 0.4-1.1 0.5-1.9 1.3-0.9 0.9-1.3 2-0.5 1.1-0.5 2.3 0 0 0 0.1 0 0.1 0 0.2 0 0 0 0.1 0 0.1 0.1 0.1 0.1-1.1 0.6-2.1 0.5-1 1.3-1.7 0.8-0.8 1.8-1.2 1.1-0.4 2.2-0.4 1.1 0 2.2 0.4 1 0.5 1.8 1.2 0.9 0.8 1.4 1.8 0.4 1 0.5 2.1 0 0 0-0.1 0.1-0.1 0.1-0.2 0-0.1 0-0.2 0 0 0-0.1 0-1.2-0.5-2.3-0.4-1.1-1.3-2-0.8-0.8-1.9-1.3-1.1-0.4-2.3-0.4zm7.7 6.4q0 0.5-0.1 1.1-0.1 0.5-0.3 1-0.2 0.5-0.4 0.9-0.2 0.5-0.5 1l0.4 0.4q0.2-0.4 0.4-0.9 0.2-0.4 0.3-0.9 0.1-0.5 0.2-0.9 0-0.5 0-1 0-0.3 0-0.7zm-15.4 0.1q-0.1 0.3-0.1 0.6c0 4.3 3.5 7.8 7.8 7.8q0.6 0 1.2-0.1 0.5-0.1 1.1-0.3 0.5-0.2 1.1-0.4 0.5-0.3 1-0.6l5.2 5.2q0.3 0.3 0.8 0.4 0.5 0 0.9-0.3 0.5-0.2 0.6-0.7 0.1-0.5-0.1-1-0.1 0.3-0.4 0.5-0.2 0.3-0.6 0.3-0.3 0.1-0.7 0-0.3-0.1-0.5-0.4l-5.2-5.2q-0.5 0.4-1 0.6-0.6 0.3-1.1 0.5-0.6 0.1-1.1 0.2-0.6 0.1-1.2 0.1c-4.1 0-7.4-3.2-7.7-7.2z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

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

Before

Width:  |  Height:  |  Size: 551 B

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

-41
View File
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="59px" height="59px" stroke-width="1.9" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg" color="#ffffff">
<path d="M15 12L15 15" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 3V6" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M18 12L18 15" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 18L21 18" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M18 21H21" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M6 12H9" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M6 6.01111L6.01 6" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M12 12.0111L12.01 12" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M3 12.0111L3.01 12" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M12 9.01111L12.01 9" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M12 15.0111L12.01 15" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M15 21.0111L15.01 21" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M12 21.0111L12.01 21" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M21 12.0111L21.01 12" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M21 15.0111L21.01 15" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M18 6.01111L18.01 6" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path
d="M9 3.6V8.4C9 8.73137 8.73137 9 8.4 9H3.6C3.26863 9 3 8.73137 3 8.4V3.6C3 3.26863 3.26863 3 3.6 3H8.4C8.73137 3 9 3.26863 9 3.6Z"
stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M21 3.6V8.4C21 8.73137 20.7314 9 20.4 9H15.6C15.2686 9 15 8.73137 15 8.4V3.6C15 3.26863 15.2686 3 15.6 3H20.4C20.7314 3 21 3.26863 21 3.6Z"
stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M6 18.0111L6.01 18" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
</path>
<path
d="M9 15.6V20.4C9 20.7314 8.73137 21 8.4 21H3.6C3.26863 21 3 20.7314 3 20.4V15.6C3 15.2686 3.26863 15 3.6 15H8.4C8.73137 15 9 15.2686 9 15.6Z"
stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M 11.742 5.658 C 14.7 9.694 12.179 15.418 7.205 15.962 C 2.231 16.505 -1.466 11.46 0.55 6.881 C 2.226 3.076 6.993 1.8 10.345 4.26 L 10.344 4.26 C 10.373 4.22 10.406 4.182 10.442 4.145 L 14.292 0.295 C 14.836 -0.25 15.766 -0.001 15.966 0.743 C 16.058 1.088 15.96 1.456 15.707 1.709 L 11.857 5.559 C 11.821 5.595 11.783 5.629 11.742 5.659 L 11.742 5.658 Z M 12 9.502 C 12 5.268 7.417 2.622 3.75 4.739 C 2.048 5.721 1 7.537 1 9.502 C 1 13.736 5.583 16.382 9.25 14.265 C 10.952 13.283 12 11.467 12 9.502" style="transform-box: fill-box; transform-origin: 50% 50%; fill: rgb(255, 255, 255);" transform="matrix(-1, 0, 0, -1, 0, 0.000001)"/>
</svg>

Before

Width:  |  Height:  |  Size: 816 B

-34
View File
@@ -1,34 +0,0 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<title>start-svg</title>
<style>
.s0 { opacity: .2;fill: #000000 }
.s1 { fill: #69a2f5 }
.s2 { opacity: .2;fill: #ffffff }
.s3 { fill: #ffffff }
</style>
<g id="Layer 1">
<path id="Layer" fill-rule="evenodd" class="s0" d="m24.5 47c-12.4 0-22.5-10-22.5-22.5 0-12.4 10.1-22.4 22.5-22.4 12.4 0 22.5 10 22.5 22.4 0 12.5-10.1 22.5-22.5 22.5z"/>
<path id="Layer" fill-rule="evenodd" class="s1" d="m24.5 45.9c-12.4 0-22.5-10.1-22.5-22.5 0-12.4 10.1-22.4 22.5-22.4 12.4 0 22.5 10 22.5 22.4 0 12.4-10.1 22.5-22.5 22.5z"/>
<path id="Layer" fill-rule="evenodd" class="s2" d="m24.5 1c-12.4 0-22.5 10-22.5 22.4q0 0.3 0.1 0.6c0.2-12.2 10.1-21.9 22.4-21.9 12.3 0 22.2 9.7 22.4 21.9q0.1-0.3 0.1-0.6c0-12.4-10.1-22.4-22.5-22.4z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m11 14.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m21.1 14.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m31.2 14.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m11 23.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m21.1 23.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m31.2 23.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m11 32.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m21.1 32.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s0" d="m31.2 32.2c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<g id="Layer">
<path id="Layer" fill-rule="evenodd" class="s3" d="m11 13.1c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m21.1 13.1c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m31.2 13.1c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m11 22.1c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m21.1 22.1c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m31.2 22.1c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.7c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m11 31c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.8c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m21.1 31c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.8c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
<path id="Layer" fill-rule="evenodd" class="s3" d="m31.2 31c0-1.1 0.9-2 2-2h2.8c1.1 0 2 0.9 2 2v2.8c0 1.1-0.9 2-2 2h-2.8c-1.1 0-2-0.9-2-2z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

+78 -169
View File
@@ -21,7 +21,6 @@ import UIDashboard from './UI/Dashboard/UIDashboard.js';
import TabApps from './UI/Dashboard/TabApps.js';
import UIAlert from './UI/UIAlert.js';
import UIComponentWindow from './UI/UIComponentWindow.js';
import UIDesktop from './UI/UIDesktop.js';
import UIWindow from './UI/UIWindow.js';
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
import UIWindowAttachCustomDomain from './UI/UIWindowAttachCustomDomain.js';
@@ -67,7 +66,10 @@ import { installAppIconFallback } from './helpers/appIcon.js';
import launch_app from './helpers/launchApp.js';
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
import { parse_url_paths } from './helpers/urlPaths.js';
import { drop_desktop_prefix, parse_url_paths } from './helpers/urlPaths.js';
import { legacy_landing } from './helpers/legacyLanding.js';
import { open_path_target } from './helpers/openPathTarget.js';
import download_url_to_desktop from './helpers/downloadUrlToDesktop.js';
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
import update_mouse_position from './helpers/updateMousePosition.js';
import update_title_based_on_uploads from './helpers/updateTitleBasedOnUploads.js';
@@ -233,23 +235,9 @@ const postAuthActions = async (action) => {
}
// -------------------------------------------------------------------------------------
// Load desktop, only if we're not embedded in a popup and not on the dashboard page
// Dashboard, unless we're embedded in a popup
// -------------------------------------------------------------------------------------
if ( !window.embedded_in_popup && !window.is_dashboard_mode ) {
if ( window.is_fullpage_mode ) {
// In fullpage mode, skip loading desktop items and background
UIDesktop({});
} else {
await window.get_auto_arrange_data();
puter.fs.stat({ path: window.desktop_path, consistency: 'eventual' }).then(desktop_fsentry => {
UIDesktop({ desktop_fsentry: desktop_fsentry });
});
}
}
// -------------------------------------------------------------------------------------
// Dashboard mode
// -------------------------------------------------------------------------------------
else if ( window.is_dashboard_mode ) {
if ( window.is_dashboard_mode ) {
// Paint in the language this browser last loaded, so the dashboard
// doesn't wait on the saved preferences; a different saved language
// reloads into it.
@@ -264,19 +252,59 @@ const postAuthActions = async (action) => {
window.dashboard_object.renderDirectory(window.dashboard_object.currentPath);
}
});
// A URL that used to open the desktop, or a `/?download=<url>` link,
// is acted on once the dashboard is up. It's read by now, so the
// address bar is swapped for the dashboard's own URL first.
const legacy = window.dashboard_legacy_landing;
const download_url = window.url_paths.length === 0
? window.url_query_params.get('download')
: null;
if ( legacy || download_url ) {
const params = new URLSearchParams(window.location.search);
for ( const key of ['app', 'path', 'download'] ) params.delete(key);
const search = params.toString();
window.history.replaceState(null, '', `/${search ? `?${search}` : ''}${window.location.hash}`);
}
// A linked folder is where Files opens; a linked file opens in its
// app once the dashboard is up.
let linked_file = null;
if ( legacy?.open_path ) {
try {
const stat = await puter.fs.stat({ path: legacy.open_path, consistency: 'eventual' });
if ( stat.is_dir ) {
window.dashboard_initial_route = { tab: 'files', path: legacy.open_path };
} else {
linked_file = stat;
}
} catch ( e ) {
UIAlert({ message: i18n('error_user_or_path_not_found'), type: 'error' });
}
}
const el_dashboard_promise = UIDashboard();
if ( legacy?.invalid_username ) {
UIAlert({ message: i18n('error_invalid_username') });
} else if ( linked_file ) {
el_dashboard_promise
.then(() => open_path_target(legacy.open_path, linked_file))
.catch(err => console.error('Could not open the linked file:', err));
}
if ( download_url ) {
el_dashboard_promise
.then(() => download_url_to_desktop(download_url))
.catch(err => console.error('Could not start the linked download:', err));
}
// Direct landing on /app/<name>: open the app in the dashboard the
// same way a tile launch does. The dashboard's route is slotted
// underneath first (replaceState) and the launch re-claims
// /app/<name> as a real history entry, so Back minimizes to the
// dashboard exactly like an in-dashboard launch. (`?c` suppresses
// the auto-launch, mirroring the desktop URL-launch flow.)
// the auto-launch.)
if ( window.url_paths[0]?.toLocaleLowerCase() === 'app'
&& window.url_paths[1]
&& ! window.url_query_params.has('c') ) {
const app_name = window.url_paths[1];
// any query param that doesn't start with 'puter.' is passed
// through to the app (mirrors the desktop URL-launch flow)
// through to the app
const app_query_params = {};
for ( const [key, value] of window.url_query_params ) {
if ( ! key.startsWith('puter.') ) {
@@ -1135,41 +1163,36 @@ if (jQuery) {
};
}
// are we in dashboard mode?
// The dashboard is the default interface at the root path; `/dashboard` is kept as an
// alias, and `/desktop` loads the desktop instead. Direct app landings (`/app/<name>`)
// open in the dashboard too: the app comes up maximized in-page with the dashboard
// route slotted underneath (see postAuthActions), so Back minimizes to the dashboard.
// To land the same app on the desktop instead, prefix the path:
// `/desktop/app/<name>` doesn't match the dashboard paths below, so it falls
// through to the desktop.
// URLs that carry a desktop-only flow keep booting the desktop: auth popups
// (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`),
// fullpage mode (`?puter.fullpage=`), and iframe embeds. A share link (`?shared=`,
// from an email) lands in the dashboard: Files, on Shared, with the items it names
// picked out; `/desktop?shared=` opens the item on the desktop instead. App metadata
// like fullpage_on_landing does NOT opt a landing out of the dashboard; it only
// affects boots that still go through the desktop flow.
// Every boot except an auth popup opens the dashboard. Direct app landings
// (`/app/<name>`) come up maximized in-page with the dashboard route slotted
// underneath (see postAuthActions), so Back minimizes to the dashboard. A share
// link (`?shared=`, from an email) lands in Files, on Shared, with the items it
// names picked out. URLs that used to open the desktop land on their dashboard
// equivalent: a `/desktop` prefix is dropped here, and the rest are resolved by
// legacy_landing once the dashboard is up.
{
const pathname = window.location.pathname;
const search_params = new URLSearchParams(window.location.search);
if (['true', '1'].includes(search_params.get('embedded_in_popup'))) {
window.embedded_in_popup = true;
}
// note: iframe detection is inlined because globals.js (window.is_embedded) loads after this module
const in_iframe = window.location !== window.parent.location;
const needs_desktop_at_root =
window.embedded_in_popup ||
in_iframe ||
search_params.has('puter.fullpage') ||
search_params.has('app') ||
search_params.has('download');
const is_dashboard_alias =
pathname === '/dashboard' || pathname === '/dashboard/';
const is_app_landing = /^\/app\/[^/]+\/?$/.test(pathname);
if (is_dashboard_alias || ((pathname === '/' || is_app_landing) && !needs_desktop_at_root)) {
const pathname = drop_desktop_prefix(window.location.pathname);
if (pathname !== window.location.pathname) {
window.history.replaceState(
null,
'',
`${pathname}${window.location.search}${window.location.hash}`,
);
}
if (!window.embedded_in_popup) {
window.is_dashboard_mode = true;
window.dashboard_initial_route = parseDashboardRoute();
window.dashboard_legacy_landing = legacy_landing(
parse_url_paths(window.location.pathname),
search_params,
);
if (window.dashboard_legacy_landing?.route) {
window.dashboard_initial_route = window.dashboard_legacy_landing.route;
}
// A share link outranks the hash: Files, on Shared, with what it names
// picked out. The values go through raw — the Files tab validates them.
if (search_params.has('shared')) {
@@ -1403,10 +1426,6 @@ window.initgui = async function (options) {
const oidcChallenge = takeOidcLoginChallenge();
const url = new URL(window.location).href;
window.url = url;
// Route segments with a leading `/desktop` dropped, so the route checks
// downstream (app landings, actions) never have to know about the prefix:
// `/desktop/app/<name>` opens the app on the desktop the same way
// `/app/<name>` opens it in the dashboard.
const url_paths = parse_url_paths(window.location.pathname);
window.url_paths = url_paths;
@@ -1533,27 +1552,8 @@ window.initgui = async function (options) {
return;
}
//--------------------------------------------------------------------------------------
// Determine if we are in full-page mode
// i.e. https://puter.com/app/<app_name>/?puter.fullpage=true
//--------------------------------------------------------------------------------------
if (
window.url_query_params.has('puter.fullpage') &&
(window.url_query_params.get('puter.fullpage') === 'false' ||
window.url_query_params.get('puter.fullpage') === '0')
) {
window.is_fullpage_mode = false;
} else if (
window.url_query_params.has('puter.fullpage') &&
(window.url_query_params.get('puter.fullpage') === 'true' ||
window.url_query_params.get('puter.fullpage') === '1')
) {
// In fullpage mode, we want to hide the taskbar for better UX
window.taskbar_height = 0;
// Puter is in fullpage mode.
window.is_fullpage_mode = true;
} else if (window.is_dashboard_mode) {
// The dashboard fills the page; only an auth popup isn't in fullpage mode.
if (window.is_dashboard_mode) {
window.is_fullpage_mode = true;
}
@@ -1851,37 +1851,10 @@ window.initgui = async function (options) {
}
//--------------------------------------------------------------------------------------
// Early check for fullpage mode from app metadata
// If the user navigated to /app/<app_name> and the app has fullpage_on_landing,
// set fullpage mode now so we can skip loading the desktop background and items.
// Dashboard mode never reaches the fetch (it sets is_fullpage_mode itself): app
// landings open in the dashboard regardless of fullpage_on_landing — the flag only
// matters for the boots that still go through the desktop flow (embeds, popups).
//--------------------------------------------------------------------------------------
if (
!window.is_fullpage_mode &&
window.url_paths[0]?.toLocaleLowerCase() === 'app' &&
window.url_paths[1]
) {
try {
const app_info = await puter.apps.get(window.url_paths[1], {
icon_size: 64,
});
if (app_info?.metadata?.fullpage_on_landing) {
window.is_fullpage_mode = true;
window.taskbar_height = 0;
window.app_launched_from_url = app_info;
}
} catch (e) {
// App metadata fetch failed; will retry later in UIDesktop
}
}
//--------------------------------------------------------------------------------------
// Desktop background (early)
// Set before action=login/signup so OIDC error redirects show the background behind the form.
// Wallpaper behind `/action/<name>` pages, whose dialogs (a password reset,
// an OIDC error on login) show before the dashboard does
// -------------------------------------------------------------------------------------
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
if (window.url_paths[0]?.toLowerCase() === 'action' && !window.embedded_in_popup) {
window.refresh_desktop_background();
}
@@ -2144,7 +2117,7 @@ window.initgui = async function (options) {
} catch (e) { /* sessionStorage unavailable */ }
}
// remove the token from URL, keeping the current path (e.g. `/` or
// `/desktop`) and hash (dashboard tab links like /#usage).
// `/app/<name>`) and hash (dashboard tab links like /#usage).
//
// `replaceState`, not `pushState`: pushing leaves the token-bearing URL
// as the previous history entry, so Back returns to a URL containing a
@@ -2280,15 +2253,9 @@ window.initgui = async function (options) {
$('.window').close();
// close all ctxmenus
$('.context-menu').remove();
// remove desktop
$('.desktop').remove();
// remove taskbar
$('.taskbar').remove();
// disable native browser exit confirmation
window.onbeforeunload = null;
// go back to the interface the user was in: dashboard users to the root
// dashboard, desktop users to /desktop
window.location.replace(window.is_dashboard_mode ? '/' : '/desktop');
window.location.replace('/');
});
$(document).ajaxError(async function (event, jqxhr) {
@@ -2439,14 +2406,6 @@ window.initgui = async function (options) {
}
}
// -------------------------------------------------------------------------------------
// Desktop Background
// If we're in fullpage/emebedded/Auth Popup mode, we don't want to load the custom background
// because it's not visible anyway and it's a waste of bandwidth
// -------------------------------------------------------------------------------------
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
window.refresh_desktop_background();
}
// -------------------------------------------------------------------------------------
// Un-authed but not first visit -> try to log in/sign up
// -------------------------------------------------------------------------------------
// No session after all: the anonymous markup the shell sent is the correct
@@ -2845,28 +2804,6 @@ window.initgui = async function (options) {
// close all windows
$('.window').close();
// -------------------------------------------------------------------------------------
// Early check for fullpage mode from app metadata (after login)
// -------------------------------------------------------------------------------------
if (
!window.is_fullpage_mode &&
window.url_paths[0]?.toLocaleLowerCase() === 'app' &&
window.url_paths[1]
) {
try {
const app_info = await puter.apps.get(window.url_paths[1], {
icon_size: 64,
});
if (app_info?.metadata?.fullpage_on_landing) {
window.is_fullpage_mode = true;
window.taskbar_height = 0;
window.app_launched_from_url = app_info;
}
} catch (e) {
// App metadata fetch failed; will retry later in UIDesktop
}
}
await postAuthActions(action);
});
@@ -2908,10 +2845,6 @@ window.initgui = async function (options) {
// click on anything will close all popovers, but there are some exceptions
if (
!$(e.target).hasClass('start-app') &&
!$(e.target).hasClass('launch-search') &&
!$(e.target).hasClass('launch-search-clear') &&
$(e.target).closest('.start-app').length === 0 &&
!isMobile.phone &&
!isMobile.tablet &&
!$(e.target).hasClass('popover') &&
@@ -2959,21 +2892,6 @@ window.initgui = async function (options) {
// Window Activation
//--------------------------------------------------------
$(document).on('mousedown', function (e) {
// if taskbar or any parts of it is clicked, drop the event
if (
$(e.target).hasClass('taskbar') ||
$(e.target).closest('.taskbar').length > 0
) {
return;
}
// if toolbar or any parts of it is clicked, drop the event
if (
$(e.target).hasClass('toolbar') ||
$(e.target).closest('.toolbar').length > 0
) {
return;
}
// if close or minimize button clicked, drop the event
if (
document
@@ -3057,15 +2975,6 @@ $(document).on('click', '.generic-close-window-button', function (e) {
$(this).closest('.window').close();
});
$(document).on('click', function (e) {
if (
!$(e.target).hasClass('window-search') &&
$(e.target).closest('.window-search').length === 0 &&
!$(e.target).is('.toolbar-btn.search-btn')
) {
$('.window-search').close();
}
});
// Re-calculate desktop height and width on window resize and re-position the login and signup windows
$(window).on('resize', function () {
+4 -252
View File
@@ -18,195 +18,19 @@
*/
import UIAlert from './UI/UIAlert.js';
import UIWindowSearch from './UI/UIWindowSearch.js';
import launch_app from './helpers/launchApp.js';
import open_item from './helpers/openItem.js';
import determine_active_container_parent from './helpers/determineActiveContainerParent.js';
$(document).bind('keydown', async function (e) {
const focused_el = document.activeElement;
//-----------------------------------------------------------------------------
// Search
// ctrl/command + f, will open UIWindowSearch — but not while the dashboard
// is open, where ctrl/cmd + f should fall through to the browser's find.
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 70 && !$(focused_el).is('input') && !$(focused_el).is('textarea') && $('.window-dashboard').length === 0 ) {
e.preventDefault();
e.stopPropagation();
UIWindowSearch();
return false;
}
//-----------------------------------------------------------------------
// ← ↑ → ↓: an arrow key is pressed
//-----------------------------------------------------------------------
if (( e.which === 37 || e.which === 38 || e.which === 39 || e.which === 40 )) {
// ----------------------------------------------
// Launch menu is open
// ----------------------------------------------
if ( $('.launch-popover').length > 0 ) {
// constants
const max_rows = $('body').hasClass('device-desktop') ? 5 : 4; // number of columns in the grid
const all_apps = $('.launch-popover .start-app-card:visible');
const recents = $('.launch-popover .launch-apps-recent .start-app-card:visible');
const recommended = $('.launch-popover .launch-apps-recommended .start-app-card:visible');
const search = $('.launch-popover .launch-search');
const selected_element = $('.launch-popover .start-app-card.launch-app-selected');
// helper functions for grid navigation
// get item at row/col in section (recents or recommended)
function item (row, col, section) {
let apps = (section === 'recents') ? recents : recommended;
const idx = row * max_rows + (col - 1);
if ( idx < 0 || idx >= apps.length ) return null;
return apps.get(idx);
}
// get row/col of item in all_apps
function coord (it) {
if ( !it || it.length === 0 ) return null;
const index = all_apps.index(it);
if ( index < 0 ) return null;
// row is 0-based; col is 1-based to match item(row,col)
return { row: Math.floor(index / max_rows), col: (index % max_rows) + 1 };
}
// select an item
function select (el) {
// clear previous
all_apps.removeClass('launch-app-selected');
// close context menus
$('.context-menu').fadeOut(200, function () {
$(this).remove();
});
if ( ! el ) return;
// add to new
$(el).addClass('launch-app-selected');
// ensure visible
if ( el.scrollIntoView ) {
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
// helpers for section-local positioning and row/column counts
// number of rows in a given section
function rows (section) {
const len = (section === 'recents' ? recents : recommended).length;
return Math.ceil(len / max_rows);
}
// number of columns in a given row of a section
function columns (section, row) {
const len = (section === 'recents' ? recents : recommended).length;
const full_rows = Math.floor(len / max_rows);
const remainder = len % max_rows;
if ( row < full_rows ) return max_rows;
if ( row === full_rows ) return remainder === 0 ? max_rows : remainder;
return 0;
}
// get local row/col/index of element in section
function coords_local (section, el) {
const list = (section === 'recents' ? recents : recommended);
const idx = list.index(el);
if ( idx < 0 ) return null;
return { index: idx, row: Math.floor(idx / max_rows), col: (idx % max_rows) + 1 };
}
const selected = coord(selected_element);
// states
const search_focused = search.is(':focus');
const selected_grid = (selected && selected_element.parent().hasClass('launch-apps-recent')) ? 'recents' : 'recommended';
if ( e.which === 38 ) { // up
if ( selected_element.length === 0 ) return false;
if ( selected_grid === 'recents' ) {
const pos = coords_local('recents', selected_element);
if ( ! pos ) return false;
if ( pos.row === 0 ) {
// move to search
search.focus();
all_apps.removeClass('launch-app-selected');
} else {
const targetCol = Math.min(pos.col, columns('recents', pos.row - 1));
select(item(pos.row - 1, targetCol, 'recents'));
}
} else { // recommended
const pos = coords_local('recommended', selected_element);
if ( ! pos ) return false;
if ( pos.row === 0 ) {
if ( recents.length > 0 ) {
const lastRow = rows('recents') - 1;
const targetCol = Math.min(pos.col, columns('recents', lastRow));
select(item(lastRow, targetCol, 'recents'));
} else {
// focus search if no recents exist
search.focus();
all_apps.removeClass('launch-app-selected');
}
} else {
const targetCol = Math.min(pos.col, columns('recommended', pos.row - 1));
select(item(pos.row - 1, targetCol, 'recommended'));
}
}
} else if ( e.which === 40 ) { // down
// select first item if none selected
if ( selected_element.length === 0 ) {
// unfocus search
search.blur();
if ( recents.length > 0 ) {
select(item(0, 1, 'recents'));
} else if ( recommended.length > 0 ) {
select(item(0, 1, 'recommended'));
}
} else {
if ( selected_grid === 'recents' ) {
const pos = coords_local('recents', selected_element);
if ( ! pos ) return false;
const rc = rows('recents');
if ( pos.row + 1 < rc ) {
const tgt = Math.min(pos.col, columns('recents', pos.row + 1));
select(item(pos.row + 1, tgt, 'recents'));
} else if ( recommended.length > 0 ) {
const tgt = Math.min(pos.col, columns('recommended', 0));
select(item(0, tgt, 'recommended'));
}
} else { // recommended
const pos = coords_local('recommended', selected_element);
if ( ! pos ) return false;
const rc = rows('recommended');
if ( pos.row + 1 < rc ) {
const tgt = Math.min(pos.col, columns('recommended', pos.row + 1));
select(item(pos.row + 1, tgt, 'recommended'));
}
}
}
} else if ( e.which === 37 ) { // left
if ( selected_element.length === 0 ) return false;
const pos = coords_local(selected_grid, selected_element);
if ( ! pos ) return false;
const count = columns(selected_grid, pos.row);
const next = pos.col > 1 ? pos.col - 1 : count;
select(item(pos.row, next, selected_grid));
} else if ( e.which === 39 ) { // right
if ( selected_element.length === 0 ) return false;
const pos = coords_local(selected_grid, selected_element);
if ( ! pos ) return false;
const count = columns(selected_grid, pos.row);
const next = pos.col < count ? pos.col + 1 : 1;
select(item(pos.row, next, selected_grid));
}
return false;
}
// ----------------------------------------------
// A context menu is open
// ----------------------------------------------
else if ( $('.context-menu').length > 0 ) {
if ( $('.context-menu').length > 0 ) {
// if no item is selected and down arrow is pressed, select the first item
if ( $('.context-menu-active .context-menu-item-active').length === 0 && (e.which === 40) ) {
let selected_item = $('.context-menu-active .context-menu-item').get(0);
@@ -411,36 +235,6 @@ $(document).bind('keydown', async function (e) {
next.el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
// ----------------------------------------------
// Navigate search results in the search window
// ----------------------------------------------
else if ( $('.window-search').length > 0 ) {
let selected_item = $('.window-search .search-result-active').get(0);
let selected_item_index = selected_item ? $('.window-search .search-result').index(selected_item) : -1;
let new_selected_item_index = selected_item_index;
let new_selected_item;
// if up arrow is pressed
if ( e.which === 38 ) {
new_selected_item_index = selected_item_index - 1;
if ( new_selected_item_index < 0 )
{
new_selected_item_index = $('.window-search .search-result').length - 1;
}
}
// if down arrow is pressed
else if ( e.which === 40 ) {
new_selected_item_index = selected_item_index + 1;
if ( new_selected_item_index >= $('.window-search .search-result').length )
{
new_selected_item_index = 0;
}
}
new_selected_item = $('.window-search .search-result').get(new_selected_item_index);
$(selected_item).removeClass('search-result-active');
$(new_selected_item).addClass('search-result-active');
new_selected_item.scrollIntoView(false);
}
}
//-----------------------------------------------------------------------
// if the Esc key is pressed on a FileDialog/Alert, close that FileDialog/Alert
@@ -464,28 +258,14 @@ $(document).bind('keydown', async function (e) {
$(focused_el).val($(focused_el).closest('.window').attr('data-path'));
$(focused_el).attr('data-path', $(focused_el).closest('.window').attr('data-path'));
}
//-----------------------------------------------------------------------
// if the Esc key is pressed on a Search Window, close the Search Window
//-----------------------------------------------------------------------
else if ( e.which === 27 && $('.window-search').length > 0 ) {
$('.window-search').close();
}
//-----------------------------------------------------------------------
// Esc key should:
// - always close open context menus
// - close the Launch Popover if it's open
// Esc key should always close open context menus
//-----------------------------------------------------------------------
if ( e.which === 27 ) {
// close open context menus
const closedMenu = $('.context-menu').length > 0;
$('.context-menu').remove();
// close the Launch Popover if it's open
$('.launch-popover').closest('.popover').fadeOut(200, function () {
$('.launch-popover').closest('.popover').remove();
});
// That Escape was the menu's; later handlers must not also act on it
// (clear a Files selection, close its preview).
if ( closedMenu ) e.stopImmediatePropagation();
@@ -682,7 +462,7 @@ $(document).bind('keyup keydown', async function (e) {
}
//-----------------------------------------------------------------------------
// Select All
// ctrl/command + a, will select all items on desktop and windows
// ctrl/command + a, will select all items in the active window
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 65 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
let $parent_container = $(window.active_element).closest('.item-container');
@@ -778,16 +558,6 @@ $(document).bind('keyup keydown', async function (e) {
return false;
}
//-----------------------------------------------------------------------
// Enter key on a search window result
//-----------------------------------------------------------------------
if ( e.which === 13 && $('.window-search').length > 0
// prevent firing twice, because this will be fired on both keyup and keydown
&& e.type === 'keydown' ) {
$('.window-search .search-result-active').trigger('click');
return false;
}
//-----------------------------------------------------------------------
// Open
// Enter key on a selected item will open it
//-----------------------------------------------------------------------
@@ -801,28 +571,10 @@ $(document).bind('keyup keydown', async function (e) {
// still receive its synthesized click when nothing below claims
// the key.
// ---------------------------------------------
// if this is a selected Launch menu item, open it
// ---------------------------------------------
if ( $('.launch-app-selected').length > 0 ) {
e.preventDefault();
e.stopPropagation();
// close launch menu
$('.launch-popover').fadeOut(200, function () {
launch_app({
name: $('.launch-app-selected').attr('data-name'),
});
$('.popover-launcher').remove();
// taskbar item inactive
$('.taskbar-item[data-name="Start"]').removeClass('has-open-popover');
});
return false;
}
// ---------------------------------------------
// if this is a selected context menu item, open it
// ---------------------------------------------
else if ( $('.context-menu-active .context-menu-item-active').length > 0 && (e.which === 13) ) {
if ( $('.context-menu-active .context-menu-item-active').length > 0 && (e.which === 13) ) {
e.preventDefault();
e.stopPropagation();
let selected_item = $('.context-menu-active .context-menu-item-active').get(0);
-1
View File
@@ -22,7 +22,6 @@ import { describe, expect, it, vi } from 'vitest';
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindowSearch.js', () => ({ default: vi.fn() }));
vi.mock('./helpers/launchApp.js', () => ({ default: vi.fn() }));
vi.mock('./helpers/openItem.js', () => ({ default: vi.fn() }));
+4 -3
View File
@@ -89,7 +89,9 @@ import { checkPermissions } from './perms/lib/holds.js';
* minimize, and maximize buttons.
* @property {number} [height] Height of the window in pixels.
* @property {boolean} [is_resizable] If `true`, the user can resize the window.
* @property {boolean} [show_in_taskbar] If `true`, the window is represented in the taskbar.
* @property {boolean} [show_in_taskbar] Has no effect: Puter no longer has a taskbar. Still accepted, so
* existing calls keep working.
* @deprecated
* @property {string} [title] Title of the window.
* @property {number} [width] Width of the window in pixels.
*/
@@ -106,8 +108,7 @@ import { checkPermissions } from './perms/lib/holds.js';
* @property {FSItem[]} [items] `FSItem` objects to open with the launched app.
* @property {string} [pseudonym] A pseudonym to launch the app under.
* @property {boolean} [background] If `true`, the app starts with its window hidden, for an app
* launched to do work rather than to be looked at. It still appears in the taskbar, so the user can
* show it (or close it) at any time, and it can show itself with `puter.ui.showWindow()`.
* launched to do work rather than to be looked at. It can show itself with `puter.ui.showWindow()`.
* @property {(connection: AppConnection) => void} [callback]
*/
@@ -1143,7 +1143,7 @@ test.describe('request-permission action hardening', () => {
);
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
// Give the post-auth action a chance to run before asserting absence.
await page.locator('.desktop').waitFor({ timeout: 60_000 });
await page.locator('.window-dashboard').waitFor({ timeout: 60_000 });
await expect(page.locator('dialog.perm-dialog')).toHaveCount(0);
});
@@ -1279,7 +1279,7 @@ test.describe('request-permission action hardening', () => {
`&origin=${encodeURIComponent('https://not-the-requester.example/')}`,
);
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
await page.locator('.desktop').waitFor({ timeout: 60_000 });
await page.locator('.window-dashboard').waitFor({ timeout: 60_000 });
await expect(page.locator('dialog.perm-dialog')).toHaveCount(0);
});