diff --git a/src/docs/src/UI/launchApp.md b/src/docs/src/UI/launchApp.md
index c2f2826e9..ce3e2e88a 100755
--- a/src/docs/src/UI/launchApp.md
+++ b/src/docs/src/UI/launchApp.md
@@ -49,6 +49,11 @@ The app still appears in the taskbar, so the user can see it is running, show it
or close it, and it can show itself at any time with
[`puter.ui.showWindow()`](/UI/showWindow). Defaults to `false`.
+A background app closes when the app that launched it closes: it was launched to
+serve that app, and the user never saw it. Once it has been shown — by
+[`puter.ui.showWindow()`](/UI/showWindow) or by the user, from the taskbar — it is
+an ordinary window and keeps running on its own.
+
## Return value
A `Promise` that will resolve to an [`AppConnection`](/Objects/AppConnection) once the app is launched.
diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js
index 7255c01dd..d1a2c9d2c 100644
--- a/src/gui/src/UI/Dashboard/TabApps.js
+++ b/src/gui/src/UI/Dashboard/TabApps.js
@@ -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;
}
diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js
index f653da37f..5ea693631 100644
--- a/src/gui/src/UI/UIWindow.js
+++ b/src/gui/src/UI/UIWindow.js
@@ -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;
}
diff --git a/src/gui/src/helpers/launch_app.js b/src/gui/src/helpers/launch_app.js
index cfd540f12..5b7e74586 100644
--- a/src/gui/src/helpers/launch_app.js
+++ b/src/gui/src/helpers/launch_app.js
@@ -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 } : {}),
diff --git a/src/gui/src/helpers/open_item.js b/src/gui/src/helpers/open_item.js
index 91d9b6f37..723f9bd1e 100644
--- a/src/gui/src/helpers/open_item.js
+++ b/src/gui/src/helpers/open_item.js
@@ -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();
}
}
}
diff --git a/src/gui/src/helpers/window_visibility.js b/src/gui/src/helpers/window_visibility.js
new file mode 100644
index 000000000..99ac6c4a5
--- /dev/null
+++ b/src/gui/src/helpers/window_visibility.js
@@ -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 .
+ */
+
+/**
+ * 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';
+};
diff --git a/src/gui/src/helpers/window_visibility.test.js b/src/gui/src/helpers/window_visibility.test.js
new file mode 100644
index 000000000..d231a7ae4
--- /dev/null
+++ b/src/gui/src/helpers/window_visibility.test.js
@@ -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 .
+ */
+
+// @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);
+ });
+});
diff --git a/src/gui/src/services/ExecService.js b/src/gui/src/services/ExecService.js
index 0b1bf9217..de2b50ea6 100644
--- a/src/gui/src/services/ExecService.js
+++ b/src/gui/src/services/ExecService.js
@@ -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,
}, '*');
});