mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-14 09:50:49 +00:00
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
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:
1 parent
dec7d1fd96
commit
ef185c04ca
6 files changed
+213
-28
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user