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:
jelveh
2026-07-22 10:50:10 -07:00
parent dec205d959
commit d5e9417d2d
4 changed files with 221 additions and 34 deletions
+7
View File
@@ -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
View File
@@ -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');
}
}
-6
View File
@@ -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
View File
@@ -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 &&