@frockbot/computer-host-runtime 0.2.3 → 0.2.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/package.json +1 -1
- package/src/runtime.test.ts +85 -0
- package/src/runtime.ts +153 -2
package/package.json
CHANGED
package/src/runtime.test.ts
CHANGED
|
@@ -141,6 +141,91 @@ describe("layout", () => {
|
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
describe("runtime files", () => {
|
|
144
|
+
test("the minimal viewer applies takeover from a fragment change without reloading", () => {
|
|
145
|
+
const viewer = COMPUTER_RUNTIME_FILES.find((file) =>
|
|
146
|
+
file.path.endsWith("/viewer/index.html"),
|
|
147
|
+
);
|
|
148
|
+
if (!viewer) throw new Error("the FrockBot viewer page is not installed");
|
|
149
|
+
const source = /<script type="module">([\s\S]*?)<\/script>/.exec(
|
|
150
|
+
viewer.content,
|
|
151
|
+
)?.[1];
|
|
152
|
+
if (!source) throw new Error("the FrockBot viewer module is missing");
|
|
153
|
+
|
|
154
|
+
const listeners = new Map<string, () => void>();
|
|
155
|
+
const classes = new Set<string>();
|
|
156
|
+
const screen = {
|
|
157
|
+
classList: {
|
|
158
|
+
toggle(name: string, enabled: boolean) {
|
|
159
|
+
if (enabled) classes.add(name);
|
|
160
|
+
else classes.delete(name);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
setAttribute() {},
|
|
164
|
+
};
|
|
165
|
+
const status = {
|
|
166
|
+
textContent: "",
|
|
167
|
+
dataset: {} as Record<string, string>,
|
|
168
|
+
};
|
|
169
|
+
const messages: unknown[] = [];
|
|
170
|
+
const windowDouble = {
|
|
171
|
+
location: {
|
|
172
|
+
href: "https://sprite.invalid/index.html#view_only=1&path=websockify%3Ftoken%3Dsecret&password=password",
|
|
173
|
+
hash: "#view_only=1&path=websockify%3Ftoken%3Dsecret&password=password",
|
|
174
|
+
protocol: "https:",
|
|
175
|
+
host: "sprite.invalid",
|
|
176
|
+
},
|
|
177
|
+
parent: { postMessage: (message: unknown) => messages.push(message) },
|
|
178
|
+
addEventListener: (name: string, listener: () => void) =>
|
|
179
|
+
listeners.set(name, listener),
|
|
180
|
+
setTimeout: () => 1,
|
|
181
|
+
clearTimeout() {},
|
|
182
|
+
};
|
|
183
|
+
const documentDouble = {
|
|
184
|
+
getElementById: (id: string) => (id === "screen" ? screen : status),
|
|
185
|
+
};
|
|
186
|
+
let instance:
|
|
187
|
+
| {
|
|
188
|
+
viewOnly: boolean;
|
|
189
|
+
showDotCursor: boolean;
|
|
190
|
+
addEventListener(name: string, listener: () => void): void;
|
|
191
|
+
}
|
|
192
|
+
| undefined;
|
|
193
|
+
class FakeRfb {
|
|
194
|
+
viewOnly = false;
|
|
195
|
+
showDotCursor = true;
|
|
196
|
+
readonly listeners = new Map<string, () => void>();
|
|
197
|
+
|
|
198
|
+
constructor() {
|
|
199
|
+
instance = this;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
addEventListener(name: string, listener: () => void): void {
|
|
203
|
+
this.listeners.set(name, listener);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const executable = source.replace(/^\s*import RFB from [^;]+;\s*/m, "");
|
|
207
|
+
new Function("RFB", "window", "document", executable)(
|
|
208
|
+
FakeRfb,
|
|
209
|
+
windowDouble,
|
|
210
|
+
documentDouble,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(instance?.viewOnly).toBe(true);
|
|
214
|
+
expect(instance?.showDotCursor).toBe(false);
|
|
215
|
+
expect(classes.has("view-only")).toBe(true);
|
|
216
|
+
|
|
217
|
+
windowDouble.location.hash =
|
|
218
|
+
"#view_only=0&path=websockify%3Ftoken%3Dsecret&password=password";
|
|
219
|
+
windowDouble.location.href = `https://sprite.invalid/index.html${windowDouble.location.hash}`;
|
|
220
|
+
listeners.get("hashchange")?.();
|
|
221
|
+
|
|
222
|
+
expect(instance?.viewOnly).toBe(false);
|
|
223
|
+
expect(classes.has("view-only")).toBe(false);
|
|
224
|
+
expect(messages).not.toContainEqual(
|
|
225
|
+
expect.objectContaining({ password: expect.anything() }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
144
229
|
test("every declared file is the one the provisioning script installs", () => {
|
|
145
230
|
for (const file of COMPUTER_RUNTIME_FILES) {
|
|
146
231
|
expect(provisionScript).toContain(
|
package/src/runtime.ts
CHANGED
|
@@ -29,6 +29,9 @@ export const HOME_ROOT = "/home/box";
|
|
|
29
29
|
export const DATA_ROOT = `${HOME_ROOT}/agent-data`;
|
|
30
30
|
export const RUNTIME_ROOT = `${HOME_ROOT}/.frockbot`;
|
|
31
31
|
export const BOTS_ROOT = `${RUNTIME_ROOT}/bots`;
|
|
32
|
+
/** FrockBot-owned noVNC shell; only noVNC's transport/core is linked into it. */
|
|
33
|
+
export const VIEWER_ROOT = `${RUNTIME_ROOT}/viewer`;
|
|
34
|
+
export const VIEWER_PAGE = `${VIEWER_ROOT}/index.html`;
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* The lease key a User-wide `desktop-gui` lease is held under.
|
|
@@ -206,6 +209,8 @@ export function desktopServiceNameV1(botKey: string): string {
|
|
|
206
209
|
* running process.
|
|
207
210
|
*/
|
|
208
211
|
export const DESKTOP_LIVE_MARKER = "__FROCKBOT_DESKTOP_LIVE__";
|
|
212
|
+
/** Prefix carrying the slot the attach exec already read back to the host. */
|
|
213
|
+
export const DESKTOP_SLOT_PREFIX = "__FROCKBOT_DESKTOP_SLOT__";
|
|
209
214
|
|
|
210
215
|
/**
|
|
211
216
|
* The one variable that separates a sanctioned GUI call from a shell-driven
|
|
@@ -650,12 +655,152 @@ while true; do
|
|
|
650
655
|
done
|
|
651
656
|
`;
|
|
652
657
|
|
|
658
|
+
/**
|
|
659
|
+
* The hosted Computer viewer, without stock noVNC application chrome.
|
|
660
|
+
*
|
|
661
|
+
* The Sprite image's Ubuntu 25.10 package installs noVNC 1.6.0 under
|
|
662
|
+
* `/usr/share/novnc`. Its `ui.js`
|
|
663
|
+
* reads `view_only` while constructing RFB and only reapplies it from its own
|
|
664
|
+
* settings control; changing an iframe's fragment therefore left the existing
|
|
665
|
+
* connection view-only. This page owns the presentation, imports only RFB,
|
|
666
|
+
* and treats the fragment as live state so P2's second-click takeover keeps
|
|
667
|
+
* one socket and one secret-bearing URL.
|
|
668
|
+
*/
|
|
669
|
+
export const viewerPage = `<!doctype html>
|
|
670
|
+
<html lang="en">
|
|
671
|
+
<head>
|
|
672
|
+
<meta charset="utf-8">
|
|
673
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
674
|
+
<title>FrockBot Computer</title>
|
|
675
|
+
<style>
|
|
676
|
+
:root { color-scheme: dark; background: #0a0d12; }
|
|
677
|
+
* { box-sizing: border-box; }
|
|
678
|
+
html, body, #screen { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
|
679
|
+
body { background: #0a0d12; }
|
|
680
|
+
#screen, #screen * { touch-action: none; }
|
|
681
|
+
#screen.view-only { pointer-events: none; }
|
|
682
|
+
#screen.view-only, #screen.view-only * { cursor: none !important; }
|
|
683
|
+
#status {
|
|
684
|
+
position: fixed;
|
|
685
|
+
z-index: 2;
|
|
686
|
+
inset: 0;
|
|
687
|
+
display: grid;
|
|
688
|
+
place-items: center;
|
|
689
|
+
padding: 24px;
|
|
690
|
+
color: #dce5f2;
|
|
691
|
+
background: #0a0d12;
|
|
692
|
+
font: 500 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
693
|
+
text-align: center;
|
|
694
|
+
}
|
|
695
|
+
#status[data-state="connected"] { display: none; }
|
|
696
|
+
#status[data-state="error"] { color: #ffb4b4; }
|
|
697
|
+
</style>
|
|
698
|
+
</head>
|
|
699
|
+
<body>
|
|
700
|
+
<div id="screen" class="view-only" aria-label="Computer desktop, view only"></div>
|
|
701
|
+
<div id="status" role="status" aria-live="polite">Connecting to desktop…</div>
|
|
702
|
+
<script type="module">
|
|
703
|
+
import RFB from "./core/rfb.js";
|
|
704
|
+
|
|
705
|
+
const screen = document.getElementById("screen");
|
|
706
|
+
const status = document.getElementById("status");
|
|
707
|
+
let rfb;
|
|
708
|
+
let reconnectTimer;
|
|
709
|
+
|
|
710
|
+
function parameters() {
|
|
711
|
+
return new URLSearchParams(window.location.hash.slice(1));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function enabled(name, fallback) {
|
|
715
|
+
const value = parameters().get(name);
|
|
716
|
+
if (value === null) return fallback;
|
|
717
|
+
return value === "1" || value === "true";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function publish(state, message) {
|
|
721
|
+
status.dataset.state = state;
|
|
722
|
+
status.textContent = message;
|
|
723
|
+
window.parent.postMessage(
|
|
724
|
+
{ type: "frockbot-viewer", state: state, message: message },
|
|
725
|
+
"*",
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function applyMode() {
|
|
730
|
+
const viewOnly = enabled("view_only", true);
|
|
731
|
+
screen.classList.toggle("view-only", viewOnly);
|
|
732
|
+
screen.setAttribute(
|
|
733
|
+
"aria-label",
|
|
734
|
+
viewOnly ? "Computer desktop, view only" : "Computer desktop, control enabled",
|
|
735
|
+
);
|
|
736
|
+
if (!rfb) return;
|
|
737
|
+
rfb.viewOnly = viewOnly;
|
|
738
|
+
rfb.showDotCursor = false;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function socketUrl(path) {
|
|
742
|
+
const url = new URL(path, window.location.href);
|
|
743
|
+
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
744
|
+
url.hash = "";
|
|
745
|
+
return url.href;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function connect() {
|
|
749
|
+
window.clearTimeout(reconnectTimer);
|
|
750
|
+
publish("connecting", "Connecting to desktop…");
|
|
751
|
+
const config = parameters();
|
|
752
|
+
try {
|
|
753
|
+
rfb = new RFB(
|
|
754
|
+
screen,
|
|
755
|
+
socketUrl(config.get("path") || "websockify"),
|
|
756
|
+
{
|
|
757
|
+
credentials: { password: config.get("password") || "" },
|
|
758
|
+
shared: true,
|
|
759
|
+
},
|
|
760
|
+
);
|
|
761
|
+
rfb.scaleViewport = config.get("resize") === "scale";
|
|
762
|
+
rfb.resizeSession = config.get("resize") === "remote";
|
|
763
|
+
rfb.showDotCursor = false;
|
|
764
|
+
applyMode();
|
|
765
|
+
rfb.addEventListener("connect", () => {
|
|
766
|
+
publish("connected", "Desktop connected");
|
|
767
|
+
});
|
|
768
|
+
rfb.addEventListener("disconnect", (event) => {
|
|
769
|
+
rfb = undefined;
|
|
770
|
+
if (enabled("reconnect", true)) {
|
|
771
|
+
publish("reconnecting", "Reconnecting…");
|
|
772
|
+
reconnectTimer = window.setTimeout(connect, 1000);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
publish(
|
|
776
|
+
"error",
|
|
777
|
+
event.detail && event.detail.clean
|
|
778
|
+
? "Desktop disconnected"
|
|
779
|
+
: "Desktop connection failed",
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
rfb.addEventListener("securityfailure", () => {
|
|
783
|
+
publish("error", "Desktop authentication failed");
|
|
784
|
+
});
|
|
785
|
+
} catch {
|
|
786
|
+
rfb = undefined;
|
|
787
|
+
publish("error", "Desktop connection failed");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
window.addEventListener("hashchange", applyMode);
|
|
792
|
+
connect();
|
|
793
|
+
</script>
|
|
794
|
+
</body>
|
|
795
|
+
</html>
|
|
796
|
+
`;
|
|
797
|
+
|
|
653
798
|
/** The port the noVNC gateway serves on, and the port box-doctor probes. */
|
|
654
799
|
export const DESKTOP_GATEWAY_PORT = 6080;
|
|
655
800
|
|
|
656
801
|
export const gatewayScript = `#!/usr/bin/env bash
|
|
657
802
|
set -eu
|
|
658
|
-
exec websockify --web
|
|
803
|
+
exec websockify --web=${VIEWER_ROOT} --token-plugin TokenFile --token-source=${RUNTIME_ROOT}/tokens ${DESKTOP_GATEWAY_PORT}
|
|
659
804
|
`;
|
|
660
805
|
|
|
661
806
|
/** Where box-doctor is installed, and the log a human reads it back from. */
|
|
@@ -1240,6 +1385,7 @@ export const COMPUTER_RUNTIME_FILES: readonly {
|
|
|
1240
1385
|
content: gatewayScript,
|
|
1241
1386
|
mode: 0o700,
|
|
1242
1387
|
},
|
|
1388
|
+
{ path: VIEWER_PAGE, content: viewerPage, mode: 0o644 },
|
|
1243
1389
|
{
|
|
1244
1390
|
path: `${RUNTIME_ROOT}/watch-workspace.sh`,
|
|
1245
1391
|
content: syncWatchScript,
|
|
@@ -1321,7 +1467,12 @@ fi`,
|
|
|
1321
1467
|
{
|
|
1322
1468
|
name: "runtime",
|
|
1323
1469
|
label: "installing the Computer runtime",
|
|
1324
|
-
body:
|
|
1470
|
+
body: `mkdir -p ${VIEWER_ROOT}
|
|
1471
|
+
${installDeclaredFiles(COMPUTER_RUNTIME_FILES)}
|
|
1472
|
+
# noVNC's ES modules in core/ import one another and ../vendor/pako. The links
|
|
1473
|
+
# keep that package-owned graph intact while FrockBot owns every rendered element.
|
|
1474
|
+
ln -sfn /usr/share/novnc/core ${VIEWER_ROOT}/core
|
|
1475
|
+
ln -sfn /usr/share/novnc/vendor ${VIEWER_ROOT}/vendor`,
|
|
1325
1476
|
},
|
|
1326
1477
|
{
|
|
1327
1478
|
name: "browser",
|