Skip to content

Commit 4675927

Browse files
committed
feat: improve SimDeck iOS server handling
1 parent c2c3620 commit 4675927

29 files changed

Lines changed: 3366 additions & 503 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ local, LAN, and Tailscale URLs when available, and shows a QR code with a
8282
`simdeck://pair` link. The QR contains the pairing code plus all detected
8383
non-loopback addresses, so pairing once can save both the LAN and Tailscale
8484
routes with the same service token.
85+
Normal service restarts preserve that token so paired clients stay connected.
86+
Use `simdeck service reset` only when you want to rotate the service token and
87+
restart the LaunchAgent.
88+
If port 4310 is already owned by a workspace daemon, the LaunchAgent uses the
89+
next available service-discovery port, up to 4320, instead of stopping that
90+
daemon.
8591

8692
CLI commands automatically use the same warm daemon:
8793

client/src/api/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,17 @@ export interface ChromeDevToolsTargetDiscovery {
188188

189189
export interface HealthResponse {
190190
ok: boolean;
191+
serverId?: string;
192+
advertiseHost?: string;
193+
hostId?: string;
194+
hostName?: string;
195+
httpPort?: number;
196+
serverKind?:
197+
| "launchAgent"
198+
| "workspace"
199+
| "foreground"
200+
| "standalone"
201+
| string;
191202
videoCodec?: string;
192203
webRtc?: {
193204
iceServers?: RTCIceServer[];

client/src/app/AppShell.tsx

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
readStoredAccessibilitySource,
110110
readStoredFlag,
111111
sanitizeAccessibilitySources,
112+
shouldRetainAccessibilityTreeDuringRefresh,
112113
TOUCH_OVERLAY_VISIBLE_STORAGE_KEY,
113114
viewportStateForUDID,
114115
writePersistedUiState,
@@ -568,6 +569,7 @@ export function AppShell({
568569
>(() => {});
569570
const accessibilityRequestIdRef = useRef(0);
570571
const accessibilityLoadingRef = useRef(false);
572+
const accessibilityRootsRef = useRef<AccessibilityNode[]>([]);
571573
const streamConfigRequestIdRef = useRef(0);
572574
const streamConfigUserChangeAtRef = useRef(0);
573575
const streamConfigUserTouchedRef = useRef(false);
@@ -588,6 +590,11 @@ export function AppShell({
588590
const zoomDockSize = useElementSize(zoomDockElement);
589591
refreshRef.current = refresh;
590592

593+
const updateAccessibilityRoots = useCallback((roots: AccessibilityNode[]) => {
594+
accessibilityRootsRef.current = roots;
595+
setAccessibilityRoots(roots);
596+
}, []);
597+
591598
const handleOuterCanvasRef = useCallback((node: HTMLDivElement | null) => {
592599
outerCanvasRef.current = node;
593600
setOuterCanvasElement(node);
@@ -1160,7 +1167,7 @@ export function AppShell({
11601167
setChromeProfile(null);
11611168
setChromeProfileReady(false);
11621169
setLocalError("");
1163-
setAccessibilityRoots([]);
1170+
updateAccessibilityRoots([]);
11641171
setAccessibilitySelectedId("");
11651172
setAccessibilityHoveredId(null);
11661173
setAccessibilityPickerActive(false);
@@ -1210,7 +1217,7 @@ export function AppShell({
12101217
: nextViewportState.rotationQuarterTurns,
12111218
);
12121219
setLocalError("");
1213-
setAccessibilityRoots([]);
1220+
updateAccessibilityRoots([]);
12141221
setAccessibilitySelectedId(
12151222
persistedState.accessibilitySelectedByUDID?.[selectedSimulator.udid] ??
12161223
"",
@@ -1231,7 +1238,7 @@ export function AppShell({
12311238
}
12321239

12331240
if (providerDisconnected) {
1234-
setAccessibilityRoots([]);
1241+
updateAccessibilityRoots([]);
12351242
setAccessibilitySelectedId("");
12361243
setAccessibilityHoveredId(null);
12371244
setAccessibilityAvailableSources([]);
@@ -1242,7 +1249,7 @@ export function AppShell({
12421249
}
12431250

12441251
if (!selectedSimulator?.isBooted) {
1245-
setAccessibilityRoots([]);
1252+
updateAccessibilityRoots([]);
12461253
setAccessibilitySelectedId("");
12471254
setAccessibilityAvailableSources([]);
12481255
setAccessibilitySource("");
@@ -1281,48 +1288,90 @@ export function AppShell({
12811288
sanitizeAccessibilitySources(snapshot.availableSources),
12821289
snapshot.source,
12831290
);
1284-
setAccessibilityRoots(roots);
1285-
setAccessibilityAvailableSources(availableSources);
1286-
setAccessibilitySource(snapshot.source);
1287-
setAccessibilityError(
1288-
roots.length === 0
1289-
? userFacingAccessibilityError(snapshot.fallbackReason ?? "")
1290-
: "",
1291-
);
1292-
const nextPreferredSource = nextAccessibilitySourcePreference(
1291+
const retainCurrentTree = shouldRetainAccessibilityTreeDuringRefresh(
12931292
accessibilityPreferredSource,
1293+
accessibilitySource,
12941294
snapshot.source,
1295-
availableSources,
1295+
roots.length,
1296+
accessibilityRootsRef.current.length,
12961297
);
1297-
if (nextPreferredSource) {
1298-
setAccessibilityPreferredSource(nextPreferredSource);
1299-
}
1300-
if (roots.length === 0) {
1301-
setAccessibilitySelectedId("");
1298+
if (retainCurrentTree) {
1299+
setAccessibilityAvailableSources(
1300+
mergeAccessibilitySources(
1301+
availableSources,
1302+
accessibilitySource,
1303+
accessibilityPreferredSource,
1304+
),
1305+
);
1306+
setAccessibilityError("");
1307+
} else {
1308+
updateAccessibilityRoots(roots);
1309+
setAccessibilityAvailableSources(availableSources);
1310+
setAccessibilitySource(snapshot.source);
1311+
setAccessibilityError(
1312+
roots.length === 0
1313+
? userFacingAccessibilityError(snapshot.fallbackReason ?? "")
1314+
: "",
1315+
);
1316+
const nextPreferredSource = nextAccessibilitySourcePreference(
1317+
accessibilityPreferredSource,
1318+
snapshot.source,
1319+
availableSources,
1320+
);
1321+
if (nextPreferredSource) {
1322+
setAccessibilityPreferredSource(nextPreferredSource);
1323+
}
1324+
if (roots.length === 0) {
1325+
setAccessibilitySelectedId("");
1326+
}
13021327
}
13031328
} catch (snapshotError) {
13041329
if (accessibilityRequestIdRef.current !== requestId) {
13051330
return;
13061331
}
1307-
setAccessibilityError(
1308-
snapshotError instanceof Error
1309-
? userFacingAccessibilityError(snapshotError.message)
1310-
: "Failed to load accessibility hierarchy.",
1332+
const retainCurrentTree = shouldRetainAccessibilityTreeDuringRefresh(
1333+
accessibilityPreferredSource,
1334+
accessibilitySource,
1335+
"native-ax",
1336+
0,
1337+
accessibilityRootsRef.current.length,
13111338
);
1312-
setAccessibilityRoots([]);
1313-
setAccessibilitySelectedId("");
1314-
setAccessibilityHoveredId(null);
1315-
setAccessibilitySource("");
1316-
if (accessibilityPreferredSource !== "auto") {
1317-
setAccessibilityPreferredSource("auto");
1339+
if (retainCurrentTree) {
1340+
setAccessibilityAvailableSources((current) =>
1341+
mergeAccessibilitySources(
1342+
current,
1343+
accessibilitySource,
1344+
accessibilityPreferredSource,
1345+
),
1346+
);
1347+
setAccessibilityError("");
1348+
} else {
1349+
setAccessibilityError(
1350+
snapshotError instanceof Error
1351+
? userFacingAccessibilityError(snapshotError.message)
1352+
: "Failed to load accessibility hierarchy.",
1353+
);
1354+
updateAccessibilityRoots([]);
1355+
setAccessibilitySelectedId("");
1356+
setAccessibilityHoveredId(null);
1357+
setAccessibilitySource("");
1358+
if (accessibilityPreferredSource !== "auto") {
1359+
setAccessibilityPreferredSource("auto");
1360+
}
13181361
}
13191362
} finally {
13201363
if (accessibilityRequestIdRef.current === requestId) {
13211364
accessibilityLoadingRef.current = false;
13221365
setAccessibilityLoading(false);
13231366
}
13241367
}
1325-
}, [accessibilityPreferredSource, providerDisconnected, selectedSimulator]);
1368+
}, [
1369+
accessibilityPreferredSource,
1370+
accessibilitySource,
1371+
providerDisconnected,
1372+
selectedSimulator,
1373+
updateAccessibilityRoots,
1374+
]);
13261375

13271376
const changeAccessibilitySource = useCallback(
13281377
(source: AccessibilitySource) => {
@@ -1332,14 +1381,14 @@ export function AppShell({
13321381
accessibilityRequestIdRef.current += 1;
13331382
accessibilityLoadingRef.current = false;
13341383
setAccessibilityPreferredSource(source);
1335-
setAccessibilityRoots([]);
1384+
updateAccessibilityRoots([]);
13361385
setAccessibilitySelectedId("");
13371386
setAccessibilityHoveredId(null);
13381387
setAccessibilityError("");
13391388
setAccessibilitySource("");
13401389
setAccessibilityLoading(false);
13411390
},
1342-
[accessibilityPreferredSource],
1391+
[accessibilityPreferredSource, updateAccessibilityRoots],
13431392
);
13441393

13451394
useEffect(() => {

client/src/app/uiState.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
readStoredFlag,
88
sanitizeAccessibilitySources,
99
sanitizePersistedUiState,
10+
shouldRetainAccessibilityTreeDuringRefresh,
1011
viewportStateForUDID,
1112
type PersistedViewportState,
1213
} from "./uiState";
@@ -60,6 +61,63 @@ describe("uiState", () => {
6061
).toBe("in-app-inspector");
6162
});
6263

64+
it("keeps an explicit Flutter preference through transient native fallback", () => {
65+
expect(
66+
nextAccessibilitySourcePreference("flutter", "native-ax", [
67+
"in-app-inspector",
68+
"native-ax",
69+
]),
70+
).toBeNull();
71+
});
72+
73+
it("retains the current Flutter tree during empty or fallback refreshes", () => {
74+
expect(
75+
shouldRetainAccessibilityTreeDuringRefresh(
76+
"auto",
77+
"flutter",
78+
"flutter",
79+
0,
80+
3,
81+
),
82+
).toBe(true);
83+
expect(
84+
shouldRetainAccessibilityTreeDuringRefresh(
85+
"flutter",
86+
"flutter",
87+
"native-ax",
88+
12,
89+
3,
90+
),
91+
).toBe(true);
92+
expect(
93+
shouldRetainAccessibilityTreeDuringRefresh(
94+
"auto",
95+
"flutter",
96+
"flutter",
97+
4,
98+
3,
99+
),
100+
).toBe(false);
101+
expect(
102+
shouldRetainAccessibilityTreeDuringRefresh(
103+
"native-ax",
104+
"native-ax",
105+
"flutter",
106+
4,
107+
3,
108+
),
109+
).toBe(false);
110+
expect(
111+
shouldRetainAccessibilityTreeDuringRefresh(
112+
"flutter",
113+
"native-ax",
114+
"native-ax",
115+
4,
116+
3,
117+
),
118+
).toBe(false);
119+
});
120+
63121
it("sanitizes persisted viewport state and falls back to defaults", () => {
64122
const sanitized = sanitizePersistedUiState({
65123
bundleIDValue: 123 as unknown as string,

client/src/app/uiState.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,13 @@ export function nextAccessibilitySourcePreference(
137137
availableSources: AccessibilitySource[],
138138
): AccessibilitySourcePreference | null {
139139
const richerSource = preferredRicherAccessibilitySource(availableSources);
140+
if (
141+
currentPreference === "flutter" &&
142+
snapshotSource !== "flutter" &&
143+
!availableSources.includes("flutter")
144+
) {
145+
return null;
146+
}
140147
if (
141148
snapshotSource === "native-ax" &&
142149
currentPreference !== "native-ax" &&
@@ -154,6 +161,27 @@ export function nextAccessibilitySourcePreference(
154161
return null;
155162
}
156163

164+
export function shouldRetainAccessibilityTreeDuringRefresh(
165+
currentPreference: AccessibilitySourcePreference,
166+
currentSource: AccessibilitySource | "",
167+
snapshotSource: AccessibilitySource,
168+
nextRootCount: number,
169+
currentRootCount: number,
170+
): boolean {
171+
if (currentRootCount <= 0) {
172+
return false;
173+
}
174+
const retainedSource =
175+
currentPreference === "auto" ? currentSource : currentPreference;
176+
if (retainedSource !== "flutter" && retainedSource !== "react-native") {
177+
return false;
178+
}
179+
if (currentSource !== retainedSource) {
180+
return false;
181+
}
182+
return snapshotSource !== retainedSource || nextRootCount === 0;
183+
}
184+
157185
export function isAccessibilitySource(
158186
value: unknown,
159187
): value is AccessibilitySource {

client/src/features/accessibility/accessibilityTree.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,4 +325,39 @@ describe("findAccessibilityItemAtPoint", () => {
325325
expect(item?.node.type).toBe("FilledButton");
326326
expect(item?.id).toBe("0.0");
327327
});
328+
329+
it("ignores Flutter composited followers that cover selectable content", () => {
330+
const roots: AccessibilityNode[] = [
331+
{
332+
source: "flutter",
333+
type: "View",
334+
frame: { x: 0, y: 0, width: 402, height: 874 },
335+
children: [
336+
{
337+
source: "flutter",
338+
type: "Text",
339+
title: "Daily budget",
340+
AXLabel: "Daily budget",
341+
frame: { x: 24, y: 180, width: 180, height: 32 },
342+
},
343+
{
344+
source: "flutter",
345+
type: "CompositedTransformFollower",
346+
title: "CompositedTransformFollower",
347+
frame: { x: 0, y: 0, width: 402, height: 874 },
348+
sourceLocation: {
349+
file: "file:///Users/dj/Developer/flutter/packages/flutter/lib/src/material/slider.dart",
350+
line: 1057,
351+
column: 35,
352+
},
353+
},
354+
],
355+
},
356+
];
357+
358+
const item = findAccessibilityItemAtPoint(roots, { x: 0.2, y: 0.224 });
359+
360+
expect(item?.node.type).toBe("Text");
361+
expect(item?.id).toBe("0.0");
362+
});
328363
});

client/src/features/accessibility/accessibilityTree.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ const flutterTransparentContainerTypes = new Set([
315315
"ClipRRect",
316316
"ClipRect",
317317
"Column",
318+
"CompositedTransformFollower",
319+
"CompositedTransformTarget",
318320
"ConstrainedBox",
319321
"Container",
320322
"CustomMultiChildLayout",

0 commit comments

Comments
 (0)