serve-sim 0.1.49 → 0.1.50-beta.145.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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serve-sim",
3
- "version": "0.1.49",
3
+ "version": "0.1.50-beta.145.1",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -28,6 +28,7 @@ import {
28
28
  Orientation,
29
29
  axDescribeAsync,
30
30
  axFrontmostAsync,
31
+ duoHidHelperPath,
31
32
  type MjpegFrame,
32
33
  } from "./native";
33
34
  import { createReadStream } from "fs";
@@ -311,7 +312,7 @@ export class DeviceSession {
311
312
  if (!this.dependencies?.createDuoMonitor) {
312
313
  void warnDeviceHubInput(this.udid).then((shadowed) => { this.deviceHubShadowed = shadowed; });
313
314
  }
314
- const createMonitor = this.dependencies?.createDuoMonitor ?? ((udid, onState) => new DuoStateMonitor(udid, onState));
315
+ const createMonitor = this.dependencies?.createDuoMonitor ?? ((udid, onState) => new DuoStateMonitor(udid, onState, { duoHelper: duoHidHelperPath }));
315
316
  this.duoMonitor = createMonitor(this.udid, (state) => {
316
317
  void this.followDuoState(state).catch((error) => {
317
318
  console.error("[duo] State update failed:", error);
package/src/duo-state.ts CHANGED
@@ -2,9 +2,9 @@ import { execFile, spawn, type ChildProcess } from "node:child_process";
2
2
  import type { SimulatorOrientation } from "./client/types";
3
3
  import { simctlArguments } from "./simctl";
4
4
 
5
+ /** One `angle <degrees>` line from `serve-sim-duo-hid watch-angle`. */
5
6
  export function parseHingeSample(line: string): number | null {
6
- if (!/AngleValid:Y\b/.test(line)) return null;
7
- const match = /\bAngle:\s*(-?[\d.]+)°/.exec(line);
7
+ const match = /^angle (\d+(?:\.\d+)?)$/.exec(line.trim());
8
8
  const angle = match ? Number(match[1]) : NaN;
9
9
  return Number.isFinite(angle) && angle >= 0 && angle <= 180 ? angle : null;
10
10
  }
@@ -40,6 +40,18 @@ export interface DuoState {
40
40
  orientations: Partial<Record<"cover" | "inner", SimulatorOrientation>>;
41
41
  }
42
42
 
43
+ export interface DuoStateMonitorOptions {
44
+ /**
45
+ * Path of the guest helper (`serve-sim-duo-hid`). Resolved when the hinge
46
+ * reader starts; if it throws, the hinge stays unknown and the rest of the
47
+ * monitor keeps running.
48
+ */
49
+ duoHelper: () => string;
50
+ /** Process launchers; tests substitute fakes. */
51
+ spawn?: typeof spawn;
52
+ execFile?: typeof execFile;
53
+ }
54
+
43
55
  /** One shared monitor per device session; phones never start these processes. */
44
56
  export class DuoStateMonitor {
45
57
  private stopped = false;
@@ -51,8 +63,17 @@ export class DuoStateMonitor {
51
63
  private retry?: ReturnType<typeof setTimeout>;
52
64
  private poll?: ReturnType<typeof setTimeout>;
53
65
  private state: DuoState = { orientations: {} };
66
+ private hingeFailureLogged = false;
67
+ private readonly spawn: typeof spawn;
68
+ private readonly execFile: typeof execFile;
54
69
 
55
- constructor(private readonly udid: string, private readonly onState: (state: DuoState) => void) {
70
+ constructor(
71
+ private readonly udid: string,
72
+ private readonly onState: (state: DuoState) => void,
73
+ private readonly options: DuoStateMonitorOptions,
74
+ ) {
75
+ this.spawn = options.spawn ?? spawn;
76
+ this.execFile = options.execFile ?? execFile;
56
77
  this.watchPanels();
57
78
  this.watchHinge();
58
79
  this.refreshOrientation();
@@ -61,7 +82,7 @@ export class DuoStateMonitor {
61
82
  refreshOrientation(): void {
62
83
  if (this.stopped || this.enumeration) return;
63
84
  if (this.poll) clearTimeout(this.poll);
64
- this.enumeration = execFile("/usr/bin/xcrun", simctlArguments(["io", this.udid, "enumerate"]),
85
+ this.enumeration = this.execFile("/usr/bin/xcrun", simctlArguments(["io", this.udid, "enumerate"]),
65
86
  { timeout: 3000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
66
87
  this.enumeration = undefined;
67
88
  if (this.stopped) return;
@@ -89,7 +110,7 @@ export class DuoStateMonitor {
89
110
  this.onState(this.state);
90
111
  this.refreshOrientation();
91
112
  };
92
- const child = spawn("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "stream", "--style", "compact", "--predicate", predicate]), { stdio: ["ignore", "pipe", "ignore"] });
113
+ const child = this.spawn("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "stream", "--style", "compact", "--predicate", predicate]), { stdio: ["ignore", "pipe", "ignore"] });
93
114
  this.panelMonitor = child;
94
115
  let pending = "";
95
116
  child.stdout.setEncoding("utf8");
@@ -103,7 +124,7 @@ export class DuoStateMonitor {
103
124
  }
104
125
  });
105
126
  // Seed from this boot's most recent transition, then let live events win.
106
- this.panelSeed = execFile("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "show", "--last", "boot", "--style", "compact", "--predicate", predicate]),
127
+ this.panelSeed = this.execFile("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "show", "--last", "boot", "--style", "compact", "--predicate", predicate]),
107
128
  { timeout: 10000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
108
129
  this.panelSeed = undefined;
109
130
  if (!error && !liveSample) {
@@ -122,10 +143,25 @@ export class DuoStateMonitor {
122
143
 
123
144
  private watchHinge(): void {
124
145
  if (this.stopped) return;
125
- const child = spawn("/usr/bin/xcrun", ["devicectl", "device", "motion", "hinge-angle", "--device", this.udid,
126
- "--session-timeout", "60", "--timeout", "65", "--change-threshold", "0.5"], { stdio: ["ignore", "pipe", "ignore"] });
146
+ let duoHelper: string;
147
+ try {
148
+ duoHelper = this.options.duoHelper();
149
+ } catch (error) {
150
+ console.error(`[duo] Hinge reader unavailable: ${error instanceof Error ? error.message : String(error)}`);
151
+ return;
152
+ }
153
+ // Read inside the guest from CoreMotion, the source UIKit's UIHinge reads,
154
+ // so the hinge doesn't depend on devicectl: `devicectl device motion` is
155
+ // missing from devicectl 651.13.5 (Xcode 27.1 27A9275) and later.
156
+ // stdin stays open: the reader exits when it closes.
157
+ const child = this.spawn("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, duoHelper, "watch-angle"]),
158
+ { stdio: ["pipe", "pipe", "pipe"] });
127
159
  this.monitor = child;
128
160
  let pending = "";
161
+ let stderr = "";
162
+ child.stdin.on("error", () => {});
163
+ child.stderr.setEncoding("utf8");
164
+ child.stderr.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-1024); });
129
165
  child.stdout.setEncoding("utf8");
130
166
  child.stdout.on("data", (chunk: string) => {
131
167
  pending += chunk;
@@ -140,9 +176,16 @@ export class DuoStateMonitor {
140
176
  }
141
177
  });
142
178
  child.on("error", () => {}); // close also fires for failed spawns; retry there.
143
- child.once("close", () => {
179
+ child.once("close", (code, signal) => {
144
180
  if (this.monitor === child) this.monitor = undefined;
145
181
  if (this.stopped) return;
182
+ // A reader that keeps failing would otherwise leave the hinge unknown
183
+ // without a trace; say why once per session.
184
+ if (!this.hingeFailureLogged) {
185
+ this.hingeFailureLogged = true;
186
+ const reason = stderr.trim() || (signal ? `signal ${signal}` : `exit code ${code}`);
187
+ console.error(`[duo] Hinge reader stopped (${reason}); retrying.`);
188
+ }
146
189
  this.retry = setTimeout(() => this.watchHinge(), 1000);
147
190
  this.retry.unref();
148
191
  });
@@ -155,6 +198,7 @@ export class DuoStateMonitor {
155
198
  this.panelSeed?.kill();
156
199
  clearTimeout(this.retry);
157
200
  clearTimeout(this.poll);
201
+ this.monitor?.stdin?.end();
158
202
  this.monitor?.kill();
159
203
  this.enumeration?.kill();
160
204
  }
package/src/native.ts CHANGED
@@ -164,6 +164,11 @@ function resolveAddon(): string {
164
164
  );
165
165
  }
166
166
 
167
+ /** The guest-side Duo helper (`simctl spawn`): HID commands and the hinge reader. */
168
+ export function duoHidHelperPath(): string {
169
+ return join(dirname(resolveAddon()), "..", "simduo", "serve-sim-duo-hid");
170
+ }
171
+
167
172
  let addon: NativeAddon | undefined;
168
173
  function load(): NativeAddon {
169
174
  if (!addon) {
@@ -184,7 +189,7 @@ export class NativeHid {
184
189
  private readonly keys: KeySequencer;
185
190
 
186
191
  constructor(udid: string) {
187
- this.handle = new (load().SimHID)(udid, join(dirname(resolveAddon()), "..", "simduo", "serve-sim-duo-hid"));
192
+ this.handle = new (load().SimHID)(udid, duoHidHelperPath());
188
193
  this.keys = new KeySequencer(this.handle);
189
194
  }
190
195