feat: let an app launch another app in the background

`puter.ui.launchApp(name, args)` had no way to say "I need this app's API,
not its window". That matters because we create and show an app's window
before the app's own code runs, so an app launched purely to serve another
one cannot avoid appearing on screen: the best it can do is call
`puter.ui.hideWindow()` once it boots, which reads as a window flashing
open and shut. In dashboard mode it was worse than a flash — the child
maximized into the tab and minimized its parent behind it, so asking a
service app a question took the user's app away from them.

So `launchApp` now accepts `background: true`, and the window starts
hidden. The app is otherwise entirely normal: it keeps its taskbar item,
so a user can see that it is running, show it, or close it, and it can
show itself with `puter.ui.showWindow()` whenever it has something to say.
Only a literal `true` counts, since the flag arrives over IPC from another
app.

The decision now lives in one predicate, `starts_hidden(app_info, options)`,
which folds this together with the existing app-level `background` flag and
is used everywhere the old flag was read — including the dashboard's
minimize-the-parent branch. `show_in_taskbar` deliberately still keys on the
app-level flag alone: an app that is always windowless has nothing to put in
the taskbar, while a background *launch* should stay visible there.

Existing callers are unaffected: with `background` unset, both paths
evaluate exactly as they did.
This commit is contained in:
Nariman Jelveh
2026-08-13 09:47:35 -07:00
parent 8ed8feed4b
commit e273431f14
6 changed files with 132 additions and 4 deletions
+33
View File
@@ -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
</body>
</html>
```
Launching an app in the background to use it as a service, with no window
appearing on screen:
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const service = await puter.ui.launchApp({
name: 'contacts',
args: { service: 'contacts-api' },
background: true,
});
service.on('message', (msg) => console.log('from contacts:', msg));
service.postMessage({ hello: 'there' });
})();
</script>
</body>
</html>
```
+8 -3
View File
@@ -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' ) {
+35
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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;
};
+45
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
+5 -1
View File
@@ -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,
+6
View File
@@ -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,
},
});