@tapflowio/relay 0.19.0 → 0.20.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 (87) hide show
  1. package/dist/RelayServer.d.ts +37 -1
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +121 -7
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts.map +1 -1
  6. package/dist/SessionManager.js +4 -0
  7. package/dist/SessionManager.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/cert/index.d.ts +1 -1
  13. package/dist/lib/cert/index.d.ts.map +1 -1
  14. package/dist/lib/cert/index.js +1 -1
  15. package/dist/lib/cert/index.js.map +1 -1
  16. package/dist/lib/cert/parseCert.d.ts +4 -0
  17. package/dist/lib/cert/parseCert.d.ts.map +1 -1
  18. package/dist/lib/cert/parseCert.js +68 -0
  19. package/dist/lib/cert/parseCert.js.map +1 -1
  20. package/dist/lib/trailingRequester.d.ts +23 -0
  21. package/dist/lib/trailingRequester.d.ts.map +1 -0
  22. package/dist/lib/trailingRequester.js +56 -0
  23. package/dist/lib/trailingRequester.js.map +1 -0
  24. package/dist/server.js +4 -2
  25. package/dist/server.js.map +1 -1
  26. package/package.json +3 -3
  27. package/public/assets/{AppCenter-DyP0aqfF.js → AppCenter-Dg4dscW-.js} +5 -5
  28. package/public/assets/AppCenter-Dg4dscW-.js.br +0 -0
  29. package/public/assets/{Default-D7ScPE30.js → Default-Dxi8aZ1t.js} +1 -1
  30. package/public/assets/Default-Dxi8aZ1t.js.br +0 -0
  31. package/public/assets/{Invite-UmhV5rAf.js → Invite-0EwoClX6.js} +1 -1
  32. package/public/assets/Invite-0EwoClX6.js.br +0 -0
  33. package/public/assets/{MacResources-MS4E00w3.js → MacResources-Dy1M3EqK.js} +1 -1
  34. package/public/assets/MacResources-Dy1M3EqK.js.br +0 -0
  35. package/public/assets/QASession-DqMj8JL1.js +140 -0
  36. package/public/assets/QASession-DqMj8JL1.js.br +0 -0
  37. package/public/assets/{ResetPassword-CAlB6CfP.js → ResetPassword-CXU2bRry.js} +1 -1
  38. package/public/assets/ResetPassword-CXU2bRry.js.br +0 -0
  39. package/public/assets/{Setup-CPm2-hPJ.js → Setup-XXChRxeL.js} +1 -1
  40. package/public/assets/Setup-XXChRxeL.js.br +0 -0
  41. package/public/assets/{Team-GEIzrM1D.js → Team-D0bXpmEY.js} +2 -2
  42. package/public/assets/Team-D0bXpmEY.js.br +0 -0
  43. package/public/assets/{Tokens-DSeBz_CJ.js → Tokens-D0YNHS5e.js} +1 -1
  44. package/public/assets/Tokens-D0YNHS5e.js.br +0 -0
  45. package/public/assets/{alert-dialog-BsXI9SVV.js → alert-dialog-CgatMXjB.js} +1 -1
  46. package/public/assets/alert-dialog-CgatMXjB.js.br +0 -0
  47. package/public/assets/{badge-BkdYQFsh.js → badge-B6Nf3A0Y.js} +1 -1
  48. package/public/assets/badge-B6Nf3A0Y.js.br +0 -0
  49. package/public/assets/{build-format-CXuQt1X2.js → build-format-BFC8gLoh.js} +3 -3
  50. package/public/assets/build-format-BFC8gLoh.js.br +0 -0
  51. package/public/assets/{dialog-DuXZpbiF.js → dialog-DK9a3b4z.js} +3 -3
  52. package/public/assets/dialog-DK9a3b4z.js.br +0 -0
  53. package/public/assets/{index-bitr6DIC.js → index-9vdYIm7s.js} +66 -71
  54. package/public/assets/index-9vdYIm7s.js.br +0 -0
  55. package/public/assets/index-ClHUQm-F.css +1 -0
  56. package/public/assets/index-ClHUQm-F.css.br +0 -0
  57. package/public/assets/{pencil-iRR275s8.js → pencil-BeLNLEKe.js} +2 -2
  58. package/public/assets/pencil-BeLNLEKe.js.br +0 -0
  59. package/public/assets/{table-CHmhGpHG.js → table-DW57-o1p.js} +1 -1
  60. package/public/assets/table-DW57-o1p.js.br +0 -0
  61. package/public/assets/{tabs-BnbeuAOe.js → tabs-Xlttuqxh.js} +1 -1
  62. package/public/assets/tabs-Xlttuqxh.js.br +0 -0
  63. package/public/assets/{trash-2-QI162w2K.js → trash-2-CGxISX8x.js} +3 -3
  64. package/public/assets/trash-2-CGxISX8x.js.br +0 -0
  65. package/public/index.html +2 -2
  66. package/public/index.html.br +0 -0
  67. package/public/assets/AppCenter-DyP0aqfF.js.br +0 -0
  68. package/public/assets/Default-D7ScPE30.js.br +0 -0
  69. package/public/assets/Invite-UmhV5rAf.js.br +0 -0
  70. package/public/assets/MacResources-MS4E00w3.js.br +0 -0
  71. package/public/assets/QASession-CjgJHDZ3.js +0 -125
  72. package/public/assets/QASession-CjgJHDZ3.js.br +0 -0
  73. package/public/assets/ResetPassword-CAlB6CfP.js.br +0 -0
  74. package/public/assets/Setup-CPm2-hPJ.js.br +0 -0
  75. package/public/assets/Team-GEIzrM1D.js.br +0 -0
  76. package/public/assets/Tokens-DSeBz_CJ.js.br +0 -0
  77. package/public/assets/alert-dialog-BsXI9SVV.js.br +0 -0
  78. package/public/assets/badge-BkdYQFsh.js.br +0 -0
  79. package/public/assets/build-format-CXuQt1X2.js.br +0 -0
  80. package/public/assets/dialog-DuXZpbiF.js.br +0 -0
  81. package/public/assets/index-Bk2hqaHZ.css +0 -1
  82. package/public/assets/index-Bk2hqaHZ.css.br +0 -0
  83. package/public/assets/index-bitr6DIC.js.br +0 -0
  84. package/public/assets/pencil-iRR275s8.js.br +0 -0
  85. package/public/assets/table-CHmhGpHG.js.br +0 -0
  86. package/public/assets/tabs-BnbeuAOe.js.br +0 -0
  87. package/public/assets/trash-2-QI162w2K.js.br +0 -0
@@ -3,6 +3,10 @@ export declare function isExternalAddress(addr: string): boolean;
3
3
  * a test carrying its own copy of the number passes when the constant moves, which is the drift the
4
4
  * wire-contract work keeps finding. Not re-exported from the package index. */
5
5
  export declare const IDR_REQUEST_THROTTLE_MS = 500;
6
+ /** The same window for `network:request-state`, and a **separate constant on purpose**: the two share
7
+ * a number today and not a policy. IDR drops inside the window; this one coalesces onto its trailing
8
+ * edge (see `networkStateRequester`). Sharing the constant would mean tuning one tunes the other. */
9
+ export declare const NETWORK_STATE_REQUEST_THROTTLE_MS = 500;
6
10
  /**
7
11
  * The owner key a connection speaks for, from the authenticated user and the client id it claimed.
8
12
  *
@@ -72,6 +76,9 @@ export declare class RelayServer {
72
76
  private audioDropHandlers;
73
77
  private droppers;
74
78
  private idrRequesters;
79
+ /** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
80
+ * `forgetSessionState`. */
81
+ private networkStateRequesters;
75
82
  private wsRoles;
76
83
  /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
77
84
  * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
@@ -134,8 +141,17 @@ export declare class RelayServer {
134
141
  * Pre-existing, and this slice is what makes it worth fixing here rather than filing: `idrRequesters`
135
142
  * used to be populated only by the binary drop path, i.e. only under backpressure, and a re-join now
136
143
  * creates an entry for any streaming session (#515). The leak went from rare to ordinary.
144
+ *
145
+ * **Now five maps, and the fifth is not dropped the same way.** `networkStateRequesters` holds a
146
+ * *coalescing* requester, so its closure owns a pending `setTimeout` — and deleting a map entry does
147
+ * not cancel a timer. Leaving it armed is not a leak but a double send: `session:leave` followed by a
148
+ * re-join inside the window fires the orphaned trailing edge *and* the new requester's leading edge,
149
+ * which is the second budget for one session that this map exists to prevent. Hence `dispose()`
150
+ * rather than `delete` alone. It was named `forgetSessionStreamState` while all four were stream
151
+ * records; the network requester is not one, so the name lost the word rather than the entry
152
+ * lost the function.
137
153
  */
138
- private forgetSessionStreamState;
154
+ private forgetSessionState;
139
155
  /**
140
156
  * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
141
157
  * agents that don't support it.
@@ -148,6 +164,26 @@ export declare class RelayServer {
148
164
  * past the map. Measured: calling it directly from one caller left every test green.
149
165
  */
150
166
  private idrRequester;
167
+ /**
168
+ * Throttled callback asking the session's agent to re-read and report the device's network
169
+ * condition (#614); ignored by agents that don't support it.
170
+ *
171
+ * **Coalescing, where `idrRequester` above drops — and the difference is not a preference.** A
172
+ * dropped IDR request costs nothing: the next periodic keyframe repairs it. Nothing re-produces a
173
+ * `network:state`, so a request dropped inside the window leaves that viewer rendering "unknown"
174
+ * for the life of the session. The trailing edge fires after the last join in a burst, and the
175
+ * relay addresses the reply to whichever socket holds the session *when it arrives* — so the
176
+ * viewer that ends up watching is the one served, which a leading edge cannot promise.
177
+ *
178
+ * The burst this is for is the dirty blip, not two people: `join()` refuses a live holder, and a
179
+ * clean `session:leave` runs `forgetSessionState` and builds a fresh requester. A browser socket
180
+ * that dies in its close handler does neither, so `lastAt` survives into the re-join — which is
181
+ * the very path this slice exists to serve.
182
+ *
183
+ * One per session, constructed here, for the reason written on `idrRequester`: the throttle lives
184
+ * in the closure, so a second requester is a second budget.
185
+ */
186
+ private networkStateRequester;
151
187
  private remoteAddressOf;
152
188
  private warnProxyMisconfigOnce;
153
189
  private handleConnection;
@@ -1 +1 @@
1
- {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
@@ -14,6 +14,7 @@ import { resolveClientAddress } from './lib/clientAddress.js';
14
14
  import { resolveCorsHeaders } from './lib/cors.js';
15
15
  import { isCsrfBlocked } from './lib/csrf.js';
16
16
  import { pickLanAddress } from './lib/lanAddress.js';
17
+ import { createTrailingRequester, systemTimerScheduler } from './lib/trailingRequester.js';
17
18
  import { getDb } from './db.js';
18
19
  import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
19
20
  import { handleVerify, handleAccept } from './api/invitations.js';
@@ -43,6 +44,10 @@ export function isExternalAddress(addr) {
43
44
  * a test carrying its own copy of the number passes when the constant moves, which is the drift the
44
45
  * wire-contract work keeps finding. Not re-exported from the package index. */
45
46
  export const IDR_REQUEST_THROTTLE_MS = 500;
47
+ /** The same window for `network:request-state`, and a **separate constant on purpose**: the two share
48
+ * a number today and not a policy. IDR drops inside the window; this one coalesces onto its trailing
49
+ * edge (see `networkStateRequester`). Sharing the constant would mean tuning one tunes the other. */
50
+ export const NETWORK_STATE_REQUEST_THROTTLE_MS = 500;
46
51
  // Ping every socket each interval; a missed pong window (~2× this) terminates the dead socket.
47
52
  const HEARTBEAT_MS = 30_000;
48
53
  // How long a session outlives its agent's socket, waiting for that agent to come back (#426).
@@ -198,6 +203,9 @@ export class RelayServer {
198
203
  droppers = new Map();
199
204
  // Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
200
205
  idrRequesters = new Map();
206
+ /** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
207
+ * `forgetSessionState`. */
208
+ networkStateRequesters = new Map();
201
209
  wsRoles = new Map();
202
210
  /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
203
211
  * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
@@ -409,6 +417,14 @@ export class RelayServer {
409
417
  for (const timer of this.agentHolds.values())
410
418
  clearTimeout(timer);
411
419
  this.agentHolds.clear();
420
+ // Same argument one line up. **Not** because the send would land — `sendTo` drops a socket that is
421
+ // not OPEN, which is why `fire` carries no check of its own — but because `unref()` only excuses a
422
+ // timer from keeping the process alive, and the test runner's process outlives the server anyway.
423
+ // An armed edge therefore still runs, holding a closure over the session map of a relay that has
424
+ // already stopped.
425
+ for (const requester of this.networkStateRequesters.values())
426
+ requester.dispose();
427
+ this.networkStateRequesters.clear();
412
428
  return new Promise((resolve, reject) => {
413
429
  this.wss.clients.forEach((ws) => ws.terminate());
414
430
  this.wss.close(() => {
@@ -467,12 +483,23 @@ export class RelayServer {
467
483
  * Pre-existing, and this slice is what makes it worth fixing here rather than filing: `idrRequesters`
468
484
  * used to be populated only by the binary drop path, i.e. only under backpressure, and a re-join now
469
485
  * creates an entry for any streaming session (#515). The leak went from rare to ordinary.
486
+ *
487
+ * **Now five maps, and the fifth is not dropped the same way.** `networkStateRequesters` holds a
488
+ * *coalescing* requester, so its closure owns a pending `setTimeout` — and deleting a map entry does
489
+ * not cancel a timer. Leaving it armed is not a leak but a double send: `session:leave` followed by a
490
+ * re-join inside the window fires the orphaned trailing edge *and* the new requester's leading edge,
491
+ * which is the second budget for one session that this map exists to prevent. Hence `dispose()`
492
+ * rather than `delete` alone. It was named `forgetSessionStreamState` while all four were stream
493
+ * records; the network requester is not one, so the name lost the word rather than the entry
494
+ * lost the function.
470
495
  */
471
- forgetSessionStreamState(sessionId) {
496
+ forgetSessionState(sessionId) {
472
497
  this.dropHandlers.delete(sessionId);
473
498
  this.audioDropHandlers.delete(sessionId);
474
499
  this.droppers.delete(sessionId);
475
500
  this.idrRequesters.delete(sessionId);
501
+ this.networkStateRequesters.get(sessionId)?.dispose();
502
+ this.networkStateRequesters.delete(sessionId);
476
503
  }
477
504
  /**
478
505
  * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
@@ -503,6 +530,48 @@ export class RelayServer {
503
530
  this.idrRequesters.set(sessionId, requester);
504
531
  return requester;
505
532
  }
533
+ /**
534
+ * Throttled callback asking the session's agent to re-read and report the device's network
535
+ * condition (#614); ignored by agents that don't support it.
536
+ *
537
+ * **Coalescing, where `idrRequester` above drops — and the difference is not a preference.** A
538
+ * dropped IDR request costs nothing: the next periodic keyframe repairs it. Nothing re-produces a
539
+ * `network:state`, so a request dropped inside the window leaves that viewer rendering "unknown"
540
+ * for the life of the session. The trailing edge fires after the last join in a burst, and the
541
+ * relay addresses the reply to whichever socket holds the session *when it arrives* — so the
542
+ * viewer that ends up watching is the one served, which a leading edge cannot promise.
543
+ *
544
+ * The burst this is for is the dirty blip, not two people: `join()` refuses a live holder, and a
545
+ * clean `session:leave` runs `forgetSessionState` and builds a fresh requester. A browser socket
546
+ * that dies in its close handler does neither, so `lastAt` survives into the re-join — which is
547
+ * the very path this slice exists to serve.
548
+ *
549
+ * One per session, constructed here, for the reason written on `idrRequester`: the throttle lives
550
+ * in the closure, so a second requester is a second budget.
551
+ */
552
+ networkStateRequester(sessionId) {
553
+ const existing = this.networkStateRequesters.get(sessionId);
554
+ if (existing)
555
+ return existing;
556
+ const requester = createTrailingRequester({
557
+ scheduler: systemTimerScheduler,
558
+ windowMs: NETWORK_STATE_REQUEST_THROTTLE_MS,
559
+ fire: () => {
560
+ const session = this.sessions.get(sessionId);
561
+ // `sendTo` already drops a socket that is not OPEN, so a closed agent needs no check here —
562
+ // an earlier draft duplicated it and no mutation could tell the copy from the original.
563
+ //
564
+ // The lookup is the guard that remains, and it should be unreachable: **every path that removes
565
+ // a session runs `forgetSessionState`, which disposes this timer.** It is kept as the cheap half
566
+ // of that pair rather than as a live case — a trailing edge is the one call here that outlives
567
+ // the statement scheduling it, so the invariant holding is worth not assuming.
568
+ if (session)
569
+ this.sendTo(session.agentSocket, { type: 'network:request-state', sessionId });
570
+ },
571
+ });
572
+ this.networkStateRequesters.set(sessionId, requester);
573
+ return requester;
574
+ }
506
575
  // Extracted so tests can simulate non-loopback origins (all test traffic is loopback).
507
576
  remoteAddressOf(request) {
508
577
  return request.socket.remoteAddress ?? '';
@@ -705,7 +774,7 @@ export class RelayServer {
705
774
  */
706
775
  settleRole(ws, inbound) {
707
776
  // Every reason that carries a type, which is all of them but the two that have none to carry.
708
- // Missing `bad-payload` here returned `false` before the caller could answer, so the twelve
777
+ // Missing `bad-payload` here returned `false` before the caller could answer, so the thirteen
709
778
  // answerable requests were classified correctly and then dropped anyway — the regression this
710
779
  // whole path exists to prevent, reintroduced one line above it.
711
780
  const type = inbound.ok ? inbound.msg.type
@@ -842,14 +911,14 @@ export class RelayServer {
842
911
  case 'session:end': {
843
912
  if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
844
913
  this.sessions.remove(msg.sessionId);
845
- this.forgetSessionStreamState(msg.sessionId);
914
+ this.forgetSessionState(msg.sessionId);
846
915
  }
847
916
  break;
848
917
  }
849
918
  case 'session:leave': {
850
919
  if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
851
920
  this.sessions.clearBrowser(msg.sessionId);
852
- this.forgetSessionStreamState(msg.sessionId);
921
+ this.forgetSessionState(msg.sessionId);
853
922
  }
854
923
  break;
855
924
  }
@@ -962,6 +1031,11 @@ export class RelayServer {
962
1031
  // Bound to the session's own agent socket, unlike the replies above: this payload
963
1032
  // lands on the viewer's host OS clipboard, so a second agent (another Mac on the
964
1033
  // same relay) must not be able to address someone else's session.
1034
+ // Bound to the session's own agent socket for the same reason clipboard is: this reply says
1035
+ // whether a **specific tester's** device is on the network, and a second agent must not be
1036
+ // able to tell that tester's viewer something about it.
1037
+ case 'network:state':
1038
+ case 'network:error':
965
1039
  case 'clipboard:data':
966
1040
  case 'clipboard:write-done':
967
1041
  case 'clipboard:error': {
@@ -1183,6 +1257,22 @@ export class RelayServer {
1183
1257
  }
1184
1258
  break;
1185
1259
  }
1260
+ // Ownership is not decoration here: taking a device off the network is the most disruptive
1261
+ // thing a non-holder could do to someone else's session short of shutting it down, and unlike
1262
+ // a shutdown it leaves the device *looking* fine. `dispatchTarget` rather than the weaker
1263
+ // `reachableTarget` `device:shutdown` uses — there is no teardown race to accommodate.
1264
+ case 'network:set': {
1265
+ const net = this.dispatchTarget(ws, msg.sessionId);
1266
+ if (net.ok) {
1267
+ net.session.agentSocket.send(JSON.stringify(msg));
1268
+ }
1269
+ else if (ws.readyState === WebSocket.OPEN) {
1270
+ this.sendTo(ws, {
1271
+ type: 'network:error', sessionId: msg.sessionId, requestId: msg.requestId, message: net.message,
1272
+ });
1273
+ }
1274
+ break;
1275
+ }
1186
1276
  }
1187
1277
  }
1188
1278
  handleAgentResources(ws, msg) {
@@ -1313,7 +1403,7 @@ export class RelayServer {
1313
1403
  }
1314
1404
  for (const s of agentSessions) {
1315
1405
  this.sessions.remove(s.id);
1316
- this.forgetSessionStreamState(s.id);
1406
+ this.forgetSessionState(s.id);
1317
1407
  }
1318
1408
  this.sessions.removeResources(ws);
1319
1409
  logger.info(cause === 'replaced'
@@ -1389,7 +1479,7 @@ export class RelayServer {
1389
1479
  this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
1390
1480
  }
1391
1481
  this.sessions.remove(s.id);
1392
- this.forgetSessionStreamState(s.id);
1482
+ this.forgetSessionState(s.id);
1393
1483
  }
1394
1484
  }
1395
1485
  // Only devices without a surviving session get a new one. Passing all of them here is what
@@ -1535,6 +1625,27 @@ export class RelayServer {
1535
1625
  // backpressure path already throttles this exact message per session; sharing that requester is
1536
1626
  // what keeps the two callers from having two policies.
1537
1627
  this.idrRequester(session.id)();
1628
+ // And ask what the device's network is doing (#614). `NetworkState` is agent-produced and is not
1629
+ // among the three replayed above, so without this a viewer that reconnects has no way to learn
1630
+ // whether the device is offline and its control renders in a guessed position.
1631
+ //
1632
+ // **Gated on the capability, unlike the IDR request beside it.** That one is safe to send blind
1633
+ // because an agent without it loses nothing — the next periodic keyframe arrives anyway. This one
1634
+ // has no such repair, so an agent that never answers is indistinguishable from one that failed to
1635
+ // read; a viewer arming a deadline would then render "could not read" on every re-join, for an
1636
+ // agent that already said it cannot do this at all. iOS is exactly that agent until #607's last
1637
+ // slice. Fifty lines up, `session:joined` carries these capabilities for the stated reason that a
1638
+ // consumer must not infer support from a timeout — asking anyway would make the relay do it.
1639
+ //
1640
+ // **Inside `readySent` too, and there as an optimisation rather than a correctness condition.**
1641
+ // `readySent` is the *stream's* readiness: `clearStreamSocket` lowers it while `deviceStatus`
1642
+ // stays `'booted'`, so a booted, genuinely offline device with a dead stream socket is not asked
1643
+ // about here — and the agent would have answered, since it still holds that serial. What makes
1644
+ // skipping safe is not the agent's silence but the viewer's: with `readySent` false the
1645
+ // `device:ready` above is not replayed either, so nothing is rendering a live device to be wrong
1646
+ // about. `deviceStatus` is not the fix — the comment on `device:ready` says why (#440).
1647
+ if (session.agentCapabilities?.includes('network-control'))
1648
+ this.networkStateRequester(session.id)();
1538
1649
  }
1539
1650
  }
1540
1651
  /** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
@@ -1775,8 +1886,11 @@ export class RelayServer {
1775
1886
  case 'clipboard:write':
1776
1887
  this.sendTo(ws, { type: 'clipboard:error', sessionId, requestId, message });
1777
1888
  break;
1889
+ case 'network:set':
1890
+ this.sendTo(ws, { type: 'network:error', sessionId, requestId, message });
1891
+ break;
1778
1892
  // The four remaining acked inputs. A `default` rather than four labels because the union is
1779
- // closed and exhaustive: adding a thirteenth answerable request without a case here would land
1893
+ // closed and exhaustive: adding a fourteenth answerable request without a case here would land
1780
1894
  // it on `input:error`, which `answerableRequestsAnswered` is what stops.
1781
1895
  default:
1782
1896
  this.sendTo(ws, { type: 'input:error', sessionId, requestId, message, reason: 'malformed' });