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;
+}