mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 16:07:13 +00:00
fix: a background app must not outlive the app that launched it
An app launched with `background: true` gets a real window from the moment it starts, just hidden. Nothing ever took it down: when the app that launched it closed, the child kept running with no way to reach it and no reason to be there. On the dashboard the only sign was a running dot on a tile the user had never lit up, and clicking that tile did nothing at all. Three things were wrong, and all three had to go: A window nobody has seen now dies with its launcher. UIWindow's close path closes hidden children the closing app launched, keyed on a marker stamped at creation. makeWindowVisible drops that marker the first time the window becomes visible — showing itself with `puter.ui.showWindow()`, or the user showing it — because from then on the window is the user's, and keeps running. The dashboard tile is a real handle again. focusExistingAppWindow only routed MINIMIZED windows through showWindow(); a hidden one fell through to focusWindow(), which leaves it invisible while handing it the keyboard. With no taskbar in dashboard mode the tile is the only handle on a background app, so that click had nowhere else to go. It now asks whether the window is on screen at all. The Files tab's row-click had the same one-line defect. And a background instance can't take a tile from the user's own session: an on-screen window wins, then a window the user has seen, then a hidden one. Removing the child then hit a crash of its own: ExecService's `remove` handler dereferenced the launcher's iframe to say goodbye, and the launcher was already gone. Throwing there aborts jQuery's remove() itself, so the window stayed in the DOM — running dot and all. That one also hit anyone closing a background app from the taskbar after its launcher had closed. The predicates behind all of this live in one helper, window_visibility.js, with showWindow() reading the same `hidden, not minimized` rule it spelled out inline before.
This commit is contained in:
@@ -7,6 +7,7 @@ import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
|
||||
import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js';
|
||||
import { appTileLink } from './appLink.js';
|
||||
import { is_window_on_screen, is_unseen_background_window } from '../../helpers/window_visibility.js';
|
||||
import {
|
||||
APP_GROUPS_KV_KEY,
|
||||
MAX_GROUP_APPS,
|
||||
@@ -248,16 +249,25 @@ function buildAddTileHtml () {
|
||||
// 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 launched in the background by another app has a window from the moment
|
||||
// it starts, and with no taskbar in dashboard mode its tile is the only handle
|
||||
// on 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.
|
||||
function focusExistingAppWindow (appName) {
|
||||
const $existing = $(`.window[data-app="${html_encode(appName)}"]`);
|
||||
if ( ! $existing.length ) return false;
|
||||
const $win = $existing.last();
|
||||
const minimized = $win.attr('data-is_minimized');
|
||||
if ( minimized === '1' || minimized === 'true' ) {
|
||||
$win.showWindow();
|
||||
} else {
|
||||
$win.focusWindow();
|
||||
const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last();
|
||||
if ( $on_screen.length ) {
|
||||
$on_screen.focusWindow();
|
||||
return true;
|
||||
}
|
||||
// Nothing on screen, so something has to be shown — and the user's own
|
||||
// instance comes first: an instance another app launched in the background
|
||||
// must not take the tile from the session they minimized.
|
||||
const $seen = $existing.filter((_, el) => ! is_unseen_background_window(el));
|
||||
($seen.length ? $seen : $existing).last().showWindow();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import launch_app from '../helpers/launch_app.js';
|
||||
import publish_as_website from '../helpers/publish_as_website.js';
|
||||
|
||||
import item_icon from '../helpers/item_icon.js';
|
||||
import { is_window_hidden } from '../helpers/window_visibility.js';
|
||||
|
||||
const el_body = document.getElementsByTagName('body')[0];
|
||||
const SNAP_PLACEHOLDER_DELAY_MS = 600; // delay before showing placeholder in any snap zone
|
||||
@@ -302,6 +303,7 @@ async function UIWindow (options) {
|
||||
data-user_set_url_params = "${html_encode(user_set_url_params)}"
|
||||
data-is_panel ="${options.is_panel ? 1 : 0}"
|
||||
data-is_visible ="${options.is_visible ? 1 : 0}"
|
||||
${options.launched_hidden ? 'data-launched_hidden ="1"' : ''}
|
||||
style=" z-index: ${zindex};
|
||||
${options.right !== undefined ? `right: ${ html_encode(options.right) }; ` : ''}
|
||||
${options.left !== undefined ? `left: ${ html_encode(options.left) }; ` : ''}
|
||||
@@ -3663,6 +3665,15 @@ $.fn.close = async function (options) {
|
||||
// close child windows
|
||||
$(`.window[data-parent_uuid="${window_uuid}"]`).close();
|
||||
|
||||
// An app this one launched in the background dies with it. It was
|
||||
// launched to serve this app, not the user: it has never been on
|
||||
// screen, nothing can talk to it once its launcher is gone, and
|
||||
// the only sign it is still running is a dot on a tile the user
|
||||
// never lit up. A background app that showed itself dropped the
|
||||
// marker when it did (makeWindowVisible) — that window is the
|
||||
// user's now, and keeps running.
|
||||
$(`.window[data-parent_instance_id="${window_uuid}"][data-launched_hidden="1"]`).close();
|
||||
|
||||
// notify other apps that we're closing
|
||||
window.report_app_closed(window_uuid, options.status_code ?? 0);
|
||||
|
||||
@@ -3869,6 +3880,10 @@ $.fn.makeWindowVisible = function (options) {
|
||||
$(this).attr({
|
||||
'data-is_visible': '1',
|
||||
});
|
||||
// Seen by the user, so no longer a window that exists purely to
|
||||
// serve whoever launched it: it outlives its launcher from here on
|
||||
// (see the close path's cleanup of background children).
|
||||
$(this).removeAttr('data-launched_hidden');
|
||||
|
||||
// if sidepanel, shift desktop toolbar to the left
|
||||
if ( $(this).attr('data-is_panel') === '1' ) {
|
||||
@@ -3928,9 +3943,7 @@ $.fn.showWindow = async function (options) {
|
||||
// un-hiding it is the whole job, and the inverse of what hid it.
|
||||
// This is what makes the taskbar item a real handle on a window
|
||||
// the user cannot currently see.
|
||||
if ( $(this).attr('data-is_visible') === '0'
|
||||
&& $(this).attr('data-is_minimized') !== '1'
|
||||
&& $(this).attr('data-is_minimized') !== 'true' ) {
|
||||
if ( is_window_hidden(this) ) {
|
||||
$(this).makeWindowVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -705,6 +705,9 @@ const launch_app = async (options) => {
|
||||
// it's resolved, e.g. a shortcut's uid becomes its target's).
|
||||
file_uid: file_signature?.uid ?? options.file_uid,
|
||||
is_visible: !starts_hidden(app_info, options),
|
||||
// Marks a window the user has never seen, so closing the app that
|
||||
// launched it can take it down with it (see UIWindow's close path).
|
||||
launched_hidden: starts_hidden(app_info, options),
|
||||
is_maximized: options.maximized,
|
||||
is_fullpage: options.is_fullpage,
|
||||
...(options.pseudonym ? { pseudonym: options.pseudonym } : {}),
|
||||
|
||||
@@ -23,6 +23,7 @@ import i18n from '../i18n/i18n.js';
|
||||
import launch_app from './launch_app.js';
|
||||
import path from '../lib/path.js';
|
||||
import item_icon from './item_icon.js';
|
||||
import { is_window_on_screen } from './window_visibility.js';
|
||||
|
||||
// Files whose app launch is still in flight, by file uid. Between the click
|
||||
// and the window's creation there is nothing in the DOM to restore, so a
|
||||
@@ -268,11 +269,10 @@ Please try recreating the link.`);
|
||||
// No window yet means the first click's launch is still in
|
||||
// flight — swallow the re-click instead of duplicating it.
|
||||
if ( $win.length ) {
|
||||
const minimized = $win.attr('data-is_minimized');
|
||||
if ( minimized === '1' || minimized === 'true' ) {
|
||||
$win.showWindow();
|
||||
} else {
|
||||
if ( is_window_on_screen($win.get(0)) ) {
|
||||
$win.focusWindow();
|
||||
} else {
|
||||
$win.showWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The two ways a window can be off screen, told apart. Both are restored by
|
||||
* `showWindow()`, and neither is reachable with `focusWindow()` — which would
|
||||
* hand the keyboard to a window nobody can see and leave it invisible.
|
||||
*/
|
||||
|
||||
const attr = (el_window, name) => (el_window?.getAttribute
|
||||
? el_window.getAttribute(name)
|
||||
: null);
|
||||
|
||||
/** Minimized: hidden with geometry to restore (data-orig-*, or in place). */
|
||||
const is_minimized = (el_window) => {
|
||||
const minimized = attr(el_window, 'data-is_minimized');
|
||||
return minimized === '1' || minimized === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Hidden outright rather than minimized: the window was created hidden by a
|
||||
* background launch (see starts_hidden) or hidden later by
|
||||
* `puter.ui.hideWindow()`. It kept its geometry, so un-hiding is the whole job.
|
||||
*
|
||||
* @param {Element} [el_window] a `.window` element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const is_window_hidden = (el_window) => {
|
||||
return attr(el_window, 'data-is_visible') === '0' && ! is_minimized(el_window);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the user can see this window right now — the question anything that
|
||||
* reopens an app already running has to ask: a window on screen only needs
|
||||
* focus, while one the user cannot see has to be shown first.
|
||||
*
|
||||
* @param {Element} [el_window] a `.window` element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const is_window_on_screen = (el_window) => {
|
||||
return ! is_minimized(el_window) && ! is_window_hidden(el_window);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this window exists only to serve the app that launched it: started
|
||||
* hidden by a background launch and never shown since. UIWindow stamps the
|
||||
* marker at creation and drops it the first time the window becomes visible,
|
||||
* because from then on the window is the user's — theirs to keep when the app
|
||||
* that launched it closes, and theirs to come back to when they reopen the app.
|
||||
*
|
||||
* @param {Element} [el_window] a `.window` element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const is_unseen_background_window = (el_window) => {
|
||||
return attr(el_window, 'data-launched_hidden') === '1';
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { is_window_hidden, is_window_on_screen, is_unseen_background_window } from './window_visibility.js';
|
||||
|
||||
// A `.window` element carrying the given data- attributes, in the shapes
|
||||
// UIWindow actually writes them (is_visible as 0/1, is_minimized as either
|
||||
// 0/1 from the markup or the string 'true'/'false' from later .attr() calls).
|
||||
const win = (attrs = {}) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'window';
|
||||
el.setAttribute('data-is_visible', attrs.visible ?? '1');
|
||||
if ( attrs.minimized !== undefined ) el.setAttribute('data-is_minimized', attrs.minimized);
|
||||
if ( attrs.launched_hidden ) el.setAttribute('data-launched_hidden', '1');
|
||||
return el;
|
||||
};
|
||||
|
||||
describe('is_window_hidden', () => {
|
||||
it('is false for a window on screen', () => {
|
||||
expect(is_window_hidden(win())).toBe(false);
|
||||
expect(is_window_hidden(win({ minimized: '0' }))).toBe(false);
|
||||
expect(is_window_hidden(win({ minimized: 'false' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for a window created hidden by a background launch', () => {
|
||||
expect(is_window_hidden(win({ visible: '0', minimized: '0' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a window hidden by puter.ui.hideWindow()', () => {
|
||||
// makeWindowInvisible only flips data-is_visible; nothing minimizes.
|
||||
expect(is_window_hidden(win({ visible: '0' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a merely minimized window, in either attribute shape', () => {
|
||||
expect(is_window_hidden(win({ minimized: '1' }))).toBe(false);
|
||||
expect(is_window_hidden(win({ minimized: 'true' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a missing element', () => {
|
||||
expect(is_window_hidden(null)).toBe(false);
|
||||
expect(is_window_hidden(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('is_window_on_screen', () => {
|
||||
it('is true only for a window the user can actually see', () => {
|
||||
expect(is_window_on_screen(win())).toBe(true);
|
||||
expect(is_window_on_screen(win({ minimized: 'false' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a minimized window', () => {
|
||||
expect(is_window_on_screen(win({ minimized: '1' }))).toBe(false);
|
||||
expect(is_window_on_screen(win({ minimized: 'true' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a hidden window, so reopening the app shows it', () => {
|
||||
// The dashboard tile / file row would otherwise focus a window that
|
||||
// stays invisible, and the click would do nothing at all.
|
||||
expect(is_window_on_screen(win({ visible: '0' }))).toBe(false);
|
||||
expect(is_window_on_screen(win({ visible: '0', minimized: '0' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('is_unseen_background_window', () => {
|
||||
it('is true only while a background launch has never been shown', () => {
|
||||
expect(is_unseen_background_window(win({ visible: '0', launched_hidden: true }))).toBe(true);
|
||||
// makeWindowVisible drops the marker the first time the window is
|
||||
// shown, whether the app showed itself or the user did.
|
||||
expect(is_unseen_background_window(win({ visible: '1' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an ordinary window, minimized or not', () => {
|
||||
expect(is_unseen_background_window(win())).toBe(false);
|
||||
expect(is_unseen_background_window(win({ minimized: 'true' }))).toBe(false);
|
||||
// A window the app hid itself with puter.ui.hideWindow() is the user's
|
||||
// too — it just isn't on screen.
|
||||
expect(is_unseen_background_window(win({ visible: '0' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a missing element', () => {
|
||||
expect(is_unseen_background_window(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -188,9 +188,12 @@ export class ExecService extends Service {
|
||||
}
|
||||
|
||||
const send_child_launched_msg = (...a) => {
|
||||
if ( ! process ) return;
|
||||
// TODO: (maybe) message process instead of iframe
|
||||
const parent_iframe = process?.references?.iframe;
|
||||
// The app that launched this one may already be gone — a child it
|
||||
// launched in the background is closed with it, and this fires on
|
||||
// the way out. Nobody to tell.
|
||||
if ( ! parent_iframe?.contentWindow ) return;
|
||||
parent_iframe.contentWindow.postMessage({
|
||||
msg: 'childAppLaunched',
|
||||
original_msg_id: msg_id,
|
||||
@@ -227,9 +230,12 @@ export class ExecService extends Service {
|
||||
window.report_app_closed(child_process.uuid);
|
||||
}
|
||||
|
||||
process.references.iframe.contentWindow.postMessage({
|
||||
// Same here: this handler runs inside jQuery's remove(), so a throw
|
||||
// on a dead parent would abort the removal itself and leave the
|
||||
// window in the DOM (running dot and all).
|
||||
parent_iframe?.contentWindow?.postMessage({
|
||||
msg: 'appClosed',
|
||||
appInstanceID: connection.forward.uuid,
|
||||
appInstanceID: connection?.forward?.uuid,
|
||||
statusCode: 0,
|
||||
}, '*');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user