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:
Nariman Jelveh
2026-08-13 14:36:08 -07:00
parent 08f075d774
commit d18ea1adb5
8 changed files with 227 additions and 16 deletions
+16 -6
View File
@@ -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;
}
+16 -3
View File
@@ -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;
}
+3
View File
@@ -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 } : {}),
+4 -4
View File
@@ -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();
}
}
}
+72
View File
@@ -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);
});
});
+9 -3
View File
@@ -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,
}, '*');
});