diff --git a/src/backend/controllers/homepage/HomepageController.test.ts b/src/backend/controllers/homepage/HomepageController.test.ts index d1bd13965..d26356719 100644 --- a/src/backend/controllers/homepage/HomepageController.test.ts +++ b/src/backend/controllers/homepage/HomepageController.test.ts @@ -173,12 +173,14 @@ describe('HomepageController shell routes', () => { expect(String(captured.body)).toMatch(//i); }); - it('serves the shell on /settings, /dashboard, /action, /@:username', async () => { + it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => { for (const path of [ '/settings', '/settings/*splat', '/dashboard', '/dashboard/', + '/desktop', + '/desktop/', '/action/*splat', '/@:username', ]) { diff --git a/src/backend/controllers/homepage/HomepageController.ts b/src/backend/controllers/homepage/HomepageController.ts index 53aca9338..c0e3970b0 100644 --- a/src/backend/controllers/homepage/HomepageController.ts +++ b/src/backend/controllers/homepage/HomepageController.ts @@ -82,6 +82,9 @@ export class HomepageController extends PuterController { router.get('/dashboard', {}, (req, res) => sendShell(req, res)); router.get('/dashboard/', {}, (req, res) => sendShell(req, res)); + router.get('/desktop', {}, (req, res) => sendShell(req, res)); + router.get('/desktop/', {}, (req, res) => sendShell(req, res)); + router.get('/action/*splat', {}, (req, res) => sendShell(req, res)); router.get('/@:username', {}, (req, res) => sendShell(req, res)); diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index 794a15277..e2606aefd 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -139,6 +139,19 @@ export class OIDCController extends PuterController { let appRedirectUri = flowRedirects[flow] ?? (origin || '/'); + // Optional GUI return path so login started from /desktop or + // /dashboard lands back there. Strict whitelist — never a + // client-supplied URL (no open redirect). + const rawReturnTo = Array.isArray(req.query.return_to) + ? req.query.return_to[0] + : req.query.return_to; + if ( + (flow === 'login' || flow === 'signup') && + (rawReturnTo === '/desktop' || rawReturnTo === '/dashboard') + ) { + appRedirectUri = `${origin}${rawReturnTo}`; + } + // Popup support const rawPopup = Array.isArray(req.query.embedded_in_popup) ? req.query.embedded_in_popup[0] diff --git a/src/gui/dev-server.js b/src/gui/dev-server.js index 0cbeb689b..747e69ebb 100644 --- a/src/gui/dev-server.js +++ b/src/gui/dev-server.js @@ -52,7 +52,7 @@ startServer(1); // build the GUI build(); -app.get(['/', '/app/*', '/action/*'], (req, res) => { +app.get(['/', '/app/*', '/action/*', '/desktop', '/dashboard'], (req, res) => { res.send(generateDevHtml({ env: env, api_origin: 'https://api.puter.com', diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 09eeb7623..0fd33de80 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -28,10 +28,12 @@ function buildRecentAppsHTML() { // Show up to 6 recent apps (2 columns x 3 rows) const recentApps = window.launch_apps.recent.slice(0, 6); for (const app_info of recentApps) { - // if title, name and uuid are the same and index_url is set, then show the hostname of index_url + // if title, name and uuid are the same and start with 'app-' and index_url + // is set, then show the hostname of index_url instead of the opaque app id if ( app_info.name === app_info.title && app_info.name === app_info.uuid && + app_info.name?.startsWith('app-') && app_info.index_url ) { app_info.title = new URL(app_info.index_url).hostname; @@ -178,14 +180,6 @@ const TabHome = { h += ''; h += ''; - // Desktop switch card (spans full width) - // h += '
'; - // h += '
'; - // h += 'Looking for Puter\'s desktop interface?'; - // h += ''; - // h += '
'; - // h += '
'; - // Usage card (spans full width on second row) h += '
'; h += @@ -208,6 +202,23 @@ const TabHome = { h += '
'; h += ''; + // Open Desktop card (spans full width, links to the desktop interface) + h += ''; + h += '
'; + h += + ''; + h += '
'; + h += '
'; + h += `

${i18n('open_desktop')}

`; + h += ''; + h += + ''; + h += 'Switch to the desktop interface'; + h += ''; + h += '
'; + h += ''; + h += '
'; + h += ''; return h; }, @@ -271,11 +282,6 @@ const TabHome = { }, ); - // Handle desktop switch button - $el_window.on('click', '.bento-desktop-switch-btn', function () { - window.location.href = '/'; - }); - // Handle "Save Account" warning click $el_window.on('click', '.bento-save-account-warning', function (e) { e.preventDefault(); diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 74de9882a..8fdedf1b9 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -149,6 +149,11 @@ async function UIDashboard (options) { const $el_window = $(el_window); + // Remove `?ref=...` from the URL, keeping the path and tab hash (mirrors UIDesktop) + if ( window.url_query_params?.has('ref') ) { + window.history.pushState(null, document.title, window.location.pathname + window.location.hash); + } + // Restore sidebar collapsed state if ( localStorage.getItem('dashboard-sidebar-collapsed') === '1' ) { $el_window.find('.dashboard-sidebar').addClass('collapsed'); @@ -285,8 +290,9 @@ async function UIDashboard (options) { window.addEventListener('hashchange', handleRouteChange); window.addEventListener('popstate', handleRouteChange); - // Sidebar item click handler - $el_window.on('click', '.dashboard-sidebar-item', function (e) { + // Sidebar item click handler — only tab items ([data-section]); plain links + // like the "Open Desktop" button navigate natively + $el_window.on('click', '.dashboard-sidebar-item[data-section]', function (e) { e.preventDefault(); const $this = $(this); const section = $this.attr('data-section'); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index d4631643e..cbbf61566 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -709,9 +709,9 @@ async function UIDesktop (options) { window.sidebar_items = val; }); - // Remove `?ref=...` from navbar URL + // Remove `?ref=...` from navbar URL, keeping the current path if ( window.url_query_params.has('ref') ) { - window.history.pushState(null, document.title, '/'); + window.history.pushState(null, document.title, window.location.pathname); } //show_hidden_files @@ -1732,7 +1732,7 @@ async function UIDesktop (options) { try { stat = await puter.fs.stat({ path: item_path, consistency: 'eventual' }); } catch ( e ) { - window.history.replaceState(null, document.title, '/'); + window.history.replaceState(null, document.title, '/desktop'); UIAlert({ message: i18n('error_user_or_path_not_found'), type: 'error', @@ -1816,9 +1816,11 @@ async function UIDesktop (options) { //-------------------------------------------------------------------------------------- // Direct download link - // i.e. https://puter.com/?download= + // i.e. https://puter.com/?download= or https://puter.com/desktop?download= //-------------------------------------------------------------------------------------- - if ( window.url_paths.length === 0 && window.url_query_params.has('download') ) { + const is_bare_desktop_path = window.url_paths.length === 0 + || (window.url_paths.length === 1 && window.url_paths[0]?.toLocaleLowerCase() === 'desktop'); + if ( is_bare_desktop_path && window.url_query_params.has('download') ) { const url = window.url_query_params.get('download'); let file_name = url.split('/').pop().split('?')[0]; @@ -2051,7 +2053,7 @@ $(document).on('contextmenu taphold', '.toolbar', function (event) { $(document).on('click', '.qr-btn', async function (e) { UIWindowQR({ message_i18n_key: 'scan_qr_c2a', - text: `${window.gui_origin }?auth_token=${ window.auth_token}`, + text: `${window.gui_origin }/desktop?auth_token=${ window.auth_token}`, }); }); diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 7a2b21862..2b12bc3c2 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -3561,7 +3561,7 @@ $.fn.close = async function (options) { // 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, '/'); + window.history.replaceState(null, document.title, '/desktop'); } // bring focus to the last window in the window-stack (only if not minimized) let next_window_focused = false; @@ -4015,7 +4015,7 @@ $.fn.hideWindow = async function (options) { // update title and window URL — only if this window was the one that owns the URL 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, '/'); + window.history.replaceState(null, document.title, '/desktop'); document.title = i18n('window_title_puter'); } } diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 19a2b444d..cfc88ab3b 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -381,7 +381,8 @@ async function UIWindowLogin (options) { if ( logo_clickable ) { $(el_window).find('.auth-logo').on('click', function () { - window.location.href = '/'; + // keep desktop users on the desktop; everyone else goes to the root dashboard + window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/'; }); } @@ -407,6 +408,12 @@ async function UIWindowLogin (options) { $(el_window).find(btnClass).on('click', function () { let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=login`; + // return to the interface the login started from (backend whitelists the path) + const return_to = window.location.pathname; + if ( return_to === '/desktop' || return_to === '/dashboard' ) { + url += `&return_to=${encodeURIComponent(return_to)}`; + } + const referrer = options.referrer ?? window.referrerStr ?? window.openerOrigin; if ( referrer ) { url += `&referrer=${encodeURIComponent(referrer)}`; diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index db2707af0..e7e319d91 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -148,7 +148,8 @@ function UIWindowSignup (options) { } if ( logo_clickable ) { $(el_window).find('.auth-logo').on('click', function () { - window.location.href = '/'; + // keep desktop users on the desktop; everyone else goes to the root dashboard + window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/'; }); } @@ -191,6 +192,11 @@ function UIWindowSignup (options) { $(el_window).find(btnClass).css('display', 'flex'); $(el_window).find(btnClass).on('click', function () { let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`; + // return to the interface the signup started from (backend whitelists the path) + const return_to = window.location.pathname; + if ( return_to === '/desktop' || return_to === '/dashboard' ) { + url += `&return_to=${encodeURIComponent(return_to)}`; + } const referrer = options.referrer ?? window.referrerStr; if ( referrer ) { url += `&referrer=${encodeURIComponent(referrer)}`; diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 4dd1f2a2a..6b92c186d 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -75,6 +75,11 @@ --dashboard-bento-usage-surface: #eef2ff; --dashboard-bento-usage-header-bg: #e0e7ff; --dashboard-bento-usage-bar-fill: #ea580c; + /* Emerald (matches the green icon accent used elsewhere) */ + --dashboard-bento-desktop-icon-bg: #059669; + --dashboard-bento-desktop-icon-shadow: rgba(5, 150, 105, 0.22); + --dashboard-bento-desktop-surface: #ecfdf5; + --dashboard-bento-desktop-header-bg: #d1fae5; --dashboard-shadow-subtle: rgba(0, 0, 0, 0.04); --dashboard-shadow-light: rgba(0, 0, 0, 0.06); @@ -166,6 +171,10 @@ body { --dashboard-bento-usage-surface: #1c1d2e; --dashboard-bento-usage-header-bg: #252a42; --dashboard-bento-usage-bar-fill: #fb923c; + --dashboard-bento-desktop-icon-bg: #10b981; + --dashboard-bento-desktop-icon-shadow: rgba(16, 185, 129, 0.32); + --dashboard-bento-desktop-surface: #16241f; + --dashboard-bento-desktop-header-bg: #172e26; --dashboard-shadow-subtle: rgba(0, 0, 0, 0.2); --dashboard-shadow-light: rgba(0, 0, 0, 0.3); @@ -643,7 +652,7 @@ body { @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { .myapps-search-wrap { - background: rgba(255, 255, 255, 0.52); + background: rgba(255, 255, 255, 0); backdrop-filter: blur(20px) saturate(1.5); -webkit-backdrop-filter: blur(20px) saturate(1.5); } @@ -1074,9 +1083,6 @@ input.myapps-search::placeholder { border-radius: 20px; overflow: hidden; border: 1px solid var(--dashboard-shadow-light); - box-shadow: - 0 1px 2px var(--dashboard-shadow-subtle), - 0 4px 16px var(--dashboard-shadow-light); } /* Welcome Card — uses same surface as .bento-card (no separate welcome tint) */ @@ -1246,6 +1252,11 @@ input.myapps-search::placeholder { color: #fff; } +.dashboard .bento-card-fancy-icon-desktop { + background-color: var(--dashboard-bento-desktop-icon-bg); + color: #fff; +} + .dashboard .bento-card-fancy-text { display: flex; flex-direction: column; @@ -1386,46 +1397,39 @@ input.myapps-search::placeholder { color: var(--dashboard-text-tertiary); } -/* Desktop switch bento card */ -.dashboard .bento-desktop-switch { +/* Open Desktop bento card (full width; links to the desktop interface) */ +.dashboard .bento-desktop { grid-column: 1 / -1; - min-height: auto; - background-color: var(--dashboard-bento-apps-surface); - padding: 16px 24px; -} - -.dashboard .bento-desktop-switch-inner { display: flex; align-items: center; - justify-content: space-between; - gap: 16px; -} - -.dashboard .bento-desktop-switch-text { - font-size: 14px; - font-weight: 500; - color: var(--dashboard-text-secondary); -} - -.dashboard .bento-desktop-switch-btn { - flex-shrink: 0; - padding: 8px 16px; - font-size: 13px; - font-weight: 500; - color: var(--dashboard-background); - background: var(--dashboard-icon-blue-start); - border: 1px solid var(--dashboard-primary); - border-radius: 8px; - cursor: pointer; + gap: 14px; + padding: 18px 24px; + background-color: var(--dashboard-bento-desktop-header-bg); text-decoration: none; - transition: background 0.15s, color 0.15s; + color: inherit; + transition: box-shadow 0.15s ease; } @media (hover: hover) { - .dashboard .bento-desktop-switch-btn:hover { - background: var(--dashboard-icon-blue-end); - color: white; + .dashboard .bento-desktop:hover { + box-shadow: + 0 2px 4px var(--dashboard-shadow-subtle), + 0 10px 28px var(--dashboard-bento-desktop-icon-shadow); } + + .dashboard .bento-desktop:hover .bento-desktop-arrow { + transform: translateX(3px); + color: var(--dashboard-text-primary); + } +} + +.dashboard .bento-desktop-arrow { + margin-left: auto; + font-size: 22px; + font-weight: 300; + line-height: 1; + color: var(--dashboard-text-hint); + transition: transform 0.15s ease, color 0.15s ease; } /* Usage bento card (cool indigo surfaces; orange bars stay as progress accent) */ @@ -1701,6 +1705,11 @@ input.myapps-search::placeholder { gap: 12px; } + .dashboard .bento-desktop { + padding: 16px 20px; + gap: 12px; + } + .dashboard .bento-card-fancy-icon { width: 40px; height: 40px; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index b1159eb19..55155441f 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -206,6 +206,7 @@ const en = { or: 'or', open: 'Open', new_window: 'New Window', + open_desktop: 'Open Desktop', open_in_ai: 'Open in AI', open_in_new_tab: 'Open in New Tab', open_in_new_window: 'Open in New Window', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 3614f057f..0e7ca5605 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -157,9 +157,29 @@ if ( jQuery ) { } // are we in dashboard mode? -if ( window.location.pathname === '/dashboard' || window.location.pathname === '/dashboard/' ) { - window.is_dashboard_mode = true; - window.dashboard_initial_route = parseDashboardRoute(); +// 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. +{ + const pathname = window.location.pathname; + const search_params = new URLSearchParams(window.location.search); + if ( ['true', '1'].includes(search_params.get('embedded_in_popup')) ) { + window.embedded_in_popup = true; + } + // note: iframe detection is inlined because globals.js (window.is_embedded) loads after this module + const in_iframe = window.location !== window.parent.location; + const needs_desktop_at_root = window.embedded_in_popup + || in_iframe + || search_params.has('puter.fullpage') + || search_params.has('app') + || search_params.has('download'); + const is_dashboard_alias = pathname === '/dashboard' || pathname === '/dashboard/'; + if ( is_dashboard_alias || (pathname === '/' && !needs_desktop_at_root) ) { + window.is_dashboard_mode = true; + window.dashboard_initial_route = parseDashboardRoute(); + } } /** @@ -431,9 +451,9 @@ window.initgui = async function (options) { //-------------------------------------------------------------------------------------- // Is GUI embedded in a popup? // i.e. https://puter.com/?embedded_in_popup=true + // (the flag itself is parsed once at module level, alongside the dashboard-mode check) //-------------------------------------------------------------------------------------- - if ( window.url_query_params.has('embedded_in_popup') && (window.url_query_params.get('embedded_in_popup') === 'true' || window.url_query_params.get('embedded_in_popup') === '1') ) { - window.embedded_in_popup = true; + if ( window.embedded_in_popup ) { $('body').addClass('embedded-in-popup'); // determine the origin of the opener (preserved across OIDC redirect via URL param, else referrer or messaging) @@ -720,8 +740,9 @@ window.initgui = async function (options) { // update auth data await window.update_auth_data(query_param_auth_token, whoami, api_origin); } - // remove auth_token from URL - window.history.pushState(null, document.title, '/'); + // remove auth_token from URL, keeping the current path (e.g. `/` or `/desktop`) + // and hash (dashboard tab links like /#usage) + window.history.pushState(null, document.title, window.location.pathname + window.location.hash); } /** @@ -845,8 +866,9 @@ window.initgui = async function (options) { $('.taskbar').remove(); // disable native browser exit confirmation window.onbeforeunload = null; - // go to home page - window.location.replace('/'); + // go back to the interface the user was in: dashboard users to the root + // dashboard, desktop users to /desktop + window.location.replace(window.is_dashboard_mode ? '/' : '/desktop'); }); // ------------------------------------------------------------------------------------- @@ -1251,6 +1273,11 @@ window.initgui = async function (options) { const needs_action = action === 'authme' || action === 'copyauth'; const reload_on_success = needs_action; if ( window.logged_in_users.length > 0 ) { + // dashboard mode skips the wallpaper (fullpage), but the session list + // has no cover page — restore the wallpaper so it isn't on a blank page + if ( window.is_dashboard_mode ) { + window.refresh_desktop_background(); + } await UIWindowSessionList({ redirect_url: needs_action ? window.location.href : undefined, });