mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
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:
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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' ) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user