Open apps from the dashboard's Home tab at their own URL (#4513)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

Recently used apps on the Home tab opened /app/<name> in a new tab, which
loads the desktop and launches the app from there. They now open the way
Apps tab tiles do: a new tab at the app's index_url through launch_app's
newTab, or in this page when the browser refuses the tab. The Apps tab's
open-window lookup moves to helpers/focusAppWindow.js so both share it,
and the shared launch lives in helpers/dashboardAppLaunch.js, outside the
app browser so the embed bundle doesn't pull in launch_app.
This commit is contained in:
Nariman Jelveh authored and GitHub committed 2026-11-05 21:30:52 -07:00
1 parent dec7d1fd96
commit ef185c04ca
6 files changed
+213 -28

No files matched your search

+2 -27
View File
@@ -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) {
+2 -1
View File
@@ -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);
}
});
+25
View File
@@ -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 = `<div class="window">${TabHome.html()}</div>`;
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);
});
});
+49
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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));
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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('<div>').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 = '<div class="window" data-app="notes"></div>';
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');
});
});
+41
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}