@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bivy.mjs +25 -4
- package/dist/apps/gateway.js +257 -23
- package/dist/apps/inspector.js +97 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +198 -17
- package/dist/apps/registry.js +117 -19
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/screenshot.js +181 -0
- package/dist/apps/service.js +179 -4
- package/dist/apps/styles.css +3 -0
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +19 -6
- package/dist/node-config.js +6 -3
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +6 -1
- package/dist/server.js +29 -4
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
9
|
+
import { WebSocket } from "ws";
|
|
10
|
+
/** Browsers tried in order; BIVY_CHROME overrides. Any Chromium works. */
|
|
11
|
+
const CANDIDATES = [
|
|
12
|
+
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
|
|
13
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
14
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
15
|
+
];
|
|
16
|
+
export function findChrome(env = process.env) {
|
|
17
|
+
if (env.BIVY_CHROME)
|
|
18
|
+
return fs.existsSync(env.BIVY_CHROME) ? env.BIVY_CHROME : undefined;
|
|
19
|
+
for (const candidate of CANDIDATES) {
|
|
20
|
+
if (candidate.startsWith("/")) {
|
|
21
|
+
if (fs.existsSync(candidate))
|
|
22
|
+
return candidate;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync("which", [candidate], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
27
|
+
}
|
|
28
|
+
catch { /* next */ }
|
|
29
|
+
}
|
|
30
|
+
// A Playwright install (common on dev machines) ships a headless shell.
|
|
31
|
+
const cache = path.join(os.homedir(), ".cache", "ms-playwright");
|
|
32
|
+
for (const dir of fs.existsSync(cache) ? fs.readdirSync(cache).sort().reverse() : []) {
|
|
33
|
+
for (const bin of ["chrome-headless-shell-linux64/chrome-headless-shell", "chrome-linux/chrome"]) {
|
|
34
|
+
const file = path.join(cache, dir, bin);
|
|
35
|
+
if (dir.startsWith("chromium") && fs.existsSync(file))
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Serves a static snapshot on loopback for the duration of a shot. */
|
|
42
|
+
async function serveSnapshot(files) {
|
|
43
|
+
const server = http.createServer((req, res) => {
|
|
44
|
+
let file = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
45
|
+
if (file.endsWith("/"))
|
|
46
|
+
file += "index.html";
|
|
47
|
+
const data = files.get(file) ?? (path.extname(file) ? undefined : files.get("/index.html"));
|
|
48
|
+
if (!data) {
|
|
49
|
+
res.writeHead(404);
|
|
50
|
+
res.end();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
res.end(data);
|
|
54
|
+
});
|
|
55
|
+
server.listen(0, "127.0.0.1");
|
|
56
|
+
await once(server, "listening");
|
|
57
|
+
return { origin: `http://127.0.0.1:${server.address().port}`, close: () => { server.close(); server.closeAllConnections(); } };
|
|
58
|
+
}
|
|
59
|
+
/** Minimal CDP client over one browser-level WebSocket (flattened sessions). */
|
|
60
|
+
class Cdp {
|
|
61
|
+
ws;
|
|
62
|
+
next = 0;
|
|
63
|
+
pending = new Map();
|
|
64
|
+
listeners = new Set();
|
|
65
|
+
constructor(ws) {
|
|
66
|
+
this.ws = ws;
|
|
67
|
+
ws.on("message", (raw) => {
|
|
68
|
+
const msg = JSON.parse(raw.toString());
|
|
69
|
+
if (msg.id !== undefined) {
|
|
70
|
+
const call = this.pending.get(msg.id);
|
|
71
|
+
this.pending.delete(msg.id);
|
|
72
|
+
if (msg.error)
|
|
73
|
+
call?.reject(new Error(msg.error.message));
|
|
74
|
+
else
|
|
75
|
+
call?.resolve(msg.result);
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
for (const fn of this.listeners)
|
|
79
|
+
fn(msg.method, msg.params, msg.sessionId);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
send(method, params = {}, sessionId) {
|
|
83
|
+
const id = ++this.next;
|
|
84
|
+
return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params, sessionId })); });
|
|
85
|
+
}
|
|
86
|
+
once(method, sessionId, ms) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const fn = (m, _p, s) => { if (m === method && s === sessionId)
|
|
89
|
+
finish(); };
|
|
90
|
+
const finish = () => { clearTimeout(timer); this.listeners.delete(fn); resolve(); };
|
|
91
|
+
const timer = setTimeout(finish, ms);
|
|
92
|
+
this.listeners.add(fn);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Screenshots web views with one headless browser, one shot at a time, so
|
|
97
|
+
* small machines cope. Service views are loaded straight from loopback;
|
|
98
|
+
* static views from a throwaway loopback server. Files are PNGs in `outDir`. */
|
|
99
|
+
export async function takeShots(views, request, outDir, chrome = findChrome()) {
|
|
100
|
+
if (!chrome)
|
|
101
|
+
throw new Error("No Chrome or Chromium found on this machine. Install one, or set BIVY_CHROME to its path.");
|
|
102
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
103
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "bivy-shot-"));
|
|
104
|
+
const servers = [];
|
|
105
|
+
let ws;
|
|
106
|
+
let browser;
|
|
107
|
+
const deadline = setTimeout(() => browser?.kill("SIGKILL"), 120_000);
|
|
108
|
+
// Chromium's sandbox is unavailable as root and where AppArmor blocks user
|
|
109
|
+
// namespaces (Ubuntu 23.10+). The pages are the session's own apps, already
|
|
110
|
+
// running as this user, so retrying without it doesn't widen what they can do.
|
|
111
|
+
const launch = (sandbox) => new Promise((resolve, reject) => {
|
|
112
|
+
const args = ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars", "--mute-audio", ...(sandbox ? [] : ["--no-sandbox"]), "about:blank"];
|
|
113
|
+
const child = spawn(chrome, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
114
|
+
browser = child;
|
|
115
|
+
let text = "";
|
|
116
|
+
const timer = setTimeout(() => reject(new Error("The browser didn't start in time.")), 20_000);
|
|
117
|
+
child.stderr.on("data", (chunk) => { text += chunk; const m = /DevTools listening on (ws:\/\/\S+)/.exec(text); if (m) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(m[1]);
|
|
120
|
+
} });
|
|
121
|
+
child.once("exit", () => { clearTimeout(timer); reject(Object.assign(new Error("The browser exited before it was ready."), { noSandbox: /No usable sandbox|--no-sandbox/i.test(text) })); });
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const endpoint = await launch(process.getuid?.() !== 0).catch((error) => {
|
|
125
|
+
if (error.noSandbox)
|
|
126
|
+
return launch(false);
|
|
127
|
+
throw error;
|
|
128
|
+
});
|
|
129
|
+
ws = new WebSocket(endpoint, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
|
|
130
|
+
await once(ws, "open");
|
|
131
|
+
const cdp = new Cdp(ws);
|
|
132
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
|
133
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
134
|
+
await cdp.send("Page.enable", {}, sessionId);
|
|
135
|
+
const shots = [];
|
|
136
|
+
for (const entry of views) {
|
|
137
|
+
let origin;
|
|
138
|
+
if (entry.target.kind === "service")
|
|
139
|
+
origin = `http://127.0.0.1:${entry.target.port}`;
|
|
140
|
+
else if (entry.target.kind === "static") {
|
|
141
|
+
const server = await serveSnapshot(entry.target.files);
|
|
142
|
+
servers.push(server);
|
|
143
|
+
origin = server.origin;
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
continue;
|
|
147
|
+
for (const width of request.widths)
|
|
148
|
+
for (const theme of request.themes) {
|
|
149
|
+
const mobile = width < 600;
|
|
150
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: mobile ? 844 : 800, deviceScaleFactor: mobile ? 2 : 1, mobile }, sessionId);
|
|
151
|
+
await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: theme }] }, sessionId);
|
|
152
|
+
const loaded = cdp.once("Page.loadEventFired", sessionId, 15_000);
|
|
153
|
+
await cdp.send("Page.navigate", { url: origin + request.path }, sessionId);
|
|
154
|
+
await loaded;
|
|
155
|
+
await new Promise((r) => setTimeout(r, 400)); // let fonts and first effects settle
|
|
156
|
+
const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
157
|
+
const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
|
|
158
|
+
fs.writeFileSync(file, Buffer.from(data, "base64"));
|
|
159
|
+
shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return shots;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(deadline);
|
|
166
|
+
ws?.terminate();
|
|
167
|
+
for (const server of servers)
|
|
168
|
+
server.close();
|
|
169
|
+
// Wait for the browser to exit before removing its profile: a killed
|
|
170
|
+
// Chrome can still be writing into it. Cleanup never fails a shot.
|
|
171
|
+
if (browser && browser.exitCode === null && browser.signalCode === null) {
|
|
172
|
+
const exited = once(browser, "exit").catch(() => { });
|
|
173
|
+
browser.kill("SIGKILL");
|
|
174
|
+
await exited;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
178
|
+
}
|
|
179
|
+
catch { /* temp dir; the OS reclaims it */ }
|
|
180
|
+
}
|
|
181
|
+
}
|
package/dist/apps/service.js
CHANGED
|
@@ -1,23 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { scanListeners } from "./listeners.js";
|
|
5
|
+
import { takeShots } from "./screenshot.js";
|
|
1
6
|
/** Composes view providers; registry never spawns processes and the gateway
|
|
2
7
|
* never knows about terminals. New providers can extend open/remove here. */
|
|
8
|
+
/** A managed server that keeps exiting is left down after this many restarts
|
|
9
|
+
* in RESTART_WINDOW; opening the view again tries afresh. */
|
|
10
|
+
const MAX_RESTARTS = 5;
|
|
11
|
+
/** Compare keeps this many shots per view, taken after servers settle. */
|
|
12
|
+
const COMPARE_KEEP = 4;
|
|
13
|
+
const COMPARE_SETTLE_MS = 1_500;
|
|
14
|
+
const RESTART_WINDOW = 10 * 60_000;
|
|
3
15
|
export class AppService {
|
|
4
16
|
registry;
|
|
5
17
|
gateway;
|
|
6
18
|
terminals;
|
|
7
19
|
terminalStarts = new Map();
|
|
8
|
-
|
|
20
|
+
servers = new Map();
|
|
21
|
+
scan;
|
|
22
|
+
serverWatchMs;
|
|
23
|
+
screenshots;
|
|
24
|
+
shooting = Promise.resolve();
|
|
25
|
+
constructor(registry, gateway, terminals, options = {}) {
|
|
9
26
|
this.registry = registry;
|
|
10
27
|
this.gateway = gateway;
|
|
11
28
|
this.terminals = terminals;
|
|
29
|
+
this.scan = options.scan ?? scanListeners;
|
|
30
|
+
this.serverWatchMs = options.serverWatchMs ?? 5_000;
|
|
31
|
+
this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
|
|
32
|
+
}
|
|
33
|
+
list(sessionId) {
|
|
34
|
+
const available = Boolean(this.gateway) && this.gateway?.available !== false;
|
|
35
|
+
const apps = this.registry.list(sessionId);
|
|
36
|
+
for (const app of apps)
|
|
37
|
+
for (const view of app.views) {
|
|
38
|
+
if (view.kind !== "web")
|
|
39
|
+
continue;
|
|
40
|
+
if (available)
|
|
41
|
+
view.address = this.gateway?.address?.(view.id);
|
|
42
|
+
const notes = this.registry.getView(view.id)?.notes;
|
|
43
|
+
if (notes?.length)
|
|
44
|
+
view.notes = structuredClone(notes);
|
|
45
|
+
}
|
|
46
|
+
return { apps, previewAvailable: available };
|
|
12
47
|
}
|
|
13
|
-
list(sessionId) { return { apps: this.registry.list(sessionId), previewAvailable: Boolean(this.gateway) && this.gateway?.available !== false }; }
|
|
14
48
|
publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
|
|
15
|
-
|
|
49
|
+
/** An agent turn changed files in this session's workspace. With agent
|
|
50
|
+
* screenshots on, Compare gets an "after" shot once servers have rebuilt. */
|
|
51
|
+
turnChanged(sessionId) {
|
|
52
|
+
const bumped = this.registry.touch(sessionId);
|
|
53
|
+
if (bumped.length && this.screenshots.enabled())
|
|
54
|
+
setTimeout(() => void this.capture(bumped), COMPARE_SETTLE_MS).unref?.();
|
|
55
|
+
return bumped;
|
|
56
|
+
}
|
|
57
|
+
/** One phone-width shot per view of the page last viewed, kept for Compare. */
|
|
58
|
+
async capture(viewIds) {
|
|
59
|
+
for (const id of viewIds) {
|
|
60
|
+
const entry = this.registry.getView(id);
|
|
61
|
+
if (!entry || entry.view.kind !== "web")
|
|
62
|
+
continue;
|
|
63
|
+
const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", id);
|
|
64
|
+
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path: entry.lastPath ?? "/" }, outDir));
|
|
65
|
+
this.shooting = run;
|
|
66
|
+
try {
|
|
67
|
+
const [shot] = await run;
|
|
68
|
+
if (!shot)
|
|
69
|
+
continue;
|
|
70
|
+
const png = fs.readFileSync(shot.file);
|
|
71
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
72
|
+
entry.shots = [...(entry.shots ?? []), { revision: entry.revision, at: Date.now(), png }].slice(-COMPARE_KEEP);
|
|
73
|
+
}
|
|
74
|
+
catch { /* a failed shot just leaves Compare without this turn */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Servers running in the workspace that this session doesn't preview yet. */
|
|
78
|
+
async offers(sessionId, workspace) {
|
|
79
|
+
const claimed = this.registry.claimedPorts(sessionId);
|
|
80
|
+
return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
|
|
81
|
+
}
|
|
82
|
+
/** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
|
|
83
|
+
async adopt(sessionId, workspace, port) {
|
|
84
|
+
const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
|
|
85
|
+
if (!offer)
|
|
86
|
+
throw new Error(`Nothing in this session's workspace is listening on port ${port} any more.`);
|
|
87
|
+
return this.registry.publish(sessionId, workspace, { version: 1, name: `${offer.command} · :${port}`.slice(0, 100), views: [{ kind: "web", name: `Port ${port}`, source: { kind: "service", port } }] });
|
|
88
|
+
}
|
|
89
|
+
/** `direct` opens the app origin itself (no shell): a signed-in device
|
|
90
|
+
* returning to a view's stable address. */
|
|
91
|
+
async open(sessionId, appId, viewId, returnTo, direct = false) {
|
|
16
92
|
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
17
93
|
if (entry.view.kind === "web") {
|
|
18
94
|
if (!this.gateway)
|
|
19
95
|
throw new Error("Bivy's preview service is unavailable on this connection.");
|
|
20
|
-
|
|
96
|
+
if (direct && !this.gateway.openDirect)
|
|
97
|
+
throw new Error("This machine can't open previews directly.");
|
|
98
|
+
const url = direct ? this.gateway.openDirect(viewId) : this.gateway.open(viewId, returnTo);
|
|
99
|
+
// Don't wait for a managed server to boot: the preview shows it starting
|
|
100
|
+
// and reloads itself once it answers.
|
|
101
|
+
if (this.servers.get(viewId))
|
|
102
|
+
this.servers.get(viewId).restarts = [];
|
|
103
|
+
void this.ensureServer(entry).catch(() => { });
|
|
104
|
+
// Compare needs a "before": take a baseline the first time it's opened.
|
|
105
|
+
if (!entry.shots?.length && this.screenshots.enabled())
|
|
106
|
+
setTimeout(() => void this.capture([viewId]), COMPARE_SETTLE_MS).unref?.();
|
|
107
|
+
return { kind: "web", url };
|
|
21
108
|
}
|
|
22
109
|
if (entry.target.kind !== "terminal")
|
|
23
110
|
throw new Error("Unsupported app view provider.");
|
|
@@ -48,6 +135,89 @@ export class AppService {
|
|
|
48
135
|
throw error;
|
|
49
136
|
}
|
|
50
137
|
}
|
|
138
|
+
/** Screenshots of a session's web views (one app, or all), for agents to
|
|
139
|
+
* check their own UI. One browser at a time node-wide; PNGs in a temp dir. */
|
|
140
|
+
async shot(sessionId, appId, input) {
|
|
141
|
+
if (!this.screenshots.enabled())
|
|
142
|
+
throw new Error("Agent screenshots are off on this machine. Turn on “Let agents screenshot their app previews” in Bivy → Settings → this machine, or run: bivy config set sessions.appScreenshots true");
|
|
143
|
+
const widths = input.widths ?? [390, 1280];
|
|
144
|
+
const themes = input.themes ?? ["light"];
|
|
145
|
+
const page = input.path ?? "/";
|
|
146
|
+
if (!Array.isArray(widths) || !widths.length || widths.length > 4 || widths.some((w) => !Number.isInteger(w) || w < 240 || w > 2560))
|
|
147
|
+
throw new Error("Widths must be 1–4 whole numbers between 240 and 2560.");
|
|
148
|
+
if (!Array.isArray(themes) || !themes.length || themes.some((t) => t !== "light" && t !== "dark"))
|
|
149
|
+
throw new Error("Themes must be light and/or dark.");
|
|
150
|
+
if (typeof page !== "string" || !page.startsWith("/") || page.startsWith("//") || page.length > 2048)
|
|
151
|
+
throw new Error("Path must start with /.");
|
|
152
|
+
const apps = appId ? [this.registry.require(sessionId, appId)] : this.registry.list(sessionId);
|
|
153
|
+
const views = apps.flatMap((app) => app.views).filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id)).filter(Boolean);
|
|
154
|
+
if (!views.length)
|
|
155
|
+
throw new Error("This session has no web views to screenshot. Publish one with bivy app publish, or preview a detected server.");
|
|
156
|
+
const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
|
|
157
|
+
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take(views, { widths, themes: [...new Set(themes)], path: page }, outDir));
|
|
158
|
+
this.shooting = run;
|
|
159
|
+
return { shots: await run };
|
|
160
|
+
}
|
|
161
|
+
/** The managed server's output, as an attachable terminal (starts it if needed). */
|
|
162
|
+
async logs(sessionId, appId, viewId) {
|
|
163
|
+
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
164
|
+
const termId = await this.ensureServer(entry);
|
|
165
|
+
if (!termId)
|
|
166
|
+
throw new Error("Only views with a start command have server logs.");
|
|
167
|
+
return { kind: "terminal", termId };
|
|
168
|
+
}
|
|
169
|
+
ensureServer(entry) {
|
|
170
|
+
if (entry.target.kind !== "service" || !entry.target.start)
|
|
171
|
+
return Promise.resolve(undefined);
|
|
172
|
+
const id = entry.view.id, start = entry.target.start;
|
|
173
|
+
let state = this.servers.get(id);
|
|
174
|
+
if (!state) {
|
|
175
|
+
state = { restarts: [] };
|
|
176
|
+
this.servers.set(id, state);
|
|
177
|
+
}
|
|
178
|
+
const server = state;
|
|
179
|
+
if (server.termId && this.terminals.has(server.termId))
|
|
180
|
+
return Promise.resolve(server.termId);
|
|
181
|
+
server.pending ??= this.terminals.start({ ...start, name: `${entry.app.name} · ${entry.view.name} server` })
|
|
182
|
+
.then((termId) => {
|
|
183
|
+
if (!this.registry.getView(id)) {
|
|
184
|
+
this.terminals.close(termId);
|
|
185
|
+
throw new Error("App was removed while starting.");
|
|
186
|
+
}
|
|
187
|
+
server.termId = termId;
|
|
188
|
+
return termId;
|
|
189
|
+
})
|
|
190
|
+
.finally(() => { server.pending = undefined; });
|
|
191
|
+
// Watch for exits and restart, backing off a crash loop.
|
|
192
|
+
server.timer ??= setInterval(() => {
|
|
193
|
+
const current = this.registry.getView(id);
|
|
194
|
+
if (!current) {
|
|
195
|
+
this.stopServer(id);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (server.pending || !server.termId || this.terminals.has(server.termId))
|
|
199
|
+
return;
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
server.restarts = server.restarts.filter((at) => now - at < RESTART_WINDOW);
|
|
202
|
+
if (server.restarts.length >= MAX_RESTARTS)
|
|
203
|
+
return;
|
|
204
|
+
server.restarts.push(now);
|
|
205
|
+
server.termId = undefined;
|
|
206
|
+
void this.ensureServer(current).catch(() => { });
|
|
207
|
+
}, this.serverWatchMs);
|
|
208
|
+
server.timer.unref?.();
|
|
209
|
+
return server.pending;
|
|
210
|
+
}
|
|
211
|
+
stopServer(id) {
|
|
212
|
+
const server = this.servers.get(id);
|
|
213
|
+
if (!server)
|
|
214
|
+
return;
|
|
215
|
+
clearInterval(server.timer);
|
|
216
|
+
if (server.termId)
|
|
217
|
+
this.terminals.close(server.termId);
|
|
218
|
+
void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
|
|
219
|
+
this.servers.delete(id);
|
|
220
|
+
}
|
|
51
221
|
share(sessionId, appId, viewId) {
|
|
52
222
|
if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
|
|
53
223
|
throw new Error("Only web views have preview links.");
|
|
@@ -55,6 +225,10 @@ export class AppService {
|
|
|
55
225
|
throw new Error("Bivy's preview service is unavailable on this connection.");
|
|
56
226
|
return this.gateway.share(viewId);
|
|
57
227
|
}
|
|
228
|
+
clearNotes(sessionId, appId, viewId) {
|
|
229
|
+
delete this.registry.requireView(sessionId, appId, viewId).notes;
|
|
230
|
+
return { ok: true };
|
|
231
|
+
}
|
|
58
232
|
/** Ends every link, browser session and open connection for one view; the app stays. */
|
|
59
233
|
revoke(sessionId, appId, viewId) {
|
|
60
234
|
this.registry.requireView(sessionId, appId, viewId);
|
|
@@ -66,6 +240,7 @@ export class AppService {
|
|
|
66
240
|
this.registry.remove(sessionId, appId);
|
|
67
241
|
for (const view of app.views) {
|
|
68
242
|
this.gateway?.revoke(view.id);
|
|
243
|
+
this.stopServer(view.id);
|
|
69
244
|
const pending = this.terminalStarts.get(view.id);
|
|
70
245
|
this.terminalStarts.delete(view.id);
|
|
71
246
|
if (pending)
|
package/dist/apps/styles.css
CHANGED
|
@@ -2957,6 +2957,9 @@ select.picker-search { appearance: auto; }
|
|
|
2957
2957
|
.app-views-heading > .btn, .app-view-actions .btn { min-block-size: var(--space-7); flex-shrink: 0; }
|
|
2958
2958
|
.app-view-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); margin-inline-start: auto; }
|
|
2959
2959
|
.app-view-notice { display: flex; flex-direction: column; gap: var(--space-2); flex-basis: 100%; min-width: 0; }
|
|
2960
|
+
.app-view-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
|
2961
|
+
/* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
|
|
2962
|
+
.preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
|
|
2960
2963
|
.app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
|
|
2961
2964
|
.app-view-command { overflow-wrap: anywhere; white-space: pre-wrap; font-size: var(--text-xs); color: var(--muted); }
|
|
2962
2965
|
.app-view-row .artifact-main { flex-basis: 60%; }
|
package/dist/config-cli.js
CHANGED
|
@@ -108,6 +108,7 @@ const ENV_FOR_KEY = {
|
|
|
108
108
|
"defaults.sandbox": "BIVY_SANDBOX",
|
|
109
109
|
"defaults.approval": "BIVY_APPROVAL_MODE",
|
|
110
110
|
"sessions.autoAttachToolImages": "BIVY_AUTO_ATTACH_TOOL_IMAGES",
|
|
111
|
+
"sessions.appScreenshots": "BIVY_APP_SCREENSHOTS",
|
|
111
112
|
"automation.checks": "BIVY_AUTOMATION_CHECKS",
|
|
112
113
|
"automation.checkTimeoutMinutes": "BIVY_AUTOMATION_CHECK_TIMEOUT_MS",
|
|
113
114
|
};
|
|
@@ -122,6 +123,7 @@ const BUILTIN_VALUES = {
|
|
|
122
123
|
"sessions.worktreeSync": false,
|
|
123
124
|
"sessions.resume": "auto",
|
|
124
125
|
"sessions.autoAttachToolImages": false,
|
|
126
|
+
"sessions.appScreenshots": false,
|
|
125
127
|
"automation.checks": ["test", "lint", "typecheck"],
|
|
126
128
|
"automation.checkTimeoutMinutes": 10,
|
|
127
129
|
};
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
export function createAppCommands(service, workspaceFor, published = () => { }) {
|
|
2
|
+
const workspaceOf = (sessionId) => {
|
|
3
|
+
const workspace = workspaceFor(sessionId);
|
|
4
|
+
if (!workspace)
|
|
5
|
+
throw new Error("Open the session on this machine before publishing an app.");
|
|
6
|
+
return workspace;
|
|
7
|
+
};
|
|
2
8
|
const operations = {
|
|
3
9
|
"apps.list": (msg) => service.list(String(msg.sessionId)),
|
|
4
10
|
"apps.publish": (msg) => {
|
|
5
11
|
const sessionId = String(msg.sessionId);
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
|
|
13
|
+
published(app);
|
|
14
|
+
return { app };
|
|
15
|
+
},
|
|
16
|
+
"apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
|
|
17
|
+
"apps.adopt": async (msg) => {
|
|
18
|
+
const sessionId = String(msg.sessionId);
|
|
19
|
+
const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
|
|
10
20
|
published(app);
|
|
11
21
|
return { app };
|
|
12
22
|
},
|
|
13
|
-
"apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined),
|
|
23
|
+
"apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true),
|
|
24
|
+
"apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
|
|
25
|
+
"apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
26
|
+
"apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
14
27
|
"apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
15
28
|
"apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
16
29
|
"apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
|
|
@@ -18,7 +31,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
18
31
|
return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
|
|
19
32
|
try {
|
|
20
33
|
ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
|
|
21
|
-
if (kind === "apps.publish" || kind === "apps.remove")
|
|
34
|
+
if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
|
|
22
35
|
ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
|
|
23
36
|
}
|
|
24
37
|
catch (error) {
|
package/dist/node-config.js
CHANGED
|
@@ -74,7 +74,7 @@ export function validateNodeConfig(value) {
|
|
|
74
74
|
const node = section(root, "node", ["workspace", "port", "maxConcurrentAutomations", "capabilities"], errors);
|
|
75
75
|
const defaults = section(root, "defaults", ["agent", "model", "sandbox", "approval"], errors);
|
|
76
76
|
const safety = section(root, "safety", ["maxSandbox", "approvalFloor"], errors);
|
|
77
|
-
const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
|
|
77
|
+
const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "appScreenshots", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
|
|
78
78
|
const github = section(root, "github", ["issuePrompt"], errors);
|
|
79
79
|
const automation = section(root, "automation", ["checks", "checkTimeoutMinutes"], errors);
|
|
80
80
|
const agentsRaw = section(root, "agents", Object.keys(record(root.agents) ?? {}), errors);
|
|
@@ -133,6 +133,7 @@ export function validateNodeConfig(value) {
|
|
|
133
133
|
if (resume !== undefined && resume !== "auto" && resume !== "manual")
|
|
134
134
|
errors.push("sessions.resume must be auto or manual");
|
|
135
135
|
const autoAttachToolImages = optionalBoolean(sessions.autoAttachToolImages, "sessions.autoAttachToolImages", errors);
|
|
136
|
+
const appScreenshots = optionalBoolean(sessions.appScreenshots, "sessions.appScreenshots", errors);
|
|
136
137
|
const forkWorkspaceMaxBytes = optionalInteger(sessions.forkWorkspaceMaxBytes, "sessions.forkWorkspaceMaxBytes", errors, 1_048_576, 1_073_741_824);
|
|
137
138
|
// Upper bound is the wall-clock turn cap (60 min): a wedged window at/above it
|
|
138
139
|
// would never fire before the cap. 0 disables the band.
|
|
@@ -216,7 +217,7 @@ export function validateNodeConfig(value) {
|
|
|
216
217
|
...(Object.keys(node).length ? { node: { workspace, port, maxConcurrentAutomations, capabilities } } : {}),
|
|
217
218
|
...(Object.keys(defaults).length ? { defaults: { agent, model, sandbox, approval } } : {}),
|
|
218
219
|
...(Object.keys(safety).length ? { safety: { maxSandbox, approvalFloor } } : {}),
|
|
219
|
-
...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
|
|
220
|
+
...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, appScreenshots, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
|
|
220
221
|
...(Object.keys(github).length ? { github: { issuePrompt } } : {}),
|
|
221
222
|
...(Object.keys(automation).length ? { automation: { checks, checkTimeoutMinutes } } : {}),
|
|
222
223
|
...(Object.keys(agents).length ? { agents } : {}),
|
|
@@ -272,6 +273,7 @@ export function configToLegacySettings(config) {
|
|
|
272
273
|
...(config.sessions?.standbyNodeId ? { syncStandbyNodeId: config.sessions.standbyNodeId } : {}),
|
|
273
274
|
...(config.sessions?.resume ? { sessionResumeMode: config.sessions.resume } : {}),
|
|
274
275
|
...(config.sessions?.autoAttachToolImages !== undefined ? { autoAttachToolImages: config.sessions.autoAttachToolImages } : {}),
|
|
276
|
+
...(config.sessions?.appScreenshots !== undefined ? { appScreenshots: config.sessions.appScreenshots } : {}),
|
|
275
277
|
...(config.sessions?.forkWorkspaceMaxBytes !== undefined ? { forkWorkspaceMaxBytes: config.sessions.forkWorkspaceMaxBytes } : {}),
|
|
276
278
|
};
|
|
277
279
|
}
|
|
@@ -335,6 +337,7 @@ export function mergeLegacyIntoNodeConfig(cli, settings) {
|
|
|
335
337
|
standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId : undefined,
|
|
336
338
|
resume: settings.sessionResumeMode === "manual" ? "manual" : "auto",
|
|
337
339
|
autoAttachToolImages: settings.autoAttachToolImages === true,
|
|
340
|
+
appScreenshots: settings.appScreenshots === true,
|
|
338
341
|
forkWorkspaceMaxBytes: Number.isInteger(settings.forkWorkspaceMaxBytes) ? Number(settings.forkWorkspaceMaxBytes) : undefined,
|
|
339
342
|
},
|
|
340
343
|
github: { issuePrompt: typeof settings.githubIssuePrompt === "string" ? settings.githubIssuePrompt : undefined },
|
|
@@ -361,7 +364,7 @@ export function setConfigValue(config, dotted, value) {
|
|
|
361
364
|
"node.workspace", "node.port", "node.maxConcurrentAutomations", "node.capabilities",
|
|
362
365
|
"defaults.agent", "defaults.model", "defaults.sandbox", "defaults.approval",
|
|
363
366
|
"safety.maxSandbox", "safety.approvalFloor",
|
|
364
|
-
"sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
|
|
367
|
+
"sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.appScreenshots", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
|
|
365
368
|
"github.issuePrompt", "automation.checks", "automation.checkTimeoutMinutes",
|
|
366
369
|
]);
|
|
367
370
|
if (!allowed.has(dotted) && !/^agents\.[a-z][a-z0-9-]{1,47}$/.test(dotted) && !/^environment\.[A-Z][A-Z0-9_]+$/.test(dotted))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Direct-mode adapters for operations whose canonical implementation is the
|
|
4
4
|
* client command registry. Relay and HTTP now differ only in framing. */
|
|
5
5
|
export const CLIENT_COMMAND_ROUTES = [
|
|
6
|
-
...["list", "publish", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
6
|
+
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
9
9
|
{ method: "post", path: "/api/session/pause", kind: "session.pause" },
|
|
@@ -10,7 +10,12 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
10
10
|
ping: Type.Object(request),
|
|
11
11
|
"apps.list": Type.Object({ ...request, ...session }),
|
|
12
12
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
13
|
-
"apps.
|
|
13
|
+
"apps.offers": Type.Object({ ...request, ...session }),
|
|
14
|
+
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
|
15
|
+
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()) }),
|
|
16
|
+
"apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
17
|
+
"apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
18
|
+
"apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
14
19
|
"apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
15
20
|
"apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
16
21
|
"apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
|
package/dist/server.js
CHANGED
|
@@ -963,6 +963,8 @@ async function harnessEndTurn(record) {
|
|
|
963
963
|
record.workspaceState = (await harness.isDirty(record.id)) ? "dirty" : "clean";
|
|
964
964
|
if (!result || result.changes.length === 0)
|
|
965
965
|
return;
|
|
966
|
+
// Open previews of this session reload; static snapshots are re-taken.
|
|
967
|
+
appService.turnChanged(record.id);
|
|
966
968
|
broadcast({
|
|
967
969
|
type: "session.changes",
|
|
968
970
|
sessionId: record.id,
|
|
@@ -1332,6 +1334,7 @@ function writeSettings(settings) {
|
|
|
1332
1334
|
...("syncStandbyNodeId" in settings ? { standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId || undefined : undefined } : {}),
|
|
1333
1335
|
...(settings.sessionResumeMode === "auto" || settings.sessionResumeMode === "manual" ? { resume: settings.sessionResumeMode } : {}),
|
|
1334
1336
|
...(typeof settings.autoAttachToolImages === "boolean" ? { autoAttachToolImages: settings.autoAttachToolImages } : {}),
|
|
1337
|
+
...(typeof settings.appScreenshots === "boolean" ? { appScreenshots: settings.appScreenshots } : {}),
|
|
1335
1338
|
...(Number.isInteger(settings.forkWorkspaceMaxBytes) ? { forkWorkspaceMaxBytes: Number(settings.forkWorkspaceMaxBytes) } : {}),
|
|
1336
1339
|
};
|
|
1337
1340
|
next.github = {
|
|
@@ -1418,6 +1421,15 @@ function nodeConfiguredDefaultAgent() {
|
|
|
1418
1421
|
}
|
|
1419
1422
|
/** How interactive sessions recover after a restart interrupted them mid-turn.
|
|
1420
1423
|
* Defaults to "auto" (re-drive the turn); "manual" waits for a user tap. */
|
|
1424
|
+
/** Agents may screenshot app previews: the node setting, or BIVY_APP_SCREENSHOTS. */
|
|
1425
|
+
function appScreenshotsEnabled() {
|
|
1426
|
+
const env = process.env.BIVY_APP_SCREENSHOTS;
|
|
1427
|
+
if (env === "1" || env === "true")
|
|
1428
|
+
return true;
|
|
1429
|
+
if (env === "0" || env === "false")
|
|
1430
|
+
return false;
|
|
1431
|
+
return readSettings().appScreenshots === true;
|
|
1432
|
+
}
|
|
1421
1433
|
function nodeSessionResumeMode() {
|
|
1422
1434
|
return readSettings().sessionResumeMode === "manual" ? "manual" : "auto";
|
|
1423
1435
|
}
|
|
@@ -1451,6 +1463,7 @@ function nodeSettingsSnapshot() {
|
|
|
1451
1463
|
})(),
|
|
1452
1464
|
sessionResumeMode: nodeSessionResumeMode(),
|
|
1453
1465
|
autoAttachToolImages: readSettings().autoAttachToolImages === true,
|
|
1466
|
+
appScreenshots: appScreenshotsEnabled(),
|
|
1454
1467
|
forkWorkspaceMaxBytes: Number.isInteger(readSettings().forkWorkspaceMaxBytes) ? Number(readSettings().forkWorkspaceMaxBytes) : 50 * 1024 * 1024,
|
|
1455
1468
|
};
|
|
1456
1469
|
}
|
|
@@ -1508,6 +1521,8 @@ async function applyNodeSettings(patch) {
|
|
|
1508
1521
|
if ("sessionResumeMode" in patch) {
|
|
1509
1522
|
settings.sessionResumeMode = patch.sessionResumeMode === "manual" ? "manual" : "auto";
|
|
1510
1523
|
}
|
|
1524
|
+
if ("appScreenshots" in patch)
|
|
1525
|
+
settings.appScreenshots = patch.appScreenshots === true;
|
|
1511
1526
|
if ("autoAttachToolImages" in patch) {
|
|
1512
1527
|
settings.autoAttachToolImages = patch.autoAttachToolImages === true;
|
|
1513
1528
|
setConfiguredAutoAttachToolImages(settings.autoAttachToolImages);
|
|
@@ -1906,14 +1921,24 @@ const appPreviewPort = Number(process.env.BIVY_APPS_PORT || 4318);
|
|
|
1906
1921
|
if (process.env.BIVY_APPS_ORIGIN && (!Number.isInteger(appPreviewPort) || appPreviewPort < 1024 || appPreviewPort > 65535 || appPreviewPort === port)) {
|
|
1907
1922
|
throw new Error("BIVY_APPS_PORT must be between 1024 and 65535 and different from the node API port.");
|
|
1908
1923
|
}
|
|
1909
|
-
|
|
1924
|
+
// Apps persist with their IDs, so chat launchers and preview addresses survive
|
|
1925
|
+
// restarts; see AppRegistry for which views are restored.
|
|
1926
|
+
const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])], path.join(appDir, "apps.json"));
|
|
1910
1927
|
const appReturnOrigins = () => {
|
|
1911
1928
|
const config = loadRelayConfig(appDir);
|
|
1912
1929
|
return [config?.clientBaseUrl, config?.controlPlaneUrl, ...(process.env.BIVY_APPS_RETURN_ORIGINS ?? "").split(",")]
|
|
1913
1930
|
.filter((value) => Boolean(value?.trim())).map((value) => new URL(value.trim()).origin);
|
|
1914
1931
|
};
|
|
1915
|
-
|
|
1916
|
-
|
|
1932
|
+
// A signed-out visit to a preview's stable address goes through the Bivy
|
|
1933
|
+
// client: open the session on this node, then the client re-opens the view.
|
|
1934
|
+
const appSignIn = (view, pagePath) => {
|
|
1935
|
+
const client = loadRelayConfig(appDir)?.clientBaseUrl;
|
|
1936
|
+
if (!client)
|
|
1937
|
+
return undefined;
|
|
1938
|
+
return `${new URL(client).origin}/sessions/${encodeURIComponent(view.app.sessionId)}?node=${encodeURIComponent(identity.nodeId)}#preview=${view.app.id}.${view.view.id}.${encodeURIComponent(pagePath)}`;
|
|
1939
|
+
};
|
|
1940
|
+
const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
|
|
1941
|
+
const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
|
|
1917
1942
|
const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
|
|
1918
1943
|
start: async (spec) => {
|
|
1919
1944
|
let failure = "Could not start the app terminal.";
|
|
@@ -1928,7 +1953,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
|
|
|
1928
1953
|
},
|
|
1929
1954
|
has: (id) => terminals.has(id),
|
|
1930
1955
|
close: (id) => { terminals.close(id); },
|
|
1931
|
-
});
|
|
1956
|
+
}, { screenshots: { enabled: appScreenshotsEnabled } });
|
|
1932
1957
|
const RELAY_COMMANDS = {
|
|
1933
1958
|
...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
|
|
1934
1959
|
const record = resolveSession(app.sessionId);
|