From d5e9417d2d72911ad60909a9c82f92d7cf3e013f Mon Sep 17 00:00:00 2001 From: jelveh Date: Wed, 22 Jul 2026 10:50:10 -0700 Subject: [PATCH] Dashboard apps own the URL: /app/ entries with Back-to-minimize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Opening an app in dashboard mode pushes a real history entry at /app/; 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/ 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/ or folder-path replaceState on focus, no /desktop resets on close or minimize); tab titles still track the focused window --- src/gui/src/UI/Dashboard/UIDashboard.js | 7 + src/gui/src/UI/UIWindow.js | 178 +++++++++++++++++++++--- src/gui/src/helpers.js | 6 - src/gui/src/initgui.js | 64 ++++++++- 4 files changed, 221 insertions(+), 34 deletions(-) diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 65ff6a3cf..acfe0c293 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -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/ 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 diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 4603f7449..439a62372 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -654,6 +654,18 @@ async function UIWindow (options) { $(el_window).focusWindow(); } + // In dashboard mode an opening app claims the URL (/app/) 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/ 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'); } } diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 4522cd0ba..16756ba55 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -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' ) { diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index c82d67a1f..b4063947b 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -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/: 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/ 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/ 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/`) +// 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/ 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 &&