mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e11e7dc79 | ||
|
|
5b1b5b8a7a |
No files matched your search
@@ -1,165 +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 { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
|
||||
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
|
||||
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
|
||||
|
||||
/**
|
||||
* Folder view preference routes: layout and sort_by/sort_order, stored on the
|
||||
* folder's fsentries row.
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — layout, sort order. Each persists on every call,
|
||||
* and the GUI fires them on direct user action, so a per-minute ceiling well
|
||||
* above human speed is enough to catch a stuck client.
|
||||
*/
|
||||
const PREFERENCE_WRITE_LIMIT = {
|
||||
scope: 'desktop-preference',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
registerRoutes(router) {
|
||||
// -- Folder layout -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_layout',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, layout } = req.body ?? {};
|
||||
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#updateFSEntry(
|
||||
req.actor,
|
||||
{ item_uid, item_path },
|
||||
{ layout },
|
||||
);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Folder sort ---------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_sort_by',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, sort_by, sort_order } =
|
||||
req.body ?? {};
|
||||
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const resolvedOrder = sort_order ?? 'asc';
|
||||
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#updateFSEntry(
|
||||
req.actor,
|
||||
{ item_uid, item_path },
|
||||
{
|
||||
sort_by,
|
||||
sort_order: resolvedOrder,
|
||||
},
|
||||
);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
|
||||
* path) or `item_path` (path lookups in FSEntryStore now have a
|
||||
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
|
||||
* works for old accounts too).
|
||||
*
|
||||
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
|
||||
* against bad paths — the previous "drop user_id entirely" theory turned
|
||||
* out to be wrong (the actual legacy issue was NULL paths, not user_id
|
||||
* drift), so this filter doesn't lock out old accounts in practice.
|
||||
*/
|
||||
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
|
||||
if (!item_uid && !item_path) {
|
||||
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const entry = item_uid
|
||||
? await this.stores.fsEntry.getEntryByUuid(item_uid)
|
||||
: await this.stores.fsEntry.getEntryByPath(item_path);
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Item not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const actorUserId = actor?.user?.id;
|
||||
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
|
||||
throw new HttpError(403, 'Not allowed to update this item', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
const keys = Object.keys(patch);
|
||||
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
|
||||
const values = keys.map((k) => patch[k]);
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
|
||||
[...values, entry.id],
|
||||
);
|
||||
|
||||
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
|
||||
}
|
||||
|
||||
onServerStart() {}
|
||||
onServerPrepareShutdown() {}
|
||||
onServerShutdown() {}
|
||||
}
|
||||
@@ -1,381 +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 { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
// redis) and re-registers DesktopController's inline lambda routes
|
||||
// onto a fresh PuterRouter so each handler is reachable. Each test
|
||||
// makes its own user via `makeUser` and exercises the live controller
|
||||
// against the real wired stores (user, fsEntry) and DB client.
|
||||
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
router = new PuterRouter();
|
||||
server.controllers.desktop.registerRoutes(router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { statusCode: 200, body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
setHeader: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
const findHandler = (method: string, path: string): RequestHandler => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
||||
return route.handler;
|
||||
};
|
||||
|
||||
const callRoute = async (
|
||||
method: string,
|
||||
path: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const handler = findHandler(method, path);
|
||||
await handler(req, res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
};
|
||||
|
||||
describe('DesktopController routes', () => {
|
||||
it('has no desktop background route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/set-desktop-bg'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('has no taskbar items route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/update-taskbar-items'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_layout ─────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_layout', () => {
|
||||
it('persists the new layout on the matching fsentry', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
expect(documents).not.toBeNull();
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: documents!.uuid, layout: 'icons' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
documents!.uuid,
|
||||
);
|
||||
expect(refreshed?.layout).toBe('icons');
|
||||
});
|
||||
|
||||
it('resolves by item_path when item_uid is omitted', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Pictures`;
|
||||
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_path: path, layout: 'list' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
|
||||
expect(refreshed?.layout).toBe('list');
|
||||
});
|
||||
|
||||
it('throws 400 for an unknown layout value', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: documents!.uuid, layout: 'gallery' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({ body: { layout: 'icons' }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 404 when the fsentry cannot be resolved', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: '00000000-0000-0000-0000-000000000000',
|
||||
layout: 'icons',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it("throws 403 when the fsentry belongs to another user", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const ownerUsername = owner.actor.user!.username!;
|
||||
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${ownerUsername}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
|
||||
actor: intruder.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
// Owner's entry remains untouched.
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
ownerEntry!.uuid,
|
||||
);
|
||||
expect(refreshed?.layout).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_sort_by ────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_sort_by', () => {
|
||||
it('persists sort_by + sort_order on the matching fsentry', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: documents!.uuid,
|
||||
sort_by: 'name',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
documents!.uuid,
|
||||
);
|
||||
expect(refreshed?.sortBy).toBe('name');
|
||||
expect(refreshed?.sortOrder).toBe('desc');
|
||||
});
|
||||
|
||||
it('defaults sort_order to "asc" when omitted', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const pictures = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
pictures!.uuid,
|
||||
);
|
||||
expect(refreshed?.sortBy).toBe('modified');
|
||||
expect(refreshed?.sortOrder).toBe('asc');
|
||||
});
|
||||
|
||||
it('throws 400 for an unknown sort_by value', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const docs = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: { item_uid: docs!.uuid, sort_by: 'random' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 for an invalid sort_order', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const docs = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: docs!.uuid,
|
||||
sort_by: 'name',
|
||||
sort_order: 'sideways',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ import { AppController } from './apps/AppController.js';
|
||||
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
|
||||
import { AuthController } from './auth/AuthController.js';
|
||||
import { BroadcastController } from './broadcast/BroadcastController.js';
|
||||
import { DesktopController } from './desktop/DesktopController.js';
|
||||
import { DriverController } from './drivers/DriverController.js';
|
||||
import { EventsController } from './events/EventsController.js';
|
||||
import { FSController } from './fs/FSController.js';
|
||||
@@ -49,7 +48,6 @@ export const puterControllers = {
|
||||
profile: ProfileController,
|
||||
apps: AppController,
|
||||
appFeedback: AppFeedbackController,
|
||||
desktop: DesktopController,
|
||||
hosting: HostingController,
|
||||
system: SystemController,
|
||||
fs: FSController,
|
||||
|
||||
+12
-58
@@ -24,6 +24,7 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { setFolderView } from '../helpers/folderViewPrefs.js';
|
||||
import { atPath } from '../helpers/pathElements.js';
|
||||
import { resolve_typed_path } from '../helpers/expandHomePath.js';
|
||||
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
|
||||
@@ -3089,9 +3090,6 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
@@ -3132,12 +3130,7 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
// The folder's sort and layout are applied by refresh_item_container below.
|
||||
}).catch(() => {
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
@@ -3489,27 +3482,7 @@ window.set_sort_by = function (item_uid, sort_by, sort_order) {
|
||||
sort_order = 'asc';
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/set_sort_by`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
sort_by: sort_by,
|
||||
item_uid: item_uid,
|
||||
sort_order: sort_order,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: function () {
|
||||
},
|
||||
});
|
||||
setFolderView(item_uid, { sort_by, sort_order });
|
||||
// update the sort_by & sort_order attr of every matching element
|
||||
$(`[data-uid="${item_uid}"]`).attr({
|
||||
'data-sort_by': sort_by,
|
||||
@@ -4987,34 +4960,15 @@ $(document).on('click', '.explore-table-headers-th', function (e) {
|
||||
});
|
||||
|
||||
window.set_layout = function (item_uid, layout) {
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/set_layout`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
item_uid: item_uid,
|
||||
layout: layout,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: function () {
|
||||
if ( layout === 'details' ) {
|
||||
let el_window = $(`.window[data-uid="${item_uid}"]`);
|
||||
if ( el_window.length > 0 ) {
|
||||
let sort_by = el_window.attr('data-sort_by');
|
||||
let sort_order = el_window.attr('data-sort_order');
|
||||
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
setFolderView(item_uid, { layout });
|
||||
if ( layout === 'details' ) {
|
||||
let el_window = $(`.window[data-uid="${item_uid}"]`);
|
||||
if ( el_window.length > 0 ) {
|
||||
let sort_by = el_window.attr('data-sort_by');
|
||||
let sort_order = el_window.attr('data-sort_order');
|
||||
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.update_details_layout_sort_visuals = function (el_window, sort_by, sort_order) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How each folder is shown — its sort and its layout — kept in the user's
|
||||
* key-value store, one key per folder uid, as `{ sort_by, sort_order, layout }`.
|
||||
*/
|
||||
|
||||
export const LAYOUTS = ['icons', 'list', 'details'];
|
||||
export const SORT_BYS = ['name', 'modified', 'type', 'size'];
|
||||
export const SORT_ORDERS = ['asc', 'desc'];
|
||||
|
||||
export const DEFAULT_FOLDER_VIEW = Object.freeze({
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
layout: 'icons',
|
||||
});
|
||||
|
||||
const ALLOWED = { sort_by: SORT_BYS, sort_order: SORT_ORDERS, layout: LAYOUTS };
|
||||
|
||||
export const folderViewKey = uid => `folder_view:${uid}`;
|
||||
|
||||
/**
|
||||
* The known preferences in `value`, without anything unrecognized.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {{ sort_by?: string, sort_order?: string, layout?: string }}
|
||||
*/
|
||||
export function pickFolderView(value) {
|
||||
if ( typeof value === 'string' ) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const picked = {};
|
||||
if ( !value || typeof value !== 'object' ) return picked;
|
||||
for ( const [field, allowed] of Object.entries(ALLOWED) ) {
|
||||
if ( allowed.includes(value[field]) ) picked[field] = value[field];
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
const usableUid = uid => typeof uid === 'string' && uid !== '' && uid !== 'null' && uid !== 'undefined';
|
||||
|
||||
// The last stored value this session knows of, per folder, so a change to
|
||||
// one preference keeps the others it doesn't touch.
|
||||
const known = new Map();
|
||||
|
||||
const kv = () => globalThis.puter.kv;
|
||||
|
||||
/**
|
||||
* How to show a folder: what's stored for it, then what its fsentry carries
|
||||
* (the server kept a layout there before), then the defaults.
|
||||
*
|
||||
* @param {object | null | undefined} fsentry - from `puter.fs.stat()`
|
||||
* @returns {Promise<{ sort_by: string, sort_order: string, layout: string }>}
|
||||
*/
|
||||
export async function getFolderView(fsentry) {
|
||||
const uid = fsentry?.uid ?? fsentry?.id;
|
||||
let stored = {};
|
||||
if ( usableUid(uid) ) {
|
||||
const read = kv().get(folderViewKey(uid)).then(pickFolderView);
|
||||
known.set(uid, read.catch(() => ({})));
|
||||
try {
|
||||
stored = await read;
|
||||
} catch ( err ) {
|
||||
console.warn('Could not load folder view:', err);
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_FOLDER_VIEW, ...pickFolderView(fsentry), ...stored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Store some of a folder's preferences; the rest stay as they were.
|
||||
*
|
||||
* @param {string} uid
|
||||
* @param {{ sort_by?: string, sort_order?: string, layout?: string }} changes
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function setFolderView(uid, changes) {
|
||||
if ( ! usableUid(uid) ) return Promise.resolve();
|
||||
const base = known.get(uid)
|
||||
?? kv().get(folderViewKey(uid)).then(pickFolderView).catch(() => ({}));
|
||||
const next = base.then(stored => ({ ...stored, ...pickFolderView(changes) }));
|
||||
known.set(uid, next);
|
||||
return next
|
||||
.then(value => kv().set(folderViewKey(uid), value))
|
||||
.catch(err => console.warn('Could not save folder view:', err));
|
||||
}
|
||||
|
||||
/** Forget what this session knows of stored values; for tests. */
|
||||
export function resetFolderViewCache() {
|
||||
known.clear();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FOLDER_VIEW,
|
||||
folderViewKey,
|
||||
getFolderView,
|
||||
pickFolderView,
|
||||
resetFolderViewCache,
|
||||
setFolderView,
|
||||
} from './folderViewPrefs.js';
|
||||
|
||||
let store;
|
||||
let kv;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
kv = {
|
||||
get: vi.fn(async key => store.get(key) ?? null),
|
||||
set: vi.fn(async (key, value) => {
|
||||
store.set(key, value);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal('puter', { kv });
|
||||
resetFolderViewCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('pickFolderView', () => {
|
||||
it('keeps known values only', () => {
|
||||
expect(pickFolderView({
|
||||
sort_by: 'size',
|
||||
sort_order: 'sideways',
|
||||
layout: 'details',
|
||||
extra: 1,
|
||||
})).toEqual({ sort_by: 'size', layout: 'details' });
|
||||
});
|
||||
|
||||
it('reads a JSON string and ignores anything else', () => {
|
||||
expect(pickFolderView('{"sort_order":"desc"}')).toEqual({ sort_order: 'desc' });
|
||||
expect(pickFolderView('nope')).toEqual({});
|
||||
expect(pickFolderView(null)).toEqual({});
|
||||
expect(pickFolderView(7)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFolderView', () => {
|
||||
it('prefers what is stored, then the fsentry, then the defaults', async () => {
|
||||
store.set(folderViewKey('dir'), { sort_by: 'modified' });
|
||||
expect(await getFolderView({ uid: 'dir', layout: 'list', sort_by: 'size' })).toEqual({
|
||||
sort_by: 'modified',
|
||||
sort_order: 'asc',
|
||||
layout: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the store can\'t be read', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
kv.get.mockRejectedValue(new Error('offline'));
|
||||
expect(await getFolderView({ id: 'dir', layout: 'details' }))
|
||||
.toEqual({ ...DEFAULT_FOLDER_VIEW, layout: 'details' });
|
||||
});
|
||||
|
||||
it('doesn\'t ask the store about a folder without a uid', async () => {
|
||||
expect(await getFolderView({ uid: 'null' })).toEqual(DEFAULT_FOLDER_VIEW);
|
||||
expect(await getFolderView(undefined)).toEqual(DEFAULT_FOLDER_VIEW);
|
||||
expect(kv.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFolderView', () => {
|
||||
it('keeps the preferences a change doesn\'t touch', async () => {
|
||||
store.set(folderViewKey('dir'), { layout: 'details', sort_by: 'size' });
|
||||
await setFolderView('dir', { sort_by: 'name', sort_order: 'desc' });
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({
|
||||
layout: 'details',
|
||||
sort_by: 'name',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds on the last change, not on a read that predates it', async () => {
|
||||
await getFolderView({ uid: 'dir' });
|
||||
const sort = setFolderView('dir', { sort_by: 'type' });
|
||||
const layout = setFolderView('dir', { layout: 'list' });
|
||||
await Promise.all([sort, layout]);
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'type', layout: 'list' });
|
||||
expect(kv.get).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drops unknown values and folders without a uid', async () => {
|
||||
await setFolderView('dir', { layout: 'grid', sort_by: 'size' });
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'size' });
|
||||
await setFolderView('null', { layout: 'list' });
|
||||
await setFolderView(undefined, { layout: 'list' });
|
||||
expect(kv.set).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports a failed save instead of throwing', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
kv.set.mockRejectedValue(new Error('offline'));
|
||||
await expect(setFolderView('dir', { layout: 'list' })).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import item_icon from './itemIcon.js';
|
||||
import list_all_shared from './listAllShared.js';
|
||||
import parse_item_metadata from './parseItemMetadata.js';
|
||||
import { remember_shared_roots } from './sharedAccess.js';
|
||||
import { getFolderView } from './folderViewPrefs.js';
|
||||
|
||||
const refresh_item_container = function (el_item_container, options) {
|
||||
// start a transaction
|
||||
@@ -105,9 +106,6 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
|
||||
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
// data-name
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
@@ -115,17 +113,27 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
$(el_window).find('.window-navbar-path-input').val(container_path);
|
||||
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
|
||||
}
|
||||
$(el_item_container).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_item_container).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
// update layout
|
||||
if ( el_window && el_window.length > 0 )
|
||||
{
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
}
|
||||
//
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
return getFolderView(fsentry).then(view => {
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
// Items may already be in, placed by the sort the container had before.
|
||||
const resort = $(el_item_container).attr('data-sort_by') !== view.sort_by
|
||||
|| $(el_item_container).attr('data-sort_order') !== view.sort_order;
|
||||
$(el_item_container).attr('data-sort_by', view.sort_by);
|
||||
$(el_item_container).attr('data-sort_order', view.sort_order);
|
||||
if ( el_window && el_window.length > 0 ) {
|
||||
$(el_window).attr('data-sort_by', view.sort_by);
|
||||
$(el_window).attr('data-sort_order', view.sort_order);
|
||||
window.update_window_layout(el_window, view.layout);
|
||||
if ( view.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, view.sort_by, view.sort_order);
|
||||
}
|
||||
}
|
||||
if ( resort ) {
|
||||
window.sort_items(el_item_container, view.sort_by, view.sort_order);
|
||||
}
|
||||
});
|
||||
}).catch(() => {
|
||||
// The listing request below reports the failure to the user.
|
||||
});
|
||||
|
||||
@@ -229,3 +229,57 @@ describe('refresh_item_container after the window moves to another folder', () =
|
||||
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh_item_container and the folder\'s stored view', () => {
|
||||
let views;
|
||||
|
||||
beforeEach(() => {
|
||||
views = {};
|
||||
puter.kv = {
|
||||
get: vi.fn((key) => {
|
||||
views[key] ??= deferred();
|
||||
return views[key].promise;
|
||||
}),
|
||||
};
|
||||
puter.fs.stat.mockImplementation(async ({ path }) => ({ id: path.slice(1), name: path.slice(1) }));
|
||||
puter.fs.readdir.mockResolvedValue([]);
|
||||
vi.spyOn(window, 'sort_items');
|
||||
vi.spyOn(window, 'update_window_layout');
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container" data-sort_by="name" data-sort_order="asc"></div></div>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete puter.kv;
|
||||
});
|
||||
|
||||
it('lays out and re-sorts the folder by what is stored for it', async () => {
|
||||
navigate('/docs');
|
||||
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
|
||||
views['folder_view:docs'].resolve({ sort_by: 'size', sort_order: 'desc', layout: 'details' });
|
||||
|
||||
await vi.waitFor(() => expect(window.sort_items).toHaveBeenCalledWith(
|
||||
expect.anything(), 'size', 'desc',
|
||||
));
|
||||
expect($('.item-container').attr('data-sort_by')).toBe('size');
|
||||
expect($('.window').attr('data-sort_order')).toBe('desc');
|
||||
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'details');
|
||||
});
|
||||
|
||||
it('ignores the view of a folder the window has left', async () => {
|
||||
navigate('/docs');
|
||||
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
|
||||
navigate('/pics');
|
||||
await vi.waitFor(() => expect(views['folder_view:pics']).toBeDefined());
|
||||
|
||||
views['folder_view:pics'].resolve(null);
|
||||
views['folder_view:docs'].resolve({ sort_by: 'size', layout: 'list' });
|
||||
await vi.waitFor(() => expect(window.update_window_layout).toHaveBeenCalledOnce());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'icons');
|
||||
expect(window.update_window_layout).not.toHaveBeenCalledWith(expect.anything(), 'list');
|
||||
expect($('.item-container').attr('data-sort_by')).toBe('name');
|
||||
expect(window.sort_items).not.toHaveBeenCalledWith(expect.anything(), 'size', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,25 @@ class Lock {
|
||||
// (using defaultGUIOrigin breaks locally-hosted apps)
|
||||
const PROD_ORIGIN = 'https://puter.com';
|
||||
|
||||
// The desktops Puter itself serves. An app learns which one launched it from
|
||||
// the URL (`puter.origin`), and only one of these is believed: the GUI origin
|
||||
// is what every message from the desktop is checked against, so taking an
|
||||
// arbitrary one would let any page that frames an app drive it.
|
||||
const FIRST_PARTY_GUI_ORIGINS = new Set([
|
||||
'https://puter.com',
|
||||
'https://desktop.puter.com',
|
||||
]);
|
||||
|
||||
/** `value`'s origin when it is a first-party desktop, otherwise null. */
|
||||
const firstPartyGUIOrigin = (value) => {
|
||||
try {
|
||||
const { origin } = new URL(value);
|
||||
return FIRST_PARTY_GUI_ORIGINS.has(origin) ? origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// localStorage key for the auth token. The retired key below is never read —
|
||||
// only deleted, so a token from before the format change doesn't linger in a
|
||||
// visitor's browser (see `discardRetiredAuthToken_`).
|
||||
@@ -213,6 +232,9 @@ export class Puter {
|
||||
|
||||
#defaultAPIOrigin = 'https://api.puter.com';
|
||||
#defaultGUIOrigin = 'https://puter.com';
|
||||
// In app mode, the first-party desktop that launched this app, when it
|
||||
// isn't the build's default one.
|
||||
#launchGUIOrigin = null;
|
||||
|
||||
/** @returns {string} */
|
||||
get defaultAPIOrigin() {
|
||||
@@ -230,6 +252,7 @@ export class Puter {
|
||||
get defaultGUIOrigin() {
|
||||
return (
|
||||
globalThis.PUTER_ORIGIN ||
|
||||
this.#launchGUIOrigin ||
|
||||
globalThis.PUTER_ORIGIN_ENV ||
|
||||
this.#defaultGUIOrigin
|
||||
);
|
||||
@@ -634,6 +657,15 @@ export class Puter {
|
||||
this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`;
|
||||
}
|
||||
|
||||
// An app launched by a first-party desktop other than puter.com
|
||||
// (e.g. desktop.puter.com) talks to that desktop: its messages come
|
||||
// from there, and replies meant only for it go there.
|
||||
if (this.env === 'app') {
|
||||
this.#launchGUIOrigin = firstPartyGUIOrigin(
|
||||
URLParams.get('puter.origin'),
|
||||
);
|
||||
}
|
||||
|
||||
// === START :: Logger ===
|
||||
|
||||
// Basic logger replacement (console-based)
|
||||
|
||||
@@ -240,6 +240,88 @@ describe('app-mode launch token', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GUI origin of an app', () => {
|
||||
const DESKTOP = 'https://desktop.puter.com';
|
||||
const launchedBy = (origin) =>
|
||||
bootApp(
|
||||
`${LAUNCH}${APP_TOKEN}&puter.origin=${encodeURIComponent(origin)}`,
|
||||
);
|
||||
|
||||
/** Sends `data` to the app as its parent at `origin` would. */
|
||||
const fromParent = async (origin, data) => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin,
|
||||
source: globalThis.parent,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const closeAnswers = () =>
|
||||
globalThis.parent.postMessage.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter((message) => message?.original_msg_id === 'close-1');
|
||||
|
||||
it('is the first-party desktop that launched it', async () => {
|
||||
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(DESKTOP);
|
||||
expect((await launchedBy('https://puter.com')).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays puter.com for any other launcher', async () => {
|
||||
for (const origin of [
|
||||
'https://evil.example',
|
||||
'https://desktop.puter.com.evil.example',
|
||||
'https://x.desktop.puter.com',
|
||||
'http://desktop.puter.com',
|
||||
'not a url',
|
||||
]) {
|
||||
expect((await launchedBy(origin)).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
}
|
||||
expect((await bootApp(`${LAUNCH}${APP_TOKEN}`)).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('yields to an explicit PUTER_ORIGIN', async () => {
|
||||
globalThis.PUTER_ORIGIN = 'https://self-hosted.test';
|
||||
try {
|
||||
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(
|
||||
'https://self-hosted.test',
|
||||
);
|
||||
} finally {
|
||||
delete globalThis.PUTER_ORIGIN;
|
||||
}
|
||||
});
|
||||
|
||||
it('is not taken from the URL of a top-level page', async () => {
|
||||
const puter = await bootWeb(
|
||||
`?puter.app_instance_id=i&puter.origin=${encodeURIComponent(DESKTOP)}`,
|
||||
);
|
||||
expect(puter.env).not.toBe('app');
|
||||
expect(puter.defaultGUIOrigin).toBe('https://puter.com');
|
||||
});
|
||||
|
||||
it('answers windowWillClose from that desktop and no other', async () => {
|
||||
await launchedBy(DESKTOP);
|
||||
const willClose = { msg: 'windowWillClose', msg_id: 'close-1' };
|
||||
|
||||
await fromParent('https://evil.example', willClose);
|
||||
await fromParent('https://puter.com', willClose);
|
||||
expect(closeAnswers()).toEqual([]);
|
||||
|
||||
await fromParent(DESKTOP, willClose);
|
||||
expect(closeAnswers()).toEqual([
|
||||
expect.objectContaining({ msg: true, original_msg_id: 'close-1' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token adoption on a top-level page', () => {
|
||||
it('takes a token from a window it opened, and from nothing else', async () => {
|
||||
const puter = await bootWeb('');
|
||||
|
||||
Reference in new issue
Block a user