diff --git a/src/gui/src/UI/Dashboard/AppBrowser.js b/src/gui/src/UI/Dashboard/AppBrowser.js index 46801002d..30786fa0d 100644 --- a/src/gui/src/UI/Dashboard/AppBrowser.js +++ b/src/gui/src/UI/Dashboard/AppBrowser.js @@ -23,7 +23,8 @@ import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KE import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js'; import { appTileLink } from './appLink.js'; import { appIconAttrs } from '../../helpers/appIcon.js'; -import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js'; +import { user_facing_windows } from '../../helpers/windowVisibility.js'; +import { focusExistingAppWindow } from '../../helpers/focusAppWindow.js'; import { APP_GROUPS_KV_KEY, MAX_GROUP_APPS, @@ -258,32 +259,6 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } = return h; } - // One instance per app when opened from the dashboard: un-hide a minimized - // instance / focus a visible one instead of launching a duplicate. Returns - // whether an existing window took the request. - // - // "Un-hide" covers a window hidden outright as well as a minimized one — an - // app can hide itself with puter.ui.hideWindow(), and with no taskbar in - // dashboard mode its tile is the only way back to it. Focusing it instead - // would leave the tile dead (and hand the keyboard to a window nobody can - // see); showWindow() tells the two cases apart. - // - // Only the user's own instances count (user_facing_windows): an instance - // another app launched in the background is that app's private helper, so the - // tile must not reopen it — the click launches a fresh instance and the helper - // stays hidden, still serving whoever launched it. - function focusExistingAppWindow (appName) { - const $existing = $(user_facing_windows($(`.window[data-app="${html_encode(appName)}"]`))); - if ( ! $existing.length ) return false; - const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last(); - if ( $on_screen.length ) { - $on_screen.focusWindow(); - return true; - } - $existing.last().showWindow(); - return true; - } - // The label a folder shows: its own name, or the default one if a rename // somehow left it blank (a nameless tile is one the user can't tell apart). function groupLabel (group) { diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index dcc1adde0..114752e40 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -23,6 +23,7 @@ import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } import { getMonthlyUsage, getStorageSpace } from './usageRequests.js'; import { appIconAttrs } from '../../helpers/appIcon.js'; import { filterLaunchableApps } from '../../helpers/launchAppsList.js'; +import { openAppInNewTab } from '../../helpers/dashboardAppLaunch.js'; import { isOrgSeat, orgSeatTeamName } from './orgSeat.js'; import { intlLocale } from '../../i18n/intlLocale.js'; @@ -393,7 +394,7 @@ const TabHome = { if (targetLink && targetLink !== '') { window.open(targetLink, '_blank', 'noopener,noreferrer'); } else if (appName) { - window.open(`/app/${encodeURIComponent(appName)}`, '_blank', 'noopener,noreferrer'); + openAppInNewTab(appName); } }); diff --git a/src/gui/src/UI/Dashboard/TabHome.test.js b/src/gui/src/UI/Dashboard/TabHome.test.js index 97dbda8e6..8421881dd 100644 --- a/src/gui/src/UI/Dashboard/TabHome.test.js +++ b/src/gui/src/UI/Dashboard/TabHome.test.js @@ -22,6 +22,7 @@ import { describe, expect, it, vi } from 'vitest'; import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; vi.mock('../UIWindowSaveAccount.js', () => ({ default: vi.fn() })); +vi.mock('../../helpers/dashboardAppLaunch.js', () => ({ openAppInNewTab: vi.fn() })); globalThis.$ = jQuery; globalThis.jQuery = jQuery; @@ -31,6 +32,7 @@ window.icons = {}; window.launch_apps = { recent: [] }; const { default: TabHome } = await import('./TabHome.js'); +const { openAppInNewTab } = await import('../../helpers/dashboardAppLaunch.js'); describe('TabHome recently used', () => { const mount = () => { @@ -71,3 +73,26 @@ describe('TabHome launch apps request', () => { expect(window.launch_apps_request).toBeNull(); }); }); + +describe('TabHome recent app clicks', () => { + it('opens an app in a new tab at its own URL, and a website shortcut at its site', () => { + window.launch_apps = { recent: [ + { name: 'notes', title: 'Notes', target_link: '' }, + { name: 'site', title: 'Site', target_link: 'https://site.example/' }, + ] }; + document.body.innerHTML = `
${TabHome.html()}
`; + const $el_window = $('.window'); + $.ajax = vi.fn(() => new Promise(() => {})); + vi.spyOn(TabHome, 'loadUsageData').mockImplementation(async () => {}); + vi.spyOn(window, 'open').mockImplementation(() => null); + TabHome.init($el_window); + + $el_window.find('.bento-recent-app[data-app-name="notes"]')[0].click(); + expect(openAppInNewTab).toHaveBeenCalledWith('notes'); + expect(window.open).not.toHaveBeenCalled(); + + $el_window.find('.bento-recent-app[data-app-name="site"]')[0].click(); + expect(window.open).toHaveBeenCalledWith('https://site.example/', '_blank', 'noopener,noreferrer'); + expect(openAppInNewTab).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gui/src/helpers/dashboardAppLaunch.js b/src/gui/src/helpers/dashboardAppLaunch.js new file mode 100644 index 000000000..09c5f55a9 --- /dev/null +++ b/src/gui/src/helpers/dashboardAppLaunch.js @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +import UIAlert from '../UI/UIAlert.js'; +import launch_app from './launchApp.js'; +import { focusExistingAppWindow } from './focusAppWindow.js'; + +// Apps whose launch is still fetching; a second click would open a second tab. +const launching = new Set(); + +/** + * Opens an app from the dashboard in a new browser tab at its own URL. When + * the browser refuses the tab, the app opens in this page instead, reusing a + * window that's already open. Call it inside the click, so popup blockers + * allow the tab. + * + * @param {string} appName + */ +export function openAppInNewTab (appName) { + if ( launching.has(appName) ) return; + // Without 'noopener': the app's SDK asks this tab for its token through + // the opener, then clears it. + const tab = window.open('', '_blank'); + if ( !tab && focusExistingAppWindow(appName) ) return; + launching.add(appName); + // The launch owns the tab from here, and closes it on failure. + return launch_app(tab ? { name: appName, newTab: tab } : { name: appName, maximized: true }) + .catch((err) => { + console.error(`Failed to launch ${appName}:`, err); + UIAlert(i18n('something_went_wrong', [], false)); + }) + .finally(() => launching.delete(appName)); +} diff --git a/src/gui/src/helpers/dashboardAppLaunch.test.js b/src/gui/src/helpers/dashboardAppLaunch.test.js new file mode 100644 index 000000000..8e4762bf0 --- /dev/null +++ b/src/gui/src/helpers/dashboardAppLaunch.test.js @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +/* + * 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 . + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./launchApp.js', () => ({ default: vi.fn() })); +vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.html_encode = value => jQuery('
').text(value).html(); +globalThis.i18n = key => key; + +const { default: launch_app } = await import('./launchApp.js'); +const { default: UIAlert } = await import('../UI/UIAlert.js'); +const { openAppInNewTab } = await import('./dashboardAppLaunch.js'); + +describe('openAppInNewTab', () => { + let opened; + + beforeEach(() => { + opened = { closed: false }; + vi.spyOn(window, 'open').mockImplementation(() => opened); + launch_app.mockResolvedValue({ launchResult: { launched: true } }); + jQuery.fn.focusWindow = vi.fn(); + jQuery.fn.showWindow = vi.fn(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('opens the app in a new tab, kept connected so the app can ask it for its token', async () => { + await openAppInNewTab('notes'); + expect(window.open).toHaveBeenCalledWith('', '_blank'); + expect(launch_app).toHaveBeenCalledWith({ name: 'notes', newTab: opened }); + }); + + it('ignores a second click while the first launch is loading', async () => { + let finish; + launch_app.mockReturnValueOnce(new Promise((resolve) => { + finish = resolve; + })); + const first = openAppInNewTab('notes'); + openAppInNewTab('notes'); + expect(window.open).toHaveBeenCalledTimes(1); + + finish(); + await first; + await openAppInNewTab('notes'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + it('brings back an open window when the browser refuses the tab', async () => { + window.open.mockReturnValue(null); + document.body.innerHTML = '
'; + await openAppInNewTab('notes'); + expect(jQuery.fn.focusWindow).toHaveBeenCalled(); + expect(launch_app).not.toHaveBeenCalled(); + }); + + it('opens the app in this page when the browser refuses the tab', async () => { + window.open.mockReturnValue(null); + await openAppInNewTab('notes'); + expect(launch_app).toHaveBeenCalledWith({ name: 'notes', maximized: true }); + }); + + it('tells the user when the launch fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + launch_app.mockRejectedValueOnce(new Error('offline')); + await openAppInNewTab('notes'); + expect(UIAlert).toHaveBeenCalledWith('something_went_wrong'); + }); +}); diff --git a/src/gui/src/helpers/focusAppWindow.js b/src/gui/src/helpers/focusAppWindow.js new file mode 100644 index 000000000..6cf25ba3f --- /dev/null +++ b/src/gui/src/helpers/focusAppWindow.js @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +import { is_window_on_screen, user_facing_windows } from './windowVisibility.js'; + +/** + * Brings back the user's open window of an app, if there is one: focused when + * on screen, otherwise shown, since focusing a minimized or hidden window + * would leave it invisible. Windows another app launched in the background + * are its helpers, not the user's, so they don't count. + * + * @param {string} appName + * @returns {boolean} whether a window was brought back + */ +export function focusExistingAppWindow (appName) { + const $existing = $(user_facing_windows($(`.window[data-app="${html_encode(appName)}"]`))); + if ( ! $existing.length ) return false; + const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last(); + if ( $on_screen.length ) { + $on_screen.focusWindow(); + return true; + } + $existing.last().showWindow(); + return true; +}