From d18ea1adb589122942e2a4eceb89c5369bf606d7 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 13 Aug 2026 14:36:08 -0700 Subject: [PATCH] fix: a background app must not outlive the app that launched it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/docs/src/UI/launchApp.md | 5 + src/gui/src/UI/Dashboard/TabApps.js | 22 ++-- src/gui/src/UI/UIWindow.js | 19 +++- src/gui/src/helpers/launch_app.js | 3 + src/gui/src/helpers/open_item.js | 8 +- src/gui/src/helpers/window_visibility.js | 72 +++++++++++++ src/gui/src/helpers/window_visibility.test.js | 102 ++++++++++++++++++ src/gui/src/services/ExecService.js | 12 ++- 8 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 src/gui/src/helpers/window_visibility.js create mode 100644 src/gui/src/helpers/window_visibility.test.js 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, }, '*'); });