@frockbot/plugin-computer 0.0.0 → 0.1.0
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/frockbot.json +25 -0
- package/package.json +54 -6
- package/src/agent.test.ts +271 -0
- package/src/agent.ts +1419 -0
- package/src/backend.test.ts +149 -0
- package/src/backend.ts +163 -0
- package/src/bot.test.ts +411 -0
- package/src/bot.ts +831 -0
- package/src/client/ComputerCard.test.ts +96 -0
- package/src/client/ComputerCard.vue +60 -0
- package/src/client/ComputerStrip.test.ts +54 -0
- package/src/client/ComputerStrip.vue +55 -0
- package/src/client/ComputerViewerOverlay.vue +252 -0
- package/src/client/application.test.ts +373 -0
- package/src/client/application.ts +340 -0
- package/src/client/cordis-client-shim.d.ts +16 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.ts +28 -0
- package/src/client/state-machine.test.ts +200 -0
- package/src/client/state-machine.ts +172 -0
- package/src/client/styles.css +594 -0
- package/src/client/viewer.ts +58 -0
- package/src/control-record.ts +57 -0
- package/src/doctor.test.ts +247 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +3 -0
- package/src/process-records.test.ts +178 -0
- package/src/process-records.ts +278 -0
- package/src/process-store.ts +96 -0
- package/src/processes.test.ts +388 -0
- package/src/protocol.ts +405 -0
- package/src/roots.ts +6 -0
- package/src/screenshot.test.ts +253 -0
- package/src/shared-provider.test.ts +56 -0
- package/src/shared-provider.ts +121 -0
- package/src/shared.ts +54 -0
- package/src/sync.test.ts +255 -0
- package/src/workspace-fixture.ts +126 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +24 -0
- package/README.md +0 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parse } from "@vue/compiler-sfc";
|
|
4
|
+
import type { ComputerState } from "../shared.js";
|
|
5
|
+
import {
|
|
6
|
+
createComputerViewerActions,
|
|
7
|
+
viewerUrlForControlV1,
|
|
8
|
+
} from "./viewer.js";
|
|
9
|
+
|
|
10
|
+
const overlaySource = readFileSync(
|
|
11
|
+
new URL("./ComputerViewerOverlay.vue", import.meta.url),
|
|
12
|
+
"utf8",
|
|
13
|
+
);
|
|
14
|
+
const cardSource = readFileSync(
|
|
15
|
+
new URL("./ComputerCard.vue", import.meta.url),
|
|
16
|
+
"utf8",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("Computer viewer", () => {
|
|
20
|
+
test("keeps one noVNC session view-only until human control is held", () => {
|
|
21
|
+
const minted =
|
|
22
|
+
"https://sprite.invalid/vnc.html#autoconnect=1&view_only=1&path=websockify%3Ftoken%3Dsecret";
|
|
23
|
+
const viewOnly = new URL(viewerUrlForControlV1(minted, false));
|
|
24
|
+
const interactive = new URL(viewerUrlForControlV1(minted, true));
|
|
25
|
+
|
|
26
|
+
expect(new URLSearchParams(viewOnly.hash.slice(1)).get("view_only")).toBe(
|
|
27
|
+
"1",
|
|
28
|
+
);
|
|
29
|
+
expect(
|
|
30
|
+
new URLSearchParams(interactive.hash.slice(1)).get("view_only"),
|
|
31
|
+
).toBe("0");
|
|
32
|
+
expect(interactive.origin + interactive.pathname).toBe(
|
|
33
|
+
viewOnly.origin + viewOnly.pathname,
|
|
34
|
+
);
|
|
35
|
+
expect(new URLSearchParams(interactive.hash.slice(1)).get("path")).toBe(
|
|
36
|
+
"websockify?token=secret",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("requires confirmation before taking control and Escape closes through shared state", async () => {
|
|
41
|
+
let confirmations = false;
|
|
42
|
+
let takeControl = 0;
|
|
43
|
+
let closeViewer = 0;
|
|
44
|
+
const state = {
|
|
45
|
+
takeControl: () => {
|
|
46
|
+
takeControl += 1;
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
},
|
|
49
|
+
closeViewer: () => {
|
|
50
|
+
closeViewer += 1;
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
},
|
|
53
|
+
} as ComputerState;
|
|
54
|
+
const actions = createComputerViewerActions(
|
|
55
|
+
() => state,
|
|
56
|
+
(open) => {
|
|
57
|
+
confirmations = open;
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
actions.requestTakeControl();
|
|
62
|
+
expect(confirmations).toBe(true);
|
|
63
|
+
expect(takeControl).toBe(0);
|
|
64
|
+
await actions.confirmTakeControl();
|
|
65
|
+
expect(confirmations).toBe(false);
|
|
66
|
+
expect(takeControl).toBe(1);
|
|
67
|
+
|
|
68
|
+
await actions.escape();
|
|
69
|
+
expect(closeViewer).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the Vue overlay binds the computed viewer src and confirm dialog", () => {
|
|
73
|
+
const parsed = parse(overlaySource, {
|
|
74
|
+
filename: "ComputerViewerOverlay.vue",
|
|
75
|
+
});
|
|
76
|
+
expect(parsed.errors).toEqual([]);
|
|
77
|
+
const template = parsed.descriptor.template?.content ?? "";
|
|
78
|
+
|
|
79
|
+
expect(template).toContain(':src="viewerSrc"');
|
|
80
|
+
expect(template).toContain('@click="actions.requestTakeControl"');
|
|
81
|
+
expect(template).toContain('role="alertdialog"');
|
|
82
|
+
expect(template).toContain(
|
|
83
|
+
"The Bot will be fenced from this desktop until you release control.",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the card shows the update phase label", () => {
|
|
88
|
+
const parsed = parse(cardSource, { filename: "ComputerCard.vue" });
|
|
89
|
+
expect(parsed.errors).toEqual([]);
|
|
90
|
+
const template = parsed.descriptor.template?.content ?? "";
|
|
91
|
+
|
|
92
|
+
expect(template).toContain("state.phase === 'updating'");
|
|
93
|
+
expect(template).toContain("Updating computer…");
|
|
94
|
+
expect(template).toContain("{{ state.message }}");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRpc } from "@cordisjs/client";
|
|
3
|
+
import { UiIcon } from "@frockbot/client-ui";
|
|
4
|
+
import { computed, inject, ref } from "vue";
|
|
5
|
+
import { computerKey, type ComputerState } from "../shared.ts";
|
|
6
|
+
|
|
7
|
+
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
8
|
+
const state = computed(() => computer.value);
|
|
9
|
+
const busy = ref(false);
|
|
10
|
+
const screenshot = computed(() => state.value.screenshots?.[0]);
|
|
11
|
+
|
|
12
|
+
async function open(): Promise<void> {
|
|
13
|
+
if (busy.value) return;
|
|
14
|
+
busy.value = true;
|
|
15
|
+
try {
|
|
16
|
+
await state.value.openViewer();
|
|
17
|
+
} catch {
|
|
18
|
+
// The shared state already holds the visible failure.
|
|
19
|
+
} finally {
|
|
20
|
+
busy.value = false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<section class="computer-card">
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="computer-screen computer-screen-thumbnail"
|
|
30
|
+
:disabled="busy"
|
|
31
|
+
aria-label="Open computer in full window"
|
|
32
|
+
@click="open"
|
|
33
|
+
>
|
|
34
|
+
<img
|
|
35
|
+
v-if="screenshot"
|
|
36
|
+
:key="screenshot.contentHash"
|
|
37
|
+
:src="screenshot.url"
|
|
38
|
+
alt=""
|
|
39
|
+
draggable="false"
|
|
40
|
+
/>
|
|
41
|
+
<span v-else class="computer-placeholder">
|
|
42
|
+
<UiIcon name="sparkle" size="lg" />
|
|
43
|
+
<strong v-if="state.phase === 'unconfigured'"
|
|
44
|
+
>Computer not configured</strong
|
|
45
|
+
>
|
|
46
|
+
<strong v-else-if="state.phase === 'provisioning'"
|
|
47
|
+
>Preparing computer…</strong
|
|
48
|
+
>
|
|
49
|
+
<strong v-else-if="state.phase === 'updating'"
|
|
50
|
+
>Updating computer…</strong
|
|
51
|
+
>
|
|
52
|
+
<strong v-else-if="state.phase === 'disconnected'"
|
|
53
|
+
>Viewer disconnected</strong
|
|
54
|
+
>
|
|
55
|
+
<strong v-else>Persistent Computer</strong>
|
|
56
|
+
<span class="computer-placeholder-message">{{ state.message }}</span>
|
|
57
|
+
</span>
|
|
58
|
+
</button>
|
|
59
|
+
</section>
|
|
60
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parse } from "@vue/compiler-sfc";
|
|
4
|
+
import {
|
|
5
|
+
initialComputerMachineState,
|
|
6
|
+
transitionComputerState,
|
|
7
|
+
} from "./state-machine.js";
|
|
8
|
+
|
|
9
|
+
test("the Vue strip keys its durable capture only by contentHash", () => {
|
|
10
|
+
const source = readFileSync(
|
|
11
|
+
new URL("./ComputerStrip.vue", import.meta.url),
|
|
12
|
+
"utf8",
|
|
13
|
+
);
|
|
14
|
+
const parsed = parse(source, { filename: "ComputerStrip.vue" });
|
|
15
|
+
expect(parsed.errors).toEqual([]);
|
|
16
|
+
const template = parsed.descriptor.template?.content ?? "";
|
|
17
|
+
|
|
18
|
+
expect(template).toContain(':key="screenshot.contentHash"');
|
|
19
|
+
expect(template).toContain(':src="screenshot.url"');
|
|
20
|
+
expect(template).not.toContain("viewerUrl");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a repeated screenshot projection preserves the rendered capture", () => {
|
|
24
|
+
const first = {
|
|
25
|
+
version: 1 as const,
|
|
26
|
+
path: "scout/latest.png",
|
|
27
|
+
capturedAt: "2026-09-02T00:00:00.000Z",
|
|
28
|
+
contentHash: "sha256:first",
|
|
29
|
+
url: "/workspace/first",
|
|
30
|
+
};
|
|
31
|
+
const state = {
|
|
32
|
+
...initialComputerMachineState(),
|
|
33
|
+
screenshots: [first],
|
|
34
|
+
};
|
|
35
|
+
const project = (contentHash: string) => ({
|
|
36
|
+
type: "projection-received" as const,
|
|
37
|
+
projection: {
|
|
38
|
+
version: 1 as const,
|
|
39
|
+
botId: "scout",
|
|
40
|
+
providerLabel: "Fake Computer",
|
|
41
|
+
phase: "idle" as const,
|
|
42
|
+
message: "Computer available",
|
|
43
|
+
screenshots: [
|
|
44
|
+
{ ...first, contentHash, url: `/workspace/${contentHash}` },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const unchanged = transitionComputerState(state, project("sha256:first"));
|
|
50
|
+
const changed = transitionComputerState(unchanged, project("sha256:second"));
|
|
51
|
+
|
|
52
|
+
expect(unchanged.screenshots[0]).toBe(first);
|
|
53
|
+
expect(changed.screenshots[0]).not.toBe(first);
|
|
54
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRpc } from "@cordisjs/client";
|
|
3
|
+
import { UiIcon } from "@frockbot/client-ui";
|
|
4
|
+
import { computed, inject, ref } from "vue";
|
|
5
|
+
import { computerKey, type ComputerState } from "../shared.ts";
|
|
6
|
+
|
|
7
|
+
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
8
|
+
const busy = ref(false);
|
|
9
|
+
const state = computed(() => computer.value);
|
|
10
|
+
const screenshot = computed(() => state.value.screenshots?.[0]);
|
|
11
|
+
const phaseLabel = computed(() =>
|
|
12
|
+
state.value.phase === "updating"
|
|
13
|
+
? state.value.message
|
|
14
|
+
: state.value.phase.replaceAll("-", " "),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
async function open(): Promise<void> {
|
|
18
|
+
if (busy.value) return;
|
|
19
|
+
busy.value = true;
|
|
20
|
+
try {
|
|
21
|
+
await state.value.openViewer();
|
|
22
|
+
} catch {
|
|
23
|
+
// The shared state already holds the visible failure.
|
|
24
|
+
} finally {
|
|
25
|
+
busy.value = false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class="computer-strip"
|
|
34
|
+
:disabled="busy"
|
|
35
|
+
:aria-label="`Open Computer, ${phaseLabel}`"
|
|
36
|
+
@click="open"
|
|
37
|
+
>
|
|
38
|
+
<span class="computer-strip-capture">
|
|
39
|
+
<img
|
|
40
|
+
v-if="screenshot"
|
|
41
|
+
:key="screenshot.contentHash"
|
|
42
|
+
:src="screenshot.url"
|
|
43
|
+
alt=""
|
|
44
|
+
draggable="false"
|
|
45
|
+
/>
|
|
46
|
+
<span v-else class="computer-strip-placeholder" aria-hidden="true">
|
|
47
|
+
<UiIcon name="sparkle" size="sm" />
|
|
48
|
+
</span>
|
|
49
|
+
</span>
|
|
50
|
+
<span class="computer-strip-phase">
|
|
51
|
+
<span class="computer-strip-dot" :class="`phase-${state.phase}`" />
|
|
52
|
+
<span>{{ phaseLabel }}</span>
|
|
53
|
+
</span>
|
|
54
|
+
</button>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRpc } from "@cordisjs/client";
|
|
3
|
+
import { UiButton, UiIconButton } from "@frockbot/client-ui";
|
|
4
|
+
import {
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
nextTick,
|
|
8
|
+
onBeforeUnmount,
|
|
9
|
+
onMounted,
|
|
10
|
+
ref,
|
|
11
|
+
watch,
|
|
12
|
+
} from "vue";
|
|
13
|
+
import { computerKey, type ComputerState } from "../shared.ts";
|
|
14
|
+
import { dialogFocusWrapTarget } from "./dialog-focus.ts";
|
|
15
|
+
import {
|
|
16
|
+
createComputerViewerActions,
|
|
17
|
+
viewerUrlForControlV1,
|
|
18
|
+
} from "./viewer.ts";
|
|
19
|
+
|
|
20
|
+
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
21
|
+
const state = computed(() => computer.value);
|
|
22
|
+
const busy = ref(false);
|
|
23
|
+
const confirming = ref(false);
|
|
24
|
+
const confirmDialog = ref<HTMLElement>();
|
|
25
|
+
let restoreFocus: HTMLElement | undefined;
|
|
26
|
+
const focusable = 'button:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
27
|
+
const actions = createComputerViewerActions(
|
|
28
|
+
() => state.value,
|
|
29
|
+
(open) => {
|
|
30
|
+
confirming.value = open;
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
const hasViewer = computed(
|
|
34
|
+
() =>
|
|
35
|
+
Boolean(state.value.viewerUrl) &&
|
|
36
|
+
state.value.phase !== "provisioning" &&
|
|
37
|
+
state.value.phase !== "updating",
|
|
38
|
+
);
|
|
39
|
+
const isHuman = computed(() => state.value.takingControl);
|
|
40
|
+
const viewerSrc = computed(() =>
|
|
41
|
+
hasViewer.value && state.value.viewerUrl
|
|
42
|
+
? viewerUrlForControlV1(state.value.viewerUrl, isHuman.value)
|
|
43
|
+
: undefined,
|
|
44
|
+
);
|
|
45
|
+
const statusLabel = computed(() => {
|
|
46
|
+
if (isHuman.value) return "Your control";
|
|
47
|
+
if (state.value.phase === "ready") return "View only";
|
|
48
|
+
if (state.value.phase === "updating") return state.value.message;
|
|
49
|
+
return state.value.phase.replaceAll("-", " ");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function invoke(action: () => Promise<void>): Promise<void> {
|
|
53
|
+
if (busy.value) return;
|
|
54
|
+
busy.value = true;
|
|
55
|
+
try {
|
|
56
|
+
await action();
|
|
57
|
+
} catch {
|
|
58
|
+
// The shared state already projects the command's visible failure.
|
|
59
|
+
} finally {
|
|
60
|
+
busy.value = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function closeViewer(escape = false): Promise<void> {
|
|
65
|
+
try {
|
|
66
|
+
await (escape ? actions.escape() : actions.closeViewer());
|
|
67
|
+
} catch {
|
|
68
|
+
// A failed release remains visible in the still-open overlay.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handleWindowKeydown(event: KeyboardEvent): void {
|
|
73
|
+
if (event.key !== "Escape" || !state.value.expanded) return;
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
void closeViewer(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleConfirmKeydown(event: KeyboardEvent): void {
|
|
79
|
+
if (event.key !== "Tab" || !confirmDialog.value) return;
|
|
80
|
+
const controls = [
|
|
81
|
+
...confirmDialog.value.querySelectorAll<HTMLElement>(focusable),
|
|
82
|
+
];
|
|
83
|
+
const target = dialogFocusWrapTarget(
|
|
84
|
+
controls,
|
|
85
|
+
document.activeElement as HTMLElement | null,
|
|
86
|
+
event.shiftKey,
|
|
87
|
+
);
|
|
88
|
+
if (!target) return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
target.focus();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
watch(
|
|
94
|
+
confirming,
|
|
95
|
+
async (open, previous) => {
|
|
96
|
+
if (open && !previous) {
|
|
97
|
+
restoreFocus =
|
|
98
|
+
document.activeElement instanceof HTMLElement
|
|
99
|
+
? document.activeElement
|
|
100
|
+
: undefined;
|
|
101
|
+
await nextTick();
|
|
102
|
+
confirmDialog.value
|
|
103
|
+
?.querySelector<HTMLElement>("[autofocus], " + focusable)
|
|
104
|
+
?.focus();
|
|
105
|
+
} else if (!open && previous) {
|
|
106
|
+
restoreFocus?.focus();
|
|
107
|
+
restoreFocus = undefined;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{ flush: "post" },
|
|
111
|
+
);
|
|
112
|
+
watch(
|
|
113
|
+
() => state.value.expanded,
|
|
114
|
+
(expanded) => {
|
|
115
|
+
if (!expanded) confirming.value = false;
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
onMounted(() => window.addEventListener("keydown", handleWindowKeydown));
|
|
120
|
+
onBeforeUnmount(() => {
|
|
121
|
+
window.removeEventListener("keydown", handleWindowKeydown);
|
|
122
|
+
restoreFocus?.focus();
|
|
123
|
+
});
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<div
|
|
128
|
+
v-if="state.expanded"
|
|
129
|
+
class="computer-overlay"
|
|
130
|
+
role="dialog"
|
|
131
|
+
aria-modal="true"
|
|
132
|
+
aria-label="Computer"
|
|
133
|
+
>
|
|
134
|
+
<header class="computer-overlay-toolbar">
|
|
135
|
+
<div class="computer-overlay-identity">
|
|
136
|
+
<strong>Computer</strong>
|
|
137
|
+
<small>{{ state.botId }} · {{ state.providerLabel }}</small>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="computer-overlay-actions">
|
|
140
|
+
<span class="computer-status" :class="`status-${state.phase}`">
|
|
141
|
+
{{ statusLabel }}
|
|
142
|
+
</span>
|
|
143
|
+
<UiButton
|
|
144
|
+
v-if="isHuman"
|
|
145
|
+
variant="primary"
|
|
146
|
+
:disabled="busy"
|
|
147
|
+
@click="invoke(state.releaseControl)"
|
|
148
|
+
>
|
|
149
|
+
Release control
|
|
150
|
+
</UiButton>
|
|
151
|
+
<UiButton
|
|
152
|
+
v-else-if="state.phase === 'disconnected'"
|
|
153
|
+
:disabled="busy"
|
|
154
|
+
@click="invoke(state.connect)"
|
|
155
|
+
>
|
|
156
|
+
Reconnect
|
|
157
|
+
</UiButton>
|
|
158
|
+
<UiButton
|
|
159
|
+
v-else-if="hasViewer"
|
|
160
|
+
:disabled="busy || state.phase === 'taking-control'"
|
|
161
|
+
@click="actions.requestTakeControl"
|
|
162
|
+
>
|
|
163
|
+
{{
|
|
164
|
+
state.phase === "taking-control" ? "Pausing Bot…" : "Take control"
|
|
165
|
+
}}
|
|
166
|
+
</UiButton>
|
|
167
|
+
<UiIconButton
|
|
168
|
+
icon="close"
|
|
169
|
+
label="Close full-window computer (Esc)"
|
|
170
|
+
variant="outlined"
|
|
171
|
+
shape="square"
|
|
172
|
+
@click="closeViewer()"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
</header>
|
|
176
|
+
|
|
177
|
+
<main class="computer-overlay-stage" :class="{ 'human-control': isHuman }">
|
|
178
|
+
<div class="computer-screen computer-screen-expanded">
|
|
179
|
+
<iframe
|
|
180
|
+
v-if="viewerSrc"
|
|
181
|
+
:src="viewerSrc"
|
|
182
|
+
title="Computer"
|
|
183
|
+
sandbox="allow-forms allow-pointer-lock allow-same-origin allow-scripts"
|
|
184
|
+
referrerpolicy="no-referrer"
|
|
185
|
+
/>
|
|
186
|
+
<div v-else class="computer-placeholder">
|
|
187
|
+
<strong v-if="state.phase === 'unconfigured'"
|
|
188
|
+
>Computer not configured</strong
|
|
189
|
+
>
|
|
190
|
+
<strong v-else-if="state.phase === 'provisioning'"
|
|
191
|
+
>Preparing computer…</strong
|
|
192
|
+
>
|
|
193
|
+
<strong v-else-if="state.phase === 'updating'"
|
|
194
|
+
>Updating computer…</strong
|
|
195
|
+
>
|
|
196
|
+
<strong v-else-if="state.phase === 'disconnected'"
|
|
197
|
+
>Viewer disconnected</strong
|
|
198
|
+
>
|
|
199
|
+
<strong v-else>Persistent Computer</strong>
|
|
200
|
+
<p>{{ state.message }}</p>
|
|
201
|
+
<UiButton
|
|
202
|
+
v-if="state.phase === 'idle' || state.phase === 'disconnected'"
|
|
203
|
+
:disabled="busy"
|
|
204
|
+
@click="invoke(state.connect)"
|
|
205
|
+
>
|
|
206
|
+
{{
|
|
207
|
+
state.phase === "disconnected" ? "Reconnect" : "Start computer"
|
|
208
|
+
}}
|
|
209
|
+
</UiButton>
|
|
210
|
+
<UiButton
|
|
211
|
+
v-else-if="state.phase === 'error'"
|
|
212
|
+
:disabled="busy"
|
|
213
|
+
@click="invoke(state.retry)"
|
|
214
|
+
>
|
|
215
|
+
Try again
|
|
216
|
+
</UiButton>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</main>
|
|
220
|
+
|
|
221
|
+
<div
|
|
222
|
+
v-if="confirming"
|
|
223
|
+
class="computer-confirm-backdrop"
|
|
224
|
+
@click.self="actions.cancelTakeControl"
|
|
225
|
+
>
|
|
226
|
+
<section
|
|
227
|
+
ref="confirmDialog"
|
|
228
|
+
class="computer-confirm-dialog"
|
|
229
|
+
role="alertdialog"
|
|
230
|
+
aria-modal="true"
|
|
231
|
+
aria-labelledby="computer-confirm-title"
|
|
232
|
+
aria-describedby="computer-confirm-detail"
|
|
233
|
+
@keydown="handleConfirmKeydown"
|
|
234
|
+
>
|
|
235
|
+
<h2 id="computer-confirm-title">Take control of this Computer?</h2>
|
|
236
|
+
<p id="computer-confirm-detail">
|
|
237
|
+
The Bot will be fenced from this desktop until you release control.
|
|
238
|
+
</p>
|
|
239
|
+
<div class="computer-confirm-actions">
|
|
240
|
+
<UiButton @click="actions.cancelTakeControl">Cancel</UiButton>
|
|
241
|
+
<UiButton
|
|
242
|
+
autofocus
|
|
243
|
+
variant="primary"
|
|
244
|
+
@click="invoke(actions.confirmTakeControl)"
|
|
245
|
+
>
|
|
246
|
+
Take control
|
|
247
|
+
</UiButton>
|
|
248
|
+
</div>
|
|
249
|
+
</section>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</template>
|