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