mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
Dashboard apps own the URL: /app/<name> entries with Back-to-minimize
- Opening an app in dashboard mode pushes a real history entry at /app/<name>; Back minimizes the app (it keeps running), Forward restores it — or relaunches it if it was closed - The minimize/close buttons consume the entry via history.back() so the address bar never names an app that is no longer on screen - Landing directly on /app/<name> now boots the dashboard with the app open maximized in-page instead of the desktop; embeds, popups, and explicit fullpage boots keep the desktop flow, and fullpage_on_landing no longer opts a landing out of the dashboard - Desktop mode no longer rewrites the URL at all (no /app/<name> or folder-path replaceState on focus, no /desktop resets on close or minimize); tab titles still track the focused window
This commit is contained in:
@@ -395,6 +395,13 @@ async function UIDashboard (options) {
|
||||
let lastHandledHref = window.location.href;
|
||||
const handleRouteChange = () => {
|
||||
if ( window.location.href === lastHandledHref ) return;
|
||||
// A traversal INTO an /app/<name> entry belongs to an open app
|
||||
// window (UIWindow's popstate handler restores/minimizes it), not
|
||||
// tab routing. lastHandledHref deliberately keeps the dashboard URL
|
||||
// underneath: traversing back out of the app returns to exactly
|
||||
// that URL and the equality check above skips re-routing (the
|
||||
// dashboard never actually left).
|
||||
if ( window.location.pathname.startsWith('/app/') ) return;
|
||||
lastHandledHref = window.location.href;
|
||||
const route = window.parseDashboardRoute();
|
||||
// Ignore unknown tab ids entirely (a stale bookmark hash, an in-page
|
||||
|
||||
+155
-23
@@ -654,6 +654,18 @@ async function UIWindow (options) {
|
||||
$(el_window).focusWindow();
|
||||
}
|
||||
|
||||
// In dashboard mode an opening app claims the URL (/app/<name>) with a
|
||||
// real history entry — Back then reads as "leave the app" and minimizes
|
||||
// it (see the popstate handler next to push_dashboard_app_url). Same
|
||||
// ownership rule as focusWindow's desktop-mode replaceState: only
|
||||
// windows asked to update the URL, and never explorer (its URL scheme
|
||||
// is the desktop's custom_path, meaningless on the dashboard).
|
||||
if ( window.is_dashboard_mode && options.is_visible
|
||||
&& options.app && options.app !== 'explorer'
|
||||
&& options.update_window_url ) {
|
||||
push_dashboard_app_url(options.app, options.title);
|
||||
}
|
||||
|
||||
// A launch from a dashboard Apps-tab tile (TabApps passes
|
||||
// morph_from_dashboard_tile) morphs the tile's icon into the opening
|
||||
// window — the reverse of hideWindow's minimize morph. The window is
|
||||
@@ -1719,6 +1731,10 @@ async function UIWindow (options) {
|
||||
// Minimize button
|
||||
// --------------------------------------------------------
|
||||
$(`#window-${win_id} > .window-head > .window-minimize-btn`).click(function () {
|
||||
// Dashboard mode: if this app owns the URL, consume its history
|
||||
// entry instead — the popstate handler does the minimize, keeping
|
||||
// the button and the browser's Back button one code path.
|
||||
if ( pop_dashboard_app_url($(el_window).attr('data-app')) ) return;
|
||||
$(el_window).hideWindow();
|
||||
});
|
||||
|
||||
@@ -2289,6 +2305,8 @@ async function UIWindow (options) {
|
||||
menu_items.push({
|
||||
html: i18n('minimize'),
|
||||
onClick: function () {
|
||||
// Same URL bookkeeping as the minimize button.
|
||||
if ( pop_dashboard_app_url($(el_window).attr('data-app')) ) return;
|
||||
$(el_window).hideWindow();
|
||||
},
|
||||
});
|
||||
@@ -3582,12 +3600,11 @@ $.fn.close = async function (options) {
|
||||
else {
|
||||
// close any open FileDialogs belonging to this window
|
||||
$(`.window-filedialog[data-parent_uuid="${window_uuid}"]`).close();
|
||||
// reset URL to desktop first; focusWindow will set the correct URL for the next focused window
|
||||
// only reset the URL if this window was the one that owns it (mirrors the open-side check in focusWindow)
|
||||
const update_window_url = $(this).attr('data-update_window_url');
|
||||
if ( ! window.is_dashboard_mode && (update_window_url === 'true' || update_window_url === null) ) {
|
||||
window.history.replaceState(null, document.title, '/desktop');
|
||||
}
|
||||
// Dashboard mode: consume this app's URL entry so the
|
||||
// address bar doesn't keep naming a dead app (the popstate
|
||||
// handler finds no window left and just restores the title;
|
||||
// no-op when the closing app doesn't own the URL).
|
||||
pop_dashboard_app_url($(this).attr('data-app'));
|
||||
// bring focus to the last window in the window-stack (only if not minimized)
|
||||
let next_window_focused = false;
|
||||
if ( window.window_stack.length > 0 ) {
|
||||
@@ -3889,6 +3906,14 @@ $.fn.showWindow = async function (options) {
|
||||
if ( ! morphed && was_hidden ) $(el_window).hide();
|
||||
}
|
||||
if ( ! morphed ) $(el_window).fadeIn(150);
|
||||
// A restore re-claims the URL for this app — except when
|
||||
// the restore was DRIVEN by a history traversal (popstate
|
||||
// passes no_history: the entry is already current).
|
||||
if ( ! options?.no_history
|
||||
&& $(el_window).attr('data-update_window_url') === 'true'
|
||||
&& $(el_window).attr('data-app') !== 'explorer' ) {
|
||||
push_dashboard_app_url($(el_window).attr('data-app'), $(el_window).attr('data-name'));
|
||||
}
|
||||
setTimeout(() => {
|
||||
$(this).focusWindow();
|
||||
}, 80);
|
||||
@@ -4001,23 +4026,13 @@ $.fn.focusWindow = function (event) {
|
||||
}
|
||||
// remove blurred class from items on this window
|
||||
$(window.active_item_container).find('.item-blurred').removeClass('item-blurred');
|
||||
//change window URL (skip in dashboard mode — URL should stay on the dashboard route)
|
||||
// Update the tab title to the focused window. The URL itself is
|
||||
// deliberately NOT touched here: on the desktop it stays /desktop
|
||||
// (apps don't rewrite it), and in dashboard mode the open app owns
|
||||
// it through real history entries (push_dashboard_app_url).
|
||||
if ( ! window.is_dashboard_mode ) {
|
||||
const update_window_url = $(this).attr('data-update_window_url');
|
||||
const url_app_name = $(this).attr('data-app_pseudonym') || $(this).attr('data-app');
|
||||
let custom_path = $(this).attr('data-custom_path');
|
||||
|
||||
if ( custom_path && custom_path !== '' ) {
|
||||
if ( update_window_url === 'true' || update_window_url === null ) {
|
||||
if ( ! custom_path.startsWith('/') ) {
|
||||
custom_path = `/${ custom_path}`;
|
||||
}
|
||||
window.history.replaceState({ window_id: $(this).attr('data-id') }, '', custom_path);
|
||||
document.title = $(this).attr('data-name');
|
||||
}
|
||||
}
|
||||
else if ( update_window_url === 'true' || update_window_url === null ) {
|
||||
window.history.replaceState({ window_id: $(this).attr('data-id') }, '', `/app/${url_app_name}${$(this).attr('data-user_set_url_params')}`);
|
||||
if ( update_window_url === 'true' || update_window_url === null ) {
|
||||
document.title = $(this).attr('data-name');
|
||||
}
|
||||
}
|
||||
@@ -4056,6 +4071,123 @@ function dashboard_tile_in_view (app_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Dashboard app URL ownership
|
||||
// ---------------------------------------------------------------------
|
||||
// In dashboard mode the open app claims the URL as /app/<name> with a REAL
|
||||
// history entry (the desktop's version of this is focusWindow's
|
||||
// replaceState, which Back can't return to), so the browser's Back button
|
||||
// reads as "leave the app": popping the entry MINIMIZES the window — the
|
||||
// app keeps running, the zoom-to-tile morph shows where it went, and
|
||||
// Forward (or the tile) brings it back. The dashboard's own route stays
|
||||
// underneath the app entries. The minimize/close buttons consume the
|
||||
// entry via pop_dashboard_app_url so the address bar never keeps naming
|
||||
// an app that is no longer on screen.
|
||||
|
||||
// The app name the URL currently claims, kept in lockstep with pushes and
|
||||
// traversals so the popstate handler knows which window a traversal LEFT
|
||||
// (module-local: every push goes through push_dashboard_app_url).
|
||||
let dashboard_url_app = null;
|
||||
|
||||
function dashboard_app_url_current () {
|
||||
const m = /^\/app\/([^/]+)\/?$/.exec(window.location.pathname);
|
||||
if ( ! m ) return null;
|
||||
// A malformed percent-sequence in a hand-edited URL must not throw
|
||||
// out of the popstate handler.
|
||||
try {
|
||||
return decodeURIComponent(m[1]);
|
||||
} catch (e) {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
|
||||
function push_dashboard_app_url (app_name, title) {
|
||||
if ( ! window.is_dashboard_mode || ! app_name ) return;
|
||||
if ( dashboard_app_url_current() === app_name ) {
|
||||
// Already current (e.g. the popstate handler relaunching a closed
|
||||
// app's entry): claim it without stacking a duplicate.
|
||||
dashboard_url_app = app_name;
|
||||
if ( title ) document.title = title;
|
||||
return;
|
||||
}
|
||||
// The title to come back to when the last app entry is popped.
|
||||
if ( window.dashboard_base_title === undefined ) {
|
||||
window.dashboard_base_title = document.title;
|
||||
}
|
||||
window.history.pushState({ dashboard_app: app_name }, '', `/app/${encodeURIComponent(app_name)}`);
|
||||
dashboard_url_app = app_name;
|
||||
if ( title ) document.title = title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume an app's URL entry (history.back()) if it is the one the URL
|
||||
* currently shows; the popstate handler then does the actual minimize —
|
||||
* so a minimize button and the browser's Back button are one code path,
|
||||
* and Forward re-restores the window either way. Returns true if the
|
||||
* back() was issued (the caller must NOT also hide the window), false
|
||||
* when this app doesn't own the URL (caller falls back to hiding
|
||||
* directly, e.g. an app stacked under another app's entry).
|
||||
*/
|
||||
function pop_dashboard_app_url (app_name) {
|
||||
if ( ! window.is_dashboard_mode || ! app_name ) return false;
|
||||
if ( dashboard_app_url_current() !== app_name ) return false;
|
||||
window.history.back();
|
||||
return true;
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
if ( ! window.is_dashboard_mode ) return;
|
||||
const new_app = dashboard_app_url_current();
|
||||
const prev_app = dashboard_url_app;
|
||||
// Same app on both sides means the traversal wasn't ours (e.g. an app
|
||||
// iframe's internal history) — leave the windows alone.
|
||||
if ( new_app === prev_app ) return;
|
||||
dashboard_url_app = new_app;
|
||||
|
||||
// The traversal left an app's entry: minimize that window (closed
|
||||
// windows are simply gone — close consumed its entry already, or the
|
||||
// entry went stale mid-stack).
|
||||
if ( prev_app ) {
|
||||
const $prev_win = $(`.window[data-app="${html_encode(prev_app)}"]`);
|
||||
if ( $prev_win.length ) {
|
||||
const $win = $prev_win.last();
|
||||
const minimized = $win.attr('data-is_minimized');
|
||||
if ( minimized !== '1' && minimized !== 'true' ) {
|
||||
$win.hideWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( new_app ) {
|
||||
// ...and landed on another app's entry (Forward, or Back across
|
||||
// two stacked apps): restore its window — or relaunch it if it
|
||||
// was closed, so the entry behaves as a live deep link.
|
||||
const $new_win = $(`.window[data-app="${html_encode(new_app)}"]`);
|
||||
if ( $new_win.length ) {
|
||||
const $win = $new_win.last();
|
||||
const minimized = $win.attr('data-is_minimized');
|
||||
if ( minimized === '1' || minimized === 'true' ) {
|
||||
// no_history: the entry being restored to is already
|
||||
// current — showWindow must not push it again.
|
||||
$win.showWindow({ no_history: true });
|
||||
} else {
|
||||
$win.focusWindow();
|
||||
}
|
||||
document.title = $win.attr('data-name') || document.title;
|
||||
} else {
|
||||
launch_app({
|
||||
name: new_app,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
}).catch((err) => {
|
||||
console.error(`Failed to launch ${new_app}:`, err);
|
||||
});
|
||||
}
|
||||
} else if ( window.dashboard_base_title !== undefined ) {
|
||||
document.title = window.dashboard_base_title;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Tiles whose click feedback has played (or is playing) for the launch
|
||||
* currently in flight. A fresh app launch has a server round-trip between
|
||||
@@ -4533,10 +4665,10 @@ $.fn.hideWindow = async function (options) {
|
||||
});
|
||||
}, 250);
|
||||
|
||||
// update title and window URL — only if this window was the one that owns the URL
|
||||
// reset the tab title (the URL is not touched — apps never
|
||||
// rewrite it on the desktop) — only if this window owned it
|
||||
const update_window_url = $(this).attr('data-update_window_url');
|
||||
if ( ! window.is_dashboard_mode && (update_window_url === 'true' || update_window_url === null) ) {
|
||||
window.history.replaceState(null, document.title, '/desktop');
|
||||
document.title = i18n('window_title_puter');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,12 +1049,6 @@ window.show_save_account_notice_if_needed = function (message) {
|
||||
});
|
||||
};
|
||||
|
||||
window.onpopstate = (event) => {
|
||||
if ( event.state !== null && event.state.window_id !== null ) {
|
||||
$(`.window[data-id="${event.state.window_id}"]`).focusWindow();
|
||||
}
|
||||
};
|
||||
|
||||
window.sort_items = (item_container, sort_by, sort_order) => {
|
||||
if ( sort_order !== 'asc' && sort_order !== 'desc' )
|
||||
{
|
||||
|
||||
+59
-5
@@ -40,6 +40,7 @@ import { PROCESS_RUNNING } from './definitions.js';
|
||||
import create_access_token from './helpers/create_access_token.js';
|
||||
import init_device_signals from './helpers/device_signals.js';
|
||||
import item_icon from './helpers/item_icon.js';
|
||||
import launch_app from './helpers/launch_app.js';
|
||||
import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js';
|
||||
import update_mouse_position from './helpers/update_mouse_position.js';
|
||||
import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js';
|
||||
@@ -116,6 +117,51 @@ const postAuthActions = async (action) => {
|
||||
// -------------------------------------------------------------------------------------
|
||||
else if ( window.is_dashboard_mode ) {
|
||||
UIDashboard();
|
||||
// Direct landing on /app/<name>: open the app in the dashboard the
|
||||
// same way a tile launch does. The dashboard's route is slotted
|
||||
// underneath first (replaceState) and the launch re-claims
|
||||
// /app/<name> as a real history entry, so Back minimizes to the
|
||||
// dashboard exactly like an in-dashboard launch. (`?c` suppresses
|
||||
// the auto-launch, mirroring the desktop URL-launch flow.)
|
||||
if ( window.url_paths[0]?.toLocaleLowerCase() === 'app'
|
||||
&& window.url_paths[1]
|
||||
&& ! window.url_query_params.has('c') ) {
|
||||
// any query param that doesn't start with 'puter.' is passed
|
||||
// through to the app (mirrors the desktop URL-launch flow)
|
||||
const app_query_params = {};
|
||||
for ( const [key, value] of window.url_query_params ) {
|
||||
if ( ! key.startsWith('puter.') ) {
|
||||
app_query_params[key] = value;
|
||||
}
|
||||
}
|
||||
let posargs;
|
||||
if ( app_query_params.posargs ) {
|
||||
try {
|
||||
posargs = JSON.parse(app_query_params.posargs);
|
||||
} catch (e) {
|
||||
// malformed posargs: launch without them
|
||||
}
|
||||
}
|
||||
// The server titles /app/<name> pages after the app, so the
|
||||
// launch's lazy base-title capture would keep the app's name
|
||||
// forever — preset the title to fall back to when the app's
|
||||
// history entry is popped.
|
||||
window.dashboard_base_title = i18n('window_title_puter');
|
||||
window.history.replaceState(null, '', '/');
|
||||
launch_app({
|
||||
name: window.url_paths[1],
|
||||
maximized: true,
|
||||
params: app_query_params,
|
||||
readURL: window.url_query_params.get('readURL'),
|
||||
...(posargs ? {
|
||||
args: {
|
||||
command_line: { args: posargs },
|
||||
},
|
||||
} : {}),
|
||||
}).catch((err) => {
|
||||
console.error(`Failed to launch ${window.url_paths[1]} from URL:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
// -------------------------------------------------------------------------------------
|
||||
// If embedded in a popup, send the token to the opener and close the popup
|
||||
@@ -503,10 +549,14 @@ if (jQuery) {
|
||||
|
||||
// are we in dashboard mode?
|
||||
// The dashboard is the default interface at the root path; `/dashboard` is kept as an
|
||||
// alias, and `/desktop` loads the desktop instead. Root URLs that carry a desktop-only
|
||||
// flow keep booting the desktop: auth popups (`?embedded_in_popup=`), app deep links
|
||||
// (`?app=`), direct downloads (`?download=`), fullpage mode (`?puter.fullpage=`), and
|
||||
// iframe embeds.
|
||||
// alias, and `/desktop` loads the desktop instead. Direct app landings (`/app/<name>`)
|
||||
// open in the dashboard too: the app comes up maximized in-page with the dashboard
|
||||
// route slotted underneath (see postAuthActions), so Back minimizes to the dashboard.
|
||||
// URLs that carry a desktop-only flow keep booting the desktop: auth popups
|
||||
// (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`),
|
||||
// fullpage mode (`?puter.fullpage=`), and iframe embeds. 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.
|
||||
{
|
||||
const pathname = window.location.pathname;
|
||||
const search_params = new URLSearchParams(window.location.search);
|
||||
@@ -523,7 +573,8 @@ if (jQuery) {
|
||||
search_params.has('download');
|
||||
const is_dashboard_alias =
|
||||
pathname === '/dashboard' || pathname === '/dashboard/';
|
||||
if (is_dashboard_alias || (pathname === '/' && !needs_desktop_at_root)) {
|
||||
const is_app_landing = /^\/app\/[^/]+\/?$/.test(pathname);
|
||||
if (is_dashboard_alias || ((pathname === '/' || is_app_landing) && !needs_desktop_at_root)) {
|
||||
window.is_dashboard_mode = true;
|
||||
window.dashboard_initial_route = parseDashboardRoute();
|
||||
}
|
||||
@@ -1000,6 +1051,9 @@ 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 &&
|
||||
|
||||
Reference in New Issue
Block a user