@omercnet/paseo-shared-browser 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +31 -1
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,109 @@
1
+ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useCallback, useState } from "react";
4
+ import { Pressable, Text, type TextStyle, type ViewStyle } from "react-native";
5
+
6
+ import { setBrowserControlTooltip } from "./browser-control-tooltip-web";
7
+
8
+ type Theme = PluginWorkspacePanelProps["theme"];
9
+
10
+ export interface ControlButtonStyles {
11
+ button: ViewStyle;
12
+ buttonSelected: ViewStyle;
13
+ buttonPrimary: ViewStyle;
14
+ buttonDanger: ViewStyle;
15
+ buttonHovered: ViewStyle;
16
+ buttonPressed: ViewStyle;
17
+ buttonFocused: ViewStyle;
18
+ buttonDisabled: ViewStyle;
19
+ buttonLarge: ViewStyle;
20
+ buttonFill: ViewStyle;
21
+ buttonPad?: ViewStyle;
22
+ buttonIconOnly?: ViewStyle;
23
+ buttonText: TextStyle;
24
+ buttonTextSelected: TextStyle;
25
+ }
26
+
27
+ interface ControlButtonProps {
28
+ styles: ControlButtonStyles;
29
+ theme: Theme;
30
+ label: string;
31
+ accessibilityLabel?: string;
32
+ icon?: string;
33
+ selected?: boolean;
34
+ primary?: boolean;
35
+ danger?: boolean;
36
+ large?: boolean;
37
+ fill?: boolean;
38
+ pad?: boolean;
39
+ iconOnly?: boolean;
40
+ disabled?: boolean;
41
+ onPress(): void;
42
+ }
43
+
44
+ /** Render the shared control geometry and theme states for browser actions. */
45
+ export function ControlButton({
46
+ styles,
47
+ theme,
48
+ label,
49
+ accessibilityLabel,
50
+ icon,
51
+ selected = false,
52
+ primary = false,
53
+ danger = false,
54
+ large = false,
55
+ fill = false,
56
+ pad = false,
57
+ iconOnly = false,
58
+ disabled = false,
59
+ onPress,
60
+ }: ControlButtonProps) {
61
+ const [focused, setFocused] = useState(false);
62
+ const [hovered, setHovered] = useState(false);
63
+ const highlighted = selected || primary;
64
+ const tooltipRef = useCallback(
65
+ (node: unknown) => setBrowserControlTooltip(node, accessibilityLabel ?? label),
66
+ [accessibilityLabel, label],
67
+ );
68
+ return (
69
+ <Pressable
70
+ ref={iconOnly ? tooltipRef : undefined}
71
+ accessibilityRole="button"
72
+ accessibilityLabel={accessibilityLabel ?? label}
73
+ accessibilityState={{ disabled, selected }}
74
+ disabled={disabled}
75
+ onFocus={() => setFocused(true)}
76
+ onBlur={() => setFocused(false)}
77
+ onHoverIn={() => setHovered(true)}
78
+ onHoverOut={() => setHovered(false)}
79
+ onPress={onPress}
80
+ style={({ pressed }) => [
81
+ styles.button,
82
+ iconOnly ? styles.buttonIconOnly : null,
83
+ selected ? styles.buttonSelected : null,
84
+ primary ? styles.buttonPrimary : null,
85
+ danger ? styles.buttonDanger : null,
86
+ hovered && !highlighted ? styles.buttonHovered : null,
87
+ pressed ? styles.buttonPressed : null,
88
+ focused ? styles.buttonFocused : null,
89
+ disabled ? styles.buttonDisabled : null,
90
+ large || pad ? styles.buttonLarge : null,
91
+ fill ? styles.buttonFill : null,
92
+ pad ? styles.buttonPad : null,
93
+ ]}
94
+ >
95
+ {icon ? (
96
+ <Icon
97
+ name={icon}
98
+ size={15}
99
+ color={highlighted ? theme.colors.accentForeground : theme.colors.foregroundMuted}
100
+ />
101
+ ) : null}
102
+ {iconOnly ? null : (
103
+ <Text style={[styles.buttonText, highlighted ? styles.buttonTextSelected : null]}>
104
+ {label}
105
+ </Text>
106
+ )}
107
+ </Pressable>
108
+ );
109
+ }
@@ -0,0 +1,71 @@
1
+ /** Confirm a one-time control request before the selected tab action continues. */
2
+ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
3
+ import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
4
+ import type { BrowserState } from "../shared/browser";
5
+ import { ControlButton } from "./browser-chrome";
6
+ import type { ControlRequest } from "./browser-control-request";
7
+ import { BrowserDialogBody } from "./browser-dialog-body";
8
+ import type { createStyles } from "./browser-panel-styles";
9
+
10
+ /** Explicitly name a displaced controller while preserving the original action. */
11
+ export function BrowserControlDialog({
12
+ styles,
13
+ theme,
14
+ state,
15
+ request,
16
+ pending,
17
+ available,
18
+ onCancel,
19
+ onConfirm,
20
+ }: {
21
+ styles: ReturnType<typeof createStyles>;
22
+ theme: PluginWorkspacePanelProps["theme"];
23
+ state: BrowserState | null;
24
+ request: ControlRequest | null;
25
+ pending: boolean;
26
+ available: boolean;
27
+ onCancel(): void;
28
+ onConfirm(): void;
29
+ }) {
30
+ const takeover = state?.controller === "other";
31
+ return (
32
+ <Modal
33
+ title={takeover ? "Take over control?" : "Take control?"}
34
+ icon={<Icon name="MousePointer2" size={18} color={theme.colors.foreground} />}
35
+ open={request !== null}
36
+ onOpenChange={(open) => {
37
+ if (!open && !pending) onCancel();
38
+ }}
39
+ >
40
+ <Modal.Content>
41
+ <BrowserDialogBody
42
+ styles={styles}
43
+ actions={
44
+ <>
45
+ <ControlButton
46
+ styles={styles}
47
+ theme={theme}
48
+ label="Cancel"
49
+ disabled={pending}
50
+ onPress={onCancel}
51
+ />
52
+ <ControlButton
53
+ styles={styles}
54
+ theme={theme}
55
+ label={takeover ? "Take over" : "Take control"}
56
+ primary
57
+ disabled={pending || !request || !available}
58
+ onPress={onConfirm}
59
+ />
60
+ </>
61
+ }
62
+ >
63
+ {takeover
64
+ ? `${state?.controllerLabel ?? "Another viewer"} controls this tab. Taking over will interrupt their input. `
65
+ : "This tab is in observation mode. "}
66
+ Take control to {request?.label}.
67
+ </BrowserDialogBody>
68
+ </Modal.Content>
69
+ </Modal>
70
+ );
71
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { BrowserState } from "../shared/browser";
3
+ import { controlRequestBasis, isControlRequestCurrent } from "./browser-control-request";
4
+
5
+ const state = {
6
+ tabId: "tab-one",
7
+ sessionId: "session-one",
8
+ navigationGeneration: 4,
9
+ viewportGeneration: 2,
10
+ } as BrowserState;
11
+
12
+ describe("browser control request identity", () => {
13
+ it("accepts only the original viewer, tab, page, and controller observation", () => {
14
+ const basis = controlRequestBasis("host/workspace", "viewer-one", state, 7);
15
+ const current = (next: BrowserState, revision = 7) =>
16
+ isControlRequestCurrent(basis, "host/workspace", "viewer-one", next, revision);
17
+
18
+ expect(current(state)).toBe(true);
19
+ expect(isControlRequestCurrent(basis, "other/workspace", "viewer-one", state, 7)).toBe(false);
20
+ expect(isControlRequestCurrent(basis, "host/workspace", "viewer-two", state, 7)).toBe(false);
21
+ expect(current({ ...state, tabId: "tab-two" })).toBe(false);
22
+ expect(current({ ...state, sessionId: "session-two" })).toBe(false);
23
+ expect(current({ ...state, navigationGeneration: 5 })).toBe(false);
24
+ expect(current({ ...state, viewportGeneration: 3 })).toBe(false);
25
+ expect(current(state, 8)).toBe(false);
26
+ });
27
+ });
@@ -0,0 +1,57 @@
1
+ /** Identity fence for a control request made from a specific visible page. */
2
+ import type { BrowserState } from "../shared/browser";
3
+
4
+ export interface ControlRequestBasis {
5
+ panelScope: string;
6
+ viewerToken: string;
7
+ tabId: string | null;
8
+ sessionId: string;
9
+ navigationGeneration: number;
10
+ viewportGeneration: number;
11
+ controllerRevision: number;
12
+ }
13
+
14
+ export interface ControlRequest {
15
+ label: string;
16
+ basis: ControlRequestBasis;
17
+ needsFrame: boolean;
18
+ run(): void;
19
+ }
20
+
21
+ /** Capture the page and ownership observation before showing a takeover dialog. */
22
+ export function controlRequestBasis(
23
+ panelScope: string,
24
+ viewerToken: string,
25
+ state: BrowserState,
26
+ controllerRevision: number,
27
+ ): ControlRequestBasis {
28
+ return {
29
+ panelScope,
30
+ viewerToken,
31
+ tabId: state.tabId ?? null,
32
+ sessionId: state.sessionId,
33
+ navigationGeneration: state.navigationGeneration,
34
+ viewportGeneration: state.viewportGeneration,
35
+ controllerRevision,
36
+ };
37
+ }
38
+
39
+ /** Reject a queued action if its viewer, tab, page, or controller observation changed. */
40
+ export function isControlRequestCurrent(
41
+ basis: ControlRequestBasis,
42
+ panelScope: string,
43
+ viewerToken: string | null,
44
+ state: BrowserState | null,
45
+ controllerRevision: number,
46
+ ): boolean {
47
+ return Boolean(
48
+ state &&
49
+ basis.panelScope === panelScope &&
50
+ basis.viewerToken === viewerToken &&
51
+ basis.tabId === (state.tabId ?? null) &&
52
+ basis.sessionId === state.sessionId &&
53
+ basis.navigationGeneration === state.navigationGeneration &&
54
+ basis.viewportGeneration === state.viewportGeneration &&
55
+ basis.controllerRevision === controllerRevision,
56
+ );
57
+ }
@@ -0,0 +1,10 @@
1
+ /** Native browser hover titles for public RN web control refs; native clients keep accessibility labels. */
2
+ import { Platform } from "react-native";
3
+
4
+ /** Attach the control's current accessible name without creating overlays or remote input handlers. */
5
+ export function setBrowserControlTooltip(node: unknown, label: string): void {
6
+ if (Platform.OS !== "web" || !node || typeof node !== "object") return;
7
+ if ("setAttribute" in node && typeof node.setAttribute === "function") {
8
+ node.setAttribute("title", label);
9
+ }
10
+ }
@@ -0,0 +1,22 @@
1
+ /** Shared confirmation layout for short Shared Browser dialogs. */
2
+ import type { ReactNode } from "react";
3
+ import { Text, View } from "react-native";
4
+ import type { createStyles } from "./browser-panel-styles";
5
+
6
+ /** Keep confirmation copy and actions aligned within the host modal's own inset. */
7
+ export function BrowserDialogBody({
8
+ styles,
9
+ children,
10
+ actions,
11
+ }: {
12
+ styles: ReturnType<typeof createStyles>;
13
+ children: ReactNode;
14
+ actions: ReactNode;
15
+ }) {
16
+ return (
17
+ <View style={styles.dialogBody}>
18
+ <Text style={styles.dialogMessage}>{children}</Text>
19
+ <View style={styles.dialogActions}>{actions}</View>
20
+ </View>
21
+ );
22
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createEmulationChoices,
4
+ localEmulationMode,
5
+ matchingResolutionPresetId,
6
+ selectionMode,
7
+ } from "./browser-emulation-mode";
8
+
9
+ describe("device defaults for shared emulation", () => {
10
+ it("uses actual platform/browser device hints, not narrow layout", () => {
11
+ expect(localEmulationMode({ platform: "web", userAgent: "Desktop Chrome" })).toBe("desktop");
12
+ expect(localEmulationMode({ platform: "web", userAgent: "Android Mobile Chrome" })).toBe(
13
+ "mobile",
14
+ );
15
+ expect(localEmulationMode({ platform: "web", mobile: true })).toBe("mobile");
16
+ expect(
17
+ localEmulationMode({ platform: "web", userAgent: "Macintosh Safari", maxTouchPoints: 5 }),
18
+ ).toBe("mobile");
19
+ expect(
20
+ localEmulationMode({ platform: "web", userAgent: "Windows Chrome", maxTouchPoints: 10 }),
21
+ ).toBe("desktop");
22
+ expect(localEmulationMode({ platform: "ios" })).toBe("mobile");
23
+ expect(localEmulationMode({ platform: "android" })).toBe("mobile");
24
+ });
25
+ it("applies a different local mode once, then honors explicit choices across reacquisition", () => {
26
+ const choices = createEmulationChoices({ platform: "ios" });
27
+ expect(choices.claimDefault("desktop")).toEqual({
28
+ presetId: "iphone-15-pro",
29
+ preserveDisplay: true,
30
+ });
31
+ expect(choices.claimDefault("desktop")).toBeNull();
32
+ });
33
+ it("keeps an already matching mode and never replays a remembered viewport on toggle", () => {
34
+ const choices = createEmulationChoices({ platform: "web" });
35
+ choices.remember(null);
36
+ expect(choices.claimDefault("desktop")).toBeNull();
37
+ expect(choices.toggle("desktop")).toEqual({ presetId: "pixel-7-sharp", preserveDisplay: true });
38
+ expect(choices.toggle("mobile")).toEqual({
39
+ presetId: "desktop-chrome",
40
+ preserveDisplay: true,
41
+ });
42
+ expect(selectionMode(null)).toBe("desktop");
43
+ expect(selectionMode("pixel-7")).toBe("mobile");
44
+ });
45
+ it("remembers behavior profiles without claiming their native dimensions", () => {
46
+ const choices = createEmulationChoices({ platform: "web" });
47
+ choices.remember("desktop-1920x1080");
48
+ choices.remember("iphone-15-pro");
49
+ expect(choices.toggle("desktop")).toEqual({ presetId: "iphone-15-pro", preserveDisplay: true });
50
+ expect(choices.toggle("mobile")).toEqual({
51
+ presetId: "desktop-1920x1080",
52
+ preserveDisplay: true,
53
+ });
54
+ expect(
55
+ matchingResolutionPresetId({
56
+ devicePresetId: "iphone-15-pro",
57
+ viewport: { width: 1920, height: 1080 },
58
+ captureScale: 1,
59
+ }),
60
+ ).toBeNull();
61
+ });
62
+ it("checks exact viewport and capture density in resolution selection", () => {
63
+ const base = {
64
+ devicePresetId: "pixel-7-sharp" as const,
65
+ viewport: { width: 412, height: 839 },
66
+ captureScale: 2,
67
+ };
68
+ expect(matchingResolutionPresetId(base)).toBe("pixel-7-sharp");
69
+ expect(matchingResolutionPresetId({ ...base, captureScale: 1 })).toBeNull();
70
+ expect(
71
+ matchingResolutionPresetId({ ...base, viewport: { width: 1920, height: 1200 } }),
72
+ ).toBeNull();
73
+ expect(matchingResolutionPresetId(null)).toBeNull();
74
+ });
75
+ });
@@ -0,0 +1,98 @@
1
+ /** Local mode defaults and remembered behavior profiles, independent of shared display size. */
2
+
3
+ import type { BrowserState } from "../shared/browser";
4
+ import { DEVICE_PRESETS, type DevicePresetId } from "../shared/device-presets";
5
+
6
+ export type BrowserEmulationMode = "desktop" | "mobile";
7
+ export type EmulationSelection = { presetId: DevicePresetId; preserveDisplay: true };
8
+ interface LocalDevice {
9
+ platform: "ios" | "android" | "web";
10
+ userAgent?: string;
11
+ mobile?: boolean;
12
+ maxTouchPoints?: number;
13
+ }
14
+
15
+ /** Compact layout can mean a narrow desktop window, so it never determines device identity. */
16
+ export function localEmulationMode(device: LocalDevice): BrowserEmulationMode {
17
+ if (device.platform !== "web") return "mobile";
18
+ if (
19
+ device.mobile === true ||
20
+ (/Macintosh/i.test(device.userAgent ?? "") && (device.maxTouchPoints ?? 0) > 1) ||
21
+ /Android|iPhone|iPad|iPod|Mobile/i.test(device.userAgent ?? "")
22
+ )
23
+ return "mobile";
24
+ return "desktop";
25
+ }
26
+
27
+ /** Browser hints are local presentation facts, not remote browser or authentication data. */
28
+ export function readLocalDevice(platform: LocalDevice["platform"]): LocalDevice {
29
+ const browser = globalThis as unknown as {
30
+ navigator?: {
31
+ userAgent?: string;
32
+ maxTouchPoints?: number;
33
+ userAgentData?: { mobile?: boolean };
34
+ };
35
+ };
36
+ const userAgent = browser.navigator?.userAgent;
37
+ const mobile = browser.navigator?.userAgentData?.mobile;
38
+ const maxTouchPoints = browser.navigator?.maxTouchPoints;
39
+ return {
40
+ platform,
41
+ ...(userAgent === undefined ? {} : { userAgent }),
42
+ ...(mobile === undefined ? {} : { mobile }),
43
+ ...(maxTouchPoints === undefined ? {} : { maxTouchPoints }),
44
+ };
45
+ }
46
+
47
+ /** The configured profile defines behavior even when its native dimensions are overridden. */
48
+ export function selectionMode(presetId: DevicePresetId | null): BrowserEmulationMode {
49
+ return DEVICE_PRESETS.find((preset) => preset.id === presetId)?.isMobile ? "mobile" : "desktop";
50
+ }
51
+
52
+ /** A profile ID alone must not check a resolution that differs from the actual display. */
53
+ export function matchingResolutionPresetId(
54
+ state: Pick<BrowserState, "devicePresetId" | "viewport" | "captureScale"> | null,
55
+ ): DevicePresetId | null {
56
+ if (!state) return null;
57
+ const preset = DEVICE_PRESETS.find((item) => item.id === state.devicePresetId);
58
+ return preset &&
59
+ preset.viewport.width === state.viewport.width &&
60
+ preset.viewport.height === state.viewport.height &&
61
+ preset.captureScale === state.captureScale
62
+ ? preset.id
63
+ : null;
64
+ }
65
+
66
+ /** One device default per mounted host/workspace; later explicit choices survive releasing control. */
67
+ export function createEmulationChoices(device: LocalDevice) {
68
+ const defaultMode = localEmulationMode(device);
69
+ const mobilePreset: DevicePresetId =
70
+ device.platform === "ios" ||
71
+ /iPhone|iPad|iPod/i.test(device.userAgent ?? "") ||
72
+ (/Macintosh/i.test(device.userAgent ?? "") && (device.maxTouchPoints ?? 0) > 1)
73
+ ? "iphone-15-pro"
74
+ : "pixel-7-sharp";
75
+ let initialized = false;
76
+ const profiles: Record<BrowserEmulationMode, DevicePresetId> = {
77
+ desktop: "desktop-chrome",
78
+ mobile: mobilePreset,
79
+ };
80
+ return {
81
+ remember(presetId: DevicePresetId | null) {
82
+ if (presetId) profiles[selectionMode(presetId)] = presetId;
83
+ },
84
+ claimDefault(currentMode: BrowserEmulationMode): EmulationSelection | null {
85
+ if (initialized) return null;
86
+ initialized = true;
87
+ return currentMode === defaultMode
88
+ ? null
89
+ : { presetId: profiles[defaultMode], preserveDisplay: true };
90
+ },
91
+ toggle(currentMode: BrowserEmulationMode): EmulationSelection {
92
+ return {
93
+ presetId: profiles[currentMode === "mobile" ? "desktop" : "mobile"],
94
+ preserveDisplay: true,
95
+ };
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getFillViewportResolution } from "./browser-fill-viewport";
3
+
4
+ describe("fill viewport resolution", () => {
5
+ it("uses the visible canvas's logical size, including fractional layout rounding", () => {
6
+ expect(getFillViewportResolution({ width: 1042.4, height: 781.6 })).toEqual({
7
+ width: 1042,
8
+ height: 782,
9
+ });
10
+ });
11
+
12
+ it("accepts both supported boundaries", () => {
13
+ expect(getFillViewportResolution({ width: 320, height: 480 })).toEqual({
14
+ width: 320,
15
+ height: 480,
16
+ });
17
+ expect(getFillViewportResolution({ width: 3840, height: 3840 })).toEqual({
18
+ width: 3840,
19
+ height: 3840,
20
+ });
21
+ });
22
+
23
+ it("preserves logical size at sharp density and rejects oversized physical captures", () => {
24
+ expect(getFillViewportResolution({ width: 1000, height: 700 }, 2)).toEqual({
25
+ width: 1000,
26
+ height: 700,
27
+ });
28
+ expect(getFillViewportResolution({ width: 1921, height: 700 }, 2)).toBeNull();
29
+ expect(getFillViewportResolution({ width: 1000, height: 1921 }, 2)).toBeNull();
30
+ });
31
+
32
+ it("rejects unavailable and unsupported sizes without substituting a resolution", () => {
33
+ for (const size of [
34
+ { width: 0, height: 0 },
35
+ { width: Number.NaN, height: 600 },
36
+ { width: 800, height: Number.POSITIVE_INFINITY },
37
+ { width: 319, height: 600 },
38
+ { width: 800, height: 479 },
39
+ { width: 3841, height: 800 },
40
+ { width: 800, height: 3841 },
41
+ ]) {
42
+ expect(getFillViewportResolution(size)).toBeNull();
43
+ }
44
+ });
45
+ });
@@ -0,0 +1,28 @@
1
+ /** Convert the visible canvas's logical dimensions to a supported browser resolution. */
2
+ import { MAX_VIEWPORT, MIN_VIEWPORT } from "../shared/viewport-limits";
3
+
4
+ /**
5
+ * Round fractional layout pixels to the nearest browser CSS pixel. Unmeasured or
6
+ * unsupported panels return null rather than silently clamping to a different size.
7
+ * captureScale bounds physical image pixels without multiplying logical dimensions.
8
+ */
9
+ export function getFillViewportResolution(
10
+ size: { width: number; height: number },
11
+ captureScale = 1,
12
+ ) {
13
+ const width = Math.round(size.width);
14
+ const height = Math.round(size.height);
15
+ if (
16
+ !Number.isFinite(width) ||
17
+ !Number.isFinite(height) ||
18
+ !Number.isFinite(captureScale) ||
19
+ captureScale < 1 ||
20
+ width < MIN_VIEWPORT.width ||
21
+ width * captureScale > MAX_VIEWPORT.width ||
22
+ height < MIN_VIEWPORT.height ||
23
+ height * captureScale > MAX_VIEWPORT.height
24
+ ) {
25
+ return null;
26
+ }
27
+ return { width, height };
28
+ }