Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 6050ff78c3 Match dashboard paths in any letter case, and stop the e2e tests waiting on .desktop
- bootsDashboard() matches /dashboard and /app/<name> case-insensitively, as the server routes them, so /App/<name> boots the dashboard.
- The request-permission hardening tests wait for the page's own answer instead of the removed .desktop element.
- Remove the error_user_or_path_not_found string from fr.js.
2026-11-12 17:31:36 -07:00
Nariman Jelveh abb647d227 Remove the error_user_or_path_not_found string
Only the desktop's shared-item opener showed it.
2026-11-12 16:53:33 -07:00
Nariman Jelveh fa2d2d226a Remove UIDesktop
Nothing reaches it except a signed-in top-level /action/* page, where it
rendered an empty surface behind the page's dialog. Those pages now show
only the dialog, without a socket connection or notification toasts.

The full-page window styling it defined moves into UIWindow, its only
caller. The desktop-only closeLocalBanner helper, ProcessService's
gui:ready hook, and the .desktop and .window-container styles go with it.
2026-11-12 16:48:36 -07:00
Nariman Jelveh bb55d6c6a4 Boot the dashboard for framed / and /app/<name>, and remove the desktop's URL app launch
The root path and app landings boot the dashboard whether or not the page
is framed, so the desktop no longer launches an app from the URL. With that
gone, the fullpage_on_landing lookups, ?puter.fullpage= and the Dev Center
"full-page mode" checkbox have nothing to act on and are removed.
2026-11-12 16:40:04 -07:00
19 changed files with 70 additions and 1136 deletions

No files matched your search

@@ -55,10 +55,7 @@ import type { IConfig } from '../../types.js';
*/
export type NotifWire =
| 'notif.message'
| 'notif.unreads'
| 'notif.ack'
| 'notif.removed';
'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
export const NOTIF_DELIVERY_EVENT = 'outer.notif.delivery';
@@ -200,7 +197,7 @@ export class NotificationSocketAdapter {
});
}
/** One notification, in the shape `UIDesktop` has always received. */
/** One notification, in the shape the GUI's socket listeners receive. */
message(userId: number, uid: string, notification: unknown): void {
const response = { uid, notification };
if (this.#dispatched(userId, 'notif.message', response)) return;
-13
View File
@@ -196,7 +196,6 @@ async function create_app (title, source_path = null, items = null) {
feedbackEnabled: true,
metadata: {
window_resizable: true,
fullpage_on_landing: true,
},
})
.then(async (app) => {
@@ -225,7 +224,6 @@ async function create_app (title, source_path = null, items = null) {
metadata: {
category: null, // default category on creation
window_resizable: true,
fullpage_on_landing: true,
},
}).then(async (app) => {
// refresh app list
@@ -471,11 +469,6 @@ function generate_edit_app_section (app) {
<label for="edit-app-background" style="display: inline;">Run as a background process.</label>
</div>
<div>
<input type="checkbox" id="edit-app-fullpage-on-landing" name="edit-app-fullpage-on-landing" value="true" style="margin-top:30px;" ${app.metadata?.fullpage_on_landing ? 'checked' : ''} ${app.background ? 'disabled' : ''}>
<label for="edit-app-fullpage-on-landing" style="display: inline;">Load in full-page mode when a user lands directly on this app.</label>
</div>
<div>
<input type="checkbox" id="edit-app-maximize-on-start" name="edit-app-maximize-on-start" value="true" style="margin-top:30px;" ${maximize_on_start ? 'checked' : ''} ${app.background ? 'disabled' : ''}>
<label for="edit-app-maximize-on-start" style="display: inline;">Maximize window on start</label>
@@ -593,7 +586,6 @@ function trackOriginalValues () {
resizableWindow: $('#edit-app-window-resizable').is(':checked'),
hideTitleBar: $('#edit-app-hide-titlebar').is(':checked'),
locked: $('#edit-app-locked').is(':checked'),
fullPageOnLanding: $('#edit-app-fullpage-on-landing').is(':checked'),
setTitleToFile: $('#edit-app-set-title-to-file').is(':checked'),
userFeedback: $('#edit-app-user-feedback').is(':checked'),
},
@@ -635,7 +627,6 @@ function hasChanges () {
$('#edit-app-window-resizable').is(':checked') !== originalValues.checkboxes.resizableWindow ||
$('#edit-app-hide-titlebar').is(':checked') !== originalValues.checkboxes.hideTitleBar ||
$('#edit-app-locked').is(':checked') !== originalValues.checkboxes.locked ||
$('#edit-app-fullpage-on-landing').is(':checked') !== originalValues.checkboxes.fullPageOnLanding ||
$('#edit-app-set-title-to-file').is(':checked') !== originalValues.checkboxes.setTitleToFile ||
$('#edit-app-user-feedback').is(':checked') !== originalValues.checkboxes.userFeedback
);
@@ -683,7 +674,6 @@ function resetToOriginalValues () {
$('#edit-app-window-resizable').prop('checked', originalValues.checkboxes.resizableWindow);
$('#edit-app-hide-titlebar').prop('checked', originalValues.checkboxes.hideTitleBar);
$('#edit-app-locked').prop('checked', originalValues.checkboxes.locked);
$('#edit-app-fullpage-on-landing').prop('checked', originalValues.checkboxes.fullPageOnLanding);
$('#edit-app-set-title-to-file').prop('checked', originalValues.checkboxes.setTitleToFile);
$('#edit-app-user-feedback').prop('checked', originalValues.checkboxes.userFeedback);
@@ -1299,7 +1289,6 @@ async function saveEditedApp (mode) {
background: $('#edit-app-background').is(':checked'),
feedbackEnabled: $('#edit-app-user-feedback').is(':checked'),
metadata: {
fullpage_on_landing: $('#edit-app-fullpage-on-landing').is(':checked'),
social_image: socialImageUrl,
category: category || null,
window_size: {
@@ -2583,7 +2572,6 @@ $(document).on('change', '#edit-app-background', function (e) {
function disable_window_settings () {
$('#edit-app-maximize-on-start').prop('disabled', true);
$('#edit-app-fullpage-on-landing').prop('disabled', true);
$('#edit-app-window-width, #edit-app-window-height').prop('disabled', true);
$('#edit-app-window-top, #edit-app-window-left').prop('disabled', true);
$('#edit-app-window-resizable').prop('disabled', true);
@@ -2592,7 +2580,6 @@ function disable_window_settings () {
function enable_window_settings () {
$('#edit-app-maximize-on-start').prop('disabled', false);
$('#edit-app-fullpage-on-landing').prop('disabled', false);
$('#edit-app-window-width, #edit-app-window-height').prop('disabled', false);
$('#edit-app-window-top, #edit-app-window-left').prop('disabled', false);
$('#edit-app-window-resizable').prop('disabled', false);
+3 -5
View File
@@ -204,8 +204,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);
}
@@ -272,7 +272,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.
// =========================================================================
@@ -367,8 +366,7 @@ 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.
// =========================================================================
// A row taken away from elsewhere: keep the footer's count and size in
-920
View File
@@ -1,920 +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 path from '../lib/path.js';
import UIItem from './UIItem.js';
import UIAlert from './UIAlert.js';
import UIWindow from './UIWindow.js';
import { atPath, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
import truncate_filename from '../helpers/truncateFilename.js';
import UINotification from './UINotification.js';
import launch_app from '../helpers/launchApp.js';
import { urlFileLaunchOptions } from '../helpers/confirmUrlFileAccess.js';
import item_icon from '../helpers/itemIcon.js';
import resolve_shared_item from '../helpers/resolveSharedItem.js';
import {
answerNotificationReceipts,
applyToastMark,
createNotificationFeed,
createReplayLog,
listUndismissed,
missedAfterReconnect,
notificationSocketAcks,
} from '../helpers/notificationFeed.js';
import { getNotification, markNotificationAcknowledged } from '../helpers/notificationApi.js';
import {
elementsWithData,
handleNotificationTap,
openAppForTap,
setPushTapHandler,
storedTapHandler,
} from '../helpers/notificationTap.js';
import { closeLocalBanner, startPushWorker } from '../helpers/pushWorker.js';
import { syncBrowserPush } from '../helpers/pushSync.js';
import { get_app_by_uid } from '../helpers/getAppByUid.js';
import { httpsIconFor, notificationTarget, senderNameFor } from './Dashboard/notificationCenter.js';
import apply_item_added_to_containers from '../helpers/applyItemAddedToContainers.js';
import parse_item_metadata from '../helpers/parseItemMetadata.js';
import { resolve_default_app } from '../helpers/defaultApp.js';
async function UIDesktop () {
// start a transaction if we're not in embedded or fullpage mode
let transaction;
if ( !window.is_embedded && !window.is_fullpage_mode ) {
transaction = new window.Transaction('desktop-is-ready');
transaction.start();
}
let h = '';
// connect socket.
window.socket = io(`${window.gui_origin }/`, {
auth: {
auth_token: window.auth_token,
acks: notificationSocketAcks(),
},
transports: ['websocket', 'polling'],
withCredentials: true,
});
answerNotificationReceipts(window.socket);
window.socket.on('error', (error) => {
console.error('GUI Socket Error:', error);
});
// Pick up reauth_required signals delivered via the socket.io
// handshake. SocketService emits `Error { data: { code:
// 'reauth_required', reason, auth_id } }` for legacy/revoked/expired
// tokens. Without this branch the disconnect would look like a
// silent network failure to the user.
window.socket.on('connect_error', (err) => {
const signal = err?.data;
if ( signal?.code === 'reauth_required' ) {
window.handleReauthRequired({
reason: signal.reason,
auth_id: signal.auth_id,
});
} else {
console.error('GUI Socket connect_error:', err);
}
});
window.socket.on('connect', function () {
// console.log('GUI Socket: Connected', window.socket.id);
window.socket.emit('puter_is_actually_open');
});
window.socket.on('reconnect', function () {
console.log('GUI Socket: Reconnected', window.socket.id);
});
window.socket.on('disconnect', () => {
console.log('GUI Socket: Disconnected');
});
window.socket.on('reconnect', (attempt) => {
console.log('GUI Socket: Reconnection', attempt);
});
window.socket.on('reconnect_attempt', (attempt) => {
console.log('GUI Socket: Reconnection Attemps', attempt);
});
window.socket.on('reconnect_error', (error) => {
console.log('GUI Socket: Reconnection Error', error);
});
window.socket.on('reconnect_failed', () => {
console.log('GUI Socket: Reconnection Failed');
});
window.socket.on('error', (error) => {
console.error('GUI Socket Error:', error);
});
window.socket.on('upload.progress', (msg) => {
if ( window.progress_tracker[msg.operation_id] ) {
window.progress_tracker[msg.operation_id].cloud_uploaded += msg.loaded_diff;
if ( window.progress_tracker[msg.operation_id][msg.item_upload_id] ) {
window.progress_tracker[msg.operation_id][msg.item_upload_id].cloud_uploaded = msg.loaded;
}
}
});
window.socket.on('download.progress', (msg) => {
if ( window.progress_tracker[msg.operation_id] ) {
if ( window.progress_tracker[msg.operation_id][msg.item_upload_id] ) {
window.progress_tracker[msg.operation_id][msg.item_upload_id].downloaded = msg.loaded;
window.progress_tracker[msg.operation_id][msg.item_upload_id].total = msg.total;
}
}
});
window.socket.on('trash.is_empty', async (msg) => {
window.update_trash_icons(msg.is_empty);
// empty trash windows if needed
if ( msg.is_empty )
{
// only the rows: the container also holds its empty, error and loading states
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).each(function () {
$(this).find('.item-container .item').removeItems();
window.update_explorer_footer_item_count(this);
window.update_explorer_footer_selected_items_count(this);
});
}
});
/**
* Clicking a share notification opens the item, or Shared if grouped.
* Shares the notification center's predicate so a toast and a list entry
* never disagree about where a click goes.
*/
const share_notification_click = (notification) => {
const target = notificationTarget(notification);
if ( target === null ) return undefined;
if ( target.kind === 'shared-item' ) {
return () => {
open_shared_item(target.path);
};
}
// Grouped: open where they all landed rather than picking one.
return () => {
UIWindow({
path: window.shared_path,
title: i18n('shared'),
icon: window.icons['sidebar-folder-shared.svg'],
is_dir: true,
app: 'explorer',
});
};
};
// The desktop has no notification list, so other types only clear.
const tap_surface = {
share: (notification) => share_notification_click(notification)?.(),
openApp: (route) => openAppForTap(route, { getApp: get_app_by_uid, launch: launch_app }),
};
const tap_notification = (tap) => handleNotificationTap(tap, tap_surface);
const toasts_for = (uid) => $(elementsWithData($('.notification').get(), 'uid', uid));
setPushTapHandler(storedTapHandler({
fetchRow: getNotification,
surface: tap_surface,
found: (uid) => {
toasts_for(uid).remove();
update_tab_notif_count_badge();
void markNotificationAcknowledged(uid).catch((err) => {
console.warn('Could not acknowledge notification:', err);
});
},
}));
startPushWorker().then(() => syncBrowserPush());
/**
* Raise one notification, whichever path it arrived on. `replay` names
* what the feed already claimed as shown while catching up, so the same
* row isn't marked twice.
*/
const show_notification = ({ uid, notification, app_uid }, { replay = false } = {}) => {
// A notification can be re-sent under its own uid when what it says has
// grown — several people sharing with you is one notification that
// counts them. Refresh the one on screen rather than stacking a copy.
const $showing = toasts_for(uid);
if ( $showing.length ) {
$showing.find('.notification-title').text(notification.title);
$showing.find('.notification-text').text(notification.text ?? '');
return;
}
UINotification({
sender: senderNameFor(notification),
title: notification.title,
text: notification.text,
icon: window.icons[notification.icon] ?? httpsIconFor(notification) ?? undefined,
value: notification,
uid,
click: () => void tap_notification({ uid, notification, appUid: app_uid }),
close: () => applyToastMark('dismissed', uid, {
eventsPath: notification_feed.isActive(),
}),
});
if ( ! replay ) {
void applyToastMark('shown', uid, { eventsPath: notification_feed.isActive() });
}
};
/**
* Notifications over the events surface, where the server says it has
* them. It renders through the same path the socket wire does, and the
* listeners below stand down only while it is up.
*/
const replayLog = createReplayLog();
const notification_feed = createNotificationFeed({
deliver: (items, { replay }) => {
for ( const item of replayLog.admit(items, { replay }) ) show_notification(item, { replay });
},
});
void notification_feed.start();
// A replayed notification with no text of its own shows its title twice.
const showReplayed = ({ uid, notification }) => show_notification({
uid,
notification: { ...notification, text: notification.text ?? notification.title },
}, { replay: true });
// A frozen or sleeping tab reconnects on resume; raise what it missed.
const knownAtLoad = listUndismissed()
.then((rows) => new Set(rows.map((row) => row.uid)))
.catch(() => null);
window.socket.io?.on('reconnect', () => {
void Promise.all([knownAtLoad, listUndismissed()])
.then(([known, rows]) => {
if ( ! known ) return;
for ( const item of missedAfterReconnect(rows, { known, log: replayLog }) ) showReplayed(item);
})
.catch((err) => console.warn('Could not catch up on notifications:', err));
});
/**
* This event is triggered if a user receives a notification during
* an active session.
*/
window.socket.on('notif.message', async ({ uid, notification }) => {
if ( notification_feed.isActive() ) return;
for ( const item of replayLog.admit([{ uid, notification }]) ) show_notification(item);
});
/**
* This event is triggered at the beginning of the session, after a websocket
* connection is established, because the backend informs the frontend of all
* unread notifications.
*
* It is not necessary to query unreads separately. If this stops working,
* then this event should be fixed rather than querying unreads separately.
* Each reconnect brings one, whichever path is live; this page raises what it missed.
*/
window.socket.on('notif.unreads', async ({ unreads }) => {
for ( const item of replayLog.admit(unreads, { replay: true }) ) showReplayed(item);
});
// Acknowledgements also arrive when the notification feed is inactive.
window.socket.on('notif.ack', ({ uid }) => {
void closeLocalBanner(uid);
replayLog.dismissed(uid);
toasts_for(uid).remove();
update_tab_notif_count_badge();
});
// Removed from the notification history: its toast goes too.
window.socket.on('notif.removed', ({ uid }) => {
toasts_for(uid).remove();
update_tab_notif_count_badge();
});
window.socket.on('item.removed', async (item) => {
// don't update if this is the original client that initiated the action
if ( item.original_client_socket_id === window.socket.id )
{
return;
}
// don't remove items if this was a descendants_only operation
if ( item.descendants_only )
{
return;
}
// remove all UIItems at the removed path
atPath('.item', item.path).fadeOut(150, function () {
const $parent_window = $(this).closest('.window');
$(this).removeItems();
window.update_explorer_footer_item_count($parent_window);
window.update_explorer_footer_selected_items_count($parent_window);
});
// close the windows showing the removed folder or anything inside it,
// whether or not its own row is displayed anywhere
// todo this has to be case-insensitive
atPath('.window[data-app="explorer"]', item.path).add(underPath('.window', item.path)).close();
});
window.socket.on('item.updated', async (item) => {
// Don't update if this is the original client that initiated the action
if ( item.original_client_socket_id === window.socket.id )
{
return;
}
// Update matching items
// set new item name
$(`.item[data-uid='${html_encode(item.uid)}'] .item-name`).html(html_encode(truncate_filename(item.name)));
// Set new icon
const new_icon = (item.is_dir ? window.icons['folder.svg'] : (await item_icon(item)).image);
$(`.item[data-uid='${item.uid}']`).find('.item-icon-thumb').attr('src', new_icon);
$(`.item[data-uid='${item.uid}']`).find('.item-icon-icon').attr('src', new_icon);
// Set new data-name. Attributes and form values are stored raw
// (.attr()/.val() don't parse HTML), as rename_file writes them.
$(`.item[data-uid='${item.uid}']`).attr('data-name', item.name);
$(`.window-${item.uid}`).attr('data-name', item.name);
// Set new title attribute
$(`.item[data-uid='${item.uid}']`).attr('title', item.name);
$(`.window-${item.uid}`).attr('title', item.name);
// Set new value for item-name-editor
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
// An overwrite arrives as an update, with a new size and date
if ( item.size !== undefined && item.modified !== undefined ) {
const $updated_items = $(`.item[data-uid='${item.uid}']`);
$updated_items.attr({ 'data-size': item.size ?? '', 'data-modified': item.modified ?? '' });
$updated_items.find('.item-attr--size > span').text(item.size ? window.byte_format(item.size) : '-');
$updated_items.find('.item-attr--modified > span').text(item.modified === 0 ? '-' : timeago.format(item.modified * 1000));
}
// Set new data-path
const new_path = item.path;
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
$(`.window-${item.uid}`).attr('data-path', new_path);
retargetPaths(item.old_path, new_path, item.name);
// Update all exact-matching windows
$(`.window-${item.uid}`).each(function () {
window.update_window_path(this, new_path);
});
// Set new name for matching open windows
$(`.window-${item.uid} .window-head-title`).text(item.name);
// Re-sort all matching item containers
$(`.item[data-uid='${item.uid}']`).parent('.item-container').each(function () {
window.sort_items(this, $(this).closest('.item-container').attr('data-sort_by'), $(this).closest('.item-container').attr('data-sort_order'));
});
});
window.socket.on('item.moved', async (resp) => {
let fsentry = resp;
// Notify all apps that are watching this item
window.sendItemChangeEventToWatchingApps(fsentry.uid, {
event: 'moved',
uid: fsentry.uid,
name: fsentry.name,
});
// don't update if this is the original client that initiated the action
if ( resp.original_client_socket_id === window.socket.id )
{
return;
}
let dest_path = path.dirname(fsentry.path);
// A JSON string on the wire; trash/restore reads its fields.
const metadata = parse_item_metadata(fsentry.metadata);
// update all shortcut_to_path
atPath('.item', resp.old_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
// remove all items with matching uids
$(`.item[data-uid='${fsentry.uid}']`).fadeOut(150, function () {
// find all parent windows that contain this item
let parent_windows = $(`.item[data-uid='${fsentry.uid}']`).closest('.window');
// remove this item
$(this).removeItems();
// update parent windows' item counts
$(parent_windows).each(function (index) {
window.update_explorer_footer_item_count(this);
window.update_explorer_footer_selected_items_count(this);
});
});
// if trashing, close windows of trashed items and its descendants
if ( dest_path === window.trash_path ) {
atPath('.window', resp.old_path).close();
// todo this has to be case-insensitive
underPath('.window', resp.old_path).close();
}
// update all paths of its and its descendants' open windows
else {
// todo this has to be case-insensitive
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
});
retargetNavHistory(resp.old_path, fsentry.path);
}
if ( dest_path === window.trash_path ) {
// if trashing dir...
if ( fsentry.is_dir ) {
// remove the website badge from all instances of the dir
$(`.item[data-uid="${fsentry.uid}"]`).find('.item-has-website-badge').fadeOut(300);
// remove File Rrequest Token
// todo, some client-side check to see if this dir has an FR associated with it before sending a whole ajax req
}
}
// if replacing an existing item, remove the old item that was just replaced
if ( fsentry.overwritten_uid !== undefined )
{
$(`.item[data-uid=${fsentry.overwritten_uid}]`).removeItems();
}
// Trashing renames the entry to its uid; the real name is in metadata.
fsentry.name = metadata.original_name ?? fsentry.name;
const moved_item_icon = await item_icon(fsentry);
const $dest_containers = atPath('.item-container', dest_path);
// the tab that made this move renders the row itself, in either order
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
// create new item on matching containers
UIItem({
appendTo: $dest_containers,
immutable: fsentry.immutable || fsentry.writable === false,
uid: fsentry.uid,
path: fsentry.path,
icon: moved_item_icon,
name: fsentry.name,
is_dir: fsentry.is_dir,
size: fsentry.size,
type: fsentry.type,
modified: fsentry.modified,
is_selected: false,
is_shortcut: fsentry.is_shortcut,
shortcut_to: fsentry.shortcut_to,
shortcut_to_path: fsentry.shortcut_to_path,
// has_website: $(el_item).attr('data-has_website') === '1',
// Raw, as every other renderer stores it; re-encoding breaks restore.
metadata: fsentry.metadata ?? '',
});
if ( fsentry.parent_dirs_created && fsentry.parent_dirs_created.length > 0 ) {
// this operation may have created some missing directories,
// see if any of the directories in the path of this file is new AND
// if these new path have any open parents that need to be updated
fsentry.parent_dirs_created.forEach(async dir => {
let item_container = atPath('.item-container', path.dirname(dir.path));
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
UIItem({
appendTo: item_container,
immutable: false,
uid: dir.uid,
path: dir.path,
icon: await item_icon(dir),
name: dir.name,
size: dir.size,
type: dir.type,
modified: dir.modified,
is_dir: true,
is_selected: false,
has_website: false,
});
}
window.sort_items(item_container, $(item_container).attr('data-sort_by'), $(item_container).attr('data-sort_order'));
});
}
//sort each container
atPath('.item-container', dest_path).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
});
window.socket.on('user.email_confirmed', (msg) => {
// don't update if this is the original client that initiated the action
if ( msg.original_client_socket_id === window.socket.id )
{
return;
}
window.refresh_user_data(window.auth_token);
});
window.socket.on('user.phone_verified', (msg) => {
// don't update if this is the original client that initiated the action
if ( msg.original_client_socket_id === window.socket.id )
{
return;
}
window.refresh_user_data(window.auth_token);
});
window.socket.on('user.card_verified', (msg) => {
// don't update if this is the original client that initiated the action
if ( msg.original_client_socket_id === window.socket.id )
{
return;
}
window.refresh_user_data(window.auth_token);
});
window.socket.on('user.email_changed', (msg) => {
// don't update if this is the original client that initiated the action
if ( msg.original_client_socket_id === window.socket.id )
{
return;
}
window.refresh_user_data(window.auth_token);
});
window.socket.on('item.renamed', async (item) => {
// Notify all apps that are watching this item
window.sendItemChangeEventToWatchingApps(item.uid, {
event: 'rename',
uid: item.uid,
// path: item.path,
new_name: item.name,
// old_path: item.old_path,
});
// Don't update if this is the original client that initiated the action
if ( item.original_client_socket_id === window.socket.id )
{
return;
}
// Update matching items
// Set new item name
$(`.item[data-uid='${html_encode(item.uid)}'] .item-name`).html(html_encode(truncate_filename(item.name)));
// Set new icon
const new_icon = (item.is_dir ? window.icons['folder.svg'] : (await item_icon(item)).image);
$(`.item[data-uid='${item.uid}']`).find('.item-icon-icon').attr('src', new_icon);
// Set new data-name. Attributes and form values are stored raw
// (.attr()/.val() don't parse HTML), as rename_file writes them.
$(`.item[data-uid='${item.uid}']`).attr('data-name', item.name);
$(`.window-${item.uid}`).attr('data-name', item.name);
// Set new title attribute
$(`.item[data-uid='${item.uid}']`).attr('title', item.name);
$(`.window-${item.uid}`).attr('title', item.name);
// Set new value for item-name-editor
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
// Set new data-path
const new_path = item.path;
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
$(`.window-${item.uid}`).attr('data-path', new_path);
retargetPaths(item.old_path, new_path, item.name);
// Update all exact-matching windows
$(`.window-${item.uid}`).each(function () {
window.update_window_path(this, new_path);
});
// Set new name for matching open windows
$(`.window-${item.uid} .window-head-title`).text(item.name);
// Re-sort all matching item containers
$(`.item[data-uid='${item.uid}']`).parent('.item-container').each(function () {
window.sort_items(this, $(this).closest('.item-container').attr('data-sort_by'), $(this).closest('.item-container').attr('data-sort_order'));
});
});
window.socket.on('item.added', async (item) => {
// if item is empty, don't proceed
if ( !item || Object.keys(item).length === 0 )
{
return;
}
// Notify all apps that are watching this item
window.sendItemChangeEventToWatchingApps(item.uid, {
event: 'write',
uid: item.uid,
// path: item.path,
new_size: item.size,
modified: item.modified,
// old_path: item.old_path,
});
// Don't update if this is the original client that initiated the action
if ( item.original_client_socket_id === window.socket.id )
{
return;
}
await apply_item_added_to_containers(item);
});
// Hidden file dialog
h += `<form name="upload-form" id="upload-form" style="display:hidden;">
<input type="hidden" name="name" id="upload-filename" value="">
<input type="hidden" name="path" id="upload-target-path" value="">
<input type="file" name="file" id="upload-file-dialog" style="display: none;" multiple="multiple">
</form>`;
h += '<div class="window-container"></div>';
// The surface windows open on. It holds no items.
h += '<div class="desktop disable-user-select"></div>';
// Get window sidebar width
puter.kv.get('window_sidebar_width').catch(() => null).then(async (val) => {
let value = parseInt(val);
// if value is a valid number
if ( !isNaN(value) && value > 0 ) {
window.window_sidebar_width = value;
}
});
// load window sidebar items from KV
puter.kv.get('sidebar_items').then(async (val) => {
// UIWindow parses this and reads `item.path` while building every
// explorer window, so a malformed value is dropped for the defaults.
let items = null;
try {
items = JSON.parse(val);
} catch { /* keep the defaults */ }
if ( Array.isArray(items) && items.every(item => typeof item?.path === 'string') ) {
window.sidebar_items = val;
}
// Catch on the chain rather than the read, so a failure leaves whatever is
// already there instead of overwriting it with a fallback.
}).catch(err => console.warn('Could not load sidebar_items:', err));
// Remove `?ref=...` from navbar URL, keeping the current path
if ( window.url_query_params.has('ref') ) {
window.history.pushState(null, document.title, window.location.pathname);
}
await window.load_user_preferences();
// Append to <body>
$('body').append(h);
const el_desktop = document.querySelector('.desktop');
window.active_element = el_desktop;
// The desktop has no menu of its own, and the browser's doesn't belong here.
$(el_desktop).on('contextmenu', function (event) {
if ( event.target === el_desktop ) event.preventDefault();
});
if ( !window.is_embedded && !window.is_fullpage_mode ) {
transaction.end();
// warm the home directory listing for the first Explorer window
puter.fs.readdir({ path: window.home_path, consistency: 'strong' });
}
// notification container
$('body').append(`<div class="notification-container"><div class="notifications-close-all">${i18n('close_all')}</div></div>`);
// track: checkpoint
//-----------------------------
// GUI is ready to launch apps!
//-----------------------------
window.dispatchEvent(new CustomEvent('desktop:ready'));
globalThis.services.emit('gui:ready');
//--------------------------------------------------------------------------------------
// Determine if an app was launched from URL
// i.e. https://puter.com/app/<app_name>
//--------------------------------------------------------------------------------------
if ( window.url_paths[0]?.toLocaleLowerCase() === 'app' && window.url_paths[1] ) {
// If app metadata was already fetched early (for fullpage detection), reuse it
if ( !window.app_launched_from_url?.name ) {
window.app_launched_from_url = window.url_paths[1];
// get app metadata
try {
window.app_launched_from_url = await puter.apps.get(window.url_paths[1], { icon_size: 64 });
window.is_fullpage_mode = window.app_launched_from_url.metadata?.fullpage_on_landing ?? window.is_fullpage_mode ?? false;
} catch (e) {
console.error('UIDesktop app path launch error', e);
}
}
// get query params, any param that doesn't start with 'puter.' will be passed to the app
window.app_query_params = {};
for ( let [key, value] of window.url_query_params ) {
if ( ! key.startsWith('puter.') )
{
window.app_query_params[key] = value;
}
}
}
// ---------------------------------------------
// load from direct app URLs: /app/app-name
// ---------------------------------------------
if ( window.app_launched_from_url ) {
if ( ! window.url_query_params.has('c') ) {
let posargs = undefined;
if ( window.app_query_params && window.app_query_params.posargs ) {
posargs = JSON.parse(window.app_query_params.posargs);
}
// `?file=<path or uid>` opens that file with the app, the same as
// double-clicking it would — but the link picked both, so the user
// is asked before the app is given the file.
launch_app({
app: window.app_launched_from_url.name,
app_obj: window.app_launched_from_url,
readURL: window.url_query_params.get('readURL'),
maximized: window.url_query_params.get('maximized'),
params: window.app_query_params ?? [],
...urlFileLaunchOptions(window.url_query_params.get('file')),
...(posargs ? {
args: {
command_line: { args: posargs },
},
} : {}),
is_fullpage: window.is_fullpage_mode,
window_options: {
stay_on_top: false,
},
});
}
}
$(el_desktop).on('mousedown touchstart', { passive: true }, function (e) {
// dimiss touchstart on regular devices
if ( e.type === 'taphold' && !isMobile.phone && !isMobile.tablet )
{
return;
}
$('.window-app-iframe').css('pointer-events', 'none');
$('.window').find('.item-selected').addClass('item-blurred');
// Keyboard shortcuts act on the active container, and a click here leaves every window's.
window.active_item_container = null;
});
$(el_desktop).on('click', function () {
// blur all windows
$('.window-active').removeClass('window-active');
});
/**
* Open a path as double-clicking would: the associated app for a file, an
* explorer window for a directory.
*
* TODO: DRY with open_item, which is coupled to a UIItem element and can't
* operate on a path alone.
*/
async function open_path_target (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,
});
// Note: I'm not adding unzipping logic here. We'll wait until
// we've refactored open_item so that Puter can have a
// properly-reusable open function.
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 = item_path.split('/').slice(0, -1).join('/');
}
UIWindow({
path: item_path,
title: path.basename(item_path),
icon: await item_icon({ is_dir: true, path: item_path }),
is_dir: true,
app: 'explorer',
});
}
/** Open an item somebody shared, addressed as `/<owner>/<uuid>/<name>`. */
async function open_shared_item (shared_path) {
const stat = await resolve_shared_item(puter.fs, shared_path);
if ( ! stat ) {
UIAlert({
message: i18n('error_user_or_path_not_found'),
type: 'error',
});
return false;
}
// `stat` returns the path this viewer may use, which is the one to open.
await open_path_target(stat.path ?? shared_path, stat);
return true;
}
}
window.enter_fullpage_mode = (el_window) => {
$(el_window).find('.window-head').hide();
$('body').addClass('fullpage-mode');
$(el_window).css({
width: '100%',
height: '100%',
top: 0,
left: 0,
'border-radius': 0,
});
};
window.exit_fullpage_mode = (el_window) => {
$('body').removeClass('fullpage-mode');
$(el_window).removeAttr('data-is_fullpage');
if ( el_window ) {
window.reset_window_size_and_position(el_window);
$(el_window).find('.window-head').show();
}
};
window.reset_window_size_and_position = (el_window) => {
$(el_window).css({
width: 680,
height: 380,
'border-radius': window.window_border_radius,
top: 'calc(50% - 190px)',
left: 'calc(50% - 340px)',
});
};
export default UIDesktop;
+1 -1
View File
@@ -51,7 +51,7 @@ const headMenuLabels = (el_window) => {
};
beforeEach(() => {
document.body.innerHTML = '<div class="desktop"></div>';
document.body.innerHTML = '';
Object.assign(window, {
global_element_id: 0,
window_counter: 0,
+10 -4
View File
@@ -1831,7 +1831,6 @@ async function UIWindow (options) {
// document mousedown -> focusWindow path, which fires before any
// drag starts.
scroll: false,
containment: '.window-container',
});
}
@@ -2478,7 +2477,15 @@ async function UIWindow (options) {
if ( options.is_fullpage ) {
$(el_window).hide();
setTimeout(function () {
window.enter_fullpage_mode(el_window);
$(el_window).find('.window-head').hide();
$('body').addClass('fullpage-mode');
$(el_window).css({
width: '100%',
height: '100%',
top: 0,
left: 0,
'border-radius': 0,
});
$(el_window).show();
}, 50);
}
@@ -3292,9 +3299,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);
// if a fullpage window is closed, show the desktop
if ( $(this).attr('data-is_fullpage') === '1' ) {
window.exit_fullpage_mode();
$('body').removeClass('fullpage-mode');
}
// FileDialog closed
-21
View File
@@ -166,15 +166,6 @@ html.popup-frame-transparent body {
user-select: text !important;
}
.window-container {
position: fixed;
top: 0;
left: -100000px;
width: 200000px;
height: 200000px;
z-index: -9999;
}
input[type=text], input[type=password], input[type=email], select {
width: 100%;
padding: 8px;
@@ -360,18 +351,6 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel
padding: 0 24px;
}
.desktop {
overflow: hidden;
height: 100vh !important;
width: 100%;
box-sizing: border-box;
}
.device-phone .desktop {
height: 100vh !important;
height: 100dvh !important;
}
.item-container-list {
display: grid;
overflow-x: scroll !important;
+7 -7
View File
@@ -19,18 +19,18 @@
/**
* Whether a page load boots the dashboard instead of the desktop. The root
* path and `/app/<name>` boot the dashboard unless the page is an iframe embed
* or a popup, which the desktop still handles; `/dashboard` always does.
* path and `/app/<name>` boot the dashboard unless the page is a popup, which
* the desktop still handles; `/dashboard` always does. The server matches
* these routes case-insensitively, so this does too.
*
* @param {Object} location
* @param {string} location.pathname
* @param {boolean} location.inIframe
* @param {boolean} location.embeddedInPopup
* @returns {boolean}
*/
export function bootsDashboard ({ pathname, inIframe, embeddedInPopup }) {
if ( pathname === '/dashboard' || pathname === '/dashboard/' ) return true;
const isAppLanding = /^\/app\/[^/]+\/?$/.test(pathname);
export function bootsDashboard ({ pathname, embeddedInPopup }) {
if ( /^\/dashboard\/?$/i.test(pathname) ) return true;
const isAppLanding = /^\/app\/[^/]+\/?$/i.test(pathname);
if ( pathname !== '/' && !isAppLanding ) return false;
return !embeddedInPopup && !inIframe;
return !embeddedInPopup;
}
+13 -6
View File
@@ -20,9 +20,9 @@
import { describe, it, expect } from 'vitest';
import { bootsDashboard } from './bootsDashboard.js';
const boots = (url, { inIframe = false, embeddedInPopup = false } = {}) => {
const boots = (url, { embeddedInPopup = false } = {}) => {
const { pathname } = new URL(url, 'https://puter.com');
return bootsDashboard({ pathname, inIframe, embeddedInPopup });
return bootsDashboard({ pathname, embeddedInPopup });
};
describe('bootsDashboard', () => {
@@ -33,17 +33,24 @@ describe('bootsDashboard', () => {
expect(boots('/app/editor')).toBe(true);
});
it('matches the paths in any letter case, as the server routes them', () => {
expect(boots('/Dashboard')).toBe(true);
expect(boots('/App/editor')).toBe(true);
expect(boots('/APP/Editor/')).toBe(true);
expect(boots('/App/editor', { embeddedInPopup: true })).toBe(false);
});
it('boots the desktop on action pages', () => {
expect(boots('/action/sign-in')).toBe(false);
});
it('boots the desktop for flows only the desktop handles', () => {
expect(boots('/', { inIframe: true })).toBe(false);
it('boots the desktop in a popup', () => {
expect(boots('/', { embeddedInPopup: true })).toBe(false);
expect(boots('/app/editor', { embeddedInPopup: true })).toBe(false);
});
it('keeps /dashboard on the dashboard even inside an iframe', () => {
expect(boots('/dashboard', { inIframe: true })).toBe(true);
it('keeps /dashboard on the dashboard even in a popup', () => {
expect(boots('/dashboard', { embeddedInPopup: true })).toBe(true);
});
it('boots the dashboard whatever the query asks for', () => {
-12
View File
@@ -87,15 +87,3 @@ export const startPushWorker = async ({
console.warn('Could not update the service worker:', err);
}
};
/** Closes a browser banner that the desktop's worker showed for this uid. */
export const closeLocalBanner = async (uid, nav = globalThis.navigator) => {
try {
const registration = await nav?.serviceWorker?.getRegistration?.('/');
for ( const notification of await registration?.getNotifications?.() ?? [] ) {
if ( notification.data?.uid === uid ) notification.close();
}
} catch ( err ) {
console.warn('Could not close a browser notification:', err);
}
};
+1 -26
View File
@@ -18,7 +18,7 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { closeLocalBanner, pushWorkerEnabled } from './pushWorker.js';
import { pushWorkerEnabled } from './pushWorker.js';
afterEach(() => {
vi.resetModules();
@@ -149,28 +149,3 @@ describe('startPushWorker', () => {
expect(handler).not.toHaveBeenCalled();
});
});
describe('closeLocalBanner', () => {
it('closes only the desktop worker notification with the acknowledged uid', async () => {
const shown = [
{ data: { uid: 'one' }, close: vi.fn() },
{ data: { uid: 'two' }, close: vi.fn() },
{ data: { puter: { uid: 'one' } }, close: vi.fn() },
];
const getRegistration = vi.fn(async () => ({ getNotifications: async () => shown }));
await closeLocalBanner('one', { serviceWorker: { getRegistration } });
expect(getRegistration).toHaveBeenCalledWith('/');
expect(shown.map((n) => n.close.mock.calls.length)).toEqual([1, 0, 0]);
});
it('works without a registered worker', async () => {
await expect(closeLocalBanner('one', {})).resolves.toBeUndefined();
});
it('contains browser API failures', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(closeLocalBanner('one', {
serviceWorker: { getRegistration: async () => { throw new Error('unavailable'); } },
})).resolves.toBeUndefined();
});
});
-1
View File
@@ -514,7 +514,6 @@ const cs = {
"approve": "Schválit",
"perm_dialog_footnote": "Toto můžete kdykoli změnit v Nastavení.",
"auth_token": "Ověřovací token",
"error_user_or_path_not_found": "Uživatel nebo cesta nenalezena.",
"token_copied": "Token zkopírován",
"perm_dialog_wants_to": "chce povolení",
"token_label": "Označení",
-2
View File
@@ -962,8 +962,6 @@ const en = {
'enable_push_not_now': 'Not now',
'enable_push_footnote': 'You can turn them off anytime in Settings.',
'error_user_or_path_not_found': 'User or path not found.',
// Auth token
auth_token: 'Auth Token',
token_copied: 'Token copied',
-2
View File
@@ -396,8 +396,6 @@ const fr = {
'perm_subdomains_read': 'voir vos sous-domaines',
'perm_subdomains_write': 'gérer vos sous-domaines',
'error_user_or_path_not_found': 'Utilisateur ou chemin introuvable.',
// Auth token
auth_token: 'Jeton d\'authentification',
token_copied: 'Jeton copié',
-1
View File
@@ -378,7 +378,6 @@ const zhtw = {
'tar': "Tar 壓縮", // In English: "Tar"
'download_as_tar': "下載為 Tar", // In English: "Download as Tar"
'tarring': "正在建立 Tar 壓縮檔 %strong%", // In English: "Tarring %strong%"
'error_user_or_path_not_found': "找不到使用者或路徑。", // In English: "User or path not found."
},
};
-1
View File
@@ -59,7 +59,6 @@ vi.mock('./UI/Dashboard/UIDashboard.js', () => ({
}),
}));
vi.mock('./UI/Dashboard/TabApps.js', () => ({ default: {} }));
vi.mock('./UI/UIDesktop.js', () => ({ default: vi.fn(async () => null) }));
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindow.js', () => ({ default: vi.fn(async () => null) }));
+7 -85
View File
@@ -20,7 +20,6 @@
import UIDashboard from './UI/Dashboard/UIDashboard.js';
import TabApps from './UI/Dashboard/TabApps.js';
import UIAlert from './UI/UIAlert.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';
@@ -268,16 +267,10 @@ const postAuthActions = async (action) => {
await UIWindowCopyToken({ show_header: true });
}
// -------------------------------------------------------------------------------------
// Load desktop, only if we're not embedded in a popup and not on the dashboard page
// -------------------------------------------------------------------------------------
if ( !window.embedded_in_popup && !window.is_dashboard_mode ) {
UIDesktop();
}
// -------------------------------------------------------------------------------------
// 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.
@@ -298,13 +291,13 @@ const postAuthActions = async (action) => {
// 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.') ) {
@@ -1249,12 +1242,9 @@ if (jQuery) {
// alias. 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.
// URLs that carry a desktop-only flow keep booting the desktop: auth popups
// (`?embedded_in_popup=`) and iframe embeds. A share link (`?shared=`,
// from an email) lands in the dashboard: Files, on Shared, with the items it names
// picked out; the desktop ignores it. 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.
// Auth popups (`?embedded_in_popup=`) keep booting the desktop flow. A share link
// (`?shared=`, from an email) lands in the dashboard: Files, on Shared, with the
// items it names picked out.
{
const pathname = window.location.pathname;
const search_params = new URLSearchParams(window.location.search);
@@ -1269,11 +1259,8 @@ if (jQuery) {
window.embedded_in_frame = !!window.tell_popup_frame;
if (window.embedded_in_frame) focusOnFrameFocus(window);
};
// note: iframe detection is inlined because globals.js (window.is_embedded) loads after this module
const in_iframe = window.location !== window.parent.location;
if (bootsDashboard({
pathname,
inIframe: in_iframe,
embeddedInPopup: !!window.embedded_in_popup,
})) {
window.is_dashboard_mode = true;
@@ -1655,25 +1642,9 @@ window.initgui = async function (options) {
return;
}
//--------------------------------------------------------------------------------------
// Determine if we are in full-page mode. The dashboard always shows apps full
// page; a desktop boot (embeds, popups) can ask with ?puter.fullpage=true.
//--------------------------------------------------------------------------------------
// The dashboard always shows apps full page.
if (window.is_dashboard_mode) {
window.is_fullpage_mode = true;
} else 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')
) {
// Puter is in fullpage mode.
window.is_fullpage_mode = true;
}
// Launch services before any UI is rendered
@@ -2004,32 +1975,6 @@ 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.app_launched_from_url = app_info;
}
} catch (e) {
// App metadata fetch failed; will retry later in UIDesktop
}
}
//--------------------------------------------------------------------------------------
// Action: Password recovery
//--------------------------------------------------------------------------------------
@@ -2425,8 +2370,6 @@ window.initgui = async function (options) {
$('.window').close();
// close all ctxmenus
$('.context-menu').remove();
// remove desktop
$('.desktop').remove();
// disable native browser exit confirmation
window.onbeforeunload = null;
window.location.replace('/');
@@ -2995,27 +2938,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.app_launched_from_url = app_info;
}
} catch (e) {
// App metadata fetch failed; will retry later in UIDesktop
}
}
await postAuthActions(action);
});
-17
View File
@@ -22,10 +22,6 @@ import { InitProcess, Service } from '../definitions.js';
const NULL_UUID = '00000000-0000-0000-0000-000000000000';
export class ProcessService extends Service {
static INITRC = [
// 'puter-linux'
];
async _init () {
this.processes = [];
this.processes_map = new Map();
@@ -37,19 +33,6 @@ export class ProcessService extends Service {
this.register_(root);
}
['__on_gui:ready'] () {
const svc_exec = this.services.get('exec');
for ( let spec of ProcessService.INITRC ) {
if ( typeof spec === 'string' ) {
spec = { name: spec };
}
svc_exec.launchApp({
app_name: spec.name,
});
}
}
get_init () {
return this.processes_map.get(NULL_UUID);
}
@@ -1131,19 +1131,38 @@ test.describe('grant-user-app permission length', () => {
});
});
/**
* A top-level request-permission page posts its answer to itself. Resolves
* with that answer, or with null if a prompt shows up first.
*/
async function answerWithoutPrompt (page, url) {
await page.addInitScript(() => {
window.addEventListener('message', (event) => {
if ( event.data?.msg === 'permissionGranted' ) window.__permissionAnswer = event.data;
});
});
await page.goto(url);
const settled = await page.waitForFunction(
() => window.__permissionAnswer ?? (document.querySelector('dialog.perm-dialog') ? 'prompted' : undefined),
null,
{ timeout: 60_000 },
);
const answer = await settled.jsonValue();
return answer === 'prompted' ? null : answer;
}
test.describe('request-permission action hardening', () => {
test('an app_uid in the URL never produces a prompt on its own', async ({ page }) => {
// The uid identifies who receives the grant, so it must come from the
// requesting origin — never from the link. A link carrying only a uid
// would otherwise prompt for an unnamed requester and grant to an app
// the dialog never showed the user.
await page.goto(
const answer = await answerWithoutPrompt(
page,
`/action/request-permission?permission=${encodeURIComponent(UNHELD_PERMISSION)}` +
'&app_uid=app-00000000-0000-4000-8000-000000000000',
);
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 });
expect(answer?.granted).toBe(false);
await expect(page.locator('dialog.perm-dialog')).toHaveCount(0);
});
@@ -1274,12 +1293,12 @@ test.describe('request-permission action hardening', () => {
// (the referrer, or the opener's reply to the `requestOrigin`
// handshake). Believing the query string would let a bare link raise a
// consent prompt in any app's name and commit the user's grant to it.
await page.goto(
const answer = await answerWithoutPrompt(
page,
`/action/request-permission?permission=${encodeURIComponent(UNHELD_PERMISSION)}` +
`&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 });
expect(answer?.granted).toBe(false);
await expect(page.locator('dialog.perm-dialog')).toHaveCount(0);
});