diff --git a/src/docs/src/UI/launchApp.md b/src/docs/src/UI/launchApp.md index fac45a900..c2f2826e9 100755 --- a/src/docs/src/UI/launchApp.md +++ b/src/docs/src/UI/launchApp.md @@ -38,6 +38,17 @@ Paths of existing files to open with the launched app. #### `options.pseudonym` (String) A pseudonym to launch the app under. +#### `options.background` (Boolean) +If `true`, the app starts with its window hidden — for an app launched to do work +rather than to be looked at, such as one serving an API to yours over its +[`AppConnection`](/Objects/AppConnection). Without this, Puter creates and shows +the window before the app's own code runs, so a service app cannot avoid briefly +appearing on screen. + +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`. + ## Return value A `Promise` that will resolve to an [`AppConnection`](/Objects/AppConnection) once the app is launched. @@ -61,3 +72,25 @@ When private-access routing applies, the resolved connection may include ``` + +Launching an app in the background to use it as a service, with no window +appearing on screen: + +```html + + + + + + +``` diff --git a/src/gui/src/helpers/launch_app.js b/src/gui/src/helpers/launch_app.js index 76ea57aff..6f86a78d3 100644 --- a/src/gui/src/helpers/launch_app.js +++ b/src/gui/src/helpers/launch_app.js @@ -20,6 +20,7 @@ import path from '../lib/path.js'; import { PROCESS_IPC_ATTACHED, PROCESS_RUNNING, PortalProcess, PseudoProcess } from '../definitions.js'; import UIWindow from '../UI/UIWindow.js'; +import { starts_hidden } from './starts_hidden.js'; const normalizePrivateAccessDecision = (privateAccess) => { if ( !privateAccess || typeof privateAccess !== 'object' ) { @@ -645,6 +646,10 @@ const launch_app = async (options) => { } // show_in_taskbar + // Deliberately keyed on the app's own `background`, not on this launch's: + // an app that always runs windowless has nothing to show in the taskbar, + // while a background *launch* keeps its item so the user can see that it + // is running and close it. let show_in_taskbar = app_info.background ? false : window_options?.show_in_taskbar; if ( window_options?.show_in_taskbar !== undefined ) { @@ -699,7 +704,7 @@ const launch_app = async (options) => { // when the same file is opened again (the signature's uid wins: // it's resolved, e.g. a shortcut's uid becomes its target's). file_uid: file_signature?.uid ?? options.file_uid, - is_visible: !app_info.background, + is_visible: !starts_hidden(app_info, options), is_maximized: options.maximized, is_fullpage: options.is_fullpage, ...(options.pseudonym ? { pseudonym: options.pseudonym } : {}), @@ -712,7 +717,7 @@ const launch_app = async (options) => { }); // If the app is not in the background, show the window - if ( ! app_info.background ) { + if ( ! starts_hidden(app_info, options) ) { $(el_win).show(); } @@ -754,7 +759,7 @@ const launch_app = async (options) => { // entry) lands on the parent's entry and the popstate handler restores // it. The parent keeps running while hidden — parent/child IPC works. if ( window.is_dashboard_mode && options.parent_instance_id - && options.maximized && !app_info.background && el ) { + && options.maximized && !starts_hidden(app_info, options) && el ) { const el_parent_win = window.window_for_app_instance(options.parent_instance_id); const parent_minimized = $(el_parent_win).attr('data-is_minimized'); if ( el_parent_win && parent_minimized !== '1' && parent_minimized !== 'true' ) { diff --git a/src/gui/src/helpers/starts_hidden.js b/src/gui/src/helpers/starts_hidden.js new file mode 100644 index 000000000..a39123ba1 --- /dev/null +++ b/src/gui/src/helpers/starts_hidden.js @@ -0,0 +1,35 @@ +/* + * 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 . + */ + +/** + * Whether an app's window should start hidden: either the app always runs + * without one (`background` on the app itself), or this particular launch asked + * for one (`puter.ui.launchApp(name, args, { background: true })`), which lets a + * dual-purpose app serve another app without a window flashing on screen. + * + * A background *launch* keeps its taskbar item, so the user can see that it is + * running, show it, or close it; an app that is always windowless has none. + * + * @param {{ background?: boolean }} [appInfo] the app record being launched + * @param {{ background?: boolean }} [options] this launch's options + * @returns {boolean} + */ +export const starts_hidden = (appInfo, options) => { + return Boolean(appInfo?.background) || options?.background === true; +}; diff --git a/src/gui/src/helpers/starts_hidden.test.js b/src/gui/src/helpers/starts_hidden.test.js new file mode 100644 index 000000000..1ac6464e8 --- /dev/null +++ b/src/gui/src/helpers/starts_hidden.test.js @@ -0,0 +1,45 @@ +/* + * 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 . + */ + +import { describe, it, expect } from 'vitest'; +import { starts_hidden } from './starts_hidden.js'; + +describe('starts_hidden', () => { + it('shows an ordinary app launched the ordinary way', () => { + expect(starts_hidden({ background: false }, { name: 'editor' })).toBe(false); + expect(starts_hidden({}, {})).toBe(false); + expect(starts_hidden(undefined, undefined)).toBe(false); + }); + + it('hides an app that always runs windowless', () => { + expect(starts_hidden({ background: true }, {})).toBe(true); + }); + + it('hides a launch that asked to start in the background', () => { + expect(starts_hidden({}, { background: true })).toBe(true); + }); + + it('only treats an explicit true as a background launch', () => { + // The flag arrives over IPC from another app, so anything truthy-ish + // must not be able to hide a window by accident. + expect(starts_hidden({}, { background: 'yes' })).toBe(false); + expect(starts_hidden({}, { background: 1 })).toBe(false); + expect(starts_hidden({}, { background: false })).toBe(false); + }); +}); diff --git a/src/gui/src/services/ExecService.js b/src/gui/src/services/ExecService.js index 6e20d0de0..79a8343ea 100644 --- a/src/gui/src/services/ExecService.js +++ b/src/gui/src/services/ExecService.js @@ -48,7 +48,7 @@ export class ExecService extends Service { } // This method is exposed to apps via IPCService. - async launchApp ({ app_name, args, pseudonym, file_paths, items }, { ipc_context, msg_id } = {}) { + async launchApp ({ app_name, args, pseudonym, file_paths, items, background }, { ipc_context, msg_id } = {}) { const app = ipc_context?.caller?.app; const process = ipc_context?.caller?.process; @@ -98,6 +98,10 @@ export class ExecService extends Service { parent_instance_id: app?.appInstanceID, uuid: child_instance_id, params, + // A background launch starts the window hidden: the caller wants the + // app to do work, not to be looked at. It keeps its taskbar item, so + // the user can still see that it is running, show it, or close it. + ...(background === true ? { background: true } : {}), ...source_app_metadata, ...(connection ? { parent_pseudo_id: connection.backward.uuid, diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 14e6bd71e..a9b13d9c5 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -104,6 +104,9 @@ import PuterDialog from './PuterDialog.js'; * @property {string[]} [file_paths] Paths of existing files to open with the launched app. * @property {FSItem[]} [items] `FSItem` objects to open with the launched app. * @property {string} [pseudonym] A pseudonym to launch the app under. + * @property {boolean} [background] If `true`, the app starts with its window hidden, for an app + * launched to do work rather than to be looked at. It still appears in the taskbar, so the user can + * show it (or close it) at any time, and it can show itself with `puter.ui.showWindow()`. * @property {(connection: AppConnection) => void} [callback] */ @@ -2362,6 +2365,7 @@ export class UIModule extends EventListener { let pseudonym = undefined; let file_paths = undefined; let items = undefined; + let background = undefined; let app_name = nameOrOptions; // becomes string after branch below // Handle case where app_name is an options object @@ -2373,6 +2377,7 @@ export class UIModule extends EventListener { callback = callback || options.callback; pseudonym = options.pseudonym; items = options.items; + background = options.background; } if ( items ) { @@ -2399,6 +2404,7 @@ export class UIModule extends EventListener { items, pseudonym, args, + background, }, });