@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.244.1
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/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1254 -0
- package/client/browser-input-queue.ts +519 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-currency.ts +45 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +375 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +1060 -0
- package/client/use-browser-video.ts +481 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +340 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/tab-close-confirmation.test.ts +97 -0
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +83 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
package/client/browser.tsx
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace browser panel orchestration: viewer/control ownership, current
|
|
3
|
+
* decoded receipts, media handoff and menu actions. Native/DOM forwarding and
|
|
4
|
+
* presentation models live in focused counterpart hooks; this module never
|
|
5
|
+
* replays published mutations or substitutes visible pixels for input authority.
|
|
6
|
+
*/
|
|
7
|
+
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
8
|
+
import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
|
|
9
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
1
10
|
import {
|
|
2
|
-
type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
type GestureResponderEvent,
|
|
12
|
-
Image,
|
|
13
|
-
type LayoutChangeEvent,
|
|
14
|
-
PanResponder,
|
|
15
|
-
Pressable,
|
|
16
|
-
type StyleProp,
|
|
17
|
-
StyleSheet,
|
|
18
|
-
Text,
|
|
19
|
-
type TextInputProps,
|
|
20
|
-
type TextStyle,
|
|
21
|
-
View,
|
|
22
|
-
type ViewStyle,
|
|
23
|
-
} from "react-native";
|
|
11
|
+
type ReactNode,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { type LayoutChangeEvent, Text, useWindowDimensions, View } from "react-native";
|
|
24
20
|
import {
|
|
25
21
|
acquireControlRpc,
|
|
26
22
|
applyDevicePresetRpc,
|
|
@@ -28,63 +24,89 @@ import {
|
|
|
28
24
|
type BrowserFrame,
|
|
29
25
|
type BrowserInputEvent,
|
|
30
26
|
type BrowserState,
|
|
27
|
+
beginBrowserGestureRpc,
|
|
31
28
|
captureBrowserRpc,
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
closeBrowserRpc,
|
|
30
|
+
closeBrowserTabRpc,
|
|
31
|
+
createBrowserTabRpc,
|
|
34
32
|
DEVICE_PRESETS,
|
|
35
33
|
type DevicePresetId,
|
|
36
34
|
detachBrowserRpc,
|
|
37
35
|
didBrowserRuntimeRestart,
|
|
36
|
+
endBrowserGestureRpc,
|
|
38
37
|
isBrowserStateCurrent,
|
|
39
|
-
|
|
38
|
+
listBrowserTabsRpc,
|
|
39
|
+
MAX_BROWSER_TABS,
|
|
40
40
|
MAX_VIEWPORT,
|
|
41
41
|
MIN_VIEWPORT,
|
|
42
42
|
navigateBrowserRpc,
|
|
43
43
|
releaseControlRpc,
|
|
44
|
+
reopenBrowserRpc,
|
|
44
45
|
resizeBrowserRpc,
|
|
45
46
|
sendBrowserInputRpc,
|
|
46
|
-
|
|
47
|
+
setCaptureDensityRpc,
|
|
48
|
+
updateBrowserGestureRpc,
|
|
47
49
|
} from "../shared/browser";
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
50
|
+
import type { BrowserFrameAuthority } from "../shared/browser-video";
|
|
51
|
+
import { groupResolutionPresets } from "../shared/resolution-menu";
|
|
52
|
+
import {
|
|
53
|
+
type CaptureDensityMode,
|
|
54
|
+
getAutomaticCaptureDensity,
|
|
55
|
+
} from "./browser-auto-capture-density";
|
|
56
|
+
import { type BrowserCanvasDisplayMode, getBrowserCanvasLayout } from "./browser-canvas-layout";
|
|
57
|
+
import { BrowserCanvasViewport } from "./browser-canvas-viewport";
|
|
58
|
+
import {
|
|
59
|
+
CanvasPlaceholder,
|
|
60
|
+
ChromeIconButton,
|
|
61
|
+
ControlButton,
|
|
62
|
+
ErrorNotice,
|
|
63
|
+
Field,
|
|
64
|
+
} from "./browser-chrome";
|
|
65
|
+
import { ComposeTextControls } from "./browser-compose-controls";
|
|
66
|
+
import { BrowserControlDialog } from "./browser-control-dialog";
|
|
67
|
+
import {
|
|
68
|
+
type ControlRequest,
|
|
69
|
+
type ControlRequestBasis,
|
|
70
|
+
controlRequestBasis,
|
|
71
|
+
isControlRequestCurrent,
|
|
72
|
+
} from "./browser-control-request";
|
|
73
|
+
import { BrowserDialogBody } from "./browser-dialog-body";
|
|
74
|
+
import { type EmulationSelection, matchingResolutionPresetId } from "./browser-emulation-mode";
|
|
75
|
+
import { getFillViewportResolution } from "./browser-fill-viewport";
|
|
76
|
+
import type { FrameCandidate } from "./browser-frame-buffer";
|
|
77
|
+
import { BrowserFrameImage } from "./browser-frame-image";
|
|
78
|
+
import { createStyles, DIMENSION, SPACE } from "./browser-panel-styles";
|
|
79
|
+
import { BrowserResolutionPicker } from "./browser-resolution-picker";
|
|
80
|
+
import { BrowserTabs } from "./browser-tabs";
|
|
81
|
+
import {
|
|
82
|
+
BrowserMenuHeading,
|
|
83
|
+
BrowserMenuItem,
|
|
84
|
+
BrowserMenuSeparator,
|
|
85
|
+
BrowserToolbarMenu,
|
|
86
|
+
} from "./browser-toolbar-menu";
|
|
87
|
+
import { isFrameCurrent, isVideoSourceCurrent } from "./browser-video-currency";
|
|
88
|
+
import { BrowserVideoSurface } from "./browser-video-surface";
|
|
89
|
+
import { isBrowserClosedError, isExpiredBrowserViewerError } from "./browser-viewer-recovery";
|
|
90
|
+
import { createFrameLifecycle } from "./frame-lifecycle";
|
|
91
|
+
import { useBrowserAutoCaptureDensity } from "./use-browser-auto-capture-density";
|
|
92
|
+
import { useBrowserCanvasInput } from "./use-browser-canvas-input";
|
|
93
|
+
import { useBrowserCaptureDensity } from "./use-browser-capture-density";
|
|
94
|
+
import { useBrowserEmulationMode } from "./use-browser-emulation-mode";
|
|
95
|
+
import { useBrowserFillViewport } from "./use-browser-fill-viewport";
|
|
96
|
+
import { useBrowserFrameBuffer } from "./use-browser-frame-buffer";
|
|
97
|
+
import { useBrowserImageCapture } from "./use-browser-image-capture";
|
|
98
|
+
import { useBrowserScopedMutation } from "./use-browser-scoped-mutation";
|
|
99
|
+
import { useBrowserVideo } from "./use-browser-video";
|
|
100
|
+
import { useBrowserViewerRecovery } from "./use-browser-viewer-recovery";
|
|
101
|
+
import { useResolutionFavorites } from "./use-resolution-favorites";
|
|
102
|
+
|
|
103
|
+
// Preserve the existing contribution entry/test import while ownership lives in a focused module.
|
|
104
|
+
export { contributeSharedBrowserClient } from "./browser-client-presence";
|
|
105
|
+
|
|
77
106
|
const MAX_VIEWER_LABEL_LENGTH = 64;
|
|
78
|
-
const PILL_PRESENCE_POLL_MS = 2_000;
|
|
79
|
-
const AGENT_DIRECTORY_PAGE_LIMIT = 200;
|
|
80
107
|
const MAX_URL_LENGTH = 8_192;
|
|
81
|
-
const MAX_TEXT_LENGTH = 4_000;
|
|
82
108
|
const BYTES_PER_KIBIBYTE = 1_024;
|
|
83
|
-
const MAX_SCROLL_DELTA = 4_000;
|
|
84
109
|
|
|
85
|
-
type Theme = PluginWorkspacePanelProps["theme"];
|
|
86
|
-
type InteractionMode = "click" | "double" | "right";
|
|
87
|
-
type SwipeMode = "scroll" | "drag";
|
|
88
110
|
type SpecialKey = Extract<BrowserInputEvent, { kind: "key" }>["key"];
|
|
89
111
|
|
|
90
112
|
interface Size {
|
|
@@ -92,9 +114,19 @@ interface Size {
|
|
|
92
114
|
height: number;
|
|
93
115
|
}
|
|
94
116
|
|
|
95
|
-
interface
|
|
96
|
-
|
|
97
|
-
|
|
117
|
+
interface ControlledActions {
|
|
118
|
+
navigate(action: "back" | "forward" | "reload" | "goto", url?: string): void;
|
|
119
|
+
applyViewport(): void;
|
|
120
|
+
fillViewport(): void;
|
|
121
|
+
selectDevicePreset(id: DevicePresetId): void;
|
|
122
|
+
changeDensity(value: CaptureDensityMode): void;
|
|
123
|
+
toggleEmulation(): void;
|
|
124
|
+
sendKey(key: SpecialKey): void;
|
|
125
|
+
openCompose(kind: "compose" | "commit"): void;
|
|
126
|
+
closeTab(): void;
|
|
127
|
+
closeBrowser(): void;
|
|
128
|
+
confirmCloseTab(): void;
|
|
129
|
+
confirmCloseBrowser(): void;
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
const SPECIAL_KEYS: readonly { key: SpecialKey; label: string }[] = [
|
|
@@ -137,840 +169,29 @@ function hasUnknownMutationOutcome(error: unknown): boolean {
|
|
|
137
169
|
return dispatched && /(transport|connection|timeout)/.test(code || name.toLowerCase());
|
|
138
170
|
}
|
|
139
171
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function createStyles(theme: Theme, compact: boolean) {
|
|
154
|
-
return StyleSheet.create({
|
|
155
|
-
screen: {
|
|
156
|
-
flex: 1,
|
|
157
|
-
minHeight: 0,
|
|
158
|
-
backgroundColor: theme.colors.surface0,
|
|
159
|
-
},
|
|
160
|
-
statusRow: {
|
|
161
|
-
minHeight: 30,
|
|
162
|
-
paddingHorizontal: SPACE.sm,
|
|
163
|
-
paddingVertical: SPACE.xxs,
|
|
164
|
-
flexDirection: "row",
|
|
165
|
-
alignItems: "center",
|
|
166
|
-
justifyContent: "space-between",
|
|
167
|
-
gap: SPACE.sm,
|
|
168
|
-
borderBottomWidth: 1,
|
|
169
|
-
borderBottomColor: theme.colors.border,
|
|
170
|
-
backgroundColor: theme.colors.surface0,
|
|
171
|
-
},
|
|
172
|
-
statusSummary: {
|
|
173
|
-
minWidth: 0,
|
|
174
|
-
flex: 1,
|
|
175
|
-
flexDirection: "row",
|
|
176
|
-
alignItems: "center",
|
|
177
|
-
flexWrap: "wrap",
|
|
178
|
-
gap: SPACE.sm,
|
|
179
|
-
},
|
|
180
|
-
statusDot: {
|
|
181
|
-
width: SPACE.sm,
|
|
182
|
-
height: SPACE.sm,
|
|
183
|
-
borderRadius: RADIUS.sm,
|
|
184
|
-
},
|
|
185
|
-
statusText: {
|
|
186
|
-
color: theme.colors.foreground,
|
|
187
|
-
fontSize: TYPE.body,
|
|
188
|
-
fontWeight: "600",
|
|
189
|
-
},
|
|
190
|
-
mutedText: {
|
|
191
|
-
color: theme.colors.foregroundMuted,
|
|
192
|
-
fontSize: TYPE.caption,
|
|
193
|
-
},
|
|
194
|
-
controllerText: {
|
|
195
|
-
color: theme.colors.foregroundMuted,
|
|
196
|
-
fontSize: TYPE.caption,
|
|
197
|
-
flexShrink: 1,
|
|
198
|
-
},
|
|
199
|
-
actionRow: {
|
|
200
|
-
flexDirection: "row",
|
|
201
|
-
alignItems: "center",
|
|
202
|
-
gap: SPACE.xs,
|
|
203
|
-
},
|
|
204
|
-
chrome: {
|
|
205
|
-
minHeight: 40,
|
|
206
|
-
paddingHorizontal: SPACE.sm,
|
|
207
|
-
paddingVertical: SPACE.xs,
|
|
208
|
-
borderBottomWidth: 1,
|
|
209
|
-
borderBottomColor: theme.colors.border,
|
|
210
|
-
backgroundColor: theme.colors.surface0,
|
|
211
|
-
},
|
|
212
|
-
addressRow: {
|
|
213
|
-
flexDirection: "row",
|
|
214
|
-
alignItems: "center",
|
|
215
|
-
gap: SPACE.xs,
|
|
216
|
-
},
|
|
217
|
-
addressInput: {
|
|
218
|
-
flex: 1,
|
|
219
|
-
minWidth: compact ? DIMENSION.addressCompact : DIMENSION.addressRegular,
|
|
220
|
-
},
|
|
221
|
-
chromeAddressInput: {
|
|
222
|
-
height: 28,
|
|
223
|
-
borderRadius: RADIUS.md,
|
|
224
|
-
backgroundColor: theme.colors.surface1,
|
|
225
|
-
},
|
|
226
|
-
chromeIconButton: {
|
|
227
|
-
width: 28,
|
|
228
|
-
height: 28,
|
|
229
|
-
borderRadius: RADIUS.md,
|
|
230
|
-
alignItems: "center",
|
|
231
|
-
justifyContent: "center",
|
|
232
|
-
},
|
|
233
|
-
chromeIconButtonHovered: {
|
|
234
|
-
backgroundColor: theme.colors.surface2,
|
|
235
|
-
},
|
|
236
|
-
chromeIconButtonPressed: {
|
|
237
|
-
opacity: 0.72,
|
|
238
|
-
},
|
|
239
|
-
chromeIconButtonDisabled: {
|
|
240
|
-
opacity: 0.45,
|
|
241
|
-
},
|
|
242
|
-
toolbarContent: {
|
|
243
|
-
flexDirection: "row",
|
|
244
|
-
alignItems: "center",
|
|
245
|
-
gap: SPACE.xs,
|
|
246
|
-
},
|
|
247
|
-
button: {
|
|
248
|
-
minHeight: DIMENSION.control,
|
|
249
|
-
minWidth: DIMENSION.control,
|
|
250
|
-
paddingHorizontal: SPACE.sm,
|
|
251
|
-
borderWidth: 1,
|
|
252
|
-
borderColor: theme.colors.border,
|
|
253
|
-
borderRadius: RADIUS.sm,
|
|
254
|
-
backgroundColor: theme.colors.surface2,
|
|
255
|
-
flexDirection: "row",
|
|
256
|
-
alignItems: "center",
|
|
257
|
-
justifyContent: "center",
|
|
258
|
-
gap: SPACE.xs,
|
|
259
|
-
},
|
|
260
|
-
buttonSelected: {
|
|
261
|
-
borderColor: theme.colors.accent,
|
|
262
|
-
backgroundColor: theme.colors.accent,
|
|
263
|
-
},
|
|
264
|
-
buttonPrimary: {
|
|
265
|
-
borderColor: theme.colors.accent,
|
|
266
|
-
backgroundColor: theme.colors.accent,
|
|
267
|
-
},
|
|
268
|
-
buttonDanger: {
|
|
269
|
-
borderColor: theme.colors.statusWarning,
|
|
270
|
-
},
|
|
271
|
-
buttonHovered: {
|
|
272
|
-
borderColor: theme.colors.foregroundMuted,
|
|
273
|
-
},
|
|
274
|
-
buttonPressed: {
|
|
275
|
-
opacity: 0.72,
|
|
276
|
-
},
|
|
277
|
-
buttonFocused: {
|
|
278
|
-
borderColor: theme.colors.accent,
|
|
279
|
-
borderWidth: 2,
|
|
280
|
-
},
|
|
281
|
-
buttonDisabled: {
|
|
282
|
-
opacity: 0.42,
|
|
283
|
-
},
|
|
284
|
-
buttonText: {
|
|
285
|
-
color: theme.colors.foreground,
|
|
286
|
-
fontSize: TYPE.caption,
|
|
287
|
-
fontWeight: "600",
|
|
288
|
-
},
|
|
289
|
-
buttonTextSelected: {
|
|
290
|
-
color: theme.colors.accentForeground,
|
|
291
|
-
},
|
|
292
|
-
field: {
|
|
293
|
-
height: DIMENSION.control,
|
|
294
|
-
paddingHorizontal: SPACE.sm,
|
|
295
|
-
borderWidth: 1,
|
|
296
|
-
borderColor: theme.colors.border,
|
|
297
|
-
borderRadius: RADIUS.sm,
|
|
298
|
-
color: theme.colors.foreground,
|
|
299
|
-
backgroundColor: theme.colors.surface2,
|
|
300
|
-
fontSize: TYPE.body,
|
|
301
|
-
},
|
|
302
|
-
fieldFocused: {
|
|
303
|
-
borderColor: theme.colors.accent,
|
|
304
|
-
borderWidth: 2,
|
|
305
|
-
},
|
|
306
|
-
fieldDisabled: {
|
|
307
|
-
opacity: 0.5,
|
|
308
|
-
},
|
|
309
|
-
errorRow: {
|
|
310
|
-
paddingHorizontal: SPACE.sm,
|
|
311
|
-
paddingVertical: SPACE.xs,
|
|
312
|
-
borderLeftWidth: SPACE.xs,
|
|
313
|
-
borderLeftColor: theme.colors.statusDanger,
|
|
314
|
-
backgroundColor: theme.colors.surface1,
|
|
315
|
-
flexDirection: "row",
|
|
316
|
-
alignItems: "center",
|
|
317
|
-
gap: SPACE.sm,
|
|
318
|
-
},
|
|
319
|
-
errorText: {
|
|
320
|
-
flex: 1,
|
|
321
|
-
color: theme.colors.statusDanger,
|
|
322
|
-
fontSize: TYPE.caption,
|
|
323
|
-
},
|
|
324
|
-
canvasShell: {
|
|
325
|
-
flex: 1,
|
|
326
|
-
minHeight: compact ? DIMENSION.canvasCompact : DIMENSION.canvasRegular,
|
|
327
|
-
minWidth: 0,
|
|
328
|
-
margin: compact ? SPACE.sm : 0,
|
|
329
|
-
borderRadius: compact ? DIMENSION.screenRadius : 0,
|
|
330
|
-
overflow: "hidden",
|
|
331
|
-
backgroundColor: theme.colors.surface1,
|
|
332
|
-
},
|
|
333
|
-
canvas: {
|
|
334
|
-
flex: 1,
|
|
335
|
-
minHeight: 0,
|
|
336
|
-
overflow: "hidden",
|
|
337
|
-
},
|
|
338
|
-
frame: {
|
|
339
|
-
position: "absolute",
|
|
340
|
-
borderRadius: compact ? DIMENSION.screenRadius - SPACE.xs : 0,
|
|
341
|
-
},
|
|
342
|
-
interactionLayer: {
|
|
343
|
-
position: "absolute",
|
|
344
|
-
},
|
|
345
|
-
canvasState: {
|
|
346
|
-
flex: 1,
|
|
347
|
-
alignItems: "center",
|
|
348
|
-
justifyContent: "center",
|
|
349
|
-
padding: SPACE.lg,
|
|
350
|
-
gap: SPACE.sm,
|
|
351
|
-
},
|
|
352
|
-
canvasTitle: {
|
|
353
|
-
color: theme.colors.foreground,
|
|
354
|
-
fontSize: TYPE.title,
|
|
355
|
-
fontWeight: "600",
|
|
356
|
-
textAlign: "center",
|
|
357
|
-
},
|
|
358
|
-
canvasDetail: {
|
|
359
|
-
color: theme.colors.foregroundMuted,
|
|
360
|
-
fontSize: TYPE.caption,
|
|
361
|
-
textAlign: "center",
|
|
362
|
-
maxWidth: DIMENSION.helperMax,
|
|
363
|
-
},
|
|
364
|
-
canvasFooter: {
|
|
365
|
-
minHeight: DIMENSION.control,
|
|
366
|
-
paddingHorizontal: SPACE.sm,
|
|
367
|
-
borderTopWidth: 1,
|
|
368
|
-
borderTopColor: theme.colors.border,
|
|
369
|
-
flexDirection: "row",
|
|
370
|
-
alignItems: "center",
|
|
371
|
-
justifyContent: "space-between",
|
|
372
|
-
gap: SPACE.sm,
|
|
373
|
-
backgroundColor: theme.colors.surface1,
|
|
374
|
-
},
|
|
375
|
-
canvasFooterText: {
|
|
376
|
-
color: theme.colors.foregroundMuted,
|
|
377
|
-
fontSize: TYPE.caption,
|
|
378
|
-
flexShrink: 1,
|
|
379
|
-
},
|
|
380
|
-
controls: {
|
|
381
|
-
paddingHorizontal: compact ? SPACE.md : SPACE.sm,
|
|
382
|
-
paddingTop: compact ? SPACE.md : SPACE.sm,
|
|
383
|
-
paddingBottom: compact ? SPACE.lg : SPACE.sm,
|
|
384
|
-
gap: compact ? SPACE.sm : SPACE.xs,
|
|
385
|
-
borderTopWidth: 1,
|
|
386
|
-
borderTopColor: theme.colors.border,
|
|
387
|
-
backgroundColor: theme.colors.surface0,
|
|
388
|
-
},
|
|
389
|
-
mobileRow: {
|
|
390
|
-
flexDirection: "row",
|
|
391
|
-
alignItems: "center",
|
|
392
|
-
gap: SPACE.sm,
|
|
393
|
-
},
|
|
394
|
-
sheetContent: {
|
|
395
|
-
gap: SPACE.md,
|
|
396
|
-
padding: SPACE.sm,
|
|
397
|
-
},
|
|
398
|
-
sheetGrid: {
|
|
399
|
-
flexDirection: "row",
|
|
400
|
-
flexWrap: "wrap",
|
|
401
|
-
gap: SPACE.sm,
|
|
402
|
-
},
|
|
403
|
-
sheetPad: {
|
|
404
|
-
alignItems: "center",
|
|
405
|
-
gap: SPACE.sm,
|
|
406
|
-
},
|
|
407
|
-
sheetPadRow: {
|
|
408
|
-
flexDirection: "row",
|
|
409
|
-
alignItems: "center",
|
|
410
|
-
justifyContent: "center",
|
|
411
|
-
gap: SPACE.sm,
|
|
412
|
-
},
|
|
413
|
-
padSpacer: {
|
|
414
|
-
width: DIMENSION.pad,
|
|
415
|
-
},
|
|
416
|
-
controlStrip: {
|
|
417
|
-
minHeight: DIMENSION.control,
|
|
418
|
-
flexDirection: "row",
|
|
419
|
-
alignItems: "center",
|
|
420
|
-
gap: SPACE.xs,
|
|
421
|
-
},
|
|
422
|
-
stripLabel: {
|
|
423
|
-
color: theme.colors.foregroundMuted,
|
|
424
|
-
fontSize: TYPE.caption,
|
|
425
|
-
fontWeight: "600",
|
|
426
|
-
marginRight: SPACE.xs,
|
|
427
|
-
},
|
|
428
|
-
separator: {
|
|
429
|
-
width: 1,
|
|
430
|
-
alignSelf: "stretch",
|
|
431
|
-
marginVertical: SPACE.xs,
|
|
432
|
-
marginHorizontal: SPACE.xs,
|
|
433
|
-
backgroundColor: theme.colors.border,
|
|
434
|
-
},
|
|
435
|
-
viewportField: {
|
|
436
|
-
width: DIMENSION.viewportField,
|
|
437
|
-
textAlign: "center",
|
|
438
|
-
},
|
|
439
|
-
multiply: {
|
|
440
|
-
color: theme.colors.foregroundMuted,
|
|
441
|
-
fontSize: TYPE.body,
|
|
442
|
-
},
|
|
443
|
-
typeRow: {
|
|
444
|
-
flexDirection: compact ? "column" : "row",
|
|
445
|
-
gap: SPACE.xs,
|
|
446
|
-
},
|
|
447
|
-
typeInput: {
|
|
448
|
-
flex: 1,
|
|
449
|
-
minWidth: compact ? undefined : DIMENSION.typeField,
|
|
450
|
-
},
|
|
451
|
-
keysContent: {
|
|
452
|
-
flexDirection: "row",
|
|
453
|
-
alignItems: "center",
|
|
454
|
-
gap: SPACE.xs,
|
|
455
|
-
paddingTop: SPACE.xs,
|
|
456
|
-
},
|
|
457
|
-
deviceModalContent: {
|
|
458
|
-
gap: SPACE.sm,
|
|
459
|
-
padding: SPACE.sm,
|
|
460
|
-
},
|
|
461
|
-
devicePresetRow: {
|
|
462
|
-
minHeight: 46,
|
|
463
|
-
paddingHorizontal: SPACE.md,
|
|
464
|
-
paddingVertical: SPACE.sm,
|
|
465
|
-
borderWidth: 1,
|
|
466
|
-
borderColor: theme.colors.border,
|
|
467
|
-
borderRadius: RADIUS.md,
|
|
468
|
-
backgroundColor: theme.colors.surface1,
|
|
469
|
-
flexDirection: "row",
|
|
470
|
-
alignItems: "center",
|
|
471
|
-
gap: SPACE.sm,
|
|
472
|
-
},
|
|
473
|
-
devicePresetRowSelected: {
|
|
474
|
-
borderColor: theme.colors.accent,
|
|
475
|
-
backgroundColor: theme.colors.surface2,
|
|
476
|
-
},
|
|
477
|
-
devicePresetText: {
|
|
478
|
-
flex: 1,
|
|
479
|
-
color: theme.colors.foreground,
|
|
480
|
-
fontSize: TYPE.body,
|
|
481
|
-
fontWeight: "600",
|
|
482
|
-
},
|
|
483
|
-
devicePresetDetail: {
|
|
484
|
-
color: theme.colors.foregroundMuted,
|
|
485
|
-
fontSize: TYPE.caption,
|
|
486
|
-
},
|
|
487
|
-
customViewportRow: {
|
|
488
|
-
flexDirection: "row",
|
|
489
|
-
alignItems: "center",
|
|
490
|
-
gap: SPACE.xs,
|
|
491
|
-
},
|
|
492
|
-
buttonLarge: {
|
|
493
|
-
minHeight: DIMENSION.touch,
|
|
494
|
-
paddingHorizontal: SPACE.md,
|
|
495
|
-
borderRadius: RADIUS.md,
|
|
496
|
-
},
|
|
497
|
-
buttonFill: {
|
|
498
|
-
flex: 1,
|
|
499
|
-
},
|
|
500
|
-
buttonPad: {
|
|
501
|
-
width: DIMENSION.pad,
|
|
502
|
-
minHeight: DIMENSION.touch,
|
|
503
|
-
},
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
interface ControlButtonStyles {
|
|
508
|
-
button: ViewStyle;
|
|
509
|
-
buttonSelected: ViewStyle;
|
|
510
|
-
buttonPrimary: ViewStyle;
|
|
511
|
-
buttonDanger: ViewStyle;
|
|
512
|
-
buttonHovered: ViewStyle;
|
|
513
|
-
buttonPressed: ViewStyle;
|
|
514
|
-
buttonFocused: ViewStyle;
|
|
515
|
-
buttonDisabled: ViewStyle;
|
|
516
|
-
buttonLarge: ViewStyle;
|
|
517
|
-
buttonFill: ViewStyle;
|
|
518
|
-
buttonPad: ViewStyle;
|
|
519
|
-
buttonText: TextStyle;
|
|
520
|
-
buttonTextSelected: TextStyle;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
interface FieldStyles {
|
|
524
|
-
field: TextStyle;
|
|
525
|
-
fieldFocused: TextStyle;
|
|
526
|
-
fieldDisabled: TextStyle;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
interface ErrorNoticeStyles extends ControlButtonStyles {
|
|
530
|
-
errorRow: ViewStyle;
|
|
531
|
-
errorText: TextStyle;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
interface CanvasPlaceholderStyles {
|
|
535
|
-
canvasState: ViewStyle;
|
|
536
|
-
canvasTitle: TextStyle;
|
|
537
|
-
canvasDetail: TextStyle;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
interface ControlButtonProps {
|
|
541
|
-
styles: ControlButtonStyles;
|
|
542
|
-
theme: Theme;
|
|
543
|
-
label: string;
|
|
544
|
-
accessibilityLabel?: string;
|
|
545
|
-
icon?: string;
|
|
546
|
-
selected?: boolean;
|
|
547
|
-
primary?: boolean;
|
|
548
|
-
danger?: boolean;
|
|
549
|
-
large?: boolean;
|
|
550
|
-
fill?: boolean;
|
|
551
|
-
pad?: boolean;
|
|
552
|
-
disabled?: boolean;
|
|
553
|
-
onPress(): void;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function ControlButton({
|
|
557
|
-
styles,
|
|
558
|
-
theme,
|
|
559
|
-
label,
|
|
560
|
-
accessibilityLabel,
|
|
561
|
-
icon,
|
|
562
|
-
selected = false,
|
|
563
|
-
primary = false,
|
|
564
|
-
danger = false,
|
|
565
|
-
large = false,
|
|
566
|
-
fill = false,
|
|
567
|
-
pad = false,
|
|
568
|
-
disabled = false,
|
|
569
|
-
onPress,
|
|
570
|
-
}: ControlButtonProps) {
|
|
571
|
-
const [focused, setFocused] = useState(false);
|
|
572
|
-
const [hovered, setHovered] = useState(false);
|
|
573
|
-
const highlighted = selected || primary;
|
|
574
|
-
return (
|
|
575
|
-
<Pressable
|
|
576
|
-
accessibilityRole="button"
|
|
577
|
-
accessibilityLabel={accessibilityLabel ?? label}
|
|
578
|
-
accessibilityState={{ disabled, selected }}
|
|
579
|
-
disabled={disabled}
|
|
580
|
-
onFocus={() => setFocused(true)}
|
|
581
|
-
onBlur={() => setFocused(false)}
|
|
582
|
-
onHoverIn={() => setHovered(true)}
|
|
583
|
-
onHoverOut={() => setHovered(false)}
|
|
584
|
-
onPress={onPress}
|
|
585
|
-
style={({ pressed }) => [
|
|
586
|
-
styles.button,
|
|
587
|
-
selected ? styles.buttonSelected : null,
|
|
588
|
-
primary ? styles.buttonPrimary : null,
|
|
589
|
-
danger ? styles.buttonDanger : null,
|
|
590
|
-
hovered && !highlighted ? styles.buttonHovered : null,
|
|
591
|
-
pressed ? styles.buttonPressed : null,
|
|
592
|
-
focused ? styles.buttonFocused : null,
|
|
593
|
-
disabled ? styles.buttonDisabled : null,
|
|
594
|
-
large || pad ? styles.buttonLarge : null,
|
|
595
|
-
fill ? styles.buttonFill : null,
|
|
596
|
-
pad ? styles.buttonPad : null,
|
|
597
|
-
]}
|
|
598
|
-
>
|
|
599
|
-
{icon ? (
|
|
600
|
-
<Icon
|
|
601
|
-
name={icon}
|
|
602
|
-
size={DIMENSION.icon}
|
|
603
|
-
color={highlighted ? theme.colors.accentForeground : theme.colors.foregroundMuted}
|
|
604
|
-
/>
|
|
605
|
-
) : null}
|
|
606
|
-
<Text style={[styles.buttonText, highlighted ? styles.buttonTextSelected : null]}>
|
|
607
|
-
{label}
|
|
608
|
-
</Text>
|
|
609
|
-
</Pressable>
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
interface ChromeIconButtonStyles {
|
|
614
|
-
chromeIconButton: ViewStyle;
|
|
615
|
-
chromeIconButtonHovered: ViewStyle;
|
|
616
|
-
chromeIconButtonPressed: ViewStyle;
|
|
617
|
-
chromeIconButtonDisabled: ViewStyle;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function ChromeIconButton({
|
|
621
|
-
styles,
|
|
622
|
-
theme,
|
|
623
|
-
label,
|
|
624
|
-
icon,
|
|
625
|
-
selected = false,
|
|
626
|
-
disabled = false,
|
|
627
|
-
onPress,
|
|
628
|
-
}: {
|
|
629
|
-
styles: ChromeIconButtonStyles;
|
|
630
|
-
theme: Theme;
|
|
631
|
-
label: string;
|
|
632
|
-
icon: string;
|
|
633
|
-
selected?: boolean;
|
|
634
|
-
disabled?: boolean;
|
|
635
|
-
onPress(): void;
|
|
636
|
-
}) {
|
|
637
|
-
const [hovered, setHovered] = useState(false);
|
|
638
|
-
return (
|
|
639
|
-
<Pressable
|
|
640
|
-
accessibilityRole="button"
|
|
641
|
-
accessibilityLabel={label}
|
|
642
|
-
accessibilityState={{ disabled, selected }}
|
|
643
|
-
disabled={disabled}
|
|
644
|
-
onHoverIn={() => setHovered(true)}
|
|
645
|
-
onHoverOut={() => setHovered(false)}
|
|
646
|
-
onPress={onPress}
|
|
647
|
-
style={({ pressed }) => [
|
|
648
|
-
styles.chromeIconButton,
|
|
649
|
-
hovered ? styles.chromeIconButtonHovered : null,
|
|
650
|
-
pressed ? styles.chromeIconButtonPressed : null,
|
|
651
|
-
disabled ? styles.chromeIconButtonDisabled : null,
|
|
652
|
-
]}
|
|
653
|
-
>
|
|
654
|
-
<Icon
|
|
655
|
-
name={icon}
|
|
656
|
-
size={16}
|
|
657
|
-
color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
|
|
658
|
-
/>
|
|
659
|
-
</Pressable>
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
interface FieldProps {
|
|
664
|
-
styles: FieldStyles;
|
|
665
|
-
theme: Theme;
|
|
666
|
-
value: string;
|
|
667
|
-
accessibilityLabel: string;
|
|
668
|
-
placeholder?: string;
|
|
669
|
-
editable?: boolean;
|
|
670
|
-
maxLength?: number;
|
|
671
|
-
keyboardType?: TextInputProps["keyboardType"];
|
|
672
|
-
inputMode?: TextInputProps["inputMode"];
|
|
673
|
-
returnKeyType?: TextInputProps["returnKeyType"];
|
|
674
|
-
selectTextOnFocus?: boolean;
|
|
675
|
-
style?: StyleProp<TextStyle>;
|
|
676
|
-
onChangeText(value: string): void;
|
|
677
|
-
onSubmit?(): void;
|
|
678
|
-
onFocus?(): void;
|
|
679
|
-
onBlur?(): void;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function Field({
|
|
683
|
-
styles,
|
|
684
|
-
theme,
|
|
685
|
-
value,
|
|
686
|
-
accessibilityLabel,
|
|
687
|
-
placeholder,
|
|
688
|
-
editable = true,
|
|
689
|
-
maxLength,
|
|
690
|
-
keyboardType,
|
|
691
|
-
inputMode,
|
|
692
|
-
returnKeyType,
|
|
693
|
-
selectTextOnFocus,
|
|
694
|
-
style,
|
|
695
|
-
onChangeText,
|
|
696
|
-
onSubmit,
|
|
697
|
-
onFocus,
|
|
698
|
-
onBlur,
|
|
699
|
-
}: FieldProps) {
|
|
700
|
-
const [focused, setFocused] = useState(false);
|
|
701
|
-
return (
|
|
702
|
-
<TextInput
|
|
703
|
-
accessibilityLabel={accessibilityLabel}
|
|
704
|
-
autoCapitalize="none"
|
|
705
|
-
autoCorrect={false}
|
|
706
|
-
spellCheck={false}
|
|
707
|
-
editable={editable}
|
|
708
|
-
keyboardType={keyboardType}
|
|
709
|
-
inputMode={inputMode}
|
|
710
|
-
maxLength={maxLength}
|
|
711
|
-
onBlur={() => {
|
|
712
|
-
setFocused(false);
|
|
713
|
-
onBlur?.();
|
|
714
|
-
}}
|
|
715
|
-
onChangeText={onChangeText}
|
|
716
|
-
onFocus={() => {
|
|
717
|
-
setFocused(true);
|
|
718
|
-
onFocus?.();
|
|
719
|
-
}}
|
|
720
|
-
onSubmitEditing={onSubmit}
|
|
721
|
-
placeholder={placeholder}
|
|
722
|
-
placeholderTextColor={theme.colors.foregroundMuted}
|
|
723
|
-
returnKeyType={returnKeyType}
|
|
724
|
-
selectionColor={theme.colors.accent}
|
|
725
|
-
selectTextOnFocus={selectTextOnFocus}
|
|
726
|
-
style={[
|
|
727
|
-
styles.field,
|
|
728
|
-
style,
|
|
729
|
-
focused ? styles.fieldFocused : null,
|
|
730
|
-
!editable ? styles.fieldDisabled : null,
|
|
731
|
-
]}
|
|
732
|
-
value={value}
|
|
733
|
-
/>
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function ErrorNotice({
|
|
738
|
-
styles,
|
|
739
|
-
theme,
|
|
740
|
-
message,
|
|
741
|
-
action,
|
|
742
|
-
onAction,
|
|
743
|
-
actionDisabled = false,
|
|
744
|
-
}: {
|
|
745
|
-
styles: ErrorNoticeStyles;
|
|
746
|
-
theme: Theme;
|
|
747
|
-
message: string;
|
|
748
|
-
action?: string | undefined;
|
|
749
|
-
onAction?: (() => void) | undefined;
|
|
750
|
-
actionDisabled?: boolean;
|
|
751
|
-
}) {
|
|
752
|
-
return (
|
|
753
|
-
<View accessibilityRole="alert" style={styles.errorRow}>
|
|
754
|
-
<Icon name="CircleAlert" size={DIMENSION.icon} color={theme.colors.statusDanger} />
|
|
755
|
-
<Text style={styles.errorText}>{message}</Text>
|
|
756
|
-
{action && onAction ? (
|
|
757
|
-
<ControlButton
|
|
758
|
-
styles={styles}
|
|
759
|
-
theme={theme}
|
|
760
|
-
label={action}
|
|
761
|
-
disabled={actionDisabled}
|
|
762
|
-
onPress={onAction}
|
|
763
|
-
/>
|
|
764
|
-
) : null}
|
|
765
|
-
</View>
|
|
766
|
-
);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function CanvasPlaceholder({
|
|
770
|
-
styles,
|
|
771
|
-
theme,
|
|
772
|
-
title,
|
|
773
|
-
detail,
|
|
774
|
-
loading = false,
|
|
775
|
-
}: {
|
|
776
|
-
styles: CanvasPlaceholderStyles;
|
|
777
|
-
theme: Theme;
|
|
778
|
-
title: string;
|
|
779
|
-
detail: string;
|
|
780
|
-
loading?: boolean;
|
|
781
|
-
}) {
|
|
782
|
-
return (
|
|
783
|
-
<View style={styles.canvasState}>
|
|
784
|
-
{loading ? <ActivityIndicator color={theme.colors.accent} /> : null}
|
|
785
|
-
<Text style={styles.canvasTitle}>{title}</Text>
|
|
786
|
-
<Text style={styles.canvasDetail}>{detail}</Text>
|
|
787
|
-
</View>
|
|
172
|
+
/** A painted document may remain visible within the same source and geometry. */
|
|
173
|
+
function isFrameDisplayCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
|
|
174
|
+
return Boolean(
|
|
175
|
+
frame.runtimeId &&
|
|
176
|
+
state.runtimeId &&
|
|
177
|
+
frame.captureEpoch !== undefined &&
|
|
178
|
+
state.bridgeEpoch !== undefined &&
|
|
179
|
+
frame.sessionId === state.sessionId &&
|
|
180
|
+
frame.runtimeId === state.runtimeId &&
|
|
181
|
+
frame.captureEpoch === state.bridgeEpoch &&
|
|
182
|
+
frame.viewportGeneration === state.viewportGeneration,
|
|
788
183
|
);
|
|
789
184
|
}
|
|
790
185
|
|
|
791
|
-
interface AgentDirectoryPage {
|
|
792
|
-
entries: Array<{ agent: { id: string; workspaceId?: string | undefined } }>;
|
|
793
|
-
pageInfo: { hasMore: boolean; nextCursor: string | null };
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
type AgentDirectoryUpdate =
|
|
797
|
-
| { kind: "remove"; agentId: string }
|
|
798
|
-
| { kind: "upsert"; agent: { id: string; workspaceId?: string | undefined } };
|
|
799
|
-
type AgentPlacement = { id: string; workspaceId: string };
|
|
800
|
-
|
|
801
|
-
export function contributeSharedBrowserClient(client: PluginClientContext) {
|
|
802
|
-
const agents = new Map<string, AgentPlacement>();
|
|
803
|
-
const pills = new Map<string, { workspaceId: string; remove: () => void }>();
|
|
804
|
-
const lifetime = new AbortController();
|
|
805
|
-
let openWorkspaceIds = new Set<string>();
|
|
806
|
-
let refreshing = false;
|
|
807
|
-
let stopped = false;
|
|
808
|
-
let directoryGeneration = 0;
|
|
809
|
-
let pendingDirectory: { generation: number; updates: AgentDirectoryUpdate[] } | null = null;
|
|
810
|
-
let unsubscribeDirectory: (() => void) | null = null;
|
|
811
|
-
let releaseDirectory: (() => Promise<void>) | null = null;
|
|
812
|
-
|
|
813
|
-
const removePill = (agentId: string) => {
|
|
814
|
-
pills.get(agentId)?.remove();
|
|
815
|
-
pills.delete(agentId);
|
|
816
|
-
};
|
|
817
|
-
const syncPill = (agent: AgentPlacement) => {
|
|
818
|
-
const current = pills.get(agent.id);
|
|
819
|
-
if (!openWorkspaceIds.has(agent.workspaceId)) {
|
|
820
|
-
removePill(agent.id);
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
if (current?.workspaceId === agent.workspaceId) return;
|
|
824
|
-
removePill(agent.id);
|
|
825
|
-
const workspaceId = agent.workspaceId;
|
|
826
|
-
const pill = client.addComposerPill({
|
|
827
|
-
id: "open-shared-browser",
|
|
828
|
-
workspaceId,
|
|
829
|
-
agentId: agent.id,
|
|
830
|
-
button: {
|
|
831
|
-
title: "Open Shared Browser",
|
|
832
|
-
icon: "PanelsTopLeft",
|
|
833
|
-
label: "Shared Browser",
|
|
834
|
-
behavior: {
|
|
835
|
-
kind: "action",
|
|
836
|
-
onPress() {
|
|
837
|
-
client.openPanel("shared-browser", { workspaceId });
|
|
838
|
-
},
|
|
839
|
-
},
|
|
840
|
-
},
|
|
841
|
-
});
|
|
842
|
-
pills.set(agent.id, { workspaceId, remove: pill.remove });
|
|
843
|
-
};
|
|
844
|
-
const syncAllPills = () => {
|
|
845
|
-
for (const agent of agents.values()) syncPill(agent);
|
|
846
|
-
};
|
|
847
|
-
const refreshPresence = async () => {
|
|
848
|
-
if (stopped || refreshing) return;
|
|
849
|
-
refreshing = true;
|
|
850
|
-
try {
|
|
851
|
-
const result = await client.rpc(listOpenBrowserWorkspacesRpc, {});
|
|
852
|
-
if (stopped) return;
|
|
853
|
-
openWorkspaceIds = new Set(result.workspaceIds);
|
|
854
|
-
syncAllPills();
|
|
855
|
-
} catch {
|
|
856
|
-
return;
|
|
857
|
-
} finally {
|
|
858
|
-
refreshing = false;
|
|
859
|
-
}
|
|
860
|
-
};
|
|
861
|
-
const applyUpdate = (target: Map<string, AgentPlacement>, update: AgentDirectoryUpdate) => {
|
|
862
|
-
if (update.kind === "remove") {
|
|
863
|
-
target.delete(update.agentId);
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
const { id, workspaceId } = update.agent;
|
|
867
|
-
if (workspaceId) target.set(id, { id, workspaceId });
|
|
868
|
-
else target.delete(id);
|
|
869
|
-
};
|
|
870
|
-
const applyLiveUpdate = (update: AgentDirectoryUpdate) => {
|
|
871
|
-
if (stopped) return;
|
|
872
|
-
pendingDirectory?.updates.push(update);
|
|
873
|
-
applyUpdate(agents, update);
|
|
874
|
-
if (update.kind === "remove") {
|
|
875
|
-
removePill(update.agentId);
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
const { id, workspaceId } = update.agent;
|
|
879
|
-
if (!workspaceId) {
|
|
880
|
-
removePill(id);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
syncPill({ id, workspaceId });
|
|
884
|
-
void refreshPresence();
|
|
885
|
-
};
|
|
886
|
-
const replaceAgents = (next: Map<string, AgentPlacement>) => {
|
|
887
|
-
for (const agentId of agents.keys()) {
|
|
888
|
-
if (!next.has(agentId)) removePill(agentId);
|
|
889
|
-
}
|
|
890
|
-
agents.clear();
|
|
891
|
-
for (const [agentId, agent] of next) agents.set(agentId, agent);
|
|
892
|
-
syncAllPills();
|
|
893
|
-
};
|
|
894
|
-
const followSnapshot = async (snapshot: AgentDirectoryPage) => {
|
|
895
|
-
if (stopped) return;
|
|
896
|
-
const generation = ++directoryGeneration;
|
|
897
|
-
const transaction = { generation, updates: [] as AgentDirectoryUpdate[] };
|
|
898
|
-
pendingDirectory = transaction;
|
|
899
|
-
const next = new Map<string, AgentPlacement>();
|
|
900
|
-
for (const { agent } of snapshot.entries) {
|
|
901
|
-
if (agent.workspaceId) next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
try {
|
|
905
|
-
let cursor = snapshot.pageInfo.hasMore ? snapshot.pageInfo.nextCursor : null;
|
|
906
|
-
while (cursor) {
|
|
907
|
-
const page = await client.paseo.agents.list({
|
|
908
|
-
scope: "active",
|
|
909
|
-
page: { limit: AGENT_DIRECTORY_PAGE_LIMIT, cursor },
|
|
910
|
-
signal: lifetime.signal,
|
|
911
|
-
});
|
|
912
|
-
if (stopped || pendingDirectory?.generation !== generation) return;
|
|
913
|
-
for (const { agent } of page.entries) {
|
|
914
|
-
if (agent.workspaceId)
|
|
915
|
-
next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
|
|
916
|
-
}
|
|
917
|
-
cursor = page.pageInfo.hasMore ? page.pageInfo.nextCursor : null;
|
|
918
|
-
}
|
|
919
|
-
if (stopped || pendingDirectory?.generation !== generation) return;
|
|
920
|
-
for (const update of transaction.updates) applyUpdate(next, update);
|
|
921
|
-
pendingDirectory = null;
|
|
922
|
-
replaceAgents(next);
|
|
923
|
-
await refreshPresence();
|
|
924
|
-
} catch {
|
|
925
|
-
if (!stopped && pendingDirectory?.generation === generation) pendingDirectory = null;
|
|
926
|
-
}
|
|
927
|
-
};
|
|
928
|
-
|
|
929
|
-
void client.paseo.agents
|
|
930
|
-
.list({
|
|
931
|
-
scope: "active",
|
|
932
|
-
page: { limit: AGENT_DIRECTORY_PAGE_LIMIT },
|
|
933
|
-
subscribe: {},
|
|
934
|
-
signal: lifetime.signal,
|
|
935
|
-
})
|
|
936
|
-
.then(({ subscription }) => {
|
|
937
|
-
if (stopped) {
|
|
938
|
-
void subscription.release().catch(() => undefined);
|
|
939
|
-
return undefined;
|
|
940
|
-
}
|
|
941
|
-
releaseDirectory = subscription.release;
|
|
942
|
-
unsubscribeDirectory = subscription.subscribe({
|
|
943
|
-
snapshot: (snapshot) => void followSnapshot(snapshot),
|
|
944
|
-
update: (message) => {
|
|
945
|
-
if (message.type === "agent_update") applyLiveUpdate(message.payload);
|
|
946
|
-
},
|
|
947
|
-
});
|
|
948
|
-
return undefined;
|
|
949
|
-
})
|
|
950
|
-
.catch(() => undefined);
|
|
951
|
-
const presenceTimer = setInterval(() => void refreshPresence(), PILL_PRESENCE_POLL_MS);
|
|
952
|
-
|
|
953
|
-
return () => {
|
|
954
|
-
if (stopped) return;
|
|
955
|
-
stopped = true;
|
|
956
|
-
directoryGeneration += 1;
|
|
957
|
-
pendingDirectory = null;
|
|
958
|
-
clearInterval(presenceTimer);
|
|
959
|
-
unsubscribeDirectory?.();
|
|
960
|
-
void releaseDirectory?.().catch(() => undefined);
|
|
961
|
-
lifetime.abort();
|
|
962
|
-
for (const { remove } of pills.values()) remove();
|
|
963
|
-
pills.clear();
|
|
964
|
-
agents.clear();
|
|
965
|
-
};
|
|
966
|
-
}
|
|
967
186
|
export function SharedBrowserPanel({
|
|
968
187
|
theme,
|
|
969
188
|
host,
|
|
970
189
|
layout,
|
|
971
190
|
workspaceId,
|
|
972
191
|
}: PluginWorkspacePanelProps) {
|
|
192
|
+
const queryClient = useQueryClient();
|
|
973
193
|
const styles = useMemo(() => createStyles(theme, layout.compact), [theme, layout.compact]);
|
|
194
|
+
const { scale: screenPixelRatio } = useWindowDimensions();
|
|
974
195
|
const viewerLabel = useState(() =>
|
|
975
196
|
`Paseo ${layout.platform} · ${host.label} · ${Date.now().toString(36)}${Math.random()
|
|
976
197
|
.toString(36)
|
|
@@ -980,40 +201,122 @@ export function SharedBrowserPanel({
|
|
|
980
201
|
const attachBrowser = useRpc(attachBrowserRpc);
|
|
981
202
|
const detachBrowser = useRpc(detachBrowserRpc);
|
|
982
203
|
const captureBrowser = useRpc(captureBrowserRpc);
|
|
204
|
+
const closeBrowser = useRpc(closeBrowserRpc);
|
|
205
|
+
const closeTab = useRpc(closeBrowserTabRpc);
|
|
206
|
+
const createTab = useRpc(createBrowserTabRpc);
|
|
207
|
+
const listTabs = useRpc(listBrowserTabsRpc);
|
|
208
|
+
const reopenBrowser = useRpc(reopenBrowserRpc);
|
|
983
209
|
const acquireControl = useRpc(acquireControlRpc);
|
|
984
210
|
const releaseControl = useRpc(releaseControlRpc);
|
|
985
211
|
const navigateBrowser = useRpc(navigateBrowserRpc);
|
|
986
212
|
const resizeBrowser = useRpc(resizeBrowserRpc);
|
|
987
213
|
const applyDevicePreset = useRpc(applyDevicePresetRpc);
|
|
214
|
+
const setCaptureDensity = useRpc(setCaptureDensityRpc);
|
|
988
215
|
const sendBrowserInput = useRpc(sendBrowserInputRpc);
|
|
216
|
+
const beginBrowserGesture = useRpc(beginBrowserGestureRpc);
|
|
217
|
+
const updateBrowserGesture = useRpc(updateBrowserGestureRpc);
|
|
218
|
+
const endBrowserGesture = useRpc(endBrowserGestureRpc);
|
|
989
219
|
|
|
990
220
|
const mountedRef = useRef(false);
|
|
991
221
|
const activeViewerTokenRef = useRef<string | null>(null);
|
|
992
222
|
const stateRef = useRef<BrowserState | null>(null);
|
|
993
|
-
|
|
994
|
-
|
|
223
|
+
// Lease observations can change while acquisition awaits with no local token.
|
|
224
|
+
// Count ownership transitions, not expiry renewals or ordinary media frames.
|
|
225
|
+
const controllerObservationRevision = useRef(0);
|
|
226
|
+
const frameRef = useRef<BrowserFrame | null>(null);
|
|
227
|
+
const committedViewport = useRef<{ sessionId: string; generation: number } | null>(null);
|
|
995
228
|
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
996
|
-
const
|
|
997
|
-
const
|
|
229
|
+
const [inputLifecycle] = useState(createFrameLifecycle);
|
|
230
|
+
const [legacyInputBusy, setLegacyInputBusy] = useState(false);
|
|
231
|
+
// A visual-only old-read paint cannot wake strict initial-admission waiters.
|
|
232
|
+
const [admittedPaint, setAdmittedPaint] = useState<{ frameId: string; epoch: number } | null>(
|
|
233
|
+
null,
|
|
234
|
+
);
|
|
235
|
+
/** Notify initial-admission waiters only when the actual painted receipt gains input authority. */
|
|
236
|
+
const publishAdmittedPaint = (frameId: string, epoch: number) => {
|
|
237
|
+
setAdmittedPaint((previous) => {
|
|
238
|
+
if (previous?.frameId === frameId && previous.epoch === epoch) return previous;
|
|
239
|
+
return { frameId, epoch };
|
|
240
|
+
});
|
|
241
|
+
};
|
|
998
242
|
|
|
999
243
|
const [state, setState] = useState<BrowserState | null>(null);
|
|
1000
|
-
const [frame, setFrame] = useState<BrowserFrame | null>(null);
|
|
1001
|
-
const [inputBusy, setInputBusy] = useState(false);
|
|
1002
244
|
const [controlToken, setControlToken] = useState<string | null>(null);
|
|
1003
245
|
const [operationError, setOperationError] = useState<string | null>(null);
|
|
1004
|
-
const [imageError, setImageError] = useState(false);
|
|
1005
246
|
const [reconnecting, setReconnecting] = useState(false);
|
|
247
|
+
const [browserClosed, setBrowserClosed] = useState(false);
|
|
248
|
+
const [selectedTabId, setSelectedTabId] = useState<string | null>(null);
|
|
249
|
+
const selectedTabIdRef = useRef(selectedTabId);
|
|
250
|
+
selectedTabIdRef.current = selectedTabId;
|
|
251
|
+
const [tabMutationPending, setTabMutationPending] = useState(false);
|
|
252
|
+
const [tabCloseConfirmationOpen, setTabCloseConfirmationOpen] = useState(false);
|
|
253
|
+
const [controlRequest, setControlRequest] = useState<ControlRequest | null>(null);
|
|
254
|
+
const [pendingControlAction, setPendingControlAction] = useState<ControlRequest | null>(null);
|
|
255
|
+
const [controlRequestPending, setControlRequestPending] = useState(false);
|
|
256
|
+
const controlRequestPendingRef = useRef(false);
|
|
257
|
+
const controlledActionsRef = useRef<ControlledActions | null>(null);
|
|
258
|
+
const [closeConfirmationOpen, setCloseConfirmationOpen] = useState(false);
|
|
259
|
+
const [closePending, setClosePending] = useState(false);
|
|
260
|
+
const [reopenPending, setReopenPending] = useState(false);
|
|
1006
261
|
const [runtimeNotice, setRuntimeNotice] = useState<string | null>(null);
|
|
1007
262
|
const [addressDraft, setAddressDraft] = useState("");
|
|
1008
263
|
const [addressFocused, setAddressFocused] = useState(false);
|
|
1009
264
|
const [viewportWidth, setViewportWidth] = useState("");
|
|
1010
265
|
const [viewportHeight, setViewportHeight] = useState("");
|
|
1011
|
-
const [typeDraft, setTypeDraft] = useState("");
|
|
1012
|
-
const [interactionMode, setInteractionMode] = useState<InteractionMode>("click");
|
|
1013
266
|
const [devicePickerOpen, setDevicePickerOpen] = useState(false);
|
|
1014
|
-
const
|
|
1015
|
-
const [
|
|
1016
|
-
const
|
|
267
|
+
const preferences = useResolutionFavorites(host.id);
|
|
268
|
+
const [keysSubmenuOpen, setKeysSubmenuOpen] = useState(false);
|
|
269
|
+
const keysAnchorRef = useRef<View | null>(null);
|
|
270
|
+
const [toolbarMenu, setToolbarMenu] = useState<"display" | "actions" | null>(null);
|
|
271
|
+
const [scaleMode, setScaleMode] = useState<BrowserCanvasDisplayMode>("fit");
|
|
272
|
+
const paneRef = useRef<View | null>(null);
|
|
273
|
+
const displayAnchorRef = useRef<View | null>(null);
|
|
274
|
+
const actionsAnchorRef = useRef<View | null>(null);
|
|
275
|
+
const [paneSize, setPaneSize] = useState<Size>({ width: 0, height: 0 });
|
|
276
|
+
const panelScope = `${host.id}/${workspaceId}`;
|
|
277
|
+
const [fillViewportScope, setFillViewportScope] = useState<string | null>(null);
|
|
278
|
+
const fillViewportEnabled = fillViewportScope === panelScope;
|
|
279
|
+
const [densitySelection, setDensitySelection] = useState<{
|
|
280
|
+
scope: string;
|
|
281
|
+
mode: CaptureDensityMode;
|
|
282
|
+
}>({ scope: panelScope, mode: "auto" });
|
|
283
|
+
const densityMode = densitySelection.scope === panelScope ? densitySelection.mode : "auto";
|
|
284
|
+
const [automaticDensityFailureScope, setAutomaticDensityFailureScope] = useState<string | null>(
|
|
285
|
+
null,
|
|
286
|
+
);
|
|
287
|
+
const automaticDensityEnabled =
|
|
288
|
+
densityMode === "auto" && automaticDensityFailureScope !== panelScope;
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
setFillViewportScope(null);
|
|
291
|
+
setDensitySelection({ scope: panelScope, mode: "auto" });
|
|
292
|
+
setAutomaticDensityFailureScope(null);
|
|
293
|
+
}, [panelScope]);
|
|
294
|
+
const panelScopeRef = useRef(panelScope);
|
|
295
|
+
panelScopeRef.current = panelScope;
|
|
296
|
+
const menuRestoreFocus = useRef(true);
|
|
297
|
+
const closeToolbarMenu = useCallback((restoreFocus = true) => {
|
|
298
|
+
menuRestoreFocus.current = restoreFocus;
|
|
299
|
+
setKeysSubmenuOpen(false);
|
|
300
|
+
setToolbarMenu(null);
|
|
301
|
+
}, []);
|
|
302
|
+
const toggleToolbarMenu = (menu: "display" | "actions") => {
|
|
303
|
+
menuRestoreFocus.current = true;
|
|
304
|
+
setKeysSubmenuOpen(false);
|
|
305
|
+
setToolbarMenu((current) => (current === menu ? null : menu));
|
|
306
|
+
};
|
|
307
|
+
const [composeRequest, setComposeRequest] = useState<{
|
|
308
|
+
id: number;
|
|
309
|
+
kind: "compose" | "commit";
|
|
310
|
+
ownershipKey: string;
|
|
311
|
+
} | null>(null);
|
|
312
|
+
const nextComposeRequest = useRef(0);
|
|
313
|
+
const [activeInput, setActiveInput] = useState(false);
|
|
314
|
+
const activeInputRef = useRef(false);
|
|
315
|
+
/** Fence automatic timers immediately, before React commits a new physical press. */
|
|
316
|
+
const handleInputActivity = useCallback((active: boolean) => {
|
|
317
|
+
activeInputRef.current = active;
|
|
318
|
+
setActiveInput(active);
|
|
319
|
+
}, []);
|
|
1017
320
|
const [containerSize, setContainerSize] = useState<Size>({ width: 0, height: 0 });
|
|
1018
321
|
|
|
1019
322
|
useEffect(() => {
|
|
@@ -1023,30 +326,66 @@ export function SharedBrowserPanel({
|
|
|
1023
326
|
};
|
|
1024
327
|
}, []);
|
|
1025
328
|
|
|
1026
|
-
const
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
)
|
|
1034
|
-
|
|
1035
|
-
}
|
|
1036
|
-
if (currentFrame && !isFrameCurrent(currentFrame, next)) {
|
|
1037
|
-
lifecycle.drop();
|
|
1038
|
-
setFrame(null);
|
|
1039
|
-
lastPointRef.current = null;
|
|
1040
|
-
}
|
|
1041
|
-
stateRef.current = next;
|
|
1042
|
-
setState(next);
|
|
1043
|
-
return true;
|
|
329
|
+
const isCandidateCurrent = useCallback((candidate: FrameCandidate) => {
|
|
330
|
+
const current = stateRef.current;
|
|
331
|
+
return (
|
|
332
|
+
mountedRef.current &&
|
|
333
|
+
candidate.viewerToken === activeViewerTokenRef.current &&
|
|
334
|
+
candidate.mutationEpoch === inputLifecycle.epoch &&
|
|
335
|
+
current !== null &&
|
|
336
|
+
isFrameCurrent(candidate.frame, current)
|
|
337
|
+
);
|
|
1044
338
|
}, []);
|
|
339
|
+
const { buffer, frame, receive, settled, discardObsoletePending, reset } = useBrowserFrameBuffer(
|
|
340
|
+
frameRef,
|
|
341
|
+
isCandidateCurrent,
|
|
342
|
+
);
|
|
343
|
+
const imageError = buffer.imageError;
|
|
344
|
+
|
|
345
|
+
const acceptState = useCallback(
|
|
346
|
+
(next: BrowserState) => {
|
|
347
|
+
const previous = stateRef.current;
|
|
348
|
+
if (previous && !isBrowserStateCurrent(previous, next)) return false;
|
|
349
|
+
// Server state projects "self" against this exact viewer token. Media can
|
|
350
|
+
// observe our acquisition before its RPC returns the control token, so
|
|
351
|
+
// that transition must not revoke its own still-pending settlement.
|
|
352
|
+
// Competing ownership and subsequent release still advance the fence.
|
|
353
|
+
const observesOwnAcquisition = previous?.controller !== "self" && next.controller === "self";
|
|
354
|
+
if (
|
|
355
|
+
previous &&
|
|
356
|
+
!observesOwnAcquisition &&
|
|
357
|
+
(previous.controller !== next.controller ||
|
|
358
|
+
previous.controllerLabel !== next.controllerLabel)
|
|
359
|
+
) {
|
|
360
|
+
controllerObservationRevision.current += 1;
|
|
361
|
+
}
|
|
362
|
+
const currentFrame = frameRef.current;
|
|
363
|
+
if (previous && didBrowserRuntimeRestart(previous, next)) {
|
|
364
|
+
setRuntimeNotice(
|
|
365
|
+
"Browser restarted. The preserved viewer connection now targets the new runtime.",
|
|
366
|
+
);
|
|
367
|
+
setControlToken(null);
|
|
368
|
+
}
|
|
369
|
+
if (currentFrame && !isFrameCurrent(currentFrame, next)) {
|
|
370
|
+
lastPointRef.current = null;
|
|
371
|
+
}
|
|
372
|
+
stateRef.current = next;
|
|
373
|
+
// Repeated captures in the new generation must not cancel its slow decoder.
|
|
374
|
+
discardObsoletePending();
|
|
375
|
+
setState(next);
|
|
376
|
+
return true;
|
|
377
|
+
},
|
|
378
|
+
[discardObsoletePending],
|
|
379
|
+
);
|
|
1045
380
|
|
|
1046
381
|
const attachQuery = useQuery({
|
|
1047
|
-
queryKey: ["shared-browser", "attach", workspaceId, viewerLabel],
|
|
382
|
+
queryKey: ["shared-browser", "attach", host.id, workspaceId, viewerLabel, selectedTabId],
|
|
1048
383
|
queryFn: async () => {
|
|
1049
|
-
const result = await attachBrowser({
|
|
384
|
+
const result = await attachBrowser({
|
|
385
|
+
workspaceId,
|
|
386
|
+
viewerLabel,
|
|
387
|
+
...(selectedTabId ? { tabId: selectedTabId } : {}),
|
|
388
|
+
});
|
|
1050
389
|
if (!mountedRef.current) {
|
|
1051
390
|
await detachBrowser({ viewerToken: result.viewerToken }).catch(() => undefined);
|
|
1052
391
|
throw new Error("The browser panel closed before attachment completed.");
|
|
@@ -1054,12 +393,44 @@ export function SharedBrowserPanel({
|
|
|
1054
393
|
return result;
|
|
1055
394
|
},
|
|
1056
395
|
retry: false,
|
|
396
|
+
enabled: !browserClosed,
|
|
1057
397
|
staleTime: Number.POSITIVE_INFINITY,
|
|
1058
398
|
gcTime: 0,
|
|
1059
399
|
refetchOnWindowFocus: false,
|
|
1060
400
|
});
|
|
1061
401
|
|
|
1062
|
-
const
|
|
402
|
+
const closedView = browserClosed || isBrowserClosedError(attachQuery.error);
|
|
403
|
+
const closedViewRef = useRef(closedView);
|
|
404
|
+
closedViewRef.current = closedView;
|
|
405
|
+
const attachedTabMatches = !selectedTabId || attachQuery.data?.state.tabId === selectedTabId;
|
|
406
|
+
const viewerToken =
|
|
407
|
+
reconnecting || closedView || !attachedTabMatches
|
|
408
|
+
? null
|
|
409
|
+
: (attachQuery.data?.viewerToken ?? null);
|
|
410
|
+
const tabsQueryKey = ["shared-browser", "tabs", host.id, workspaceId];
|
|
411
|
+
const tabsQuery = useQuery({
|
|
412
|
+
queryKey: tabsQueryKey,
|
|
413
|
+
queryFn: () => listTabs({ viewerToken: viewerToken! }),
|
|
414
|
+
enabled: Boolean(viewerToken),
|
|
415
|
+
refetchInterval: 2_000,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const tabs = tabsQuery.data?.tabs;
|
|
420
|
+
const selected = selectedTabId ?? stateRef.current?.tabId;
|
|
421
|
+
if (!tabs || !selected || tabs.some((tab) => tab.id === selected)) return;
|
|
422
|
+
setSelectedTabId(tabs[0]?.id ?? null);
|
|
423
|
+
}, [selectedTabId, tabsQuery.data]);
|
|
424
|
+
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
if (isBrowserClosedError(attachQuery.error)) setBrowserClosed(true);
|
|
427
|
+
}, [attachQuery.error]);
|
|
428
|
+
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!closedView) return;
|
|
431
|
+
setControlRequest(null);
|
|
432
|
+
setPendingControlAction(null);
|
|
433
|
+
}, [closedView]);
|
|
1063
434
|
|
|
1064
435
|
useEffect(() => {
|
|
1065
436
|
if (!viewerToken) return;
|
|
@@ -1076,56 +447,132 @@ export function SharedBrowserPanel({
|
|
|
1076
447
|
|
|
1077
448
|
useEffect(() => {
|
|
1078
449
|
stateRef.current = null;
|
|
1079
|
-
|
|
450
|
+
frameRef.current = null;
|
|
1080
451
|
setState(null);
|
|
1081
|
-
|
|
1082
|
-
|
|
452
|
+
reset();
|
|
453
|
+
inputLifecycle.bump();
|
|
454
|
+
inputLifecycle.reset();
|
|
455
|
+
setLegacyInputBusy(false);
|
|
1083
456
|
setControlToken(null);
|
|
1084
457
|
setOperationError(null);
|
|
1085
458
|
setRuntimeNotice(null);
|
|
1086
|
-
|
|
459
|
+
setBrowserClosed(false);
|
|
460
|
+
setSelectedTabId(null);
|
|
461
|
+
setTabCloseConfirmationOpen(false);
|
|
462
|
+
setControlRequest(null);
|
|
463
|
+
setPendingControlAction(null);
|
|
464
|
+
setCloseConfirmationOpen(false);
|
|
465
|
+
}, [reset, workspaceId, host.id, inputLifecycle]);
|
|
1087
466
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
// A tab selection changes the viewer's page and frame authority, even
|
|
469
|
+
// though the underlying Chromium process and saved profile stay shared.
|
|
470
|
+
inputLifecycle.bump();
|
|
471
|
+
activeViewerTokenRef.current = null;
|
|
472
|
+
stateRef.current = null;
|
|
473
|
+
frameRef.current = null;
|
|
474
|
+
reset();
|
|
475
|
+
setState(null);
|
|
476
|
+
setControlToken(null);
|
|
477
|
+
setControlRequest(null);
|
|
478
|
+
setPendingControlAction(null);
|
|
479
|
+
setOperationError(null);
|
|
480
|
+
setReconnecting(false);
|
|
481
|
+
}, [selectedTabId, inputLifecycle, reset]);
|
|
482
|
+
|
|
483
|
+
useLayoutEffect(() => {
|
|
484
|
+
committedViewport.current = state
|
|
485
|
+
? { sessionId: state.sessionId, generation: state.viewportGeneration }
|
|
486
|
+
: null;
|
|
487
|
+
}, [state?.sessionId, state?.viewportGeneration]);
|
|
488
|
+
|
|
489
|
+
const video = useBrowserVideo({
|
|
490
|
+
viewerToken,
|
|
491
|
+
quality: "high",
|
|
492
|
+
bitrate: preferences.videoBitrate,
|
|
493
|
+
fps: preferences.videoFps,
|
|
494
|
+
epoch: () => inputLifecycle.epoch,
|
|
495
|
+
viewport: () => stateRef.current?.viewport ?? null,
|
|
496
|
+
isCurrent: (packet, epoch) =>
|
|
497
|
+
isVideoSourceCurrent(
|
|
498
|
+
{
|
|
499
|
+
mounted: mountedRef.current,
|
|
500
|
+
viewerActive: activeViewerTokenRef.current === viewerToken,
|
|
501
|
+
epoch: inputLifecycle.epoch,
|
|
502
|
+
committedViewport: committedViewport.current,
|
|
503
|
+
state: stateRef.current,
|
|
504
|
+
},
|
|
505
|
+
packet,
|
|
506
|
+
epoch,
|
|
507
|
+
),
|
|
508
|
+
isDisplayCurrent: (packet) => {
|
|
509
|
+
const current = stateRef.current;
|
|
510
|
+
return (
|
|
511
|
+
mountedRef.current &&
|
|
512
|
+
activeViewerTokenRef.current === viewerToken &&
|
|
513
|
+
current !== null &&
|
|
514
|
+
isFrameDisplayCurrent(packet.frame, current)
|
|
515
|
+
);
|
|
1105
516
|
},
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
517
|
+
acceptState,
|
|
518
|
+
onPresented: (packet, epoch) => {
|
|
519
|
+
inputLifecycle.accept(epoch, packet.frame);
|
|
520
|
+
publishAdmittedPaint(packet.frame.frameId, epoch);
|
|
521
|
+
setLegacyInputBusy(inputLifecycle.busy);
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const {
|
|
526
|
+
query: captureQuery,
|
|
527
|
+
refreshCapture,
|
|
528
|
+
retryFrameCapture,
|
|
529
|
+
} = useBrowserImageCapture({
|
|
530
|
+
active: !video.supported || video.active,
|
|
531
|
+
viewerToken,
|
|
532
|
+
quality: preferences.captureQuality,
|
|
533
|
+
activeInput,
|
|
534
|
+
fallbackRevision: video.fallbackRevision,
|
|
535
|
+
videoOwnsPresentation: Boolean(
|
|
536
|
+
video.front &&
|
|
537
|
+
!video.fallbackRevision &&
|
|
538
|
+
state &&
|
|
539
|
+
isFrameDisplayCurrent(video.front.frame, state),
|
|
540
|
+
),
|
|
541
|
+
hasVideoPresentation: () =>
|
|
542
|
+
Boolean(
|
|
543
|
+
video.front &&
|
|
544
|
+
!video.fallbackRevision &&
|
|
545
|
+
activeViewerTokenRef.current === viewerToken &&
|
|
546
|
+
stateRef.current &&
|
|
547
|
+
isFrameDisplayCurrent(video.front.frame, stateRef.current),
|
|
548
|
+
),
|
|
549
|
+
refreshVideo: video.refresh,
|
|
550
|
+
mutationEpoch: () => inputLifecycle.epoch,
|
|
551
|
+
knownFrame: () => frameRef.current,
|
|
552
|
+
capture: captureBrowser,
|
|
1115
553
|
});
|
|
1116
554
|
|
|
1117
555
|
useEffect(() => {
|
|
1118
556
|
const result = captureQuery.data;
|
|
1119
|
-
if (
|
|
557
|
+
if (
|
|
558
|
+
!result ||
|
|
559
|
+
result.viewerToken !== activeViewerTokenRef.current ||
|
|
560
|
+
result.captureQuality !== preferences.captureQuality ||
|
|
561
|
+
result.mutationEpoch !== inputLifecycle.epoch ||
|
|
562
|
+
!acceptState(result.state)
|
|
563
|
+
) {
|
|
1120
564
|
return;
|
|
1121
565
|
}
|
|
1122
566
|
if (result.frame && isFrameCurrent(result.frame, result.state)) {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
567
|
+
receive({
|
|
568
|
+
frame: result.frame,
|
|
569
|
+
viewport: result.state.viewport,
|
|
570
|
+
viewerToken: result.viewerToken,
|
|
571
|
+
mutationEpoch: result.mutationEpoch,
|
|
572
|
+
fallbackRevision: result.fallbackRevision,
|
|
573
|
+
});
|
|
1127
574
|
}
|
|
1128
|
-
}, [acceptState, captureQuery.data]);
|
|
575
|
+
}, [acceptState, captureQuery.data, preferences.captureQuality, receive]);
|
|
1129
576
|
|
|
1130
577
|
useEffect(() => {
|
|
1131
578
|
if (state?.controller !== "self" && controlToken) setControlToken(null);
|
|
@@ -1142,18 +589,6 @@ export function SharedBrowserPanel({
|
|
|
1142
589
|
setViewportHeight(String(state.viewport.height));
|
|
1143
590
|
}, [state?.sessionId, state?.viewport.height, state?.viewport.width]);
|
|
1144
591
|
|
|
1145
|
-
useEffect(() => {
|
|
1146
|
-
setImageError(false);
|
|
1147
|
-
}, [frame?.frameId]);
|
|
1148
|
-
|
|
1149
|
-
const refreshCapture = useCallback(() => {
|
|
1150
|
-
const requestWasInFlight = captureInFlightRef.current;
|
|
1151
|
-
const request = captureQuery.refetch({ cancelRefetch: false });
|
|
1152
|
-
if (requestWasInFlight) {
|
|
1153
|
-
void request.then(() => captureQuery.refetch({ cancelRefetch: false }));
|
|
1154
|
-
}
|
|
1155
|
-
}, [captureQuery.refetch]);
|
|
1156
|
-
|
|
1157
592
|
const mutationFailed = useCallback(
|
|
1158
593
|
(error: unknown) => {
|
|
1159
594
|
const message = errorMessage(error);
|
|
@@ -1176,108 +611,224 @@ export function SharedBrowserPanel({
|
|
|
1176
611
|
[acceptState, refreshCapture],
|
|
1177
612
|
);
|
|
1178
613
|
|
|
1179
|
-
const
|
|
614
|
+
const mutationIdentity = JSON.stringify([
|
|
615
|
+
host.id,
|
|
616
|
+
workspaceId,
|
|
617
|
+
viewerToken,
|
|
618
|
+
controlToken,
|
|
619
|
+
controllerObservationRevision.current,
|
|
620
|
+
]);
|
|
621
|
+
// Ref observations revoke settlement immediately, before the state commit.
|
|
622
|
+
const currentMutationIdentity = () =>
|
|
623
|
+
JSON.stringify([
|
|
624
|
+
host.id,
|
|
625
|
+
workspaceId,
|
|
626
|
+
activeViewerTokenRef.current,
|
|
627
|
+
controlToken,
|
|
628
|
+
controllerObservationRevision.current,
|
|
629
|
+
]);
|
|
630
|
+
const acquireMutation = useBrowserScopedMutation({
|
|
631
|
+
identity: mutationIdentity,
|
|
632
|
+
currentIdentity: currentMutationIdentity,
|
|
1180
633
|
mutationFn: acquireControl,
|
|
1181
|
-
retry: false,
|
|
1182
634
|
onSuccess: (result) => {
|
|
1183
635
|
setControlToken(result.controlToken);
|
|
1184
636
|
mutationSucceeded(result.state);
|
|
1185
637
|
},
|
|
1186
638
|
onError: mutationFailed,
|
|
1187
639
|
});
|
|
1188
|
-
const releaseMutation =
|
|
640
|
+
const releaseMutation = useBrowserScopedMutation({
|
|
641
|
+
identity: mutationIdentity,
|
|
642
|
+
currentIdentity: currentMutationIdentity,
|
|
1189
643
|
mutationFn: releaseControl,
|
|
1190
|
-
retry: false,
|
|
1191
644
|
onSuccess: (result) => {
|
|
1192
645
|
setControlToken(null);
|
|
1193
646
|
mutationSucceeded(result.state);
|
|
1194
647
|
},
|
|
1195
648
|
onError: mutationFailed,
|
|
1196
649
|
});
|
|
1197
|
-
const navigateMutation =
|
|
650
|
+
const navigateMutation = useBrowserScopedMutation({
|
|
651
|
+
identity: mutationIdentity,
|
|
652
|
+
currentIdentity: currentMutationIdentity,
|
|
1198
653
|
mutationFn: navigateBrowser,
|
|
1199
|
-
|
|
1200
|
-
|
|
654
|
+
onSuccess: (result) => {
|
|
655
|
+
mutationSucceeded(result.state);
|
|
656
|
+
// Only acknowledged navigation grants explicit viewing recovery. Ordinary
|
|
657
|
+
// gesture/image refresh does not rearm an exhausted video reader.
|
|
658
|
+
video.retry();
|
|
659
|
+
},
|
|
1201
660
|
onError: mutationFailed,
|
|
1202
661
|
});
|
|
1203
|
-
const resizeMutation =
|
|
662
|
+
const resizeMutation = useBrowserScopedMutation({
|
|
663
|
+
identity: mutationIdentity,
|
|
664
|
+
currentIdentity: currentMutationIdentity,
|
|
1204
665
|
mutationFn: resizeBrowser,
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
666
|
+
onSuccess: (result) => {
|
|
667
|
+
setDevicePickerOpen(false);
|
|
668
|
+
mutationSucceeded(result.state);
|
|
669
|
+
},
|
|
670
|
+
onError: (error) => {
|
|
671
|
+
setFillViewportScope(null);
|
|
672
|
+
setAutomaticDensityFailureScope(panelScope);
|
|
673
|
+
mutationFailed(error);
|
|
674
|
+
},
|
|
1208
675
|
});
|
|
1209
|
-
const deviceMutation =
|
|
676
|
+
const deviceMutation = useBrowserScopedMutation({
|
|
677
|
+
identity: mutationIdentity,
|
|
678
|
+
currentIdentity: currentMutationIdentity,
|
|
1210
679
|
mutationFn: applyDevicePreset,
|
|
1211
|
-
retry: false,
|
|
1212
680
|
onSuccess: (result) => mutationSucceeded(result.state),
|
|
1213
681
|
onError: mutationFailed,
|
|
1214
682
|
});
|
|
1215
|
-
|
|
1216
|
-
|
|
683
|
+
// Upstream's settlement gate protects discrete key controls and native text fallback.
|
|
684
|
+
// Continuous gestures retain their own ordered channel without waiting per event.
|
|
685
|
+
const settleLegacyInput = () => {
|
|
686
|
+
inputLifecycle.settle();
|
|
687
|
+
setLegacyInputBusy(true);
|
|
1217
688
|
};
|
|
1218
|
-
const inputMutation =
|
|
689
|
+
const inputMutation = useBrowserScopedMutation({
|
|
690
|
+
identity: mutationIdentity,
|
|
691
|
+
currentIdentity: currentMutationIdentity,
|
|
1219
692
|
mutationFn: sendBrowserInput,
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
if (variables.event.kind === "type") {
|
|
1223
|
-
const sentText = variables.event.text;
|
|
1224
|
-
setTypeDraft((current) => (current === sentText ? "" : current));
|
|
1225
|
-
}
|
|
1226
|
-
settleInput();
|
|
693
|
+
onSuccess: (result) => {
|
|
694
|
+
settleLegacyInput();
|
|
1227
695
|
mutationSucceeded(result.state);
|
|
1228
696
|
},
|
|
1229
697
|
onError: (error) => {
|
|
1230
|
-
|
|
698
|
+
settleLegacyInput();
|
|
1231
699
|
mutationFailed(error);
|
|
1232
700
|
},
|
|
1233
701
|
});
|
|
1234
702
|
|
|
1235
|
-
const
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
703
|
+
const densityControl = useBrowserCaptureDensity({
|
|
704
|
+
identity: JSON.stringify([host.id, workspaceId, viewerToken, controlToken]),
|
|
705
|
+
current: () => {
|
|
706
|
+
const current = stateRef.current;
|
|
707
|
+
const viewer = activeViewerTokenRef.current;
|
|
708
|
+
if (viewingExpired || !current || !viewer || !controlToken || current.controller !== "self")
|
|
709
|
+
return null;
|
|
710
|
+
return {
|
|
711
|
+
state: current,
|
|
712
|
+
context: {
|
|
713
|
+
viewerToken: viewer,
|
|
714
|
+
controlToken,
|
|
715
|
+
expected: {
|
|
716
|
+
sessionId: current.sessionId,
|
|
717
|
+
navigationGeneration: current.navigationGeneration,
|
|
718
|
+
viewportGeneration: current.viewportGeneration,
|
|
719
|
+
...(current.runtimeId ? { runtimeId: current.runtimeId } : {}),
|
|
720
|
+
...(current.bridgeEpoch !== undefined ? { bridgeEpoch: current.bridgeEpoch } : {}),
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
},
|
|
725
|
+
change: setCaptureDensity,
|
|
726
|
+
beforeChange: () => inputLifecycle.bump(),
|
|
727
|
+
onSuccess: mutationSucceeded,
|
|
728
|
+
onError: (error) => {
|
|
729
|
+
setAutomaticDensityFailureScope(panelScope);
|
|
730
|
+
setFillViewportScope(null);
|
|
731
|
+
mutationFailed(error);
|
|
732
|
+
},
|
|
733
|
+
});
|
|
1246
734
|
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
735
|
+
const anyMutationPending =
|
|
736
|
+
acquireMutation.isPending ||
|
|
737
|
+
releaseMutation.isPending ||
|
|
738
|
+
navigateMutation.isPending ||
|
|
739
|
+
resizeMutation.isPending ||
|
|
740
|
+
densityControl.pending ||
|
|
741
|
+
deviceMutation.isPending ||
|
|
742
|
+
inputMutation.isPending ||
|
|
743
|
+
controlRequestPending ||
|
|
744
|
+
closePending;
|
|
745
|
+
const videoViewerExpired =
|
|
746
|
+
video.errorViewerToken === viewerToken && isExpiredBrowserViewerError(video.error);
|
|
747
|
+
const viewingExpired = videoViewerExpired || isExpiredBrowserViewerError(captureQuery.error);
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
if (isBrowserClosedError(captureQuery.error) || isBrowserClosedError(video.error))
|
|
750
|
+
setBrowserClosed(true);
|
|
751
|
+
}, [captureQuery.error, video.error]);
|
|
752
|
+
const canControl = Boolean(
|
|
753
|
+
viewerToken &&
|
|
754
|
+
controlToken &&
|
|
755
|
+
state?.controller === "self" &&
|
|
756
|
+
(!selectedTabId || state.tabId === selectedTabId) &&
|
|
757
|
+
!viewingExpired,
|
|
1259
758
|
);
|
|
759
|
+
const frontLayer = buffer.front === null ? null : buffer.layers[buffer.front];
|
|
760
|
+
useLayoutEffect(() => {
|
|
761
|
+
const candidate = frontLayer?.candidate;
|
|
762
|
+
if (
|
|
763
|
+
candidate &&
|
|
764
|
+
candidate.fallbackRevision === video.fallbackRevision &&
|
|
765
|
+
candidate.mutationEpoch === inputLifecycle.epoch &&
|
|
766
|
+
candidate.viewerToken === viewerToken &&
|
|
767
|
+
state &&
|
|
768
|
+
isFrameCurrent(candidate.frame, state)
|
|
769
|
+
)
|
|
770
|
+
video.completeFallback(candidate.fallbackRevision ?? 0);
|
|
771
|
+
}, [frontLayer, video.fallbackRevision, state, viewerToken, inputLifecycle]);
|
|
772
|
+
const videoFrame =
|
|
773
|
+
video.front && state && isFrameCurrent(video.front.frame, state) ? video.front.frame : null;
|
|
774
|
+
const currentFrame =
|
|
775
|
+
videoFrame ??
|
|
776
|
+
(frame &&
|
|
777
|
+
state &&
|
|
778
|
+
frontLayer?.candidate.viewerToken === viewerToken &&
|
|
779
|
+
isFrameCurrent(frame, state)
|
|
780
|
+
? frame
|
|
781
|
+
: null);
|
|
1260
782
|
useEffect(() => {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
783
|
+
const candidate = frontLayer?.candidate;
|
|
784
|
+
if (videoFrame || !frame || !candidate || candidate.mutationEpoch !== inputLifecycle.epoch)
|
|
785
|
+
return;
|
|
786
|
+
inputLifecycle.accept(candidate.mutationEpoch, frame);
|
|
787
|
+
publishAdmittedPaint(frame.frameId, candidate.mutationEpoch);
|
|
788
|
+
setLegacyInputBusy(inputLifecycle.busy);
|
|
789
|
+
}, [frame, frontLayer, inputLifecycle, videoFrame]);
|
|
790
|
+
const canSendInput =
|
|
791
|
+
canControl && Boolean(currentFrame) && !inputMutation.isPending && !legacyInputBusy;
|
|
792
|
+
|
|
793
|
+
// Layout belongs to the decoded front frame. A denser capture adds detail,
|
|
794
|
+
// not a larger 100% layout; scale never writes to the shared browser state.
|
|
795
|
+
const visualFrame = video.front?.frame ?? frame;
|
|
796
|
+
const canvasLayout = useMemo(
|
|
1265
797
|
() =>
|
|
1266
|
-
|
|
1267
|
-
?
|
|
798
|
+
visualFrame
|
|
799
|
+
? getBrowserCanvasLayout(
|
|
1268
800
|
containerSize,
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
801
|
+
visualFrame,
|
|
802
|
+
video.front
|
|
803
|
+
? (video.frontViewport ?? visualFrame)
|
|
804
|
+
: (frontLayer?.candidate.viewport ?? state?.viewport ?? visualFrame),
|
|
805
|
+
scaleMode,
|
|
1272
806
|
)
|
|
1273
807
|
: null,
|
|
1274
|
-
[
|
|
808
|
+
[
|
|
809
|
+
containerSize,
|
|
810
|
+
visualFrame,
|
|
811
|
+
frontLayer,
|
|
812
|
+
state?.viewport,
|
|
813
|
+
scaleMode,
|
|
814
|
+
video.front,
|
|
815
|
+
video.frontViewport,
|
|
816
|
+
],
|
|
1275
817
|
);
|
|
818
|
+
const displayRect = canvasLayout?.frameRect ?? null;
|
|
1276
819
|
|
|
1277
820
|
const controlContext = useCallback(() => {
|
|
1278
821
|
const viewer = activeViewerTokenRef.current;
|
|
1279
822
|
const current = stateRef.current;
|
|
1280
|
-
if (
|
|
823
|
+
if (
|
|
824
|
+
viewingExpired ||
|
|
825
|
+
!viewer ||
|
|
826
|
+
!controlToken ||
|
|
827
|
+
!current ||
|
|
828
|
+
current.controller !== "self" ||
|
|
829
|
+
(selectedTabId && current.tabId !== selectedTabId)
|
|
830
|
+
)
|
|
831
|
+
return null;
|
|
1281
832
|
return {
|
|
1282
833
|
viewerToken: viewer,
|
|
1283
834
|
controlToken,
|
|
@@ -1287,13 +838,25 @@ export function SharedBrowserPanel({
|
|
|
1287
838
|
viewportGeneration: current.viewportGeneration,
|
|
1288
839
|
},
|
|
1289
840
|
};
|
|
1290
|
-
}, [controlToken]);
|
|
841
|
+
}, [controlToken, viewingExpired, selectedTabId]);
|
|
1291
842
|
|
|
1292
843
|
const inputContext = useCallback(() => {
|
|
1293
844
|
const context = controlContext();
|
|
1294
845
|
const current = stateRef.current;
|
|
1295
|
-
const
|
|
1296
|
-
|
|
846
|
+
const targetVideo = video.frontRef.current;
|
|
847
|
+
// A revoked video remains painted until React commits the fallback. Never
|
|
848
|
+
// borrow the JPEG token while those older video pixels still cover it.
|
|
849
|
+
if (video.front && !targetVideo) return null;
|
|
850
|
+
const targetFrame = targetVideo?.frame ?? frameRef.current;
|
|
851
|
+
if (
|
|
852
|
+
!context ||
|
|
853
|
+
!current ||
|
|
854
|
+
!targetFrame ||
|
|
855
|
+
!isFrameCurrent(targetFrame, current) ||
|
|
856
|
+
(!targetVideo && frontLayer?.candidate.viewerToken !== context.viewerToken)
|
|
857
|
+
) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
1297
860
|
return {
|
|
1298
861
|
...context,
|
|
1299
862
|
target: {
|
|
@@ -1302,7 +865,7 @@ export function SharedBrowserPanel({
|
|
|
1302
865
|
viewportGeneration: targetFrame.viewportGeneration,
|
|
1303
866
|
},
|
|
1304
867
|
};
|
|
1305
|
-
}, [controlContext]);
|
|
868
|
+
}, [controlContext, frontLayer, video.frontRef, video.front]);
|
|
1306
869
|
|
|
1307
870
|
const requireControlContext = useCallback(() => {
|
|
1308
871
|
const context = controlContext();
|
|
@@ -1325,81 +888,111 @@ export function SharedBrowserPanel({
|
|
|
1325
888
|
|
|
1326
889
|
const sendEvent = useCallback(
|
|
1327
890
|
(event: BrowserInputEvent) => {
|
|
1328
|
-
if (lifecycle.busy) return;
|
|
1329
891
|
const context = requireInputContext();
|
|
1330
|
-
if (!context || !
|
|
1331
|
-
|
|
892
|
+
if (!context || inputMutation.isPending || !inputLifecycle.begin()) return;
|
|
893
|
+
setLegacyInputBusy(true);
|
|
1332
894
|
inputMutation.mutate({ ...context, event });
|
|
1333
895
|
},
|
|
1334
|
-
[inputMutation,
|
|
896
|
+
[inputMutation, requireInputContext],
|
|
1335
897
|
);
|
|
1336
898
|
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
899
|
+
const liveInputEnabled =
|
|
900
|
+
canSendInput &&
|
|
901
|
+
!navigateMutation.isPending &&
|
|
902
|
+
!resizeMutation.isPending &&
|
|
903
|
+
!deviceMutation.isPending &&
|
|
904
|
+
!releaseMutation.isPending &&
|
|
905
|
+
!acquireMutation.isPending &&
|
|
906
|
+
toolbarMenu === null;
|
|
907
|
+
/** Existing channels retain control incarnation while their next frame decodes. */
|
|
908
|
+
const gestureControlContext = useCallback(() => {
|
|
909
|
+
const context = controlContext();
|
|
910
|
+
const current = stateRef.current;
|
|
911
|
+
if (!mountedRef.current || !context || !current?.runtimeId || current.bridgeEpoch === undefined)
|
|
912
|
+
return null;
|
|
913
|
+
return {
|
|
914
|
+
...context,
|
|
915
|
+
expected: {
|
|
916
|
+
...context.expected,
|
|
917
|
+
runtimeId: current.runtimeId,
|
|
918
|
+
bridgeEpoch: current.bridgeEpoch,
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}, [controlContext]);
|
|
1346
922
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
clickCount: interactionMode === "double" ? 2 : 1,
|
|
1381
|
-
});
|
|
923
|
+
// Forward physical mouse events and genuine touch on every remote preset.
|
|
924
|
+
const canvasInput = useBrowserCanvasInput({
|
|
925
|
+
authority: () => {
|
|
926
|
+
const input = inputContext();
|
|
927
|
+
const context = gestureControlContext();
|
|
928
|
+
return input && context ? { ...context, target: input.target } : null;
|
|
929
|
+
},
|
|
930
|
+
controlAuthority: gestureControlContext,
|
|
931
|
+
transport: { begin: beginBrowserGesture, update: updateBrowserGesture, end: endBrowserGesture },
|
|
932
|
+
ownershipKey: JSON.stringify([
|
|
933
|
+
viewerToken,
|
|
934
|
+
controlToken,
|
|
935
|
+
state?.sessionId,
|
|
936
|
+
state?.runtimeId,
|
|
937
|
+
state?.bridgeEpoch,
|
|
938
|
+
state?.navigationGeneration,
|
|
939
|
+
state?.viewportGeneration,
|
|
940
|
+
displayRect?.width,
|
|
941
|
+
displayRect?.height,
|
|
942
|
+
containerSize.width,
|
|
943
|
+
containerSize.height,
|
|
944
|
+
scaleMode,
|
|
945
|
+
liveInputEnabled,
|
|
946
|
+
]),
|
|
947
|
+
decodedFrameId: admittedPaint?.epoch === inputLifecycle.epoch ? admittedPaint.frameId : null,
|
|
948
|
+
enabled: liveInputEnabled,
|
|
949
|
+
displaySize: displayRect,
|
|
950
|
+
viewport: state?.viewport ?? null,
|
|
951
|
+
onState: acceptState,
|
|
952
|
+
onError: mutationFailed,
|
|
953
|
+
onFinish: refreshCapture,
|
|
954
|
+
onPoint: (point) => {
|
|
955
|
+
lastPointRef.current = { x: point.x, y: point.y };
|
|
1382
956
|
},
|
|
1383
|
-
|
|
1384
|
-
|
|
957
|
+
onActivity: handleInputActivity,
|
|
958
|
+
onInputAcknowledged: () => {
|
|
959
|
+
// Pixels requested before the host acknowledged this input cannot authorize
|
|
960
|
+
// the next press, even when their request began after the local press.
|
|
961
|
+
inputLifecycle.bump();
|
|
962
|
+
},
|
|
963
|
+
onInputBoundary: () => {
|
|
964
|
+
inputLifecycle.bump();
|
|
965
|
+
},
|
|
966
|
+
});
|
|
1385
967
|
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
);
|
|
968
|
+
const composeOwnershipKey = JSON.stringify([
|
|
969
|
+
viewerToken,
|
|
970
|
+
controlToken,
|
|
971
|
+
state?.sessionId,
|
|
972
|
+
state?.runtimeId,
|
|
973
|
+
state?.bridgeEpoch,
|
|
974
|
+
state?.navigationGeneration,
|
|
975
|
+
state?.viewportGeneration,
|
|
976
|
+
]);
|
|
977
|
+
const composeDocumentKey = JSON.stringify([
|
|
978
|
+
viewerToken,
|
|
979
|
+
state?.sessionId,
|
|
980
|
+
state?.runtimeId,
|
|
981
|
+
state?.bridgeEpoch,
|
|
982
|
+
state?.navigationGeneration,
|
|
983
|
+
state?.viewportGeneration,
|
|
984
|
+
]);
|
|
985
|
+
// Touch-first surfaces (native and compact web) get the explicit draft; desktop web types on the canvas.
|
|
986
|
+
const composeAvailable = layout.platform !== "web" || layout.compact;
|
|
987
|
+
const requestCompose = (kind: "compose" | "commit") => {
|
|
988
|
+
if (!canSendInput) return;
|
|
989
|
+
nextComposeRequest.current += 1;
|
|
990
|
+
closeToolbarMenu(false);
|
|
991
|
+
setComposeRequest({ id: nextComposeRequest.current, kind, ownershipKey: composeOwnershipKey });
|
|
992
|
+
};
|
|
993
|
+
const composeRequestHandled = useCallback((id: number) => {
|
|
994
|
+
setComposeRequest((current) => (current?.id === id ? null : current));
|
|
995
|
+
}, []);
|
|
1403
996
|
|
|
1404
997
|
const handleCanvasLayout = useCallback((event: LayoutChangeEvent) => {
|
|
1405
998
|
const { width, height } = event.nativeEvent.layout;
|
|
@@ -1411,11 +1004,136 @@ export function SharedBrowserPanel({
|
|
|
1411
1004
|
const takeControl = useCallback(
|
|
1412
1005
|
(takeover: boolean) => {
|
|
1413
1006
|
if (!viewerToken || acquireMutation.isPending) return;
|
|
1414
|
-
|
|
1007
|
+
inputLifecycle.bump();
|
|
1415
1008
|
acquireMutation.mutate({ viewerToken, takeover });
|
|
1416
1009
|
},
|
|
1417
1010
|
[acquireMutation, viewerToken],
|
|
1418
1011
|
);
|
|
1012
|
+
|
|
1013
|
+
/** Ask for authority before running an action, preserving its exact tab and page basis. */
|
|
1014
|
+
const requestControlled = (label: string, run: () => void, needsFrame = false) => {
|
|
1015
|
+
if (anyMutationPending || controlRequestPendingRef.current) return;
|
|
1016
|
+
if (controlContext()) {
|
|
1017
|
+
run();
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const current = stateRef.current;
|
|
1021
|
+
const viewer = activeViewerTokenRef.current;
|
|
1022
|
+
if (!current || !viewer || (selectedTabId && current.tabId !== selectedTabId) || viewingExpired)
|
|
1023
|
+
return;
|
|
1024
|
+
setControlRequest({
|
|
1025
|
+
label,
|
|
1026
|
+
basis: controlRequestBasis(
|
|
1027
|
+
panelScope,
|
|
1028
|
+
viewer,
|
|
1029
|
+
current,
|
|
1030
|
+
controllerObservationRevision.current,
|
|
1031
|
+
),
|
|
1032
|
+
needsFrame,
|
|
1033
|
+
run,
|
|
1034
|
+
});
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const requestIsCurrent = useCallback(
|
|
1038
|
+
(basis: ControlRequestBasis) =>
|
|
1039
|
+
!closedViewRef.current &&
|
|
1040
|
+
(!selectedTabIdRef.current || selectedTabIdRef.current === basis.tabId) &&
|
|
1041
|
+
isControlRequestCurrent(
|
|
1042
|
+
basis,
|
|
1043
|
+
panelScopeRef.current,
|
|
1044
|
+
activeViewerTokenRef.current,
|
|
1045
|
+
stateRef.current,
|
|
1046
|
+
controllerObservationRevision.current,
|
|
1047
|
+
),
|
|
1048
|
+
[],
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
/** Acquire once, then let the committed controlled render run the queued action once. */
|
|
1052
|
+
const confirmControlRequest = async () => {
|
|
1053
|
+
const request = controlRequest;
|
|
1054
|
+
if (!request || controlRequestPendingRef.current) return;
|
|
1055
|
+
const current = stateRef.current;
|
|
1056
|
+
if (!requestIsCurrent(request.basis)) {
|
|
1057
|
+
setControlRequest(null);
|
|
1058
|
+
setOperationError("The browser changed. Select the action again.");
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
controlRequestPendingRef.current = true;
|
|
1063
|
+
setControlRequestPending(true);
|
|
1064
|
+
setOperationError(null);
|
|
1065
|
+
try {
|
|
1066
|
+
inputLifecycle.bump();
|
|
1067
|
+
const result = await acquireControl({
|
|
1068
|
+
viewerToken: request.basis.viewerToken,
|
|
1069
|
+
takeover: current?.controller === "other",
|
|
1070
|
+
});
|
|
1071
|
+
if (!mountedRef.current || !requestIsCurrent(request.basis)) {
|
|
1072
|
+
await releaseControl({
|
|
1073
|
+
viewerToken: request.basis.viewerToken,
|
|
1074
|
+
controlToken: result.controlToken,
|
|
1075
|
+
}).catch(() => undefined);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
setControlRequest(null);
|
|
1079
|
+
if (!acceptState(result.state)) {
|
|
1080
|
+
await releaseControl({
|
|
1081
|
+
viewerToken: request.basis.viewerToken,
|
|
1082
|
+
controlToken: result.controlToken,
|
|
1083
|
+
}).catch(() => undefined);
|
|
1084
|
+
setOperationError("The browser changed. Select the action again.");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
setControlToken(result.controlToken);
|
|
1088
|
+
refreshCapture();
|
|
1089
|
+
if (
|
|
1090
|
+
requestIsCurrent(request.basis) &&
|
|
1091
|
+
isControlRequestCurrent(
|
|
1092
|
+
request.basis,
|
|
1093
|
+
panelScopeRef.current,
|
|
1094
|
+
activeViewerTokenRef.current,
|
|
1095
|
+
result.state,
|
|
1096
|
+
controllerObservationRevision.current,
|
|
1097
|
+
)
|
|
1098
|
+
) {
|
|
1099
|
+
setPendingControlAction(request);
|
|
1100
|
+
} else {
|
|
1101
|
+
setOperationError("The browser changed. Select the action again.");
|
|
1102
|
+
}
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
setControlRequest(null);
|
|
1105
|
+
mutationFailed(error);
|
|
1106
|
+
} finally {
|
|
1107
|
+
controlRequestPendingRef.current = false;
|
|
1108
|
+
if (mountedRef.current) setControlRequestPending(false);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
useEffect(() => {
|
|
1113
|
+
if (!pendingControlAction || controlRequestPending) return;
|
|
1114
|
+
if (!requestIsCurrent(pendingControlAction.basis)) {
|
|
1115
|
+
setPendingControlAction(null);
|
|
1116
|
+
setOperationError("The browser changed. Select the action again.");
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (
|
|
1120
|
+
!canControl ||
|
|
1121
|
+
(pendingControlAction.needsFrame &&
|
|
1122
|
+
(!canSendInput || admittedPaint?.epoch !== inputLifecycle.epoch))
|
|
1123
|
+
)
|
|
1124
|
+
return;
|
|
1125
|
+
setPendingControlAction(null);
|
|
1126
|
+
pendingControlAction.run();
|
|
1127
|
+
}, [
|
|
1128
|
+
pendingControlAction,
|
|
1129
|
+
controlRequestPending,
|
|
1130
|
+
canControl,
|
|
1131
|
+
canSendInput,
|
|
1132
|
+
admittedPaint,
|
|
1133
|
+
state,
|
|
1134
|
+
requestIsCurrent,
|
|
1135
|
+
]);
|
|
1136
|
+
|
|
1419
1137
|
const release = useCallback(() => {
|
|
1420
1138
|
const viewer = activeViewerTokenRef.current;
|
|
1421
1139
|
if (
|
|
@@ -1426,7 +1144,7 @@ export function SharedBrowserPanel({
|
|
|
1426
1144
|
) {
|
|
1427
1145
|
return;
|
|
1428
1146
|
}
|
|
1429
|
-
|
|
1147
|
+
inputLifecycle.bump();
|
|
1430
1148
|
releaseMutation.mutate({ viewerToken: viewer, controlToken });
|
|
1431
1149
|
}, [controlToken, releaseMutation]);
|
|
1432
1150
|
|
|
@@ -1440,11 +1158,11 @@ export function SharedBrowserPanel({
|
|
|
1440
1158
|
setOperationError("Enter an address to navigate.");
|
|
1441
1159
|
return;
|
|
1442
1160
|
}
|
|
1443
|
-
|
|
1161
|
+
inputLifecycle.bump();
|
|
1444
1162
|
navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
|
|
1445
1163
|
return;
|
|
1446
1164
|
}
|
|
1447
|
-
|
|
1165
|
+
inputLifecycle.bump();
|
|
1448
1166
|
navigateMutation.mutate({ ...context, action: { kind: action } });
|
|
1449
1167
|
},
|
|
1450
1168
|
[navigateMutation, requireControlContext],
|
|
@@ -1468,46 +1186,116 @@ export function SharedBrowserPanel({
|
|
|
1468
1186
|
);
|
|
1469
1187
|
return;
|
|
1470
1188
|
}
|
|
1471
|
-
|
|
1189
|
+
inputLifecycle.bump();
|
|
1190
|
+
setFillViewportScope(null);
|
|
1472
1191
|
resizeMutation.mutate({ ...context, viewport: { width, height } });
|
|
1473
1192
|
}, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
|
|
1474
1193
|
|
|
1194
|
+
const fillViewportLimitDensity = densityMode === "auto" ? 1 : (state?.captureScale ?? 1);
|
|
1195
|
+
const fillTarget = getFillViewportResolution(containerSize, fillViewportLimitDensity);
|
|
1196
|
+
|
|
1197
|
+
/** Enable following and resize using current geometry after control acquisition. */
|
|
1198
|
+
const fillViewport = useCallback(() => {
|
|
1199
|
+
const context = requireControlContext();
|
|
1200
|
+
if (!context || resizeMutation.isPending) return;
|
|
1201
|
+
const viewport = fillTarget;
|
|
1202
|
+
if (!viewport) {
|
|
1203
|
+
setOperationError(
|
|
1204
|
+
"The visible browser area is outside the supported resolution range at the current capture density.",
|
|
1205
|
+
);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
inputLifecycle.bump();
|
|
1209
|
+
setFillViewportScope(panelScope);
|
|
1210
|
+
setScaleMode("fit");
|
|
1211
|
+
const captureDensity =
|
|
1212
|
+
densityMode === "auto"
|
|
1213
|
+
? getAutomaticCaptureDensity(viewport, containerSize, screenPixelRatio, "fit")
|
|
1214
|
+
: null;
|
|
1215
|
+
resizeMutation.mutate({
|
|
1216
|
+
...context,
|
|
1217
|
+
viewport,
|
|
1218
|
+
preserveEmulation: true,
|
|
1219
|
+
...(captureDensity === null ? {} : { captureDensity }),
|
|
1220
|
+
});
|
|
1221
|
+
}, [
|
|
1222
|
+
requireControlContext,
|
|
1223
|
+
resizeMutation,
|
|
1224
|
+
containerSize,
|
|
1225
|
+
inputLifecycle,
|
|
1226
|
+
panelScope,
|
|
1227
|
+
fillTarget,
|
|
1228
|
+
densityMode,
|
|
1229
|
+
screenPixelRatio,
|
|
1230
|
+
]);
|
|
1231
|
+
|
|
1232
|
+
useBrowserFillViewport({
|
|
1233
|
+
identity: mutationIdentity,
|
|
1234
|
+
enabled: fillViewportEnabled,
|
|
1235
|
+
canResize: canControl && !activeInput && !anyMutationPending && state?.status === "ready",
|
|
1236
|
+
target: fillTarget,
|
|
1237
|
+
viewport: state?.viewport ?? null,
|
|
1238
|
+
apply: () => {
|
|
1239
|
+
if (!activeInputRef.current) fillViewport();
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
const followingNeedsResize =
|
|
1244
|
+
fillViewportEnabled &&
|
|
1245
|
+
(fillTarget?.width !== state?.viewport.width || fillTarget?.height !== state?.viewport.height);
|
|
1246
|
+
useBrowserAutoCaptureDensity({
|
|
1247
|
+
identity: mutationIdentity,
|
|
1248
|
+
enabled: automaticDensityEnabled,
|
|
1249
|
+
canChange:
|
|
1250
|
+
canControl &&
|
|
1251
|
+
!activeInput &&
|
|
1252
|
+
!anyMutationPending &&
|
|
1253
|
+
!followingNeedsResize &&
|
|
1254
|
+
state?.status === "ready",
|
|
1255
|
+
target: state
|
|
1256
|
+
? getAutomaticCaptureDensity(state.viewport, containerSize, screenPixelRatio, scaleMode)
|
|
1257
|
+
: null,
|
|
1258
|
+
currentDensity: state?.captureScale ?? 1,
|
|
1259
|
+
apply: (density) => {
|
|
1260
|
+
if (!activeInputRef.current) densityControl.select(density);
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1475
1264
|
const selectDevicePreset = useCallback(
|
|
1476
1265
|
(presetId: DevicePresetId) => {
|
|
1477
1266
|
const context = requireControlContext();
|
|
1478
1267
|
if (!context || deviceMutation.isPending) return;
|
|
1479
|
-
|
|
1268
|
+
inputLifecycle.bump();
|
|
1269
|
+
setFillViewportScope(null);
|
|
1480
1270
|
setDevicePickerOpen(false);
|
|
1481
1271
|
deviceMutation.mutate({ ...context, presetId });
|
|
1482
1272
|
},
|
|
1483
1273
|
[deviceMutation, requireControlContext],
|
|
1484
1274
|
);
|
|
1485
1275
|
|
|
1486
|
-
const
|
|
1487
|
-
(
|
|
1488
|
-
const
|
|
1489
|
-
if (!
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
const y = previous && previous.y <= displayRect.height ? previous.y : displayRect.height / 2;
|
|
1493
|
-
sendEvent({
|
|
1494
|
-
kind: "scroll",
|
|
1495
|
-
point: toViewportPoint({ x, y }, displayRect, viewport),
|
|
1496
|
-
deltaX,
|
|
1497
|
-
deltaY,
|
|
1498
|
-
});
|
|
1276
|
+
const applyEmulationSelection = useCallback(
|
|
1277
|
+
(selection: EmulationSelection) => {
|
|
1278
|
+
const context = requireControlContext();
|
|
1279
|
+
if (!context || anyMutationPending) return;
|
|
1280
|
+
inputLifecycle.bump();
|
|
1281
|
+
deviceMutation.mutate({ ...context, ...selection });
|
|
1499
1282
|
},
|
|
1500
|
-
[
|
|
1283
|
+
[requireControlContext, anyMutationPending, inputLifecycle, deviceMutation],
|
|
1501
1284
|
);
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1285
|
+
const emulation = useBrowserEmulationMode({
|
|
1286
|
+
identity: JSON.stringify([host.id, workspaceId]),
|
|
1287
|
+
platform: layout.platform,
|
|
1288
|
+
state,
|
|
1289
|
+
canControl,
|
|
1290
|
+
pending: anyMutationPending || Boolean(pendingControlAction),
|
|
1291
|
+
apply: applyEmulationSelection,
|
|
1292
|
+
});
|
|
1507
1293
|
|
|
1508
1294
|
const reconnect = useCallback(() => {
|
|
1509
1295
|
if (attachQuery.isFetching) return;
|
|
1510
|
-
|
|
1296
|
+
inputLifecycle.bump();
|
|
1297
|
+
// Immediately revoke local input while the viewing-only reattachment awaits.
|
|
1298
|
+
activeViewerTokenRef.current = null;
|
|
1511
1299
|
setReconnecting(true);
|
|
1512
1300
|
setControlToken(null);
|
|
1513
1301
|
setOperationError(null);
|
|
@@ -1516,7 +1304,150 @@ export function SharedBrowserPanel({
|
|
|
1516
1304
|
});
|
|
1517
1305
|
}, [attachQuery.isFetching, attachQuery.refetch]);
|
|
1518
1306
|
|
|
1519
|
-
|
|
1307
|
+
/** Add a page to the shared profile and select it only for this panel. */
|
|
1308
|
+
const createBrowserTab = async () => {
|
|
1309
|
+
const viewerToken = activeViewerTokenRef.current;
|
|
1310
|
+
if (!viewerToken || tabMutationPending) return;
|
|
1311
|
+
setTabMutationPending(true);
|
|
1312
|
+
setOperationError(null);
|
|
1313
|
+
try {
|
|
1314
|
+
const created = await createTab({ viewerToken });
|
|
1315
|
+
// A pre-creation list can otherwise undo selection through the missing-tab
|
|
1316
|
+
// fallback. Cancel its receipt and include the confirmed blank page first.
|
|
1317
|
+
await queryClient.cancelQueries({ queryKey: tabsQueryKey, exact: true });
|
|
1318
|
+
if (
|
|
1319
|
+
!mountedRef.current ||
|
|
1320
|
+
panelScopeRef.current !== panelScope ||
|
|
1321
|
+
activeViewerTokenRef.current !== viewerToken
|
|
1322
|
+
) {
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
queryClient.setQueryData<Awaited<ReturnType<typeof listTabs>>>(tabsQueryKey, (previous) => {
|
|
1326
|
+
if (previous?.tabs.some((tab) => tab.id === created.tabId)) return previous;
|
|
1327
|
+
return {
|
|
1328
|
+
tabs: [
|
|
1329
|
+
...(previous?.tabs ?? []),
|
|
1330
|
+
{
|
|
1331
|
+
id: created.tabId,
|
|
1332
|
+
title: "",
|
|
1333
|
+
url: "about:blank",
|
|
1334
|
+
viewerCount: 0,
|
|
1335
|
+
controllerLabel: null,
|
|
1336
|
+
},
|
|
1337
|
+
],
|
|
1338
|
+
};
|
|
1339
|
+
});
|
|
1340
|
+
setSelectedTabId(created.tabId);
|
|
1341
|
+
void tabsQuery.refetch();
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
setOperationError(errorMessage(error));
|
|
1344
|
+
} finally {
|
|
1345
|
+
setTabMutationPending(false);
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
/** Close only the controlled page; other tabs and their viewers stay attached. */
|
|
1350
|
+
const confirmCloseSelectedTab = async () => {
|
|
1351
|
+
const viewerToken = activeViewerTokenRef.current;
|
|
1352
|
+
const tabId = selectedTabId ?? stateRef.current?.tabId;
|
|
1353
|
+
if (!viewerToken || !controlToken || !tabId || tabMutationPending) return;
|
|
1354
|
+
setTabMutationPending(true);
|
|
1355
|
+
setOperationError(null);
|
|
1356
|
+
try {
|
|
1357
|
+
await closeTab({ viewerToken, controlToken, tabId });
|
|
1358
|
+
setTabCloseConfirmationOpen(false);
|
|
1359
|
+
const fallback = tabsQuery.data?.tabs.find((tab) => tab.id !== tabId);
|
|
1360
|
+
setSelectedTabId(fallback?.id ?? null);
|
|
1361
|
+
void tabsQuery.refetch();
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
setOperationError(errorMessage(error));
|
|
1364
|
+
} finally {
|
|
1365
|
+
setTabMutationPending(false);
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
/** Confirmed close ends every viewer's runtime and immediately drops this panel's media. */
|
|
1370
|
+
const confirmCloseBrowser = async () => {
|
|
1371
|
+
const current = stateRef.current;
|
|
1372
|
+
const viewerToken = activeViewerTokenRef.current;
|
|
1373
|
+
if (!current?.runtimeId || !viewerToken || !controlToken || closePending) return;
|
|
1374
|
+
setClosePending(true);
|
|
1375
|
+
setOperationError(null);
|
|
1376
|
+
try {
|
|
1377
|
+
await closeBrowser({
|
|
1378
|
+
viewerToken,
|
|
1379
|
+
controlToken,
|
|
1380
|
+
sessionId: current.sessionId,
|
|
1381
|
+
runtimeId: current.runtimeId,
|
|
1382
|
+
});
|
|
1383
|
+
if (panelScopeRef.current !== panelScope) return;
|
|
1384
|
+
inputLifecycle.bump();
|
|
1385
|
+
activeViewerTokenRef.current = null;
|
|
1386
|
+
stateRef.current = null;
|
|
1387
|
+
frameRef.current = null;
|
|
1388
|
+
reset();
|
|
1389
|
+
setState(null);
|
|
1390
|
+
setControlToken(null);
|
|
1391
|
+
setBrowserClosed(true);
|
|
1392
|
+
setCloseConfirmationOpen(false);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
setOperationError(errorMessage(error));
|
|
1395
|
+
} finally {
|
|
1396
|
+
setClosePending(false);
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
// Queued actions always resolve through this render's scoped mutation callbacks.
|
|
1401
|
+
// A callback captured before acquisition must never reuse its old control identity.
|
|
1402
|
+
controlledActionsRef.current = {
|
|
1403
|
+
navigate,
|
|
1404
|
+
applyViewport,
|
|
1405
|
+
fillViewport,
|
|
1406
|
+
selectDevicePreset,
|
|
1407
|
+
changeDensity: (value) => {
|
|
1408
|
+
setDensitySelection({ scope: panelScope, mode: value });
|
|
1409
|
+
setAutomaticDensityFailureScope(null);
|
|
1410
|
+
if (value !== "auto") densityControl.select(value);
|
|
1411
|
+
},
|
|
1412
|
+
toggleEmulation: emulation.toggle,
|
|
1413
|
+
sendKey: (key) => sendEvent({ kind: "key", key }),
|
|
1414
|
+
openCompose: requestCompose,
|
|
1415
|
+
closeTab: () => setTabCloseConfirmationOpen(true),
|
|
1416
|
+
closeBrowser: () => setCloseConfirmationOpen(true),
|
|
1417
|
+
confirmCloseTab: () => void confirmCloseSelectedTab(),
|
|
1418
|
+
confirmCloseBrowser: () => void confirmCloseBrowser(),
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
/** Explicit user action is the only way to clear the server's closed fence. */
|
|
1422
|
+
const reopenClosedBrowser = async () => {
|
|
1423
|
+
if (reopenPending) return;
|
|
1424
|
+
setReopenPending(true);
|
|
1425
|
+
setOperationError(null);
|
|
1426
|
+
try {
|
|
1427
|
+
await reopenBrowser({ workspaceId });
|
|
1428
|
+
if (panelScopeRef.current !== panelScope) return;
|
|
1429
|
+
setBrowserClosed(false);
|
|
1430
|
+
reconnect();
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
setOperationError(errorMessage(error));
|
|
1433
|
+
} finally {
|
|
1434
|
+
setReopenPending(false);
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
useBrowserViewerRecovery({
|
|
1439
|
+
identity: JSON.stringify([host.id, workspaceId]),
|
|
1440
|
+
viewerToken,
|
|
1441
|
+
failedViewerToken: videoViewerExpired ? video.errorViewerToken : viewerToken,
|
|
1442
|
+
captureError: videoViewerExpired ? video.error : captureQuery.error,
|
|
1443
|
+
pending: reconnecting || attachQuery.isFetching,
|
|
1444
|
+
reconnect,
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
// Expiry belongs to the viewing-only recovery path, not an action failure.
|
|
1448
|
+
// A failed reattachment still surfaces its attachQuery error and manual retry.
|
|
1449
|
+
const connectionError =
|
|
1450
|
+
attachQuery.error ?? (viewingExpired ? null : (captureQuery.error ?? video.error));
|
|
1520
1451
|
const recoveryError =
|
|
1521
1452
|
state?.recoveryState === "runtime-unavailable"
|
|
1522
1453
|
? (state.error ?? "Browser runtime unavailable.")
|
|
@@ -1525,7 +1456,8 @@ export function SharedBrowserPanel({
|
|
|
1525
1456
|
operationError ??
|
|
1526
1457
|
recoveryError ??
|
|
1527
1458
|
state?.error ??
|
|
1528
|
-
(connectionError ? errorMessage(connectionError) : null)
|
|
1459
|
+
(connectionError ? errorMessage(connectionError) : null) ??
|
|
1460
|
+
(tabsQuery.error ? errorMessage(tabsQuery.error) : null);
|
|
1529
1461
|
const statusColor =
|
|
1530
1462
|
state?.status === "ready"
|
|
1531
1463
|
? theme.colors.statusSuccess
|
|
@@ -1534,27 +1466,33 @@ export function SharedBrowserPanel({
|
|
|
1534
1466
|
: theme.colors.statusWarning;
|
|
1535
1467
|
const statusLabel =
|
|
1536
1468
|
state?.status === "ready" ? "Ready" : state?.status === "error" ? "Error" : "Starting";
|
|
1537
|
-
const leaseExpiry = state?.controllerExpiresAt
|
|
1538
|
-
? new Date(state.controllerExpiresAt).toLocaleTimeString()
|
|
1539
|
-
: null;
|
|
1540
|
-
const leaseDetail = leaseExpiry ? ` · lease until ${leaseExpiry}` : "";
|
|
1541
|
-
const controllerLabel =
|
|
1542
|
-
state?.controller === "self"
|
|
1543
|
-
? controlToken
|
|
1544
|
-
? `You have control${leaseDetail}`
|
|
1545
|
-
: "Control token unavailable"
|
|
1546
|
-
: state?.controller === "other"
|
|
1547
|
-
? `${state.controllerLabel ?? "Another viewer"} has control${leaseDetail}`
|
|
1548
|
-
: "Observe-only · no controller";
|
|
1549
1469
|
const activeDevicePreset = state?.devicePresetId
|
|
1550
1470
|
? DEVICE_PRESETS.find(({ id }) => id === state.devicePresetId)
|
|
1551
1471
|
: null;
|
|
1552
|
-
const
|
|
1553
|
-
const
|
|
1554
|
-
const
|
|
1472
|
+
const selectedResolutionPresetId = fillViewportEnabled ? null : matchingResolutionPresetId(state);
|
|
1473
|
+
const fillViewportResolution = fillTarget;
|
|
1474
|
+
const fillViewportDetail = fillViewportResolution
|
|
1475
|
+
? `${fillViewportResolution.width} × ${fillViewportResolution.height}`
|
|
1476
|
+
: `Requires ${MIN_VIEWPORT.width}–${Math.floor(MAX_VIEWPORT.width / fillViewportLimitDensity)} × ${MIN_VIEWPORT.height}–${Math.floor(MAX_VIEWPORT.height / fillViewportLimitDensity)} at current capture density`;
|
|
1477
|
+
let deviceLabel = `${emulation.mode === "mobile" ? "Mobile" : "Desktop"} · custom display`;
|
|
1478
|
+
if (fillViewportEnabled) {
|
|
1479
|
+
deviceLabel = "Fill viewport";
|
|
1480
|
+
} else if (selectedResolutionPresetId) {
|
|
1481
|
+
deviceLabel = activeDevicePreset?.label ?? "Custom display";
|
|
1482
|
+
}
|
|
1483
|
+
const transportLabel =
|
|
1484
|
+
video.front && video.fallbackRevision
|
|
1485
|
+
? "Updating video"
|
|
1486
|
+
: video.front
|
|
1487
|
+
? "Video"
|
|
1488
|
+
: frame?.transport === "cdp-screencast"
|
|
1489
|
+
? "CDP"
|
|
1490
|
+
: "fallback";
|
|
1491
|
+
const summaryFrame = video.front?.frame ?? currentFrame;
|
|
1492
|
+
const frameSummary = summaryFrame
|
|
1555
1493
|
? layout.compact
|
|
1556
|
-
? `${
|
|
1557
|
-
: `${
|
|
1494
|
+
? `${summaryFrame.width}×${summaryFrame.height} · ${transportLabel}`
|
|
1495
|
+
: `${summaryFrame.width} × ${summaryFrame.height} · ${Math.ceil((video.front ? (video.front.dataBase64.length * 3) / 4 : (frame?.byteLength ?? 0)) / BYTES_PER_KIBIBYTE)} KB · ${transportLabel} · ${deviceLabel}`
|
|
1558
1496
|
: state
|
|
1559
1497
|
? `${state.viewport.width} × ${state.viewport.height} canonical`
|
|
1560
1498
|
: "No frame";
|
|
@@ -1567,6 +1505,7 @@ export function SharedBrowserPanel({
|
|
|
1567
1505
|
theme={theme}
|
|
1568
1506
|
label="Release"
|
|
1569
1507
|
icon="LogOut"
|
|
1508
|
+
iconOnly
|
|
1570
1509
|
disabled={anyMutationPending}
|
|
1571
1510
|
onPress={release}
|
|
1572
1511
|
/>
|
|
@@ -1578,6 +1517,7 @@ export function SharedBrowserPanel({
|
|
|
1578
1517
|
theme={theme}
|
|
1579
1518
|
label="Take over"
|
|
1580
1519
|
icon="Crown"
|
|
1520
|
+
iconOnly
|
|
1581
1521
|
danger
|
|
1582
1522
|
disabled={!viewerToken || anyMutationPending}
|
|
1583
1523
|
onPress={() => takeControl(true)}
|
|
@@ -1590,6 +1530,7 @@ export function SharedBrowserPanel({
|
|
|
1590
1530
|
theme={theme}
|
|
1591
1531
|
label={state?.controller === "self" ? "Reacquire" : "Take control"}
|
|
1592
1532
|
icon="MousePointer2"
|
|
1533
|
+
iconOnly
|
|
1593
1534
|
primary
|
|
1594
1535
|
disabled={!viewerToken || anyMutationPending}
|
|
1595
1536
|
onPress={() => takeControl(false)}
|
|
@@ -1597,17 +1538,6 @@ export function SharedBrowserPanel({
|
|
|
1597
1538
|
);
|
|
1598
1539
|
}
|
|
1599
1540
|
|
|
1600
|
-
const imageStyle = displayRect
|
|
1601
|
-
? [
|
|
1602
|
-
styles.frame,
|
|
1603
|
-
{
|
|
1604
|
-
left: displayRect.x,
|
|
1605
|
-
top: displayRect.y,
|
|
1606
|
-
width: displayRect.width,
|
|
1607
|
-
height: displayRect.height,
|
|
1608
|
-
},
|
|
1609
|
-
]
|
|
1610
|
-
: styles.frame;
|
|
1611
1541
|
const interactionStyle = displayRect
|
|
1612
1542
|
? [
|
|
1613
1543
|
styles.interactionLayer,
|
|
@@ -1620,8 +1550,69 @@ export function SharedBrowserPanel({
|
|
|
1620
1550
|
]
|
|
1621
1551
|
: styles.interactionLayer;
|
|
1622
1552
|
|
|
1553
|
+
if (closedView) {
|
|
1554
|
+
return (
|
|
1555
|
+
<View style={styles.screen}>
|
|
1556
|
+
<CanvasPlaceholder
|
|
1557
|
+
styles={styles}
|
|
1558
|
+
theme={theme}
|
|
1559
|
+
title="Shared browser closed"
|
|
1560
|
+
detail="The browser was closed for everyone. Its saved site data is available when you reopen it."
|
|
1561
|
+
>
|
|
1562
|
+
<ControlButton
|
|
1563
|
+
styles={styles}
|
|
1564
|
+
theme={theme}
|
|
1565
|
+
label={reopenPending ? "Opening…" : "Open shared browser"}
|
|
1566
|
+
icon="PanelsTopLeft"
|
|
1567
|
+
primary
|
|
1568
|
+
large
|
|
1569
|
+
disabled={reopenPending}
|
|
1570
|
+
onPress={() => void reopenClosedBrowser()}
|
|
1571
|
+
/>
|
|
1572
|
+
</CanvasPlaceholder>
|
|
1573
|
+
{operationError ? (
|
|
1574
|
+
<ErrorNotice styles={styles} theme={theme} message={operationError} />
|
|
1575
|
+
) : null}
|
|
1576
|
+
</View>
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1623
1580
|
return (
|
|
1624
|
-
<View
|
|
1581
|
+
<View
|
|
1582
|
+
ref={paneRef}
|
|
1583
|
+
collapsable={false}
|
|
1584
|
+
style={styles.screen}
|
|
1585
|
+
onLayout={(event) => {
|
|
1586
|
+
const { width, height } = event.nativeEvent.layout;
|
|
1587
|
+
setPaneSize((previous) =>
|
|
1588
|
+
previous.width === width && previous.height === height ? previous : { width, height },
|
|
1589
|
+
);
|
|
1590
|
+
}}
|
|
1591
|
+
>
|
|
1592
|
+
<BrowserTabs
|
|
1593
|
+
styles={styles}
|
|
1594
|
+
theme={theme}
|
|
1595
|
+
tabs={tabsQuery.data?.tabs ?? []}
|
|
1596
|
+
selectedId={selectedTabId ?? state?.tabId ?? null}
|
|
1597
|
+
selectedState={state}
|
|
1598
|
+
statusColor={statusColor}
|
|
1599
|
+
statusLabel={statusLabel}
|
|
1600
|
+
controlAction={controlAction}
|
|
1601
|
+
canCreate={
|
|
1602
|
+
Boolean(viewerToken) &&
|
|
1603
|
+
!tabMutationPending &&
|
|
1604
|
+
(tabsQuery.data?.tabs.length ?? 0) < MAX_BROWSER_TABS
|
|
1605
|
+
}
|
|
1606
|
+
canClose={
|
|
1607
|
+
Boolean(viewerToken) && !tabMutationPending && (tabsQuery.data?.tabs.length ?? 0) > 1
|
|
1608
|
+
}
|
|
1609
|
+
onSelect={setSelectedTabId}
|
|
1610
|
+
onCreate={() => void createBrowserTab()}
|
|
1611
|
+
onClose={() =>
|
|
1612
|
+
requestControlled("close this tab", () => controlledActionsRef.current?.closeTab())
|
|
1613
|
+
}
|
|
1614
|
+
/>
|
|
1615
|
+
|
|
1625
1616
|
<View style={styles.chrome}>
|
|
1626
1617
|
<View style={styles.addressRow}>
|
|
1627
1618
|
<ChromeIconButton
|
|
@@ -1629,24 +1620,34 @@ export function SharedBrowserPanel({
|
|
|
1629
1620
|
theme={theme}
|
|
1630
1621
|
label="Back"
|
|
1631
1622
|
icon="ArrowLeft"
|
|
1632
|
-
disabled={!
|
|
1633
|
-
onPress={() =>
|
|
1623
|
+
disabled={!viewerToken || !state?.canGoBack || anyMutationPending}
|
|
1624
|
+
onPress={() =>
|
|
1625
|
+
requestControlled("go back", () => controlledActionsRef.current?.navigate("back"))
|
|
1626
|
+
}
|
|
1634
1627
|
/>
|
|
1635
1628
|
<ChromeIconButton
|
|
1636
1629
|
styles={styles}
|
|
1637
1630
|
theme={theme}
|
|
1638
1631
|
label="Forward"
|
|
1639
1632
|
icon="ArrowRight"
|
|
1640
|
-
disabled={!
|
|
1641
|
-
onPress={() =>
|
|
1633
|
+
disabled={!viewerToken || !state?.canGoForward || anyMutationPending}
|
|
1634
|
+
onPress={() =>
|
|
1635
|
+
requestControlled("go forward", () =>
|
|
1636
|
+
controlledActionsRef.current?.navigate("forward"),
|
|
1637
|
+
)
|
|
1638
|
+
}
|
|
1642
1639
|
/>
|
|
1643
1640
|
<ChromeIconButton
|
|
1644
1641
|
styles={styles}
|
|
1645
1642
|
theme={theme}
|
|
1646
1643
|
label="Reload"
|
|
1647
1644
|
icon="RotateCw"
|
|
1648
|
-
disabled={!
|
|
1649
|
-
onPress={() =>
|
|
1645
|
+
disabled={!viewerToken || anyMutationPending}
|
|
1646
|
+
onPress={() =>
|
|
1647
|
+
requestControlled("reload the page", () =>
|
|
1648
|
+
controlledActionsRef.current?.navigate("reload"),
|
|
1649
|
+
)
|
|
1650
|
+
}
|
|
1650
1651
|
/>
|
|
1651
1652
|
<Field
|
|
1652
1653
|
styles={styles}
|
|
@@ -1654,48 +1655,65 @@ export function SharedBrowserPanel({
|
|
|
1654
1655
|
value={addressDraft}
|
|
1655
1656
|
accessibilityLabel="Browser address"
|
|
1656
1657
|
placeholder="Enter a URL"
|
|
1657
|
-
editable={
|
|
1658
|
+
editable={Boolean(viewerToken) && !navigateMutation.isPending}
|
|
1659
|
+
dimWhenReadOnly={false}
|
|
1660
|
+
keyboardType="url"
|
|
1658
1661
|
maxLength={MAX_URL_LENGTH}
|
|
1659
1662
|
returnKeyType="go"
|
|
1660
1663
|
style={[styles.addressInput, styles.chromeAddressInput]}
|
|
1661
1664
|
onChangeText={setAddressDraft}
|
|
1662
1665
|
onFocus={() => setAddressFocused(true)}
|
|
1663
1666
|
onBlur={() => setAddressFocused(false)}
|
|
1664
|
-
onSubmit={() =>
|
|
1667
|
+
onSubmit={() => {
|
|
1668
|
+
const target = addressDraft;
|
|
1669
|
+
requestControlled("open this address", () =>
|
|
1670
|
+
controlledActionsRef.current?.navigate("goto", target),
|
|
1671
|
+
);
|
|
1672
|
+
}}
|
|
1665
1673
|
/>
|
|
1674
|
+
<View ref={displayAnchorRef} collapsable={false}>
|
|
1675
|
+
<ChromeIconButton
|
|
1676
|
+
styles={styles}
|
|
1677
|
+
theme={theme}
|
|
1678
|
+
label="Display options"
|
|
1679
|
+
icon="Monitor"
|
|
1680
|
+
selected={toolbarMenu === "display"}
|
|
1681
|
+
expanded={toolbarMenu === "display"}
|
|
1682
|
+
onPress={() => toggleToolbarMenu("display")}
|
|
1683
|
+
/>
|
|
1684
|
+
</View>
|
|
1666
1685
|
<ChromeIconButton
|
|
1667
1686
|
styles={styles}
|
|
1668
1687
|
theme={theme}
|
|
1669
|
-
label={
|
|
1670
|
-
icon=
|
|
1671
|
-
selected={
|
|
1672
|
-
disabled={!
|
|
1673
|
-
onPress={() =>
|
|
1688
|
+
label={`${emulation.mode === "mobile" ? "Disable" : "Enable"} mobile emulation`}
|
|
1689
|
+
icon="Smartphone"
|
|
1690
|
+
selected={emulation.mode === "mobile"}
|
|
1691
|
+
disabled={!viewerToken || anyMutationPending}
|
|
1692
|
+
onPress={() =>
|
|
1693
|
+
requestControlled("change mobile emulation", () =>
|
|
1694
|
+
controlledActionsRef.current?.toggleEmulation(),
|
|
1695
|
+
)
|
|
1696
|
+
}
|
|
1674
1697
|
/>
|
|
1698
|
+
<View ref={actionsAnchorRef} collapsable={false}>
|
|
1699
|
+
<ChromeIconButton
|
|
1700
|
+
styles={styles}
|
|
1701
|
+
theme={theme}
|
|
1702
|
+
label="Browser menu"
|
|
1703
|
+
icon="EllipsisVertical"
|
|
1704
|
+
selected={toolbarMenu === "actions"}
|
|
1705
|
+
expanded={toolbarMenu === "actions"}
|
|
1706
|
+
onPress={() => toggleToolbarMenu("actions")}
|
|
1707
|
+
/>
|
|
1708
|
+
</View>
|
|
1675
1709
|
</View>
|
|
1676
1710
|
</View>
|
|
1677
1711
|
|
|
1678
|
-
|
|
1679
|
-
<View style={styles.statusSummary}>
|
|
1680
|
-
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
|
1681
|
-
<Text style={styles.statusText}>{state ? statusLabel : "Connecting"}</Text>
|
|
1682
|
-
<Text style={styles.mutedText}>
|
|
1683
|
-
{state
|
|
1684
|
-
? `${state.viewerCount} viewer${state.viewerCount === 1 ? "" : "s"}`
|
|
1685
|
-
: viewerLabel}
|
|
1686
|
-
</Text>
|
|
1687
|
-
<Text numberOfLines={1} style={styles.controllerText}>
|
|
1688
|
-
{state ? controllerLabel : "Attaching to workspace browser"}
|
|
1689
|
-
</Text>
|
|
1690
|
-
</View>
|
|
1691
|
-
<View style={styles.actionRow}>{controlAction}</View>
|
|
1692
|
-
</View>
|
|
1693
|
-
|
|
1694
|
-
{runtimeNotice || visibleError ? (
|
|
1712
|
+
{runtimeNotice || visibleError || state?.notice ? (
|
|
1695
1713
|
<ErrorNotice
|
|
1696
1714
|
styles={styles}
|
|
1697
1715
|
theme={theme}
|
|
1698
|
-
message={runtimeNotice ?? visibleError ?? ""}
|
|
1716
|
+
message={runtimeNotice ?? visibleError ?? state?.notice ?? ""}
|
|
1699
1717
|
action={connectionError || reconnecting ? "Reconnect" : undefined}
|
|
1700
1718
|
onAction={connectionError || reconnecting ? reconnect : undefined}
|
|
1701
1719
|
actionDisabled={attachQuery.isFetching}
|
|
@@ -1703,31 +1721,64 @@ export function SharedBrowserPanel({
|
|
|
1703
1721
|
) : null}
|
|
1704
1722
|
|
|
1705
1723
|
<View style={styles.canvasShell}>
|
|
1706
|
-
<
|
|
1707
|
-
{
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1724
|
+
<BrowserCanvasViewport
|
|
1725
|
+
mode={scaleMode}
|
|
1726
|
+
style={styles.canvas}
|
|
1727
|
+
contentSize={canvasLayout?.contentSize ?? containerSize}
|
|
1728
|
+
localPanEnabled={layout.platform === "web" || !canControl}
|
|
1729
|
+
onLayout={handleCanvasLayout}
|
|
1730
|
+
>
|
|
1731
|
+
{buffer.layers.map((layer, slot) => {
|
|
1732
|
+
if (!layer) return null;
|
|
1733
|
+
const rect = getBrowserCanvasLayout(
|
|
1734
|
+
containerSize,
|
|
1735
|
+
layer.candidate.frame,
|
|
1736
|
+
layer.candidate.viewport ?? state?.viewport ?? layer.candidate.frame,
|
|
1737
|
+
scaleMode,
|
|
1738
|
+
)?.frameRect;
|
|
1739
|
+
if (!rect) return null;
|
|
1740
|
+
const visible = slot === buffer.front;
|
|
1741
|
+
return (
|
|
1742
|
+
<BrowserFrameImage
|
|
1743
|
+
key={layer.ticket}
|
|
1744
|
+
ticket={layer.ticket}
|
|
1745
|
+
visible={visible}
|
|
1746
|
+
label={state?.title ? `Shared browser: ${state.title}` : "Shared browser frame"}
|
|
1747
|
+
settled={settled}
|
|
1748
|
+
retry={retryFrameCapture}
|
|
1749
|
+
source={layer.source}
|
|
1750
|
+
x={rect.x}
|
|
1751
|
+
y={rect.y}
|
|
1752
|
+
width={rect.width}
|
|
1753
|
+
height={rect.height}
|
|
1754
|
+
cornerRadius={layout.compact ? DIMENSION.screenRadius - SPACE.xs : 0}
|
|
1728
1755
|
/>
|
|
1729
|
-
|
|
1730
|
-
)
|
|
1756
|
+
);
|
|
1757
|
+
})}
|
|
1758
|
+
{video.supported ? (
|
|
1759
|
+
<BrowserVideoSurface
|
|
1760
|
+
canvasRef={video.canvasRef}
|
|
1761
|
+
style={{
|
|
1762
|
+
position: "absolute",
|
|
1763
|
+
left: displayRect?.x ?? 0,
|
|
1764
|
+
top: displayRect?.y ?? 0,
|
|
1765
|
+
width: displayRect?.width ?? containerSize.width,
|
|
1766
|
+
height: displayRect?.height ?? containerSize.height,
|
|
1767
|
+
opacity: video.front ? 1 : 0,
|
|
1768
|
+
overflow: "hidden",
|
|
1769
|
+
borderRadius: layout.compact ? DIMENSION.screenRadius - SPACE.xs : 0,
|
|
1770
|
+
}}
|
|
1771
|
+
/>
|
|
1772
|
+
) : null}
|
|
1773
|
+
{currentFrame && displayRect ? (
|
|
1774
|
+
<View
|
|
1775
|
+
ref={canvasInput.canvasRef}
|
|
1776
|
+
{...canvasInput.panHandlers}
|
|
1777
|
+
accessible={false}
|
|
1778
|
+
pointerEvents={liveInputEnabled ? "auto" : "none"}
|
|
1779
|
+
style={interactionStyle}
|
|
1780
|
+
/>
|
|
1781
|
+
) : video.front ? null : imageError ? (
|
|
1731
1782
|
<CanvasPlaceholder
|
|
1732
1783
|
styles={styles}
|
|
1733
1784
|
theme={theme}
|
|
@@ -1761,7 +1812,7 @@ export function SharedBrowserPanel({
|
|
|
1761
1812
|
loading={attachQuery.isPending || reconnecting || captureQuery.isFetching}
|
|
1762
1813
|
/>
|
|
1763
1814
|
)}
|
|
1764
|
-
</
|
|
1815
|
+
</BrowserCanvasViewport>
|
|
1765
1816
|
<View style={styles.canvasFooter}>
|
|
1766
1817
|
<Text numberOfLines={1} style={styles.canvasFooterText}>
|
|
1767
1818
|
{state?.title || state?.url || "Shared browser"}
|
|
@@ -1772,391 +1823,111 @@ export function SharedBrowserPanel({
|
|
|
1772
1823
|
</View>
|
|
1773
1824
|
</View>
|
|
1774
1825
|
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
icon="Send"
|
|
1797
|
-
primary
|
|
1798
|
-
large
|
|
1799
|
-
disabled={!canSendInput || !typeDraft || inputMutation.isPending}
|
|
1800
|
-
onPress={sendText}
|
|
1801
|
-
/>
|
|
1802
|
-
</View>
|
|
1803
|
-
<View style={styles.mobileRow}>
|
|
1804
|
-
<ControlButton
|
|
1805
|
-
styles={styles}
|
|
1806
|
-
theme={theme}
|
|
1807
|
-
label={swipeMode === "scroll" ? "Swipe scrolls" : "Swipe drags"}
|
|
1808
|
-
accessibilityLabel="Pointer and scrolling options"
|
|
1809
|
-
icon="MousePointer2"
|
|
1810
|
-
large
|
|
1811
|
-
fill
|
|
1812
|
-
disabled={!canControl}
|
|
1813
|
-
onPress={() => setPointerSheetOpen(true)}
|
|
1814
|
-
/>
|
|
1815
|
-
<ControlButton
|
|
1816
|
-
styles={styles}
|
|
1817
|
-
theme={theme}
|
|
1818
|
-
label="Keys"
|
|
1819
|
-
icon="Keyboard"
|
|
1820
|
-
large
|
|
1821
|
-
fill
|
|
1822
|
-
disabled={!canSendInput}
|
|
1823
|
-
onPress={() => setKeysSheetOpen(true)}
|
|
1824
|
-
/>
|
|
1825
|
-
</View>
|
|
1826
|
-
</>
|
|
1827
|
-
) : (
|
|
1828
|
-
<>
|
|
1829
|
-
<ScrollView
|
|
1830
|
-
horizontal
|
|
1831
|
-
keyboardShouldPersistTaps="handled"
|
|
1832
|
-
showsHorizontalScrollIndicator={false}
|
|
1833
|
-
contentContainerStyle={styles.toolbarContent}
|
|
1834
|
-
>
|
|
1835
|
-
<View style={styles.controlStrip}>
|
|
1836
|
-
<Text style={styles.stripLabel}>Tap</Text>
|
|
1837
|
-
<ControlButton
|
|
1838
|
-
styles={styles}
|
|
1839
|
-
theme={theme}
|
|
1840
|
-
label="Click"
|
|
1841
|
-
selected={interactionMode === "click"}
|
|
1842
|
-
disabled={!canSendInput}
|
|
1843
|
-
onPress={() => setInteractionMode("click")}
|
|
1844
|
-
/>
|
|
1845
|
-
<ControlButton
|
|
1846
|
-
styles={styles}
|
|
1847
|
-
theme={theme}
|
|
1848
|
-
label="Double"
|
|
1849
|
-
selected={interactionMode === "double"}
|
|
1850
|
-
disabled={!canSendInput}
|
|
1851
|
-
onPress={() => setInteractionMode("double")}
|
|
1852
|
-
/>
|
|
1853
|
-
<ControlButton
|
|
1854
|
-
styles={styles}
|
|
1855
|
-
theme={theme}
|
|
1856
|
-
label="Right"
|
|
1857
|
-
selected={interactionMode === "right"}
|
|
1858
|
-
disabled={!canSendInput}
|
|
1859
|
-
onPress={() => setInteractionMode("right")}
|
|
1860
|
-
/>
|
|
1861
|
-
<View style={styles.separator} />
|
|
1862
|
-
<Text style={styles.stripLabel}>Drag</Text>
|
|
1863
|
-
<ControlButton
|
|
1864
|
-
styles={styles}
|
|
1865
|
-
theme={theme}
|
|
1866
|
-
label="Scrolls"
|
|
1867
|
-
selected={swipeMode === "scroll"}
|
|
1868
|
-
disabled={!canControl}
|
|
1869
|
-
onPress={() => setSwipeMode("scroll")}
|
|
1870
|
-
/>
|
|
1871
|
-
<ControlButton
|
|
1872
|
-
styles={styles}
|
|
1873
|
-
theme={theme}
|
|
1874
|
-
label="Drags"
|
|
1875
|
-
selected={swipeMode === "drag"}
|
|
1876
|
-
disabled={!canControl}
|
|
1877
|
-
onPress={() => setSwipeMode("drag")}
|
|
1878
|
-
/>
|
|
1879
|
-
<View style={styles.separator} />
|
|
1880
|
-
<Text style={styles.stripLabel}>Scroll</Text>
|
|
1881
|
-
<ControlButton
|
|
1882
|
-
styles={styles}
|
|
1883
|
-
theme={theme}
|
|
1884
|
-
label="←"
|
|
1885
|
-
accessibilityLabel="Scroll left"
|
|
1886
|
-
disabled={!canSendInput}
|
|
1887
|
-
onPress={() => scroll(-SCROLL_STEP, 0)}
|
|
1888
|
-
/>
|
|
1889
|
-
<ControlButton
|
|
1890
|
-
styles={styles}
|
|
1891
|
-
theme={theme}
|
|
1892
|
-
label="↑"
|
|
1893
|
-
accessibilityLabel="Scroll up"
|
|
1894
|
-
disabled={!canSendInput}
|
|
1895
|
-
onPress={() => scroll(0, -SCROLL_STEP)}
|
|
1896
|
-
/>
|
|
1897
|
-
<ControlButton
|
|
1898
|
-
styles={styles}
|
|
1899
|
-
theme={theme}
|
|
1900
|
-
label="↓"
|
|
1901
|
-
accessibilityLabel="Scroll down"
|
|
1902
|
-
disabled={!canSendInput}
|
|
1903
|
-
onPress={() => scroll(0, SCROLL_STEP)}
|
|
1904
|
-
/>
|
|
1905
|
-
<ControlButton
|
|
1906
|
-
styles={styles}
|
|
1907
|
-
theme={theme}
|
|
1908
|
-
label="→"
|
|
1909
|
-
accessibilityLabel="Scroll right"
|
|
1910
|
-
disabled={!canSendInput}
|
|
1911
|
-
onPress={() => scroll(SCROLL_STEP, 0)}
|
|
1912
|
-
/>
|
|
1913
|
-
</View>
|
|
1914
|
-
</ScrollView>
|
|
1915
|
-
|
|
1916
|
-
<View style={styles.typeRow}>
|
|
1917
|
-
<Field
|
|
1918
|
-
styles={styles}
|
|
1919
|
-
theme={theme}
|
|
1920
|
-
value={typeDraft}
|
|
1921
|
-
accessibilityLabel="Text to type in the shared browser"
|
|
1922
|
-
placeholder={
|
|
1923
|
-
canSendInput
|
|
1924
|
-
? "Type into the focused page element"
|
|
1925
|
-
: "Take control and focus a page field"
|
|
1926
|
-
}
|
|
1927
|
-
editable={canSendInput}
|
|
1928
|
-
maxLength={MAX_TEXT_LENGTH}
|
|
1929
|
-
returnKeyType="send"
|
|
1930
|
-
style={styles.typeInput}
|
|
1931
|
-
onChangeText={setTypeDraft}
|
|
1932
|
-
onSubmit={sendText}
|
|
1933
|
-
/>
|
|
1934
|
-
<ControlButton
|
|
1935
|
-
styles={styles}
|
|
1936
|
-
theme={theme}
|
|
1937
|
-
label="Send"
|
|
1938
|
-
icon="Send"
|
|
1939
|
-
primary
|
|
1940
|
-
disabled={!canSendInput || !typeDraft || inputMutation.isPending}
|
|
1941
|
-
onPress={sendText}
|
|
1942
|
-
/>
|
|
1943
|
-
</View>
|
|
1944
|
-
<ScrollView
|
|
1945
|
-
horizontal
|
|
1946
|
-
keyboardShouldPersistTaps="handled"
|
|
1947
|
-
showsHorizontalScrollIndicator={false}
|
|
1948
|
-
contentContainerStyle={styles.keysContent}
|
|
1949
|
-
>
|
|
1950
|
-
<Text style={styles.stripLabel}>Keys</Text>
|
|
1951
|
-
{SPECIAL_KEYS.map(({ key, label }) => (
|
|
1952
|
-
<ControlButton
|
|
1953
|
-
key={key}
|
|
1954
|
-
styles={styles}
|
|
1955
|
-
theme={theme}
|
|
1956
|
-
label={label}
|
|
1957
|
-
accessibilityLabel={`Send ${key} key`}
|
|
1958
|
-
disabled={!canSendInput}
|
|
1959
|
-
onPress={() => sendEvent({ kind: "key", key })}
|
|
1960
|
-
/>
|
|
1961
|
-
))}
|
|
1962
|
-
</ScrollView>
|
|
1963
|
-
</>
|
|
1964
|
-
)}
|
|
1965
|
-
</View>
|
|
1826
|
+
{composeAvailable ? (
|
|
1827
|
+
<ComposeTextControls
|
|
1828
|
+
styles={styles}
|
|
1829
|
+
theme={theme}
|
|
1830
|
+
composeText={canvasInput.composeText}
|
|
1831
|
+
cancelInput={canvasInput.cancel}
|
|
1832
|
+
enabled={canSendInput}
|
|
1833
|
+
ownershipKey={composeOwnershipKey}
|
|
1834
|
+
documentKey={composeDocumentKey}
|
|
1835
|
+
request={composeRequest}
|
|
1836
|
+
onRequestHandled={composeRequestHandled}
|
|
1837
|
+
canRequestControl={Boolean(viewerToken) && !canControl}
|
|
1838
|
+
onRequestControlForCompose={() =>
|
|
1839
|
+
requestControlled(
|
|
1840
|
+
"insert the composed text",
|
|
1841
|
+
() => controlledActionsRef.current?.openCompose("commit"),
|
|
1842
|
+
true,
|
|
1843
|
+
)
|
|
1844
|
+
}
|
|
1845
|
+
/>
|
|
1846
|
+
) : null}
|
|
1966
1847
|
|
|
1967
1848
|
<Modal
|
|
1968
|
-
title="
|
|
1969
|
-
icon={<Icon name="
|
|
1970
|
-
open={
|
|
1971
|
-
onOpenChange={
|
|
1849
|
+
title="Resolution and quality"
|
|
1850
|
+
icon={<Icon name="Smartphone" size={18} color={theme.colors.foreground} />}
|
|
1851
|
+
open={devicePickerOpen}
|
|
1852
|
+
onOpenChange={setDevicePickerOpen}
|
|
1972
1853
|
>
|
|
1973
1854
|
<Modal.Content>
|
|
1974
|
-
<View style={styles.
|
|
1975
|
-
<
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
<ControlButton
|
|
2021
|
-
styles={styles}
|
|
2022
|
-
theme={theme}
|
|
2023
|
-
label="Right"
|
|
2024
|
-
large
|
|
2025
|
-
fill
|
|
2026
|
-
selected={interactionMode === "right"}
|
|
2027
|
-
disabled={!canControl}
|
|
2028
|
-
onPress={() => setInteractionMode("right")}
|
|
2029
|
-
/>
|
|
2030
|
-
</View>
|
|
2031
|
-
<Text style={styles.stripLabel}>Nudge scroll</Text>
|
|
2032
|
-
<View style={styles.sheetPad}>
|
|
2033
|
-
<ControlButton
|
|
2034
|
-
styles={styles}
|
|
2035
|
-
theme={theme}
|
|
2036
|
-
label="↑"
|
|
2037
|
-
accessibilityLabel="Scroll up"
|
|
2038
|
-
pad
|
|
2039
|
-
disabled={!canSendInput}
|
|
2040
|
-
onPress={() => scroll(0, -SCROLL_STEP)}
|
|
2041
|
-
/>
|
|
2042
|
-
<View style={styles.sheetPadRow}>
|
|
2043
|
-
<ControlButton
|
|
2044
|
-
styles={styles}
|
|
2045
|
-
theme={theme}
|
|
2046
|
-
label="←"
|
|
2047
|
-
accessibilityLabel="Scroll left"
|
|
2048
|
-
pad
|
|
2049
|
-
disabled={!canSendInput}
|
|
2050
|
-
onPress={() => scroll(-SCROLL_STEP, 0)}
|
|
2051
|
-
/>
|
|
2052
|
-
<View style={styles.padSpacer} />
|
|
1855
|
+
<View style={styles.deviceModalContent}>
|
|
1856
|
+
<BrowserResolutionPicker
|
|
1857
|
+
theme={theme}
|
|
1858
|
+
groups={groupResolutionPresets()}
|
|
1859
|
+
selectedPresetId={selectedResolutionPresetId}
|
|
1860
|
+
fillViewportResolution={fillViewportResolution}
|
|
1861
|
+
fillViewportSelected={fillViewportEnabled}
|
|
1862
|
+
fillViewportDetail={fillViewportDetail}
|
|
1863
|
+
onFillViewport={() =>
|
|
1864
|
+
requestControlled("fill the browser viewport", () =>
|
|
1865
|
+
controlledActionsRef.current?.fillViewport(),
|
|
1866
|
+
)
|
|
1867
|
+
}
|
|
1868
|
+
favoritePresetIds={preferences.favoritePresetIds}
|
|
1869
|
+
selectDisabled={!viewerToken || anyMutationPending}
|
|
1870
|
+
favoriteDisabled={preferences.disabled}
|
|
1871
|
+
density={state?.captureScale ?? 1}
|
|
1872
|
+
densityMode={densityMode}
|
|
1873
|
+
viewport={state?.viewport ?? null}
|
|
1874
|
+
onDensityChange={(density) => {
|
|
1875
|
+
requestControlled("change capture density", () =>
|
|
1876
|
+
controlledActionsRef.current?.changeDensity(density),
|
|
1877
|
+
);
|
|
1878
|
+
}}
|
|
1879
|
+
captureQuality={preferences.captureQuality}
|
|
1880
|
+
videoBitrate={preferences.videoBitrate}
|
|
1881
|
+
videoFps={preferences.videoFps}
|
|
1882
|
+
onVideoBitrateChange={preferences.changeVideoBitrate}
|
|
1883
|
+
onVideoFpsChange={preferences.changeVideoFps}
|
|
1884
|
+
onSelect={(id) =>
|
|
1885
|
+
requestControlled("change the browser resolution", () =>
|
|
1886
|
+
controlledActionsRef.current?.selectDevicePreset(id),
|
|
1887
|
+
)
|
|
1888
|
+
}
|
|
1889
|
+
onToggleFavorite={(id) => {
|
|
1890
|
+
void preferences.toggleFavorite(id);
|
|
1891
|
+
}}
|
|
1892
|
+
onQualityChange={(quality) => {
|
|
1893
|
+
void preferences.changeQuality(quality);
|
|
1894
|
+
}}
|
|
1895
|
+
/>
|
|
1896
|
+
{preferences.error ? (
|
|
1897
|
+
<View style={styles.controlStrip}>
|
|
1898
|
+
<Text accessibilityRole="alert" style={styles.mutedText}>
|
|
1899
|
+
{preferences.error}
|
|
1900
|
+
</Text>
|
|
2053
1901
|
<ControlButton
|
|
2054
1902
|
styles={styles}
|
|
2055
1903
|
theme={theme}
|
|
2056
|
-
label="
|
|
2057
|
-
|
|
2058
|
-
pad
|
|
2059
|
-
disabled={!canSendInput}
|
|
2060
|
-
onPress={() => scroll(SCROLL_STEP, 0)}
|
|
1904
|
+
label="Reload preferences"
|
|
1905
|
+
onPress={preferences.reload}
|
|
2061
1906
|
/>
|
|
2062
1907
|
</View>
|
|
2063
|
-
|
|
2064
|
-
styles={styles}
|
|
2065
|
-
theme={theme}
|
|
2066
|
-
label="↓"
|
|
2067
|
-
accessibilityLabel="Scroll down"
|
|
2068
|
-
pad
|
|
2069
|
-
disabled={!canSendInput}
|
|
2070
|
-
onPress={() => scroll(0, SCROLL_STEP)}
|
|
2071
|
-
/>
|
|
2072
|
-
</View>
|
|
2073
|
-
<Text style={styles.devicePresetDetail}>
|
|
2074
|
-
Tap the page to click. Swipe to scroll or drag, depending on the gesture above.
|
|
2075
|
-
</Text>
|
|
2076
|
-
</View>
|
|
2077
|
-
</Modal.Content>
|
|
2078
|
-
</Modal>
|
|
2079
|
-
|
|
2080
|
-
<Modal
|
|
2081
|
-
title="Keyboard keys"
|
|
2082
|
-
icon={<Icon name="Keyboard" size={18} color={theme.colors.foreground} />}
|
|
2083
|
-
open={keysSheetOpen}
|
|
2084
|
-
onOpenChange={setKeysSheetOpen}
|
|
2085
|
-
>
|
|
2086
|
-
<Modal.Content>
|
|
2087
|
-
<View style={styles.sheetContent}>
|
|
2088
|
-
<View style={styles.sheetGrid}>
|
|
2089
|
-
{SPECIAL_KEYS.map(({ key, label }) => (
|
|
2090
|
-
<ControlButton
|
|
2091
|
-
key={key}
|
|
2092
|
-
styles={styles}
|
|
2093
|
-
theme={theme}
|
|
2094
|
-
label={label}
|
|
2095
|
-
accessibilityLabel={`Send ${key} key`}
|
|
2096
|
-
large
|
|
2097
|
-
disabled={!canSendInput}
|
|
2098
|
-
onPress={() => sendEvent({ kind: "key", key })}
|
|
2099
|
-
/>
|
|
2100
|
-
))}
|
|
2101
|
-
</View>
|
|
2102
|
-
<Text style={styles.devicePresetDetail}>
|
|
2103
|
-
Keys go to the page element that currently has focus in the shared browser.
|
|
2104
|
-
</Text>
|
|
2105
|
-
</View>
|
|
2106
|
-
</Modal.Content>
|
|
2107
|
-
</Modal>
|
|
2108
|
-
<Modal
|
|
2109
|
-
title="Device emulation"
|
|
2110
|
-
icon={<Icon name="Smartphone" size={18} color={theme.colors.foreground} />}
|
|
2111
|
-
open={devicePickerOpen}
|
|
2112
|
-
onOpenChange={setDevicePickerOpen}
|
|
2113
|
-
>
|
|
2114
|
-
<Modal.Content>
|
|
2115
|
-
<View style={styles.deviceModalContent}>
|
|
2116
|
-
{DEVICE_PRESETS.map((preset) => {
|
|
2117
|
-
const selected = state?.devicePresetId === preset.id;
|
|
2118
|
-
return (
|
|
2119
|
-
<Pressable
|
|
2120
|
-
key={preset.id}
|
|
2121
|
-
accessibilityRole="button"
|
|
2122
|
-
accessibilityLabel={`Emulate ${preset.label}`}
|
|
2123
|
-
accessibilityState={{ selected }}
|
|
2124
|
-
disabled={!canControl || deviceMutation.isPending}
|
|
2125
|
-
onPress={() => selectDevicePreset(preset.id)}
|
|
2126
|
-
style={({ pressed }) => [
|
|
2127
|
-
styles.devicePresetRow,
|
|
2128
|
-
selected ? styles.devicePresetRowSelected : null,
|
|
2129
|
-
pressed ? styles.buttonPressed : null,
|
|
2130
|
-
!canControl ? styles.buttonDisabled : null,
|
|
2131
|
-
]}
|
|
2132
|
-
>
|
|
2133
|
-
<Icon
|
|
2134
|
-
name={preset.isMobile ? "Smartphone" : "Monitor"}
|
|
2135
|
-
size={18}
|
|
2136
|
-
color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
|
|
2137
|
-
/>
|
|
2138
|
-
<Text style={styles.devicePresetText}>{preset.label}</Text>
|
|
2139
|
-
<Text style={styles.devicePresetDetail}>
|
|
2140
|
-
{preset.viewport.width} × {preset.viewport.height}
|
|
2141
|
-
</Text>
|
|
2142
|
-
</Pressable>
|
|
2143
|
-
);
|
|
2144
|
-
})}
|
|
1908
|
+
) : null}
|
|
2145
1909
|
<Text style={styles.stripLabel}>Custom viewport</Text>
|
|
1910
|
+
{visibleError ? (
|
|
1911
|
+
<ErrorNotice styles={styles} theme={theme} message={visibleError} />
|
|
1912
|
+
) : null}
|
|
2146
1913
|
<View style={styles.customViewportRow}>
|
|
2147
1914
|
<Field
|
|
2148
1915
|
styles={styles}
|
|
2149
1916
|
theme={theme}
|
|
2150
1917
|
value={viewportWidth}
|
|
2151
1918
|
accessibilityLabel="Canonical viewport width"
|
|
2152
|
-
editable={
|
|
1919
|
+
editable={Boolean(viewerToken) && !resizeMutation.isPending}
|
|
2153
1920
|
keyboardType="number-pad"
|
|
2154
1921
|
inputMode="numeric"
|
|
2155
1922
|
maxLength={4}
|
|
2156
1923
|
selectTextOnFocus
|
|
2157
1924
|
style={styles.viewportField}
|
|
2158
1925
|
onChangeText={setViewportWidth}
|
|
2159
|
-
onSubmit={
|
|
1926
|
+
onSubmit={() =>
|
|
1927
|
+
requestControlled("resize the browser", () =>
|
|
1928
|
+
controlledActionsRef.current?.applyViewport(),
|
|
1929
|
+
)
|
|
1930
|
+
}
|
|
2160
1931
|
/>
|
|
2161
1932
|
<Text style={styles.multiply}>×</Text>
|
|
2162
1933
|
<Field
|
|
@@ -2164,21 +1935,29 @@ export function SharedBrowserPanel({
|
|
|
2164
1935
|
theme={theme}
|
|
2165
1936
|
value={viewportHeight}
|
|
2166
1937
|
accessibilityLabel="Canonical viewport height"
|
|
2167
|
-
editable={
|
|
1938
|
+
editable={Boolean(viewerToken) && !resizeMutation.isPending}
|
|
2168
1939
|
keyboardType="number-pad"
|
|
2169
1940
|
inputMode="numeric"
|
|
2170
1941
|
maxLength={4}
|
|
2171
1942
|
selectTextOnFocus
|
|
2172
1943
|
style={styles.viewportField}
|
|
2173
1944
|
onChangeText={setViewportHeight}
|
|
2174
|
-
onSubmit={
|
|
1945
|
+
onSubmit={() =>
|
|
1946
|
+
requestControlled("resize the browser", () =>
|
|
1947
|
+
controlledActionsRef.current?.applyViewport(),
|
|
1948
|
+
)
|
|
1949
|
+
}
|
|
2175
1950
|
/>
|
|
2176
1951
|
<ControlButton
|
|
2177
1952
|
styles={styles}
|
|
2178
1953
|
theme={theme}
|
|
2179
1954
|
label="Apply"
|
|
2180
|
-
disabled={!
|
|
2181
|
-
onPress={
|
|
1955
|
+
disabled={!viewerToken || anyMutationPending}
|
|
1956
|
+
onPress={() =>
|
|
1957
|
+
requestControlled("resize the browser", () =>
|
|
1958
|
+
controlledActionsRef.current?.applyViewport(),
|
|
1959
|
+
)
|
|
1960
|
+
}
|
|
2182
1961
|
/>
|
|
2183
1962
|
</View>
|
|
2184
1963
|
<Text style={styles.devicePresetDetail}>
|
|
@@ -2187,6 +1966,298 @@ export function SharedBrowserPanel({
|
|
|
2187
1966
|
</View>
|
|
2188
1967
|
</Modal.Content>
|
|
2189
1968
|
</Modal>
|
|
1969
|
+
<Modal
|
|
1970
|
+
title="Close browser tab?"
|
|
1971
|
+
icon={<Icon name="X" size={18} color={theme.colors.statusDanger} />}
|
|
1972
|
+
open={tabCloseConfirmationOpen}
|
|
1973
|
+
onOpenChange={(open) => {
|
|
1974
|
+
if (!tabMutationPending) setTabCloseConfirmationOpen(open);
|
|
1975
|
+
}}
|
|
1976
|
+
>
|
|
1977
|
+
<Modal.Content>
|
|
1978
|
+
<BrowserDialogBody
|
|
1979
|
+
styles={styles}
|
|
1980
|
+
actions={
|
|
1981
|
+
<>
|
|
1982
|
+
<ControlButton
|
|
1983
|
+
styles={styles}
|
|
1984
|
+
theme={theme}
|
|
1985
|
+
label="Cancel"
|
|
1986
|
+
disabled={tabMutationPending}
|
|
1987
|
+
onPress={() => setTabCloseConfirmationOpen(false)}
|
|
1988
|
+
/>
|
|
1989
|
+
<ControlButton
|
|
1990
|
+
styles={styles}
|
|
1991
|
+
theme={theme}
|
|
1992
|
+
label={tabMutationPending ? "Closing…" : "Close tab"}
|
|
1993
|
+
danger
|
|
1994
|
+
disabled={!viewerToken || tabMutationPending}
|
|
1995
|
+
onPress={() =>
|
|
1996
|
+
requestControlled("close this tab", () =>
|
|
1997
|
+
controlledActionsRef.current?.confirmCloseTab(),
|
|
1998
|
+
)
|
|
1999
|
+
}
|
|
2000
|
+
/>
|
|
2001
|
+
</>
|
|
2002
|
+
}
|
|
2003
|
+
>
|
|
2004
|
+
This removes the tab for everyone viewing it, including agents. Other tabs stay open.
|
|
2005
|
+
</BrowserDialogBody>
|
|
2006
|
+
</Modal.Content>
|
|
2007
|
+
</Modal>
|
|
2008
|
+
<Modal
|
|
2009
|
+
title="Close shared browser?"
|
|
2010
|
+
icon={<Icon name="X" size={18} color={theme.colors.statusDanger} />}
|
|
2011
|
+
open={closeConfirmationOpen}
|
|
2012
|
+
onOpenChange={(open) => {
|
|
2013
|
+
if (!closePending) setCloseConfirmationOpen(open);
|
|
2014
|
+
}}
|
|
2015
|
+
>
|
|
2016
|
+
<Modal.Content>
|
|
2017
|
+
<BrowserDialogBody
|
|
2018
|
+
styles={styles}
|
|
2019
|
+
actions={
|
|
2020
|
+
<>
|
|
2021
|
+
<ControlButton
|
|
2022
|
+
styles={styles}
|
|
2023
|
+
theme={theme}
|
|
2024
|
+
label="Cancel"
|
|
2025
|
+
disabled={closePending}
|
|
2026
|
+
onPress={() => setCloseConfirmationOpen(false)}
|
|
2027
|
+
/>
|
|
2028
|
+
<ControlButton
|
|
2029
|
+
styles={styles}
|
|
2030
|
+
theme={theme}
|
|
2031
|
+
label={closePending ? "Closing…" : "Close browser"}
|
|
2032
|
+
disabled={!viewerToken || closePending}
|
|
2033
|
+
onPress={() =>
|
|
2034
|
+
requestControlled("close the shared browser", () =>
|
|
2035
|
+
controlledActionsRef.current?.confirmCloseBrowser(),
|
|
2036
|
+
)
|
|
2037
|
+
}
|
|
2038
|
+
/>
|
|
2039
|
+
</>
|
|
2040
|
+
}
|
|
2041
|
+
>
|
|
2042
|
+
This closes the browser for every viewer and agent in this workspace. The saved site
|
|
2043
|
+
data stays available when someone explicitly reopens it.
|
|
2044
|
+
</BrowserDialogBody>
|
|
2045
|
+
</Modal.Content>
|
|
2046
|
+
</Modal>
|
|
2047
|
+
<BrowserControlDialog
|
|
2048
|
+
styles={styles}
|
|
2049
|
+
theme={theme}
|
|
2050
|
+
state={state}
|
|
2051
|
+
request={controlRequest}
|
|
2052
|
+
pending={controlRequestPending}
|
|
2053
|
+
available={Boolean(viewerToken)}
|
|
2054
|
+
onCancel={() => setControlRequest(null)}
|
|
2055
|
+
onConfirm={() => void confirmControlRequest()}
|
|
2056
|
+
/>
|
|
2057
|
+
{toolbarMenu ? (
|
|
2058
|
+
<BrowserToolbarMenu
|
|
2059
|
+
key={toolbarMenu}
|
|
2060
|
+
theme={theme}
|
|
2061
|
+
compact={layout.compact}
|
|
2062
|
+
title={toolbarMenu === "display" ? "Display options" : "Browser menu"}
|
|
2063
|
+
paneRef={paneRef}
|
|
2064
|
+
paneSize={paneSize}
|
|
2065
|
+
anchorRef={toolbarMenu === "display" ? displayAnchorRef : actionsAnchorRef}
|
|
2066
|
+
preferredHeight={
|
|
2067
|
+
toolbarMenu === "display"
|
|
2068
|
+
? 300 + preferences.favoritePresetIds.length * (layout.compact ? 44 : 36)
|
|
2069
|
+
: composeAvailable
|
|
2070
|
+
? 220
|
|
2071
|
+
: 132
|
|
2072
|
+
}
|
|
2073
|
+
onClose={() => closeToolbarMenu()}
|
|
2074
|
+
shouldRestoreFocus={() => menuRestoreFocus.current}
|
|
2075
|
+
onSubmenuOpen={() => {
|
|
2076
|
+
if (canSendInput) setKeysSubmenuOpen(true);
|
|
2077
|
+
}}
|
|
2078
|
+
submenu={
|
|
2079
|
+
toolbarMenu === "actions" && keysSubmenuOpen
|
|
2080
|
+
? {
|
|
2081
|
+
title: "Send keys",
|
|
2082
|
+
anchorRef: keysAnchorRef,
|
|
2083
|
+
preferredHeight: 40 + SPECIAL_KEYS.length * (layout.compact ? 44 : 36),
|
|
2084
|
+
onBack: () => setKeysSubmenuOpen(false),
|
|
2085
|
+
children: SPECIAL_KEYS.map(({ key, label }) => (
|
|
2086
|
+
<BrowserMenuItem
|
|
2087
|
+
key={key}
|
|
2088
|
+
theme={theme}
|
|
2089
|
+
compact={layout.compact}
|
|
2090
|
+
label={label}
|
|
2091
|
+
disabled={!viewerToken || (canControl && !canSendInput)}
|
|
2092
|
+
onPress={() => {
|
|
2093
|
+
closeToolbarMenu();
|
|
2094
|
+
requestControlled(
|
|
2095
|
+
`send ${label}`,
|
|
2096
|
+
() => controlledActionsRef.current?.sendKey(key),
|
|
2097
|
+
true,
|
|
2098
|
+
);
|
|
2099
|
+
}}
|
|
2100
|
+
/>
|
|
2101
|
+
)),
|
|
2102
|
+
}
|
|
2103
|
+
: undefined
|
|
2104
|
+
}
|
|
2105
|
+
>
|
|
2106
|
+
{toolbarMenu === "display" ? (
|
|
2107
|
+
<>
|
|
2108
|
+
<BrowserMenuHeading theme={theme}>View size</BrowserMenuHeading>
|
|
2109
|
+
<BrowserMenuItem
|
|
2110
|
+
theme={theme}
|
|
2111
|
+
compact={layout.compact}
|
|
2112
|
+
label="Fit to panel"
|
|
2113
|
+
icon="Minimize"
|
|
2114
|
+
selected={scaleMode === "fit"}
|
|
2115
|
+
onPress={() => {
|
|
2116
|
+
closeToolbarMenu();
|
|
2117
|
+
setScaleMode("fit");
|
|
2118
|
+
}}
|
|
2119
|
+
/>
|
|
2120
|
+
<BrowserMenuItem
|
|
2121
|
+
theme={theme}
|
|
2122
|
+
compact={layout.compact}
|
|
2123
|
+
label="Actual size (100%)"
|
|
2124
|
+
icon="Maximize"
|
|
2125
|
+
selected={scaleMode === "actual"}
|
|
2126
|
+
onPress={() => {
|
|
2127
|
+
closeToolbarMenu();
|
|
2128
|
+
setScaleMode("actual");
|
|
2129
|
+
}}
|
|
2130
|
+
/>
|
|
2131
|
+
{layout.platform !== "web" && scaleMode === "actual" ? (
|
|
2132
|
+
<Text
|
|
2133
|
+
style={[styles.devicePresetDetail, { paddingHorizontal: 12, paddingVertical: 4 }]}
|
|
2134
|
+
>
|
|
2135
|
+
Release control to pan this view. While controlling, swipes go to the page.
|
|
2136
|
+
</Text>
|
|
2137
|
+
) : null}
|
|
2138
|
+
<BrowserMenuSeparator theme={theme} />
|
|
2139
|
+
<BrowserMenuItem
|
|
2140
|
+
theme={theme}
|
|
2141
|
+
compact={layout.compact}
|
|
2142
|
+
label="Fill viewport"
|
|
2143
|
+
icon="Maximize"
|
|
2144
|
+
selected={fillViewportEnabled}
|
|
2145
|
+
disabled={!viewerToken || anyMutationPending || !fillViewportResolution}
|
|
2146
|
+
onPress={() => {
|
|
2147
|
+
closeToolbarMenu();
|
|
2148
|
+
requestControlled("fill the browser viewport", () =>
|
|
2149
|
+
controlledActionsRef.current?.fillViewport(),
|
|
2150
|
+
);
|
|
2151
|
+
}}
|
|
2152
|
+
/>
|
|
2153
|
+
<Text
|
|
2154
|
+
style={[styles.devicePresetDetail, { paddingHorizontal: 12, paddingVertical: 4 }]}
|
|
2155
|
+
>
|
|
2156
|
+
{fillViewportDetail}
|
|
2157
|
+
</Text>
|
|
2158
|
+
<BrowserMenuSeparator theme={theme} />
|
|
2159
|
+
<BrowserMenuHeading theme={theme}>Favorite resolutions</BrowserMenuHeading>
|
|
2160
|
+
{preferences.favoritePresetIds.length === 0 ? (
|
|
2161
|
+
<Text
|
|
2162
|
+
style={[styles.devicePresetDetail, { paddingHorizontal: 12, paddingVertical: 4 }]}
|
|
2163
|
+
>
|
|
2164
|
+
Star resolutions in the full list to add them here.
|
|
2165
|
+
</Text>
|
|
2166
|
+
) : (
|
|
2167
|
+
preferences.favoritePresetIds.map((id) => {
|
|
2168
|
+
const preset = DEVICE_PRESETS.find((item) => item.id === id);
|
|
2169
|
+
return preset ? (
|
|
2170
|
+
<BrowserMenuItem
|
|
2171
|
+
key={id}
|
|
2172
|
+
theme={theme}
|
|
2173
|
+
compact={layout.compact}
|
|
2174
|
+
label={preset.label}
|
|
2175
|
+
icon={preset.isMobile ? "Smartphone" : "Monitor"}
|
|
2176
|
+
selected={selectedResolutionPresetId === id}
|
|
2177
|
+
disabled={!viewerToken || anyMutationPending}
|
|
2178
|
+
onPress={() => {
|
|
2179
|
+
closeToolbarMenu();
|
|
2180
|
+
requestControlled("change the browser resolution", () =>
|
|
2181
|
+
controlledActionsRef.current?.selectDevicePreset(id),
|
|
2182
|
+
);
|
|
2183
|
+
}}
|
|
2184
|
+
/>
|
|
2185
|
+
) : null;
|
|
2186
|
+
})
|
|
2187
|
+
)}
|
|
2188
|
+
<BrowserMenuSeparator theme={theme} />
|
|
2189
|
+
<BrowserMenuItem
|
|
2190
|
+
theme={theme}
|
|
2191
|
+
compact={layout.compact}
|
|
2192
|
+
label="All resolutions and quality"
|
|
2193
|
+
icon="Settings2"
|
|
2194
|
+
onPress={() => {
|
|
2195
|
+
closeToolbarMenu(false);
|
|
2196
|
+
setDevicePickerOpen(true);
|
|
2197
|
+
}}
|
|
2198
|
+
/>
|
|
2199
|
+
</>
|
|
2200
|
+
) : (
|
|
2201
|
+
<>
|
|
2202
|
+
{composeAvailable ? (
|
|
2203
|
+
<BrowserMenuItem
|
|
2204
|
+
theme={theme}
|
|
2205
|
+
compact={layout.compact}
|
|
2206
|
+
label="Compose text"
|
|
2207
|
+
icon="Pencil"
|
|
2208
|
+
disabled={!viewerToken || (canControl && !canSendInput)}
|
|
2209
|
+
onPress={() => {
|
|
2210
|
+
closeToolbarMenu();
|
|
2211
|
+
requestControlled(
|
|
2212
|
+
"compose browser text",
|
|
2213
|
+
() => controlledActionsRef.current?.openCompose("compose"),
|
|
2214
|
+
true,
|
|
2215
|
+
);
|
|
2216
|
+
}}
|
|
2217
|
+
/>
|
|
2218
|
+
) : null}
|
|
2219
|
+
<View ref={keysAnchorRef}>
|
|
2220
|
+
<BrowserMenuItem
|
|
2221
|
+
theme={theme}
|
|
2222
|
+
compact={layout.compact}
|
|
2223
|
+
label="Send keys"
|
|
2224
|
+
icon="Keyboard"
|
|
2225
|
+
expanded={keysSubmenuOpen}
|
|
2226
|
+
disabled={!viewerToken || (canControl && !canSendInput)}
|
|
2227
|
+
onPress={() =>
|
|
2228
|
+
requestControlled("send browser keys", () => setKeysSubmenuOpen(true))
|
|
2229
|
+
}
|
|
2230
|
+
/>
|
|
2231
|
+
</View>
|
|
2232
|
+
<BrowserMenuItem
|
|
2233
|
+
theme={theme}
|
|
2234
|
+
compact={layout.compact}
|
|
2235
|
+
label="Reconnect viewer"
|
|
2236
|
+
icon="RotateCw"
|
|
2237
|
+
disabled={attachQuery.isFetching}
|
|
2238
|
+
onPress={() => {
|
|
2239
|
+
closeToolbarMenu();
|
|
2240
|
+
reconnect();
|
|
2241
|
+
}}
|
|
2242
|
+
/>
|
|
2243
|
+
<BrowserMenuSeparator theme={theme} />
|
|
2244
|
+
<BrowserMenuItem
|
|
2245
|
+
theme={theme}
|
|
2246
|
+
compact={layout.compact}
|
|
2247
|
+
label="Close shared browser"
|
|
2248
|
+
icon="X"
|
|
2249
|
+
disabled={!viewerToken || anyMutationPending}
|
|
2250
|
+
onPress={() => {
|
|
2251
|
+
closeToolbarMenu(false);
|
|
2252
|
+
requestControlled("close the shared browser", () =>
|
|
2253
|
+
controlledActionsRef.current?.closeBrowser(),
|
|
2254
|
+
);
|
|
2255
|
+
}}
|
|
2256
|
+
/>
|
|
2257
|
+
</>
|
|
2258
|
+
)}
|
|
2259
|
+
</BrowserToolbarMenu>
|
|
2260
|
+
) : null}
|
|
2190
2261
|
</View>
|
|
2191
2262
|
);
|
|
2192
2263
|
}
|