@q9labsai/chalk-client 5.0.1 → 5.2.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 (236) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +39 -0
  3. package/dist/access/base64-url.d.ts +0 -1
  4. package/dist/access/grant.d.ts +0 -1
  5. package/dist/access/manager.d.ts +0 -1
  6. package/dist/chat-files/http-transport.d.ts +0 -1
  7. package/dist/chat-files/index.d.ts +0 -1
  8. package/dist/chat-files/types.d.ts +3 -1
  9. package/dist/{chunk-IPAN4LUZ.js → chunk-BCO3AZUB.js} +1463 -583
  10. package/dist/{chunk-AVXJ4HQU.js → chunk-D2GXPX2P.js} +298 -47
  11. package/dist/{chunk-CMVO7USN.js → chunk-DHGAKJXP.js} +1 -1
  12. package/dist/{chunk-O2NOZKJP.js → chunk-DWKTGBHR.js} +20 -8
  13. package/dist/{chunk-2HJIJVE2.js → chunk-HO4745TX.js} +591 -5
  14. package/dist/client.d.ts +277 -1
  15. package/dist/collaboration/index.d.ts +0 -1
  16. package/dist/collaboration/types.d.ts +0 -1
  17. package/dist/collaboration/wire.d.ts +0 -1
  18. package/dist/connection/dependencies.d.ts +8 -3
  19. package/dist/connection/diagnostics.d.ts +3 -3
  20. package/dist/connection/index.d.ts +1 -1
  21. package/dist/connection/lifecycle.d.ts +3 -3
  22. package/dist/connection/media-devices.d.ts +0 -1
  23. package/dist/connection/production.d.ts +0 -1
  24. package/dist/connection/types.d.ts +4 -2
  25. package/dist/effect.d.ts +0 -1
  26. package/dist/effect.js +146 -4
  27. package/dist/foreground-deadline.d.ts +0 -1
  28. package/dist/generated/http-api.d.ts +273 -1
  29. package/dist/generated/schemas.d.ts +732 -7
  30. package/dist/generated/sync.d.ts +74 -4
  31. package/dist/generated/whiteboard-v1.d.ts +114 -9
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +21 -15
  34. package/dist/media/camera-uplink.d.ts +0 -1
  35. package/dist/media/capture-error.d.ts +0 -1
  36. package/dist/media/client.d.ts +3 -2
  37. package/dist/media/cloudflare-sfu.d.ts +0 -1
  38. package/dist/media/index.d.ts +0 -1
  39. package/dist/media/index.js +1 -1
  40. package/dist/media/observers.d.ts +0 -1
  41. package/dist/media/plane.d.ts +0 -1
  42. package/dist/media/remote-track-response.d.ts +0 -1
  43. package/dist/media/target.d.ts +0 -1
  44. package/dist/media/tracks.d.ts +0 -1
  45. package/dist/media/transport.d.ts +0 -1
  46. package/dist/media/types.d.ts +0 -1
  47. package/dist/public-invites/client.d.ts +0 -1
  48. package/dist/public-invites/index.d.ts +0 -1
  49. package/dist/public-invites/index.js +2 -2
  50. package/dist/public-invites/types.d.ts +0 -1
  51. package/dist/server/access-refresh.d.ts +0 -1
  52. package/dist/server/{chunk-C4XITY5U.js → chunk-XQOQTCUL.js} +22 -0
  53. package/dist/server/client.d.ts +0 -1
  54. package/dist/server/errors.d.cts +8 -1
  55. package/dist/server/errors.d.ts +8 -2
  56. package/dist/server/index.cjs +31 -7
  57. package/dist/server/index.d.cts +1 -1
  58. package/dist/server/index.d.ts +1 -2
  59. package/dist/server/index.js +10 -7
  60. package/dist/server/server-only.d.ts +0 -1
  61. package/dist/server/server-only.js +1 -1
  62. package/dist/server/transport.d.ts +0 -1
  63. package/dist/server/types.d.ts +0 -1
  64. package/dist/space-client/chat-controller-helpers.d.ts +0 -1
  65. package/dist/space-client/chat-controller.d.ts +0 -1
  66. package/dist/space-client/controller-effects.d.ts +0 -1
  67. package/dist/space-client/core.d.ts +0 -1
  68. package/dist/space-client/effect.d.ts +0 -1
  69. package/dist/space-client/errors.d.ts +4 -2
  70. package/dist/space-client/index.d.ts +1 -2
  71. package/dist/space-client/media-controller.d.ts +3 -2
  72. package/dist/space-client/media-device-selection.d.ts +0 -1
  73. package/dist/space-client/offline-actions-storage.d.ts +0 -1
  74. package/dist/space-client/offline-actions.d.ts +0 -1
  75. package/dist/space-client/participants-controller.d.ts +0 -1
  76. package/dist/space-client/promise-facade.d.ts +0 -1
  77. package/dist/space-client/reactions-controller.d.ts +0 -1
  78. package/dist/space-client/recording-controller.d.ts +0 -1
  79. package/dist/space-client/reload-rejoin.d.ts +1 -2
  80. package/dist/space-client/space-client.d.ts +0 -1
  81. package/dist/space-client/store.d.ts +0 -1
  82. package/dist/space-client/types.d.ts +15 -3
  83. package/dist/space-client/whiteboard-controller.d.ts +0 -1
  84. package/dist/sync/browser.d.ts +0 -1
  85. package/dist/sync/canonical.d.ts +0 -1
  86. package/dist/sync/connect-diagnostics.d.ts +10 -0
  87. package/dist/sync/dial-deadline.d.ts +3 -0
  88. package/dist/sync/errors.d.ts +0 -1
  89. package/dist/sync/index.d.ts +0 -1
  90. package/dist/sync/index.js +1 -1
  91. package/dist/sync/react-native.d.ts +0 -1
  92. package/dist/sync/types.d.ts +1 -1
  93. package/dist/sync/v1-client.d.ts +4 -2
  94. package/dist/sync/v1-codec.d.ts +0 -1
  95. package/dist/sync/v1-collaboration-state.d.ts +1 -1
  96. package/dist/sync/v1-command-scheduler.d.ts +0 -1
  97. package/dist/sync/v1-create.d.ts +0 -1
  98. package/dist/sync/v1-deferred.d.ts +0 -1
  99. package/dist/sync/v1-error.d.ts +0 -1
  100. package/dist/sync/v1-frame-signature.d.ts +0 -1
  101. package/dist/sync/v1-health.d.ts +15 -0
  102. package/dist/sync/v1-live-target-coordinator.d.ts +0 -1
  103. package/dist/sync/v1-persistence.d.ts +0 -1
  104. package/dist/sync/v1-platform-persistence.d.ts +0 -1
  105. package/dist/sync/v1-reducer.d.ts +3 -1
  106. package/dist/sync/v1-types.d.ts +14 -1
  107. package/dist/webhooks/errors.d.ts +0 -1
  108. package/dist/webhooks/generated/event-v1.d.ts +0 -1
  109. package/dist/webhooks/generated/fixtures-v1.d.ts +0 -1
  110. package/dist/webhooks/index.d.ts +0 -1
  111. package/dist/webhooks/processor.d.ts +0 -1
  112. package/dist/webhooks/server-only.d.ts +0 -1
  113. package/dist/webhooks/test.d.ts +0 -1
  114. package/dist/webhooks/types.d.ts +0 -1
  115. package/dist/webhooks/validate.d.ts +0 -1
  116. package/dist/webhooks/verify.d.ts +0 -1
  117. package/dist/whiteboard/file-download-transport.d.ts +0 -1
  118. package/dist/whiteboard/index.d.ts +2 -1
  119. package/dist/whiteboard/types.d.ts +54 -5
  120. package/dist/whiteboard/v1-client.d.ts +5 -2
  121. package/dist/whiteboard/v1-codec.d.ts +5 -2
  122. package/dist/whiteboard/v1-create.d.ts +0 -1
  123. package/dist/whiteboard/v1-multipart.d.ts +1 -2
  124. package/dist/whiteboard/v1-persistence.d.ts +0 -1
  125. package/dist/whiteboard/view-bounds.d.ts +5 -0
  126. package/dist/whiteboard/view-versions.d.ts +9 -0
  127. package/package.json +4 -2
  128. package/dist/access/base64-url.d.ts.map +0 -1
  129. package/dist/access/grant.d.ts.map +0 -1
  130. package/dist/access/grant.test.helpers.d.ts +0 -10
  131. package/dist/access/grant.test.helpers.d.ts.map +0 -1
  132. package/dist/access/manager.d.ts.map +0 -1
  133. package/dist/chat-files/http-transport.d.ts.map +0 -1
  134. package/dist/chat-files/index.d.ts.map +0 -1
  135. package/dist/chat-files/types.d.ts.map +0 -1
  136. package/dist/client.d.ts.map +0 -1
  137. package/dist/collaboration/index.d.ts.map +0 -1
  138. package/dist/collaboration/types.d.ts.map +0 -1
  139. package/dist/collaboration/wire.d.ts.map +0 -1
  140. package/dist/connection/dependencies.d.ts.map +0 -1
  141. package/dist/connection/diagnostics.d.ts.map +0 -1
  142. package/dist/connection/index.d.ts.map +0 -1
  143. package/dist/connection/lifecycle.d.ts.map +0 -1
  144. package/dist/connection/media-devices.d.ts.map +0 -1
  145. package/dist/connection/production.d.ts.map +0 -1
  146. package/dist/connection/types.d.ts.map +0 -1
  147. package/dist/effect.d.ts.map +0 -1
  148. package/dist/foreground-deadline.d.ts.map +0 -1
  149. package/dist/generated/http-api.d.ts.map +0 -1
  150. package/dist/generated/schemas.d.ts.map +0 -1
  151. package/dist/generated/sync.d.ts.map +0 -1
  152. package/dist/generated/whiteboard-v1.d.ts.map +0 -1
  153. package/dist/index.d.ts.map +0 -1
  154. package/dist/media/camera-uplink.d.ts.map +0 -1
  155. package/dist/media/capture-error.d.ts.map +0 -1
  156. package/dist/media/client.d.ts.map +0 -1
  157. package/dist/media/cloudflare-sfu.d.ts.map +0 -1
  158. package/dist/media/index.d.ts.map +0 -1
  159. package/dist/media/observers.d.ts.map +0 -1
  160. package/dist/media/plane.d.ts.map +0 -1
  161. package/dist/media/remote-track-response.d.ts.map +0 -1
  162. package/dist/media/target.d.ts.map +0 -1
  163. package/dist/media/tracks.d.ts.map +0 -1
  164. package/dist/media/transport.d.ts.map +0 -1
  165. package/dist/media/types.d.ts.map +0 -1
  166. package/dist/public-invites/client.d.ts.map +0 -1
  167. package/dist/public-invites/index.d.ts.map +0 -1
  168. package/dist/public-invites/types.d.ts.map +0 -1
  169. package/dist/server/access-refresh.d.ts.map +0 -1
  170. package/dist/server/client.d.ts.map +0 -1
  171. package/dist/server/errors.d.ts.map +0 -1
  172. package/dist/server/index.d.ts.map +0 -1
  173. package/dist/server/server-only.d.ts.map +0 -1
  174. package/dist/server/transport.d.ts.map +0 -1
  175. package/dist/server/types.d.ts.map +0 -1
  176. package/dist/space-client/chat-controller-helpers.d.ts.map +0 -1
  177. package/dist/space-client/chat-controller.d.ts.map +0 -1
  178. package/dist/space-client/controller-effects.d.ts.map +0 -1
  179. package/dist/space-client/core.d.ts.map +0 -1
  180. package/dist/space-client/core.test.helpers.d.ts +0 -15
  181. package/dist/space-client/core.test.helpers.d.ts.map +0 -1
  182. package/dist/space-client/effect.d.ts.map +0 -1
  183. package/dist/space-client/errors.d.ts.map +0 -1
  184. package/dist/space-client/index.d.ts.map +0 -1
  185. package/dist/space-client/media-controller.d.ts.map +0 -1
  186. package/dist/space-client/media-device-selection.d.ts.map +0 -1
  187. package/dist/space-client/offline-actions-storage.d.ts.map +0 -1
  188. package/dist/space-client/offline-actions.d.ts.map +0 -1
  189. package/dist/space-client/participants-controller.d.ts.map +0 -1
  190. package/dist/space-client/promise-facade.d.ts.map +0 -1
  191. package/dist/space-client/reactions-controller.d.ts.map +0 -1
  192. package/dist/space-client/recording-controller.d.ts.map +0 -1
  193. package/dist/space-client/reload-rejoin.d.ts.map +0 -1
  194. package/dist/space-client/space-client.d.ts.map +0 -1
  195. package/dist/space-client/store.d.ts.map +0 -1
  196. package/dist/space-client/terminal.test.helpers.d.ts +0 -136
  197. package/dist/space-client/terminal.test.helpers.d.ts.map +0 -1
  198. package/dist/space-client/types.d.ts.map +0 -1
  199. package/dist/space-client/whiteboard-controller.d.ts.map +0 -1
  200. package/dist/sync/browser.d.ts.map +0 -1
  201. package/dist/sync/canonical.d.ts.map +0 -1
  202. package/dist/sync/errors.d.ts.map +0 -1
  203. package/dist/sync/index.d.ts.map +0 -1
  204. package/dist/sync/react-native.d.ts.map +0 -1
  205. package/dist/sync/types.d.ts.map +0 -1
  206. package/dist/sync/v1-client.d.ts.map +0 -1
  207. package/dist/sync/v1-codec.d.ts.map +0 -1
  208. package/dist/sync/v1-collaboration-state.d.ts.map +0 -1
  209. package/dist/sync/v1-command-scheduler.d.ts.map +0 -1
  210. package/dist/sync/v1-create.d.ts.map +0 -1
  211. package/dist/sync/v1-deferred.d.ts.map +0 -1
  212. package/dist/sync/v1-error.d.ts.map +0 -1
  213. package/dist/sync/v1-frame-signature.d.ts.map +0 -1
  214. package/dist/sync/v1-live-target-coordinator.d.ts.map +0 -1
  215. package/dist/sync/v1-persistence.d.ts.map +0 -1
  216. package/dist/sync/v1-platform-persistence.d.ts.map +0 -1
  217. package/dist/sync/v1-reducer.d.ts.map +0 -1
  218. package/dist/sync/v1-types.d.ts.map +0 -1
  219. package/dist/webhooks/errors.d.ts.map +0 -1
  220. package/dist/webhooks/generated/event-v1.d.ts.map +0 -1
  221. package/dist/webhooks/generated/fixtures-v1.d.ts.map +0 -1
  222. package/dist/webhooks/index.d.ts.map +0 -1
  223. package/dist/webhooks/processor.d.ts.map +0 -1
  224. package/dist/webhooks/server-only.d.ts.map +0 -1
  225. package/dist/webhooks/test.d.ts.map +0 -1
  226. package/dist/webhooks/types.d.ts.map +0 -1
  227. package/dist/webhooks/validate.d.ts.map +0 -1
  228. package/dist/webhooks/verify.d.ts.map +0 -1
  229. package/dist/whiteboard/file-download-transport.d.ts.map +0 -1
  230. package/dist/whiteboard/index.d.ts.map +0 -1
  231. package/dist/whiteboard/types.d.ts.map +0 -1
  232. package/dist/whiteboard/v1-client.d.ts.map +0 -1
  233. package/dist/whiteboard/v1-codec.d.ts.map +0 -1
  234. package/dist/whiteboard/v1-create.d.ts.map +0 -1
  235. package/dist/whiteboard/v1-multipart.d.ts.map +0 -1
  236. package/dist/whiteboard/v1-persistence.d.ts.map +0 -1
@@ -1,12 +1,12 @@
1
- import {
2
- AccessGrantError,
3
- requireParsedAccessGrant
4
- } from "./chunk-2HJIJVE2.js";
5
1
  import {
6
2
  CloudflareSFUClient,
7
3
  createCloudflareSFUHTTPTransport,
8
4
  describeMediaCaptureError
9
- } from "./chunk-O2NOZKJP.js";
5
+ } from "./chunk-DWKTGBHR.js";
6
+ import {
7
+ AccessGrantError,
8
+ requireParsedAccessGrant
9
+ } from "./chunk-HO4745TX.js";
10
10
  import {
11
11
  ChatAttachmentMimeTypeSchema,
12
12
  ChatReactionSetFrameSchema,
@@ -16,8 +16,10 @@ import {
16
16
  V1SyncError,
17
17
  createBrowserSyncLifecycle,
18
18
  createBrowserWebSocketFactory,
19
- createV1SyncClient
20
- } from "./chunk-AVXJ4HQU.js";
19
+ createV1SyncClient,
20
+ formatSyncConnectDiagnostic,
21
+ scheduleSyncDialDeadline
22
+ } from "./chunk-D2GXPX2P.js";
21
23
 
22
24
  // src/access/manager.ts
23
25
  import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
@@ -32,24 +34,24 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
32
34
  Effect.gen(function* () {
33
35
  const state = yield* SynchronizedRef.make({ access: null });
34
36
  const automaticMediaReplacementListeners = /* @__PURE__ */ new Set();
35
- const fetch = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs })).pipe(
37
+ const fetch2 = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs })).pipe(
36
38
  Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
37
39
  Effect.map((result) => result.access)
38
40
  );
39
- const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure2) => failure2.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch(reason, false, true) : Effect.fail(failure2)));
41
+ const initialize = (reason = "join") => fetch2(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure2) => failure2.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch2(reason, false, true) : Effect.fail(failure2)));
40
42
  const millisecondsUntilRefresh = Effect.gen(function* () {
41
43
  const access = yield* SynchronizedRef.get(state);
42
44
  if (!access.access) return null;
43
45
  return millisecondsUntil(access.access, yield* Clock.currentTimeMillis, refreshWindowMs);
44
46
  });
45
- const ensureFresh = (reason = "scheduled_refresh") => fetch(reason, false, false);
47
+ const ensureFresh = (reason = "scheduled_refresh") => fetch2(reason, false, false);
46
48
  return {
47
49
  current: SynchronizedRef.get(state).pipe(Effect.map((value) => value.access)),
48
50
  currentUnsafe: () => SynchronizedRef.getUnsafe(state).access,
49
51
  initialize,
50
52
  ensureFresh,
51
- refresh: (reason, replaceMediaConnection) => fetch(reason, replaceMediaConnection, true),
52
- refreshAfterRejection: () => fetch("access_retry", true, true),
53
+ refresh: (reason, replaceMediaConnection) => fetch2(reason, replaceMediaConnection, true),
54
+ refreshAfterRejection: () => fetch2("access_retry", true, true),
53
55
  getSyncToken: (reason = "sync_recovery") => ensureFresh(reason).pipe(Effect.map((access) => access.sync.token)),
54
56
  getMediaToken: () => ensureFresh("scheduled_refresh").pipe(Effect.map((access) => access.media.token)),
55
57
  subscribeAutomaticMediaReplacement: (listener) => Effect.sync(() => {
@@ -80,7 +82,7 @@ function fetchAccessGrant(provider, input) {
80
82
  try: () => validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection),
81
83
  catch: (cause) => new ConnectionAccessFailure({ code: "access.invalid", cause })
82
84
  });
83
- return [{ access: next, automaticMediaReplacement: mediaExpired && !input.replaceMediaConnection }, { access: next }];
85
+ return [{ access: next, automaticMediaReplacement: currentAccess !== null && !input.replaceMediaConnection && !hasSameMediaConnection(currentAccess, next) }, { access: next }];
84
86
  });
85
87
  }
86
88
  function connectionAccessRequest(input, replaceMediaConnection) {
@@ -114,9 +116,10 @@ function validateExpiration(access, now) {
114
116
  }
115
117
  function validateRefresh(previous, next, replaceMediaConnection) {
116
118
  if (!hasSameSubject(previous, next)) throw new TypeError("Access grant refresh changed its subject");
117
- const mediaConnectionChanged = previous.media.provider !== next.media.provider || mediaBinding(previous) !== mediaBinding(next);
118
- if (replaceMediaConnection === mediaConnectionChanged) return;
119
- throw new TypeError(replaceMediaConnection ? "Access grant replacement did not change its media binding" : "Access grant refresh unexpectedly replaced its media connection");
119
+ if (replaceMediaConnection && hasSameMediaConnection(previous, next)) throw new TypeError("Access grant replacement did not change its media binding");
120
+ }
121
+ function hasSameMediaConnection(previous, next) {
122
+ return previous.media.provider === next.media.provider && mediaBinding(previous) === mediaBinding(next);
120
123
  }
121
124
  function hasSameSubject(previous, next) {
122
125
  const left = previous.subject;
@@ -182,16 +185,19 @@ var CHALK_CHAT_ATTACHMENT_MIME_TYPES = [
182
185
  ];
183
186
 
184
187
  // src/chat-files/types.ts
188
+ var CHAT_FILE_REACHABILITY_MESSAGE = "Could not reach the chat attachment service (network or browser access failure).";
185
189
  var ChalkChatFileError = class extends Error {
186
190
  operation;
187
191
  code;
188
192
  recoverable;
193
+ stage;
189
194
  constructor(failure2, options) {
190
195
  super(failure2.message, options);
191
196
  this.name = "ChalkChatFileError";
192
197
  this.operation = failure2.operation;
193
198
  this.code = failure2.code;
194
199
  this.recoverable = failure2.recoverable;
200
+ this.stage = failure2.stage ?? "request";
195
201
  }
196
202
  };
197
203
 
@@ -238,15 +244,19 @@ function createChatFileRequest(options) {
238
244
  const fetchImplementation = options.fetch ?? globalThis.fetch;
239
245
  const baseUrl = options.baseUrl.replace(/\/+$/u, "");
240
246
  return async function request(operation, path, init, accepts) {
241
- let response;
247
+ const headers = new Headers(init.headers);
242
248
  try {
243
- const headers = new Headers(init.headers);
244
249
  headers.set("authorization", `Bearer ${await options.token()}`);
250
+ } catch (cause) {
251
+ throw new ChalkChatFileError({ operation, code: "unavailable", stage: "token", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE }, { cause });
252
+ }
253
+ let response;
254
+ try {
245
255
  response = await fetchImplementation(`${baseUrl}${path}`, { ...init, headers });
246
256
  } catch (cause) {
247
- throw new ChalkChatFileError({ operation, code: "unavailable", recoverable: true, message: "Chat attachment storage is unavailable." }, { cause });
257
+ throw new ChalkChatFileError({ operation, code: "unavailable", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE }, { cause });
248
258
  }
249
- if (!response.ok) throw responseError(response, operation);
259
+ if (!response.ok) throw await responseError(response, operation);
250
260
  let value;
251
261
  try {
252
262
  value = await response.json();
@@ -257,9 +267,14 @@ function createChatFileRequest(options) {
257
267
  return value;
258
268
  };
259
269
  }
260
- function responseError(response, operation) {
270
+ async function responseError(response, operation) {
261
271
  const code = responseErrorCode(response.status);
262
- return chatFileError(operation, code, response.status === 429 || response.status >= 500, "Chat attachment request failed.");
272
+ let message = "Chat attachment request failed.";
273
+ if (response.status === 503) {
274
+ const value = await response.json().catch(() => null);
275
+ if (isRecord(value) && isRecord(value.error) && value.error.code === "chat.storage_unavailable") message = "Chat attachment storage is unavailable.";
276
+ }
277
+ return chatFileError(operation, code, response.status === 429 || response.status >= 500, message);
263
278
  }
264
279
  function responseErrorCode(status) {
265
280
  if (status === 401) return "access.invalid";
@@ -304,71 +319,6 @@ function isAllowedMimeType(value) {
304
319
  return typeof value === "string" && CHALK_CHAT_ATTACHMENT_MIME_TYPES.includes(value);
305
320
  }
306
321
 
307
- // src/whiteboard/file-download-transport.ts
308
- function createChalkWhiteboardV1FileDownloadTransport(options) {
309
- const fetchImplementation = options.fetch ?? globalThis.fetch;
310
- let baseUrlEnd = options.baseUrl.length;
311
- while (baseUrlEnd > 0 && options.baseUrl[baseUrlEnd - 1] === "/") baseUrlEnd -= 1;
312
- const baseUrl = options.baseUrl.slice(0, baseUrlEnd);
313
- return {
314
- getDownloadUrl: async (fileId) => {
315
- const response = await authorizedFetch(fetchImplementation, options.token, `${baseUrl}/v1/whiteboard/files/${encodeURIComponent(fileId)}/download`, { method: "GET" });
316
- if (!response.ok) throw await responseError2(response, "get_file_download");
317
- const value = await response.json();
318
- if (!isDownloadResponse2(value)) {
319
- throw whiteboardError("get_file_download", "file_transfer_failed", false, "Whiteboard download service returned an invalid response.");
320
- }
321
- return value;
322
- }
323
- };
324
- }
325
- async function authorizedFetch(fetchImplementation, token, url, init) {
326
- try {
327
- const authorization = await token();
328
- const headers = new Headers(init.headers);
329
- headers.set("authorization", `Bearer ${authorization}`);
330
- return await fetchImplementation(url, { ...init, headers });
331
- } catch (cause) {
332
- throw new ChalkWhiteboardV1Error(
333
- {
334
- operation: "get_file_download",
335
- code: "storage_unavailable",
336
- recoverable: true,
337
- message: "Whiteboard file storage is unavailable."
338
- },
339
- { cause }
340
- );
341
- }
342
- }
343
- async function responseError2(response, operation) {
344
- const code = response.status === 401 || response.status === 403 ? "permission_denied" : response.status === 400 ? "invalid_payload" : response.status >= 500 || response.status === 429 ? "storage_unavailable" : "file_transfer_failed";
345
- return whiteboardError(operation, code, response.status >= 500 || response.status === 429, "Whiteboard file request failed.");
346
- }
347
- function whiteboardError(operation, code, recoverable, message) {
348
- return new ChalkWhiteboardV1Error({ operation, code, recoverable, message });
349
- }
350
- function isDownloadResponse2(value) {
351
- return isExactObject(value, ["downloadUrl", "expiresAt"]) && isHttpUrl2(value.downloadUrl) && isDate2(value.expiresAt);
352
- }
353
- function isExactObject(value, keys) {
354
- return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
355
- }
356
- function isHttpUrl2(value) {
357
- if (typeof value !== "string") return false;
358
- try {
359
- const url = new URL(value);
360
- return url.protocol === "http:" || url.protocol === "https:";
361
- } catch {
362
- return false;
363
- }
364
- }
365
- function isDate2(value) {
366
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
367
- }
368
-
369
- // src/whiteboard/v1-codec.ts
370
- import { Schema as Schema2 } from "effect";
371
-
372
322
  // src/generated/whiteboard-v1.ts
373
323
  import { Schema } from "effect";
374
324
  var WhiteboardV1ProtocolMetadata = {
@@ -383,6 +333,16 @@ var WhiteboardV1ProtocolMetadata = {
383
333
  presentation: {
384
334
  name: "presentation_v1",
385
335
  exactFields: ["name"]
336
+ },
337
+ view: {
338
+ name: "view_v1",
339
+ exactFields: ["name"],
340
+ extendedExactFields: ["name", "origins"],
341
+ ownerExactFields: ["name", "origins", "owner"]
342
+ },
343
+ drawPolicy: {
344
+ name: "draw_policy_v1",
345
+ exactFields: ["name"]
386
346
  }
387
347
  },
388
348
  identity: {
@@ -391,6 +351,7 @@ var WhiteboardV1ProtocolMetadata = {
391
351
  generationFenced: true
392
352
  },
393
353
  capabilities: ["drawWhiteboard", "manageWhiteboard"],
354
+ drawPolicies: ["everyone", "hosts", "picked"],
394
355
  phases: [
395
356
  {
396
357
  id: "awaiting_hello",
@@ -399,7 +360,7 @@ var WhiteboardV1ProtocolMetadata = {
399
360
  },
400
361
  {
401
362
  id: "live",
402
- accepts: ["submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "ping"],
363
+ accepts: ["submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_draw_policy", "set_presentation", "cursor", "view", "ping"],
403
364
  heartbeatIntervalMs: 2e4,
404
365
  missedDeadlinesBeforeClose: 2
405
366
  }
@@ -429,6 +390,7 @@ var WhiteboardV1ProtocolMetadata = {
429
390
  socketQueueMaxAgeMs: 5e3,
430
391
  cursorTtlMs: 1e4,
431
392
  cursorRatePerSecond: 60,
393
+ viewPeopleMaxItems: 500,
432
394
  pendingOperationMaxItems: 128,
433
395
  multipartUpdateMaxParts: 128,
434
396
  multipartUpdateMaxItems: 1e4,
@@ -436,7 +398,8 @@ var WhiteboardV1ProtocolMetadata = {
436
398
  multipartUpdateTimeoutMs: 1e4,
437
399
  sceneElementMaxItems: 1e4,
438
400
  sceneJsonMaxBytes: 67108864,
439
- sceneObjectMaxBytes: 268435456
401
+ sceneObjectMaxBytes: 268435456,
402
+ drawAccessMaxGrants: 1024
440
403
  },
441
404
  cursor: {
442
405
  sceneId: "uuid",
@@ -472,6 +435,9 @@ var WhiteboardV1ProtocolMetadata = {
472
435
  setDrawPermission: {
473
436
  exactFields: ["type", "operation_id", "participant_id", "can_draw"]
474
437
  },
438
+ setDrawPolicy: {
439
+ exactFields: ["type", "operation_id", "policy"]
440
+ },
475
441
  setPresentation: {
476
442
  exactFields: ["type", "operation_id", "presenting"]
477
443
  },
@@ -479,7 +445,18 @@ var WhiteboardV1ProtocolMetadata = {
479
445
  clientExactFields: ["type", "x", "y"],
480
446
  serverExactFields: ["type", "participant_id", "display_name", "x", "y", "occurred_at"]
481
447
  },
448
+ view: {
449
+ maxCoordinate: 1e6,
450
+ maxExtent: 2e6,
451
+ epochField: "origin_epoch",
452
+ inputFields: ["input_id", "move"],
453
+ ownerFields: ["owner_id", "owner_sequence"],
454
+ originFields: ["origin_participant_id", "origin_sequence"],
455
+ clientExactFields: ["type", "x", "y", "width", "height", "bring"],
456
+ serverExactFields: ["type", "participant_id", "display_name", "x", "y", "width", "height", "bring", "occurred_at"]
457
+ },
482
458
  welcome: {
459
+ ownerField: "view_owner_id",
483
460
  exactFields: ["type", "protocol", "participant_id", "participant_generation", "capabilities", "participant_capabilities", "scene_id", "revision", "can_draw"],
484
461
  extendedExactFields: ["type", "protocol", "participant_id", "participant_generation", "capabilities", "participant_capabilities", "scene_id", "revision", "can_draw", "presenting"]
485
462
  },
@@ -498,6 +475,10 @@ var WhiteboardV1ProtocolMetadata = {
498
475
  permissionUpdated: {
499
476
  exactFields: ["type", "participant_id", "can_draw"]
500
477
  },
478
+ drawAccess: {
479
+ exactFields: ["type", "policy", "can_draw", "capabilities", "grants"],
480
+ grantExactFields: ["participant_id", "can_draw"]
481
+ },
501
482
  presentationUpdated: {
502
483
  exactFields: ["type", "scene_id", "revision", "presenting"]
503
484
  },
@@ -508,9 +489,9 @@ var WhiteboardV1ProtocolMetadata = {
508
489
  exactFields: ["type", "correlation_id", "operation", "code", "recoverable", "message"]
509
490
  }
510
491
  },
511
- clientFrames: ["hello", "submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "ping"],
512
- serverFrames: ["welcome", "snapshot_page", "update", "update_part", "commit", "cursor", "permission_updated", "presentation_updated", "reset_required", "operation_error", "pong"],
513
- receiptOperations: ["submit_update", "clear", "set_draw_permission", "set_presentation"],
492
+ clientFrames: ["hello", "submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_draw_policy", "set_presentation", "cursor", "view", "ping"],
493
+ serverFrames: ["welcome", "snapshot_page", "update", "update_part", "commit", "cursor", "view", "permission_updated", "draw_access", "presentation_updated", "reset_required", "operation_error", "view_ack", "pong"],
494
+ receiptOperations: ["submit_update", "clear", "set_draw_permission", "set_draw_policy", "set_presentation"],
514
495
  receiptOutcomes: ["committed", "duplicate"],
515
496
  resetReasons: ["scene_changed", "cursor_expired", "gap"],
516
497
  errorCodes: ["unavailable", "permission_denied", "invalid_payload", "stale_scene", "cursor_reset_required", "rate_limited", "overloaded", "storage_unavailable"],
@@ -612,7 +593,7 @@ function isClientFrame(value) {
612
593
  if (!isRecord2(value)) return false;
613
594
  switch (value.type) {
614
595
  case "hello":
615
- return (hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.extendedExactFields) && isPresentationExtensions(value.extensions)) && value.protocol === "whiteboard-v1" && typeof value.token === "string" && utf8Bytes(value.token) > 0 && utf8Bytes(value.token) <= WhiteboardV1ProtocolLimits.tokenBytes && (value.cursor === null || isCursor(value.cursor));
596
+ return (hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.extendedExactFields) && isExtensions(value.extensions)) && value.protocol === "whiteboard-v1" && typeof value.token === "string" && utf8Bytes(value.token) > 0 && utf8Bytes(value.token) <= WhiteboardV1ProtocolLimits.tokenBytes && (value.cursor === null || isCursor(value.cursor));
616
597
  case "submit_update":
617
598
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.submitUpdate.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isUuid(value.scene_id) && typeof value.sync_all === "boolean" && isElementBatch(value.elements, WhiteboardV1ProtocolLimits.elementBatchMaxItems);
618
599
  case "submit_update_part":
@@ -625,23 +606,67 @@ function isClientFrame(value) {
625
606
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.clear.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isUuid(value.scene_id);
626
607
  case "set_draw_permission":
627
608
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setDrawPermission.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
609
+ case "set_draw_policy":
610
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setDrawPolicy.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isDrawPolicy(value.policy);
628
611
  case "set_presentation":
629
612
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setPresentation.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && typeof value.presenting === "boolean";
630
613
  case "cursor":
631
614
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.cursor.clientExactFields) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
615
+ case "view":
616
+ return isViewInput(value, WhiteboardV1ProtocolMetadata.frames.view.clientExactFields) && isViewRect(value) && typeof value.bring === "boolean" && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
632
617
  case "ping":
633
618
  return hasExactKeys(value, ["type"]);
634
619
  default:
635
620
  return false;
636
621
  }
637
622
  }
623
+ function isViewInputId(input_id) {
624
+ return typeof input_id === "string" && /^[0-9a-fA-F-]{36}:[1-9][0-9]{0,15}$/.test(input_id) && isUuid(input_id.split(":")[0]) && Number.isSafeInteger(Number(input_id.split(":")[1]));
625
+ }
626
+ function isViewInput(value, fields) {
627
+ if (!("input_id" in value || "move" in value)) return isViewFields(value, fields);
628
+ const { input_id, move, ...rectangle } = value;
629
+ return isViewInputId(input_id) && typeof move === "boolean" && isViewFields(rectangle, fields);
630
+ }
631
+ function isViewOrder(value, fields) {
632
+ if (!("owner_id" in value || "owner_sequence" in value)) return isViewFields(value, fields);
633
+ const { owner_id, owner_sequence, ...rectangle } = value;
634
+ return isUuid(owner_id) && Number.isSafeInteger(owner_sequence) && Number(owner_sequence) > 0 && isViewFields(rectangle, fields);
635
+ }
636
+ function isViewFields(value, fields) {
637
+ if (hasExactRecordKeys(value, fields)) return true;
638
+ const originFields = [...fields, ...WhiteboardV1ProtocolMetadata.frames.view.originFields];
639
+ const validFields = hasExactRecordKeys(value, originFields) || hasExactRecordKeys(value, [...originFields, WhiteboardV1ProtocolMetadata.frames.view.epochField]) && isUuid(value.origin_epoch);
640
+ return validFields && isUuid(value.origin_participant_id) && Number.isSafeInteger(value.origin_sequence) && Number(value.origin_sequence) > 0;
641
+ }
642
+ function isViewRect(value) {
643
+ return isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.width) && isFiniteNumber(value.height) && value.width > 0 && value.height > 0 && Math.abs(value.x) <= WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate && Math.abs(value.y) <= WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate && value.width <= WhiteboardV1ProtocolMetadata.frames.view.maxExtent && value.height <= WhiteboardV1ProtocolMetadata.frames.view.maxExtent;
644
+ }
638
645
  function isAppState(value) {
639
646
  return value === null || hasExactKeys(value, WhiteboardV1ProtocolMetadata.sharedAppState.exactFields) && typeof value.view_background_color === "string" && utf8Bytes(value.view_background_color) <= 64;
640
647
  }
641
- function isPresentationExtensions(value) {
642
- return Array.isArray(value) && value.length === 1 && hasExactKeys(value[0], WhiteboardV1ProtocolMetadata.extensions.presentation.exactFields) && value[0].name === WhiteboardV1ProtocolMetadata.extensions.presentation.name;
648
+ function isExtension(value, extension) {
649
+ return hasExactKeys(value, extension.exactFields) && value.name === extension.name;
650
+ }
651
+ function isExtensions(value) {
652
+ const { presentation, view, drawPolicy } = WhiteboardV1ProtocolMetadata.extensions;
653
+ if (!Array.isArray(value)) return false;
654
+ const drawPolicyLast = value.length > 0 && isExtension(value[value.length - 1], drawPolicy);
655
+ const base = drawPolicyLast ? value.slice(0, -1) : value;
656
+ if (base.length === 0) return drawPolicyLast;
657
+ return (base.length === 1 || base.length === 2) && isExtension(base[0], presentation) && (base.length === 1 || isExtension(base[1], view) || hasExactKeys(base[1], view.ownerExactFields) && base[1].name === view.name && base[1].origins === true && base[1].owner === true || hasExactKeys(base[1], view.extendedExactFields) && base[1].name === view.name && base[1].origins === true);
658
+ }
659
+ function isDrawPolicy(value) {
660
+ return typeof value === "string" && WhiteboardV1ProtocolMetadata.drawPolicies.some((policy) => policy === value);
661
+ }
662
+ function isDrawGrant(value) {
663
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.drawAccess.grantExactFields) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
643
664
  }
644
665
  function isWelcome(value) {
666
+ if ("view_owner_id" in value) {
667
+ const { view_owner_id, ...welcome } = value;
668
+ return isUuid(view_owner_id) && isWelcome(welcome);
669
+ }
645
670
  const exact = hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.extendedExactFields) && typeof value.presenting === "boolean";
646
671
  return exact && value.protocol === "whiteboard-v1" && isUuid(value.participant_id) && Number.isSafeInteger(value.participant_generation) && Number(value.participant_generation) > 0 && isCapabilities(value.capabilities) && isCapabilities(value.participant_capabilities) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.can_draw === "boolean";
647
672
  }
@@ -660,14 +685,20 @@ function isServerFrame(value) {
660
685
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.commit.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && typeof value.outcome === "string" && WhiteboardV1ProtocolMetadata.receiptOutcomes.some((outcome) => outcome === value.outcome) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision);
661
686
  case "cursor":
662
687
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.cursor.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isFiniteNumber(value.x) && isFiniteNumber(value.y) && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
688
+ case "view":
689
+ return isViewOrder(value, WhiteboardV1ProtocolMetadata.frames.view.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isViewRect(value) && typeof value.bring === "boolean" && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
663
690
  case "permission_updated":
664
691
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.permissionUpdated.exactFields) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
692
+ case "draw_access":
693
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.drawAccess.exactFields) && isDrawPolicy(value.policy) && typeof value.can_draw === "boolean" && isCapabilities(value.capabilities) && Array.isArray(value.grants) && value.grants.length <= WhiteboardV1ProtocolLimits.drawAccessMaxGrants && value.grants.every(isDrawGrant);
665
694
  case "presentation_updated":
666
695
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.presentationUpdated.exactFields) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.presenting === "boolean";
667
696
  case "reset_required":
668
697
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.resetRequired.exactFields) && isUuid(value.scene_id) && typeof value.reason === "string" && WhiteboardV1ProtocolMetadata.resetReasons.some((reason) => reason === value.reason);
669
698
  case "operation_error":
670
- return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.operationError.exactFields) && isBoundedIdentifier(value.correlation_id, WhiteboardV1ProtocolLimits.requestIdMinBytes, WhiteboardV1ProtocolLimits.requestIdMaxBytes) && ["submit_update", "request_snapshot", "clear", "set_draw_permission", "set_presentation"].includes(String(value.operation)) && typeof value.code === "string" && WhiteboardV1ProtocolMetadata.errorCodes.some((code) => code === value.code) && typeof value.recoverable === "boolean" && typeof value.message === "string" && utf8Bytes(value.message) <= WhiteboardV1ProtocolLimits.errorMessageMaxBytes;
699
+ return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.operationError.exactFields) && isBoundedIdentifier(value.correlation_id, WhiteboardV1ProtocolLimits.requestIdMinBytes, WhiteboardV1ProtocolLimits.requestIdMaxBytes) && ["submit_update", "request_snapshot", "clear", "set_draw_permission", "set_draw_policy", "set_presentation"].includes(String(value.operation)) && typeof value.code === "string" && WhiteboardV1ProtocolMetadata.errorCodes.some((code) => code === value.code) && typeof value.recoverable === "boolean" && typeof value.message === "string" && utf8Bytes(value.message) <= WhiteboardV1ProtocolLimits.errorMessageMaxBytes;
700
+ case "view_ack":
701
+ return hasExactKeys(value, ["type", "input_id"]) && isViewInputId(value.input_id);
671
702
  case "pong":
672
703
  return hasExactKeys(value, ["type"]);
673
704
  default:
@@ -685,11 +716,118 @@ function encodeWhiteboardV1Frame(frame) {
685
716
  return JSON.stringify(frame);
686
717
  }
687
718
 
719
+ // src/whiteboard/view-bounds.ts
720
+ var ChalkWhiteboardViewBounds = {
721
+ maxCoordinate: WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate,
722
+ maxExtent: WhiteboardV1ProtocolMetadata.frames.view.maxExtent
723
+ };
724
+
725
+ // src/whiteboard/file-download-transport.ts
726
+ function createChalkWhiteboardV1FileDownloadTransport(options) {
727
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
728
+ let baseUrlEnd = options.baseUrl.length;
729
+ while (baseUrlEnd > 0 && options.baseUrl[baseUrlEnd - 1] === "/") baseUrlEnd -= 1;
730
+ const baseUrl = options.baseUrl.slice(0, baseUrlEnd);
731
+ return {
732
+ getDownloadUrl: async (fileId) => {
733
+ const response = await authorizedFetch(fetchImplementation, options.token, `${baseUrl}/v1/whiteboard/files/${encodeURIComponent(fileId)}/download`, { method: "GET" });
734
+ if (!response.ok) throw await responseError2(response, "get_file_download");
735
+ const value = await response.json();
736
+ if (!isDownloadResponse2(value)) {
737
+ throw whiteboardError("get_file_download", "file_transfer_failed", false, "Whiteboard download service returned an invalid response.");
738
+ }
739
+ return value;
740
+ }
741
+ };
742
+ }
743
+ async function authorizedFetch(fetchImplementation, token, url, init) {
744
+ try {
745
+ const authorization = await token();
746
+ const headers = new Headers(init.headers);
747
+ headers.set("authorization", `Bearer ${authorization}`);
748
+ return await fetchImplementation(url, { ...init, headers });
749
+ } catch (cause) {
750
+ throw new ChalkWhiteboardV1Error(
751
+ {
752
+ operation: "get_file_download",
753
+ code: "storage_unavailable",
754
+ recoverable: true,
755
+ message: "Whiteboard file storage is unavailable."
756
+ },
757
+ { cause }
758
+ );
759
+ }
760
+ }
761
+ async function responseError2(response, operation) {
762
+ const code = response.status === 401 || response.status === 403 ? "permission_denied" : response.status === 400 ? "invalid_payload" : response.status >= 500 || response.status === 429 ? "storage_unavailable" : "file_transfer_failed";
763
+ return whiteboardError(operation, code, response.status >= 500 || response.status === 429, "Whiteboard file request failed.");
764
+ }
765
+ function whiteboardError(operation, code, recoverable, message) {
766
+ return new ChalkWhiteboardV1Error({ operation, code, recoverable, message });
767
+ }
768
+ function isDownloadResponse2(value) {
769
+ return isExactObject(value, ["downloadUrl", "expiresAt"]) && isHttpUrl2(value.downloadUrl) && isDate2(value.expiresAt);
770
+ }
771
+ function isExactObject(value, keys) {
772
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
773
+ }
774
+ function isHttpUrl2(value) {
775
+ if (typeof value !== "string") return false;
776
+ try {
777
+ const url = new URL(value);
778
+ return url.protocol === "http:" || url.protocol === "https:";
779
+ } catch {
780
+ return false;
781
+ }
782
+ }
783
+ function isDate2(value) {
784
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
785
+ }
786
+
787
+ // src/whiteboard/view-versions.ts
788
+ var ChalkWhiteboardViewVersions = class {
789
+ #versions = /* @__PURE__ */ new Map();
790
+ isNew(view, participantId) {
791
+ const key = this.#key(view, participantId);
792
+ const sequence = view.ownerSequence ?? view.originSequence;
793
+ return key === void 0 || sequence === void 0 || sequence > (this.#versions.get(key) ?? 0);
794
+ }
795
+ remember(view, participantId) {
796
+ const key = this.#key(view, participantId);
797
+ const sequence = view.ownerSequence ?? view.originSequence;
798
+ if (key === void 0 || sequence === void 0 || !this.isNew(view, participantId)) return;
799
+ this.#versions.delete(key);
800
+ this.#versions.set(key, sequence);
801
+ if (this.#versions.size > WhiteboardV1ProtocolLimits.viewPeopleMaxItems) {
802
+ const oldest = this.#versions.keys().next().value;
803
+ if (oldest !== void 0) this.#versions.delete(oldest);
804
+ }
805
+ }
806
+ forgetParticipant(participantId) {
807
+ for (const key of this.#versions.keys()) {
808
+ if (key.startsWith(`${participantId}:`) || key.endsWith(`:${participantId}`)) this.#versions.delete(key);
809
+ }
810
+ }
811
+ clear() {
812
+ this.#versions.clear();
813
+ }
814
+ #key(view, participantId) {
815
+ if (view.ownerId && participantId) return `${view.ownerId}:${participantId}`;
816
+ return view.originParticipantId ? `${view.originParticipantId}:${view.originEpoch ?? ""}` : void 0;
817
+ }
818
+ };
819
+
688
820
  // src/whiteboard/v1-codec.ts
821
+ import { Schema as Schema2 } from "effect";
689
822
  function encodeWhiteboardV1ClientFrame(frame) {
690
823
  return encodeWhiteboardV1Frame(Schema2.decodeUnknownSync(WhiteboardV1ClientFrameSchema)(frame));
691
824
  }
692
825
  function decodeWhiteboardV1ServerFrame(wire) {
826
+ const frame = decodeWhiteboardV1WireFrame(wire);
827
+ if (frame.type === "view_ack" || frame.type === "draw_access") throw new Error("Negotiated whiteboard control frame");
828
+ return frame;
829
+ }
830
+ function decodeWhiteboardV1WireFrame(wire) {
693
831
  return Schema2.decodeUnknownSync(WhiteboardV1ServerFrameSchema)(JSON.parse(wire));
694
832
  }
695
833
 
@@ -884,15 +1022,28 @@ var MAX_TIMER_DRIFT_MS = 250;
884
1022
  var MAX_SLOW_CONNECTION_BACKOFF_MS = 4e3;
885
1023
  var DEFAULT_RECONNECT_DELAY_MS = 250;
886
1024
  var MAX_RECONNECT_DELAY_MS = 5e3;
1025
+ var MAX_REJECTED_HELLO_RETRIES = 4;
1026
+ var CLOSE_STEPS = {
1027
+ 1008: { connecting: "retry-hello", authenticating: "retry-hello" },
1028
+ 1009: { authenticating: "step-down" }
1029
+ };
887
1030
  var encoder2 = new TextEncoder();
888
1031
  var ChalkWhiteboardV1Client = class {
889
1032
  files;
890
1033
  #options;
891
1034
  #store;
892
1035
  #listeners = /* @__PURE__ */ new Set();
1036
+ #latestViews = /* @__PURE__ */ new Map();
893
1037
  #summaryListeners = /* @__PURE__ */ new Set();
894
1038
  #operations = /* @__PURE__ */ new Map();
1039
+ /** Strictly increasing, so a restore replays operations made in the same millisecond in the order they were made. */
1040
+ #lastCreatedAt = 0;
895
1041
  #reservedOperationIds = /* @__PURE__ */ new Set();
1042
+ /**
1043
+ * Policy changes and per-person choices in the order the host made them. Only the first one is sent, and only once it
1044
+ * is saved, so the order comes from creation and never from which save finishes first.
1045
+ */
1046
+ #accessChanges = [];
896
1047
  #awaitingOperationIds = /* @__PURE__ */ new Set();
897
1048
  #pendingStoreWrites = /* @__PURE__ */ new Set();
898
1049
  #operationRetryTimers = /* @__PURE__ */ new Map();
@@ -900,6 +1051,7 @@ var ChalkWhiteboardV1Client = class {
900
1051
  #updateAssembler = new WhiteboardV1UpdateAssembler();
901
1052
  #updateAssemblyTimers = /* @__PURE__ */ new Map();
902
1053
  #socket = null;
1054
+ #cancelDial;
903
1055
  #started = false;
904
1056
  #startupGeneration = 0;
905
1057
  #phase = "idle";
@@ -908,9 +1060,17 @@ var ChalkWhiteboardV1Client = class {
908
1060
  #revision;
909
1061
  #capabilities = [];
910
1062
  #canDraw = false;
1063
+ #drawPolicy = null;
1064
+ #drawPermissions = [];
911
1065
  #presentationSupported = false;
1066
+ #viewsSupported = false;
912
1067
  #presenting = false;
913
- #useLegacyHello = false;
1068
+ #hello = "owner-view";
1069
+ #viewSequence = 0;
1070
+ #viewOwnerId = null;
1071
+ #viewVersions = new ChalkWhiteboardViewVersions();
1072
+ #viewEpoch = crypto.randomUUID();
1073
+ #pendingLeadingView = null;
914
1074
  #summaryStatus = "unsubscribed";
915
1075
  #summaryError = null;
916
1076
  #latestSnapshot = null;
@@ -920,6 +1080,7 @@ var ChalkWhiteboardV1Client = class {
920
1080
  #waitingForOperations = false;
921
1081
  #reconnectTimer;
922
1082
  #reconnectAttempt = 0;
1083
+ #rejectedHelloRetries = 0;
923
1084
  #heartbeatTimer;
924
1085
  #heartbeats = [];
925
1086
  #ignoredHeartbeats = 0;
@@ -957,7 +1118,9 @@ var ChalkWhiteboardV1Client = class {
957
1118
  if (this.#started && this.#startPromise) return this.#startPromise;
958
1119
  this.#started = true;
959
1120
  globalThis.document?.addEventListener("visibilitychange", this.#visibilityChanged);
960
- this.#useLegacyHello = false;
1121
+ this.#hello = "owner-view";
1122
+ this.#rejectedHelloRetries = 0;
1123
+ this.#viewsSupported = false;
961
1124
  this.#phase = "connecting";
962
1125
  this.#publishSummary("loading", null);
963
1126
  const generation = ++this.#startupGeneration;
@@ -981,11 +1144,15 @@ var ChalkWhiteboardV1Client = class {
981
1144
  }
982
1145
  #stopNow() {
983
1146
  this.#started = false;
1147
+ this.#viewsSupported = false;
1148
+ this.#pendingLeadingView = null;
1149
+ this.#latestViews.clear();
984
1150
  this.#removeVisibilityListener();
985
1151
  this.#startupGeneration += 1;
986
1152
  this.#unsubscribeLifecycle?.();
987
1153
  this.#unsubscribeLifecycle = void 0;
988
1154
  this.#clearReconnect();
1155
+ this.#clearDial();
989
1156
  this.#clearHeartbeat();
990
1157
  this.#clearOperationRetryTimers();
991
1158
  this.#clearUpdateAssemblies();
@@ -1002,18 +1169,26 @@ var ChalkWhiteboardV1Client = class {
1002
1169
  this.#participantId = null;
1003
1170
  this.#capabilities = [];
1004
1171
  this.#canDraw = false;
1172
+ this.#drawPolicy = null;
1173
+ this.#drawPermissions = [];
1005
1174
  this.#presenting = false;
1006
1175
  this.#publishSummary("unsubscribed", null);
1007
1176
  }
1008
1177
  subscribe(listener) {
1009
1178
  this.#listeners.add(listener);
1010
1179
  if (this.#phase !== "live") return () => this.#listeners.delete(listener);
1011
- if (this.#latestSnapshot && this.#latestSnapshot.sceneId === this.#sceneId && this.#latestSnapshot.revision === this.#revision) {
1012
- listener(this.#latestSnapshot);
1013
- }
1014
- if (this.#snapshots.size === 0 && !this.#waitingForOperations) void this.#requestSnapshot().catch(() => void 0);
1180
+ const currentSnapshot = this.#currentSnapshot();
1181
+ if (currentSnapshot) listener(currentSnapshot);
1182
+ for (const view of this.#latestViews.values()) listener({ ...view, bring: false });
1183
+ if (!currentSnapshot && this.#snapshots.size === 0 && !this.#waitingForOperations) void this.#requestSnapshot().catch(() => void 0);
1015
1184
  return () => this.#listeners.delete(listener);
1016
1185
  }
1186
+ #currentSnapshot() {
1187
+ const snapshot = this.#latestSnapshot;
1188
+ if (snapshot?.sceneId !== this.#sceneId) return null;
1189
+ if (snapshot?.revision !== this.#revision) return null;
1190
+ return snapshot;
1191
+ }
1017
1192
  subscribeSummary(listener) {
1018
1193
  this.#summaryListeners.add(listener);
1019
1194
  this.#notifySummaryListener(listener, this.#summary());
@@ -1021,7 +1196,7 @@ var ChalkWhiteboardV1Client = class {
1021
1196
  }
1022
1197
  submitUpdate(input) {
1023
1198
  if (!this.#started || !this.#sceneId) this.#assertLive("submit_update");
1024
- if (!this.#canDraw || !this.#capabilities.includes("drawWhiteboard")) {
1199
+ if (!this.#canDraw) {
1025
1200
  throw error(failure("submit_update", "permission_denied", false, "Whiteboard draw permission is required."));
1026
1201
  }
1027
1202
  const operationId = this.#nextId();
@@ -1034,7 +1209,7 @@ var ChalkWhiteboardV1Client = class {
1034
1209
  });
1035
1210
  }
1036
1211
  sendCursor(input) {
1037
- if (this.#phase !== "live" || this.#summaryStatus !== "ready" || this.#snapshots.size > 0 || !this.#canDraw) return;
1212
+ if (!this.#presenceReady || !this.#canDraw) return;
1038
1213
  const now = this.#now();
1039
1214
  if (now - this.#lastCursorAt < 1e3 / WhiteboardV1ProtocolLimits.cursorRatePerSecond) return;
1040
1215
  const frame = { type: "cursor", x: input.x, y: input.y };
@@ -1042,6 +1217,57 @@ var ChalkWhiteboardV1Client = class {
1042
1217
  this.#lastCursorAt = now;
1043
1218
  this.#send(frame);
1044
1219
  }
1220
+ get supportsViews() {
1221
+ return this.#started && this.#viewsSupported && this.#viewExtensionEnabled;
1222
+ }
1223
+ get #viewExtensionEnabled() {
1224
+ return viewsNegotiated(this.#hello);
1225
+ }
1226
+ get #presenceReady() {
1227
+ return this.#phase === "live" && this.#summaryStatus === "ready" && this.#snapshots.size === 0;
1228
+ }
1229
+ forgetParticipant(participantId) {
1230
+ this.#latestViews.delete(participantId);
1231
+ this.#viewVersions.forgetParticipant(participantId);
1232
+ }
1233
+ sendView(view) {
1234
+ if (!this.#started || !this.#viewExtensionEnabled) return false;
1235
+ const frame = this.#viewFrame(view);
1236
+ encodeWhiteboardV1ClientFrame(frame);
1237
+ if (view.bring || view.lead !== false) this.#pendingLeadingView = { sceneId: this.#sceneId, view: { ...view }, frame };
1238
+ if (!this.#presenceReady) return true;
1239
+ this.#send(frame);
1240
+ if (!this.#viewOwnerId && (view.bring || view.lead !== false)) this.#pendingLeadingView = null;
1241
+ return true;
1242
+ }
1243
+ #viewFrame(view) {
1244
+ const rectangle = { type: "view", x: view.x, y: view.y, width: view.width, height: view.height, bring: view.bring };
1245
+ if (this.#viewOwnerId) {
1246
+ const input = { input_id: `${this.#viewEpoch}:${++this.#viewSequence}`, move: view.lead !== false || view.bring };
1247
+ return view.originParticipantId ? { ...rectangle, ...input, origin_participant_id: view.originParticipantId, origin_sequence: view.originSequence } : { ...rectangle, ...input };
1248
+ }
1249
+ if (view.bring || !viewOriginsNegotiated(this.#hello)) return rectangle;
1250
+ if (view.originParticipantId !== void 0) return { ...rectangle, origin_participant_id: view.originParticipantId, origin_sequence: view.originSequence, ...view.originEpoch !== void 0 ? { origin_epoch: view.originEpoch } : {} };
1251
+ if (!this.#participantId) return rectangle;
1252
+ return { ...rectangle, origin_participant_id: this.#participantId, origin_sequence: ++this.#viewSequence, origin_epoch: this.#viewEpoch };
1253
+ }
1254
+ #flushPendingBring() {
1255
+ if (!this.#presenceReady) return;
1256
+ const pending = this.#pendingLeadingView;
1257
+ if (!pending) return;
1258
+ if (pending.sceneId !== null && pending.sceneId !== this.#sceneId) {
1259
+ this.#pendingLeadingView = null;
1260
+ return;
1261
+ }
1262
+ if (!this.#viewOwnerId) {
1263
+ this.#pendingLeadingView = null;
1264
+ this.sendView(pending.view);
1265
+ return;
1266
+ }
1267
+ const frame = pending.frame.input_id ? pending.frame : this.#viewFrame(pending.view);
1268
+ this.#pendingLeadingView = { ...pending, frame };
1269
+ this.#send(frame);
1270
+ }
1045
1271
  requestSnapshot() {
1046
1272
  this.#assertLive("request_snapshot");
1047
1273
  return this.#requestSnapshot();
@@ -1068,12 +1294,22 @@ var ChalkWhiteboardV1Client = class {
1068
1294
  };
1069
1295
  await this.#queueOperation(frame);
1070
1296
  }
1297
+ async setDrawPolicy(policy) {
1298
+ this.#assertLive("set_draw_policy");
1299
+ if (this.#drawPolicy === null) {
1300
+ throw error(policyUnavailable());
1301
+ }
1302
+ if (!this.#capabilities.includes("manageWhiteboard")) {
1303
+ throw error(failure("set_draw_policy", "permission_denied", false, "Whiteboard management permission is required."));
1304
+ }
1305
+ await this.#queueOperation({ type: "set_draw_policy", operation_id: this.#nextId(), policy });
1306
+ }
1071
1307
  async setPresentation(presenting) {
1072
1308
  this.#assertLive("set_presentation");
1073
1309
  if (!this.#presentationSupported) {
1074
1310
  throw error(failure("set_presentation", "unavailable", true, "Whiteboard presentation is unavailable on this Sync server."));
1075
1311
  }
1076
- if (!this.#canDraw || !this.#capabilities.includes("drawWhiteboard")) {
1312
+ if (!this.#canDraw) {
1077
1313
  throw error(failure("set_presentation", "permission_denied", false, "Whiteboard draw permission is required."));
1078
1314
  }
1079
1315
  const frame = {
@@ -1103,8 +1339,10 @@ var ChalkWhiteboardV1Client = class {
1103
1339
  void this.#store.remove(pending.operationId).catch(() => void 0);
1104
1340
  continue;
1105
1341
  }
1106
- if (this.#operations.size >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) return;
1107
- this.#operations.set(pending.operationId, { pending, retries: 0 });
1342
+ if (this.#operationCount() >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) return;
1343
+ this.#lastCreatedAt = Math.max(this.#lastCreatedAt, pending.createdAt);
1344
+ if (!isAccessChange(pending.frame)) this.#operations.set(pending.operationId, { pending, retries: 0 });
1345
+ else if (!this.#accessChange(pending.operationId)) this.#restoreAccessChange(pending);
1108
1346
  }
1109
1347
  }
1110
1348
  #validRestoredOperation(pending) {
@@ -1128,7 +1366,21 @@ var ChalkWhiteboardV1Client = class {
1128
1366
  this.#phase = "connecting";
1129
1367
  const socket = this.#options.webSocket.connect(this.#options.url);
1130
1368
  this.#socket = socket;
1131
- socket.onopen = () => void this.#authenticate(socket);
1369
+ this.#cancelDial = scheduleSyncDialDeadline(
1370
+ this.#clock(),
1371
+ () => {
1372
+ this.#cancelDial = void 0;
1373
+ socket.close(CLIENT_RESTART_CLOSE_CODE, "WebSocket dial timeout");
1374
+ this.#disconnected(socket, CLIENT_RESTART_CLOSE_CODE);
1375
+ this.#publishSummary("recovering", failure("start_scene_subscription", "unavailable", true, "The board connection timed out before opening."));
1376
+ },
1377
+ this.#options.dialTimeoutMs
1378
+ );
1379
+ socket.onopen = () => {
1380
+ if (socket !== this.#socket) return;
1381
+ this.#clearDial();
1382
+ void this.#authenticate(socket);
1383
+ };
1132
1384
  socket.onmessage = (event) => {
1133
1385
  this.#inbound = this.#inbound.then(() => this.#receive(socket, event.data));
1134
1386
  };
@@ -1142,7 +1394,7 @@ var ChalkWhiteboardV1Client = class {
1142
1394
  this.#phase = "authenticating";
1143
1395
  this.#presentationSupported = false;
1144
1396
  const cursor = this.#sceneId && this.#revision ? { scene_id: this.#sceneId, revision: this.#revision } : null;
1145
- this.#send(this.#useLegacyHello ? { type: "hello", protocol: "whiteboard-v1", token, cursor } : { type: "hello", protocol: "whiteboard-v1", token, cursor, extensions: [{ name: "presentation_v1" }] });
1397
+ this.#send(helloFrame(this.#hello, token, cursor));
1146
1398
  } catch {
1147
1399
  socket.close(CLIENT_RESTART_CLOSE_CODE, "whiteboard authentication failed");
1148
1400
  }
@@ -1153,7 +1405,7 @@ var ChalkWhiteboardV1Client = class {
1153
1405
  if (typeof data !== "string" || encoder2.encode(data).byteLength > WhiteboardV1ProtocolLimits.encodedOutboundFrameBytes) {
1154
1406
  throw new Error("invalid whiteboard frame size");
1155
1407
  }
1156
- const frame = decodeWhiteboardV1ServerFrame(data);
1408
+ const frame = decodeWhiteboardV1WireFrame(data);
1157
1409
  if (frame.type !== "pong") {
1158
1410
  this.#lastProgressAt = this.#elapsedNow();
1159
1411
  this.#resetProbeState();
@@ -1193,13 +1445,46 @@ var ChalkWhiteboardV1Client = class {
1193
1445
  occurredAt: frame.occurred_at
1194
1446
  });
1195
1447
  return;
1448
+ case "view_ack":
1449
+ this.#requireLive();
1450
+ if (this.#pendingLeadingView?.frame.input_id === frame.input_id) this.#pendingLeadingView = null;
1451
+ return;
1452
+ case "view": {
1453
+ this.#requireLive();
1454
+ if (this.#viewOwnerId && frame.owner_id !== this.#viewOwnerId) return;
1455
+ const rectangle = {
1456
+ type: "view",
1457
+ participantId: frame.participant_id,
1458
+ displayName: frame.display_name,
1459
+ x: frame.x,
1460
+ y: frame.y,
1461
+ width: frame.width,
1462
+ height: frame.height,
1463
+ bring: frame.bring,
1464
+ occurredAt: frame.occurred_at
1465
+ };
1466
+ const event = frame.owner_id ? { ...rectangle, ownerId: frame.owner_id, ownerSequence: frame.owner_sequence } : rectangle;
1467
+ if (!this.#viewVersions.isNew(event, event.participantId)) return;
1468
+ this.#viewVersions.remember(event, event.participantId);
1469
+ this.#emit(frame.origin_participant_id ? { ...event, originParticipantId: frame.origin_participant_id, originSequence: frame.origin_sequence, ...frame.origin_epoch ? { originEpoch: frame.origin_epoch } : {} } : event);
1470
+ return;
1471
+ }
1196
1472
  case "permission_updated":
1197
1473
  this.#requireLive();
1198
1474
  if (frame.participant_id === this.#participantId) this.#canDraw = frame.can_draw;
1199
1475
  this.#publishSummary("ready", null);
1200
1476
  return;
1477
+ case "draw_access":
1478
+ this.#requireLive();
1479
+ this.#canDraw = frame.can_draw;
1480
+ this.#capabilities = [...frame.capabilities];
1481
+ this.#drawPolicy = frame.policy;
1482
+ this.#drawPermissions = frame.grants.map((grant) => ({ participantId: grant.participant_id, canDraw: grant.can_draw }));
1483
+ this.#publishSummary(this.#summaryStatus, this.#summaryError);
1484
+ return;
1201
1485
  case "presentation_updated":
1202
1486
  this.#requireLive();
1487
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1203
1488
  this.#sceneId = frame.scene_id;
1204
1489
  this.#revision = frame.revision;
1205
1490
  this.#presenting = frame.presenting;
@@ -1207,6 +1492,7 @@ var ChalkWhiteboardV1Client = class {
1207
1492
  return;
1208
1493
  case "reset_required":
1209
1494
  this.#requireLive();
1495
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1210
1496
  this.#sceneId = frame.scene_id;
1211
1497
  this.#revision = null;
1212
1498
  this.#publishSummary("recovering", failure("request_snapshot", "cursor_reset_required", true, "Whiteboard snapshot recovery is required."));
@@ -1261,25 +1547,50 @@ var ChalkWhiteboardV1Client = class {
1261
1547
  #welcome(frame) {
1262
1548
  if (this.#phase !== "authenticating") throw new Error("unexpected whiteboard welcome");
1263
1549
  this.#phase = "live";
1550
+ this.#viewsSupported = this.#viewExtensionEnabled;
1551
+ this.#adoptViewOwner("view_owner_id" in frame ? frame.view_owner_id ?? null : null);
1264
1552
  this.#reconnectAttempt = 0;
1553
+ this.#rejectedHelloRetries = 0;
1265
1554
  this.#participantId = frame.participant_id;
1555
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1266
1556
  this.#sceneId = frame.scene_id;
1267
1557
  this.#revision = frame.revision;
1268
1558
  this.#capabilities = [...frame.capabilities];
1269
1559
  this.#canDraw = frame.can_draw;
1560
+ this.#drawPolicy = null;
1561
+ this.#drawPermissions = [];
1270
1562
  this.#presentationSupported = "presenting" in frame;
1271
1563
  this.#presenting = "presenting" in frame ? frame.presenting : false;
1272
- this.#publishSummary("loading", null);
1564
+ const refused = !drawPolicyNegotiated(this.#hello) && this.#dropPolicyChanges();
1565
+ this.#publishSummary("loading", refused ? policyUnavailable() : null);
1273
1566
  this.#clearHeartbeat();
1274
- const pendingOperations = [...this.#operations.values()].sort((left, right) => compareChalkWhiteboardV1PendingOperations(left.pending, right.pending));
1567
+ this.#resumePendingOperations();
1568
+ this.#startHeartbeat(HEARTBEAT_INTERVAL_MS + Math.max(0, this.#nextSlowConnectionReplacementAt - this.#elapsedNow()));
1569
+ }
1570
+ #resumePendingOperations() {
1571
+ this.#clearOperationRetryTimers();
1572
+ for (const change of this.#accessChanges) if (change.state === "sent") change.state = "ready";
1275
1573
  this.#awaitingOperationIds.clear();
1276
1574
  this.#waitingForOperations = true;
1277
- for (const entry of pendingOperations) this.#awaitingOperationIds.add(entry.pending.operationId);
1278
- for (const entry of pendingOperations) {
1279
- this.#sendOperation(entry.pending.frame);
1280
- }
1575
+ this.#sendInCreationOrder([...this.#operations.values()]);
1281
1576
  this.#requestSnapshotAfterOperations();
1282
- this.#startHeartbeat(HEARTBEAT_INTERVAL_MS + Math.max(0, this.#nextSlowConnectionReplacementAt - this.#elapsedNow()));
1577
+ }
1578
+ /** A Sync without draw_policy_v1 closes the socket on a policy change, so a change saved for a newer Sync is dropped, not replayed. */
1579
+ #dropPolicyChanges() {
1580
+ const dropped = this.#accessChanges.filter((change) => change.entry.pending.frame.type === "set_draw_policy");
1581
+ for (const { entry } of dropped) {
1582
+ const { operationId } = entry.pending;
1583
+ this.#removeAccessChange(operationId);
1584
+ this.#clearOperationRetryTimer(operationId);
1585
+ entry.deferred && rejectDeferred(entry.deferred, error(policyUnavailable()));
1586
+ this.#discardStoredOperation(operationId);
1587
+ }
1588
+ return dropped.length > 0;
1589
+ }
1590
+ #adoptViewOwner(ownerId) {
1591
+ this.#viewOwnerId = ownerId;
1592
+ this.#latestViews.clear();
1593
+ this.#viewVersions.clear();
1283
1594
  }
1284
1595
  #requestSnapshotAfterOperations() {
1285
1596
  if (this.#phase !== "live" || !this.#waitingForOperations || this.#awaitingOperationIds.size > 0) return;
@@ -1325,6 +1636,7 @@ var ChalkWhiteboardV1Client = class {
1325
1636
  }
1326
1637
  #completeSnapshot(frame, assembly, elements) {
1327
1638
  this.#snapshots.delete(frame.request_id);
1639
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1328
1640
  this.#sceneId = frame.scene_id;
1329
1641
  this.#revision = frame.revision;
1330
1642
  this.#publishSummary("ready", null);
@@ -1348,46 +1660,80 @@ var ChalkWhiteboardV1Client = class {
1348
1660
  const pending = {
1349
1661
  operationId: frame.operation_id,
1350
1662
  frame,
1351
- createdAt: this.#now(),
1663
+ createdAt: this.#lastCreatedAt = Math.max(this.#now(), this.#lastCreatedAt + 1),
1352
1664
  bytes: whiteboardV1PendingOperationBytes(frame)
1353
1665
  };
1354
- const queuedBytes = [...this.#operations.values()].reduce((total, entry) => total + entry.pending.bytes, 0);
1666
+ const queuedBytes = this.#pendingEntries().reduce((total, entry) => total + entry.pending.bytes, 0);
1355
1667
  if (queuedBytes + pending.bytes > WhiteboardV1ProtocolLimits.multipartUpdateMaxBytes) {
1356
1668
  throw error(failure("submit_update", "unavailable", true, "Whiteboard operation byte capacity is full."));
1357
1669
  }
1670
+ const { promise, deferred } = commitDeferred();
1671
+ if (isAccessChange(frame)) return this.#queueAccessChange({ pending, deferred, retries: 0 }, deferred, promise);
1358
1672
  this.#reservedOperationIds.add(frame.operation_id);
1673
+ const saveFailure = await this.#persist(pending);
1674
+ this.#reservedOperationIds.delete(frame.operation_id);
1675
+ if (saveFailure) throw saveFailure;
1676
+ this.#operations.set(frame.operation_id, { pending, deferred, retries: 0 });
1677
+ if (this.#phase === "live" && this.#snapshots.size === 0) {
1678
+ if (this.#waitingForOperations) this.#awaitingOperationIds.add(frame.operation_id);
1679
+ this.#sendOperation(frame);
1680
+ }
1681
+ return promise;
1682
+ }
1683
+ /**
1684
+ * The change joins the list before its save starts, so a later change can't overtake it while it saves. The caller
1685
+ * gets its one promise at once: a refusal or a rejected connection during the save settles it without waiting for
1686
+ * the store, and a failed save settles it otherwise.
1687
+ */
1688
+ #queueAccessChange(entry, deferred, promise) {
1689
+ const change = { entry, state: "saving" };
1690
+ this.#accessChanges.push(change);
1691
+ void this.#saveAccessChange(change, deferred);
1692
+ return promise;
1693
+ }
1694
+ async #saveAccessChange(change, deferred) {
1695
+ const { pending } = change.entry;
1696
+ const saveFailure = await this.#persist(pending);
1697
+ if (saveFailure) {
1698
+ this.#removeAccessChange(pending.operationId);
1699
+ this.#sendNextAccessChange();
1700
+ rejectDeferred(deferred, saveFailure);
1701
+ return;
1702
+ }
1703
+ if (!this.#accessChanges.includes(change)) {
1704
+ this.#discardStoredOperation(pending.operationId);
1705
+ return;
1706
+ }
1707
+ change.state = "ready";
1708
+ this.#sendNextAccessChange();
1709
+ }
1710
+ /** Returns the failure instead of throwing it, so an access change can hand it to the promise its caller holds. */
1711
+ async #persist(pending) {
1359
1712
  let storeWrite;
1360
1713
  try {
1361
1714
  storeWrite = this.#store.put(pending);
1362
- } catch (cause) {
1363
- throw error(failure(operationName(frame), "unavailable", true, "Unable to persist the whiteboard operation."), cause);
1364
- }
1365
- this.#pendingStoreWrites.add(storeWrite);
1366
- try {
1715
+ this.#pendingStoreWrites.add(storeWrite);
1367
1716
  await storeWrite;
1717
+ return void 0;
1368
1718
  } catch (cause) {
1369
- throw error(failure(operationName(frame), "unavailable", true, "Unable to persist the whiteboard operation."), cause);
1719
+ this.#discardStoredOperation(pending.operationId);
1720
+ return error(failure(operationName(pending.frame), "unavailable", true, "Unable to persist the whiteboard operation."), cause);
1370
1721
  } finally {
1371
- this.#pendingStoreWrites.delete(storeWrite);
1372
- this.#reservedOperationIds.delete(frame.operation_id);
1373
- }
1374
- let deferred;
1375
- const promise = new Promise((resolve, reject) => {
1376
- deferred = { resolve, reject, settled: false };
1377
- });
1378
- this.#operations.set(frame.operation_id, { pending, deferred, retries: 0 });
1379
- if (this.#phase === "live" && this.#snapshots.size === 0) {
1380
- if (this.#waitingForOperations) this.#awaitingOperationIds.add(frame.operation_id);
1381
- this.#sendOperation(frame);
1722
+ if (storeWrite) this.#pendingStoreWrites.delete(storeWrite);
1382
1723
  }
1383
- return promise;
1724
+ }
1725
+ #discardStoredOperation(operationId) {
1726
+ const cleanup = this.#store.remove(operationId).catch(() => void 0);
1727
+ this.#pendingStoreWrites.add(cleanup);
1728
+ void cleanup.finally(() => this.#pendingStoreWrites.delete(cleanup));
1384
1729
  }
1385
1730
  #commit(frame) {
1386
1731
  this.#requireLive();
1387
- const entry = this.#operations.get(frame.operation_id);
1732
+ const entry = this.#sentEntry(frame.operation_id);
1388
1733
  if (!entry) return;
1389
- this.#operations.delete(frame.operation_id);
1734
+ this.#forgetOperation(frame.operation_id);
1390
1735
  this.#clearOperationRetryTimer(frame.operation_id);
1736
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1391
1737
  this.#sceneId = frame.scene_id;
1392
1738
  this.#revision = frame.revision;
1393
1739
  this.#awaitingOperationIds.delete(frame.operation_id);
@@ -1398,27 +1744,35 @@ var ChalkWhiteboardV1Client = class {
1398
1744
  revision: frame.revision
1399
1745
  });
1400
1746
  void this.#store.remove(frame.operation_id).catch(() => void 0);
1747
+ this.#sendNextAccessChange();
1401
1748
  this.#requestSnapshotAfterOperations();
1402
1749
  }
1403
1750
  #operationError(frame) {
1404
1751
  this.#requireLive();
1405
- const entry = this.#operations.get(frame.correlation_id);
1752
+ const entry = this.#sentEntry(frame.correlation_id);
1406
1753
  if (entry) {
1407
- if (frame.recoverable && retryableOperationCode(frame.code)) {
1408
- this.#scheduleOperationRetry(frame.correlation_id, entry);
1409
- return;
1410
- }
1411
- this.#operations.delete(frame.correlation_id);
1412
- this.#clearOperationRetryTimer(frame.correlation_id);
1413
- this.#awaitingOperationIds.delete(frame.correlation_id);
1414
- entry.deferred && rejectDeferred(entry.deferred, error(failure(operationName(entry.pending.frame), mapErrorCode(frame.code), frame.recoverable, frame.message)));
1415
- this.#publishSummary("ready", failure(operationName(entry.pending.frame), mapErrorCode(frame.code), frame.recoverable, frame.message));
1416
- void this.#store.remove(frame.correlation_id).catch(() => void 0);
1417
- this.#requestSnapshotAfterOperations();
1754
+ this.#failOperation(entry, frame);
1418
1755
  return;
1419
1756
  }
1420
1757
  const snapshot = this.#snapshots.get(frame.correlation_id);
1421
- if (!snapshot) return;
1758
+ if (snapshot) this.#failSnapshot(snapshot, frame);
1759
+ }
1760
+ #failOperation(entry, frame) {
1761
+ if (frame.recoverable && retryableOperationCode(frame.code)) {
1762
+ this.#scheduleOperationRetry(frame.correlation_id, entry);
1763
+ return;
1764
+ }
1765
+ this.#forgetOperation(frame.correlation_id);
1766
+ this.#clearOperationRetryTimer(frame.correlation_id);
1767
+ this.#awaitingOperationIds.delete(frame.correlation_id);
1768
+ const operationFailure = failure(operationName(entry.pending.frame), mapErrorCode(frame.code), frame.recoverable, frame.message);
1769
+ entry.deferred && rejectDeferred(entry.deferred, error(operationFailure));
1770
+ this.#publishSummary("ready", operationFailure);
1771
+ void this.#store.remove(frame.correlation_id).catch(() => void 0);
1772
+ this.#sendNextAccessChange();
1773
+ this.#requestSnapshotAfterOperations();
1774
+ }
1775
+ #failSnapshot(snapshot, frame) {
1422
1776
  this.#snapshots.delete(frame.correlation_id);
1423
1777
  const snapshotError = error(failure("request_snapshot", mapErrorCode(frame.code), frame.recoverable, frame.message));
1424
1778
  this.#publishSummary(frame.recoverable ? "recovering" : "failed", failure("request_snapshot", mapErrorCode(frame.code), frame.recoverable, frame.message));
@@ -1428,20 +1782,69 @@ var ChalkWhiteboardV1Client = class {
1428
1782
  this.#initialSnapshot = null;
1429
1783
  }
1430
1784
  if (frame.recoverable) this.#flushQueuedOperations();
1785
+ else this.#sendNextAccessChange();
1431
1786
  }
1432
1787
  #flushQueuedOperations() {
1433
1788
  if (this.#phase !== "live" || this.#snapshots.size > 0) return;
1434
- for (const entry of this.#operations.values()) {
1435
- if (!this.#awaitingOperationIds.has(entry.pending.operationId)) this.#sendOperation(entry.pending.frame);
1789
+ this.#sendInCreationOrder([...this.#operations.values()].filter((entry) => !this.#awaitingOperationIds.has(entry.pending.operationId)));
1790
+ }
1791
+ #sendNextAccessChange() {
1792
+ this.#sendInCreationOrder([]);
1793
+ }
1794
+ /** Sends the operations, and the first access change when it may go, in the order they were made. */
1795
+ #sendInCreationOrder(operations) {
1796
+ const head = this.#sendableAccessChange();
1797
+ if (head) head.state = "sent";
1798
+ const entries = [...operations, ...head ? [head.entry] : []].sort((left, right) => compareChalkWhiteboardV1PendingOperations(left.pending, right.pending));
1799
+ for (const entry of entries) {
1800
+ if (this.#waitingForOperations) this.#awaitingOperationIds.add(entry.pending.operationId);
1801
+ this.#sendOperation(entry.pending.frame);
1436
1802
  }
1437
1803
  }
1804
+ /**
1805
+ * Access changes go out one at a time in the host's order, so a retried older change can't land after a newer one.
1806
+ * Sync also stores a per-person choice according to the policy it lands under, so their order matters too.
1807
+ */
1808
+ #sendableAccessChange() {
1809
+ if (this.#phase !== "live" || this.#snapshots.size > 0) return void 0;
1810
+ const head = this.#accessChanges[0];
1811
+ if (head?.state !== "ready" || this.#accessChanges.some((change) => change.state === "sent")) return void 0;
1812
+ return head;
1813
+ }
1814
+ /** A restart keeps the changes it already holds, so a restored one goes where its creation puts it. */
1815
+ #restoreAccessChange(pending) {
1816
+ const later = this.#accessChanges.findIndex((change) => compareChalkWhiteboardV1PendingOperations(change.entry.pending, pending) > 0);
1817
+ this.#accessChanges.splice(later < 0 ? this.#accessChanges.length : later, 0, { entry: { pending, retries: 0 }, state: "ready" });
1818
+ }
1819
+ #accessChange(operationId) {
1820
+ return this.#accessChanges.find((change) => change.entry.pending.operationId === operationId);
1821
+ }
1822
+ #removeAccessChange(operationId) {
1823
+ const index = this.#accessChanges.findIndex((change) => change.entry.pending.operationId === operationId);
1824
+ if (index >= 0) this.#accessChanges.splice(index, 1);
1825
+ }
1826
+ /** The operation a receipt or an error can be about: any queued operation, or the access change that was sent. */
1827
+ #sentEntry(operationId) {
1828
+ const change = this.#accessChange(operationId);
1829
+ return this.#operations.get(operationId) ?? (change?.state === "sent" ? change.entry : void 0);
1830
+ }
1831
+ #forgetOperation(operationId) {
1832
+ this.#operations.delete(operationId);
1833
+ this.#removeAccessChange(operationId);
1834
+ }
1835
+ #pendingEntries() {
1836
+ return [...this.#operations.values(), ...this.#accessChanges.map((change) => change.entry)];
1837
+ }
1838
+ #operationCount() {
1839
+ return this.#operations.size + this.#accessChanges.length;
1840
+ }
1438
1841
  #scheduleOperationRetry(operationId, entry) {
1439
1842
  if (this.#operationRetryTimers.has(operationId)) return;
1440
1843
  entry.retries += 1;
1441
1844
  const timer = this.#clock().setTimeout(
1442
1845
  () => {
1443
1846
  this.#operationRetryTimers.delete(operationId);
1444
- if (this.#phase === "live" && this.#operations.get(operationId) === entry) this.#sendOperation(entry.pending.frame);
1847
+ if (this.#phase === "live" && this.#sentEntry(operationId) === entry) this.#sendOperation(entry.pending.frame);
1445
1848
  },
1446
1849
  this.#options.retryDelayMs ?? Math.min(100 * 2 ** Math.min(entry.retries - 1, 4), 2e3)
1447
1850
  );
@@ -1449,25 +1852,41 @@ var ChalkWhiteboardV1Client = class {
1449
1852
  }
1450
1853
  #disconnected(socket, closeCode) {
1451
1854
  if (socket !== this.#socket) return;
1452
- const rejectedPresentationHello = this.#rejectedPresentationHello(closeCode);
1855
+ const phase = this.#phase;
1453
1856
  this.#resetDisconnectedState();
1454
1857
  if (!this.#started || !this.#transportAvailable) return;
1455
- if (rejectedPresentationHello) {
1456
- this.#useLegacyHello = true;
1457
- this.#scheduleReconnect();
1458
- return;
1459
- }
1460
- if (terminalCloseCode(closeCode)) {
1461
- this.#failRejectedConnection(closeCode);
1462
- return;
1463
- }
1464
- this.#scheduleReconnect();
1858
+ const step = CLOSE_STEPS[closeCode]?.[phase] ?? (terminalCloseCode(closeCode) ? "fail" : "reconnect");
1859
+ if (this.#takeCloseStep(step, phase)) this.#scheduleReconnect();
1860
+ else this.#failRejectedConnection(closeCode);
1861
+ }
1862
+ /** Returns false when the step has nothing left to try, so the connection fails. */
1863
+ #takeCloseStep(step, phase) {
1864
+ if (step === "fail") return false;
1865
+ if (step === "step-down") return this.#stepDownHello();
1866
+ if (step === "retry-hello") return this.#retryHello();
1867
+ if (phase === "live") this.#hello = "owner-view";
1868
+ return true;
1465
1869
  }
1466
- #rejectedPresentationHello(closeCode) {
1467
- return this.#phase === "authenticating" && !this.#useLegacyHello && terminalCloseCode(closeCode);
1870
+ #stepDownHello() {
1871
+ if (this.#hello === "legacy") return false;
1872
+ this.#fallbackHello();
1873
+ return true;
1874
+ }
1875
+ #retryHello() {
1876
+ if (this.#rejectedHelloRetries >= MAX_REJECTED_HELLO_RETRIES) return false;
1877
+ this.#rejectedHelloRetries += 1;
1878
+ return true;
1879
+ }
1880
+ #fallbackHello() {
1881
+ this.#hello = HELLO_LEVELS[Math.min(HELLO_LEVELS.indexOf(this.#hello) + 1, HELLO_LEVELS.length - 1)] ?? "legacy";
1882
+ if (!viewsNegotiated(this.#hello)) this.#pendingLeadingView = null;
1468
1883
  }
1469
1884
  #resetDisconnectedState() {
1885
+ this.#viewOwnerId = null;
1886
+ this.#latestViews.clear();
1887
+ this.#viewVersions.clear();
1470
1888
  this.#socket = null;
1889
+ this.#clearDial();
1471
1890
  this.#clearHeartbeat();
1472
1891
  this.#clearUpdateAssemblies();
1473
1892
  this.#phase = "connecting";
@@ -1484,6 +1903,10 @@ var ChalkWhiteboardV1Client = class {
1484
1903
  if (this.#initialSnapshot) rejectDeferred(this.#initialSnapshot, error(terminalFailure));
1485
1904
  this.#rejectOperationCallers(terminalFailure);
1486
1905
  }
1906
+ #clearDial() {
1907
+ this.#cancelDial?.();
1908
+ this.#cancelDial = void 0;
1909
+ }
1487
1910
  #scheduleReconnect() {
1488
1911
  this.#clearReconnect();
1489
1912
  const configuredDelay = this.#options.reconnectDelayMs;
@@ -1508,13 +1931,13 @@ var ChalkWhiteboardV1Client = class {
1508
1931
  if (this.#phase !== "live") throw new Error("whiteboard frame arrived before welcome");
1509
1932
  }
1510
1933
  #assertOperationCapacity() {
1511
- if (this.#operations.size + this.#reservedOperationIds.size >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) {
1934
+ if (this.#operationCount() + this.#reservedOperationIds.size >= WhiteboardV1ProtocolLimits.pendingOperationMaxItems) {
1512
1935
  throw error(failure("submit_update", "unavailable", true, "Whiteboard operation capacity is full."));
1513
1936
  }
1514
1937
  }
1515
1938
  #nextId() {
1516
1939
  const id = this.#options.ids?.next() ?? crypto.randomUUID();
1517
- if (this.#operations.has(id) || this.#reservedOperationIds.has(id) || this.#snapshots.has(id)) {
1940
+ if (this.#operations.has(id) || this.#accessChange(id) || this.#reservedOperationIds.has(id) || this.#snapshots.has(id)) {
1518
1941
  throw error(failure("submit_update", "invalid_payload", false, "Whiteboard operation ID is already pending."));
1519
1942
  }
1520
1943
  return id;
@@ -1527,11 +1950,26 @@ var ChalkWhiteboardV1Client = class {
1527
1950
  }
1528
1951
  #emit(event) {
1529
1952
  if (event.type === "snapshot") this.#latestSnapshot = event;
1953
+ if (event.type === "view") {
1954
+ this.#latestViews.delete(event.participantId);
1955
+ this.#latestViews.set(event.participantId, event);
1956
+ if (this.#latestViews.size > WhiteboardV1ProtocolLimits.viewPeopleMaxItems) {
1957
+ const oldest = this.#latestViews.keys().next().value;
1958
+ if (oldest !== void 0) this.#latestViews.delete(oldest);
1959
+ }
1960
+ }
1530
1961
  for (const listener of this.#listeners) listener(event);
1962
+ if (event.type === "snapshot") {
1963
+ for (const view of this.#latestViews.values()) {
1964
+ for (const listener of this.#listeners) listener({ ...view, bring: false });
1965
+ }
1966
+ }
1531
1967
  }
1532
1968
  #publishSummary(status, summaryError) {
1533
1969
  this.#summaryStatus = status;
1534
1970
  this.#summaryError = summaryError;
1971
+ if (status === "failed") this.#pendingLeadingView = null;
1972
+ if (status === "ready") this.#flushPendingBring();
1535
1973
  const summary = this.#summary();
1536
1974
  try {
1537
1975
  this.#options.onSummary?.(summary);
@@ -1547,6 +1985,8 @@ var ChalkWhiteboardV1Client = class {
1547
1985
  capabilities: [...this.#capabilities],
1548
1986
  canDraw: this.#canDraw,
1549
1987
  canClear: this.#capabilities.includes("manageWhiteboard"),
1988
+ drawPolicy: this.#drawPolicy,
1989
+ drawPermissions: this.#drawPermissions,
1550
1990
  presenting: this.#presenting,
1551
1991
  error: this.#summaryError
1552
1992
  };
@@ -1645,7 +2085,7 @@ var ChalkWhiteboardV1Client = class {
1645
2085
  this.#snapshots.clear();
1646
2086
  }
1647
2087
  #rejectOperationCallers(value) {
1648
- for (const entry of this.#operations.values()) entry.deferred && rejectDeferred(entry.deferred, error({ ...value, operation: operationName(entry.pending.frame) }));
2088
+ for (const entry of this.#pendingEntries()) entry.deferred && rejectDeferred(entry.deferred, error({ ...value, operation: operationName(entry.pending.frame) }));
1649
2089
  }
1650
2090
  #clearOperationRetryTimers() {
1651
2091
  for (const timer of this.#operationRetryTimers.values()) this.#clock().clearTimeout(timer);
@@ -1752,6 +2192,28 @@ function operationName(frame) {
1752
2192
  function retryableOperationCode(code) {
1753
2193
  return code === "unavailable" || code === "overloaded" || code === "rate_limited" || code === "storage_unavailable";
1754
2194
  }
2195
+ var HELLO_LEVELS = ["owner-view", "draw-policy", "versioned-view", "view", "presentation", "legacy"];
2196
+ function viewsNegotiated(level) {
2197
+ return level === "owner-view" || level === "draw-policy" || level === "versioned-view" || level === "view";
2198
+ }
2199
+ function viewOriginsNegotiated(level) {
2200
+ return level === "owner-view" || level === "draw-policy" || level === "versioned-view";
2201
+ }
2202
+ function drawPolicyNegotiated(level) {
2203
+ return level === "owner-view" || level === "draw-policy";
2204
+ }
2205
+ function helloFrame(level, token, cursor) {
2206
+ const hello = { type: "hello", protocol: "whiteboard-v1", token, cursor };
2207
+ if (level === "legacy") return hello;
2208
+ if (level === "presentation") return { ...hello, extensions: [{ name: "presentation_v1" }] };
2209
+ if (level === "view") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1" }] };
2210
+ if (level === "versioned-view") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true }] };
2211
+ if (level === "draw-policy") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true }, { name: "draw_policy_v1" }] };
2212
+ return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true, owner: true }, { name: "draw_policy_v1" }] };
2213
+ }
2214
+ function policyUnavailable() {
2215
+ return failure("set_draw_policy", "unavailable", true, "The call-wide drawing policy is unavailable on this Sync server.");
2216
+ }
1755
2217
  function terminalCloseCode(closeCode) {
1756
2218
  return closeCode === 1008 || closeCode === 1009;
1757
2219
  }
@@ -1764,6 +2226,16 @@ function failure(operation, code, recoverable, message) {
1764
2226
  function error(value, cause) {
1765
2227
  return new ChalkWhiteboardV1Error(value, cause === void 0 ? void 0 : { cause });
1766
2228
  }
2229
+ function commitDeferred() {
2230
+ let deferred;
2231
+ const promise = new Promise((resolve, reject) => {
2232
+ deferred = { resolve, reject, settled: false };
2233
+ });
2234
+ return { promise, deferred };
2235
+ }
2236
+ function isAccessChange(frame) {
2237
+ return frame.type === "set_draw_policy" || frame.type === "set_draw_permission";
2238
+ }
1767
2239
  function resolveDeferred(deferred, value) {
1768
2240
  if (deferred.settled) return;
1769
2241
  deferred.settled = true;
@@ -1797,12 +2269,14 @@ var ConnectionError = class extends Error {
1797
2269
  code;
1798
2270
  action;
1799
2271
  recoverable;
2272
+ sync;
1800
2273
  constructor(failure2, options) {
1801
2274
  super(failure2.message, options);
1802
2275
  this.name = "ConnectionError";
1803
2276
  this.code = failure2.code;
1804
2277
  this.action = failure2.action;
1805
2278
  this.recoverable = failure2.recoverable;
2279
+ this.sync = failure2.sync;
1806
2280
  }
1807
2281
  };
1808
2282
 
@@ -1865,6 +2339,21 @@ function createDefaultConnectionDependencies(options) {
1865
2339
  clearTimeout: (handle) => globalThis.clearTimeout(handle)
1866
2340
  },
1867
2341
  mediaDevices: createBrowserMediaDevices(),
2342
+ cancelPendingAdmission: async (access, signal) => {
2343
+ const { tenantId, spaceId, episodeId, participantId } = access.subject;
2344
+ const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
2345
+ let baseEnd = options.apiBaseURL.length;
2346
+ while (baseEnd > 0 && options.apiBaseURL[baseEnd - 1] === "/") baseEnd--;
2347
+ const baseURL = options.apiBaseURL.slice(0, baseEnd);
2348
+ const response = await fetch(`${baseURL}/v1/tenants/${path[0]}/spaces/${path[1]}/episodes/${path[2]}/participants/${path[3]}/cancel-join`, {
2349
+ method: "POST",
2350
+ headers: { Authorization: `Bearer ${access.media.token}` },
2351
+ signal
2352
+ });
2353
+ if (!response.ok) throw new Error(`Admission cancellation failed with HTTP ${response.status}`);
2354
+ const body = await response.json();
2355
+ return typeof body === "object" && body !== null && "cancelled" in body && body.cancelled === true;
2356
+ },
1868
2357
  createMediaClient: (input) => createMediaClient(options.apiBaseURL, input),
1869
2358
  createSyncClient: (input) => createSyncClient(options.syncURL, input),
1870
2359
  createChatFileTransport: (input) => createChatFileTransport(options.apiBaseURL, input),
@@ -1957,7 +2446,8 @@ function createSyncClient(syncURL, input) {
1957
2446
  token: input.token,
1958
2447
  mediaPlane: input.media,
1959
2448
  collaboration: { name: "collaboration_v2", chatCursor: { afterSequence: null, retainedFloorSequence: null } },
1960
- persistenceScope: `${input.access.subject.tenantId}:${input.access.subject.episodeId}:${input.access.subject.participantId}`
2449
+ persistenceScope: `${input.access.subject.tenantId}:${input.access.subject.episodeId}:${input.access.subject.participantId}`,
2450
+ entryConfirmation: true
1961
2451
  });
1962
2452
  }
1963
2453
  function createWhiteboardClient(apiBaseURL, url, input) {
@@ -2019,7 +2509,7 @@ var ConnectionDiagnostics = class {
2019
2509
  this.#onEvent = options.onEvent;
2020
2510
  }
2021
2511
  record(input) {
2022
- const event = Object.freeze({ timestamp: this.#now(), ...input });
2512
+ const event = Object.freeze({ timestamp: this.#now(), ...input, ...input.sync ? { sync: Object.freeze({ ...input.sync }) } : {} });
2023
2513
  this.#events.push(event);
2024
2514
  if (this.#events.length > this.#limit) this.#events.splice(0, this.#events.length - this.#limit);
2025
2515
  try {
@@ -2123,6 +2613,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2123
2613
  syncNoticeUnresponsive: false,
2124
2614
  mediaSnapshot: null,
2125
2615
  failure: null,
2616
+ startInitialMedia: null,
2126
2617
  initialMedia: (intent) => Effect2.tryPromise({
2127
2618
  try: () => intent.microphone || intent.camera ? platform.mediaDevices.getUserMedia({ audio: intent.microphone, video: intent.camera }) : Promise.resolve(streamFromTracks([])),
2128
2619
  catch: (cause) => cause
@@ -2139,15 +2630,16 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2139
2630
  refreshInFlight: false,
2140
2631
  recoveryQueued: null,
2141
2632
  joinCancelled: false,
2633
+ admissionCleanupConfirmed: true,
2142
2634
  closed: false,
2143
2635
  epoch: 0
2144
2636
  };
2145
2637
  const mediaAttempt = { expiresAt: null };
2146
- const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => lifecycleFailure(code, true, message, cause) });
2638
+ const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure(code, true, message, cause) });
2147
2639
  const withinForegroundBudget = (effect, milliseconds, failure2) => Effect2.raceFirst(
2148
2640
  effect,
2149
2641
  Effect2.callback((resume) => {
2150
- const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(failure2)));
2642
+ const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(typeof failure2 === "function" ? failure2() : failure2)));
2151
2643
  return Effect2.sync(cancel);
2152
2644
  })
2153
2645
  );
@@ -2267,8 +2759,24 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2267
2759
  model.syncBindingCleanup?.();
2268
2760
  model.syncNoticeUnresponsive = false;
2269
2761
  let latestSnapshot = null;
2762
+ let observedDialTimeouts = 0;
2763
+ let observedConnectFailure;
2270
2764
  let snapshotQueued = false;
2765
+ const recordSyncDiagnostics = (snapshot) => {
2766
+ if (sync !== model.sync) return;
2767
+ const failure2 = snapshot.connection.connectFailure;
2768
+ if (failure2 && failure2 !== observedConnectFailure) {
2769
+ observedConnectFailure = failure2;
2770
+ diagnostics.record({ event: "sync_connect_failed", state: model.state, epoch: model.epoch, code: "sync_hello_timeout", sync: failure2 });
2771
+ }
2772
+ const count = snapshot.connection.dialTimeoutCount ?? 0;
2773
+ if (count > observedDialTimeouts) {
2774
+ observedDialTimeouts = count;
2775
+ diagnostics.record({ event: "sync_dial_timeout", state: model.state, epoch: model.epoch, attempt: count });
2776
+ }
2777
+ };
2271
2778
  const unsubscribe = sync.subscribe((snapshot) => {
2779
+ recordSyncDiagnostics(snapshot);
2272
2780
  if (sync === model.sync && snapshot.connection.phase === "live") {
2273
2781
  const notice = snapshot.connection.noticeUnresponsive === true;
2274
2782
  if (notice !== model.syncNoticeUnresponsive) {
@@ -2330,7 +2838,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2330
2838
  const unsubscribe = sync.subscribe(observe);
2331
2839
  observe(sync.getSnapshot());
2332
2840
  return Effect2.sync(unsubscribe);
2333
- }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")));
2841
+ }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, () => syncStartupFailure(sync.getSnapshot())));
2334
2842
  const trace = (step, effect) => {
2335
2843
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2336
2844
  return effect.pipe(
@@ -2338,6 +2846,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2338
2846
  Effect2.tapError(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed" })))
2339
2847
  );
2340
2848
  };
2849
+ const confirmSyncEntry = (sync) => {
2850
+ const confirmEntered = sync.confirmEntered?.bind(sync);
2851
+ return confirmEntered ? trace("confirm_entered", foreign7(confirmEntered, "sync_start_failed", "Entry could not be confirmed")) : Effect2.void;
2852
+ };
2341
2853
  const performJoin = (reason = "join", retried = false) => Effect2.gen(function* () {
2342
2854
  if (model.state === "live") return;
2343
2855
  if (model.state === "leaving" || model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Cannot join an inactive Connection"));
@@ -2357,7 +2869,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2357
2869
  stream = captured.stream;
2358
2870
  captureWarning = captured.failure;
2359
2871
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2360
- const grant = yield* trace("access_initialize", access.initialize(reason));
2872
+ const grant = yield* trace("access_initialize", reason === "access_retry" ? access.refreshAfterRejection() : access.initialize(reason));
2873
+ model.admissionCleanupConfirmed = false;
2361
2874
  const media = yield* trace(
2362
2875
  "create_media_client",
2363
2876
  Effect2.try({
@@ -2388,12 +2901,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2388
2901
  [
2389
2902
  trace(
2390
2903
  "start_media",
2391
- foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
2904
+ model.startInitialMedia ? model.startInitialMedia(media, stream).pipe(Effect2.mapError((cause) => lifecycleFailure("media_start_failed", true, "The media layer could not start", cause))) : foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
2392
2905
  ),
2393
- trace("start_sync", foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))))
2906
+ trace(
2907
+ "start_sync",
2908
+ foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(
2909
+ Effect2.mapError((failure2) => sync.getSnapshot().connection.connectFailure ? syncStartupFailure(sync.getSnapshot()) : failure2),
2910
+ Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))
2911
+ )
2912
+ )
2394
2913
  ],
2395
2914
  { concurrency: "unbounded", discard: true }
2396
2915
  );
2916
+ if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2917
+ yield* confirmSyncEntry(sync);
2397
2918
  }).pipe(
2398
2919
  Effect2.matchEffect({
2399
2920
  onFailure: (cause) => Effect2.gen(function* () {
@@ -2407,8 +2928,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2407
2928
  span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
2408
2929
  const retry = !retried && failure2.code === "invalid_access";
2409
2930
  if (!retry) yield* recordFailure(failure2);
2931
+ model.admissionCleanupConfirmed = yield* cancelPendingAdmission();
2410
2932
  yield* stopPorts();
2411
- if (retry) return yield* performJoin("access_retry", true);
2933
+ if (retry) return yield* performJoin(model.admissionCleanupConfirmed ? "join" : "access_retry", true);
2412
2934
  yield* transition("failed");
2413
2935
  return yield* Effect2.fail(failure2);
2414
2936
  }),
@@ -2425,7 +2947,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2425
2947
  );
2426
2948
  });
2427
2949
  const performLeave = () => Effect2.gen(function* performLeaveEffect() {
2428
- if (model.state === "idle" || model.state === "left") return;
2950
+ if (model.state === "idle") return;
2951
+ if (model.state === "left") {
2952
+ if (model.failure) return yield* Effect2.fail(lifecycleFailure("leave_unconfirmed", false, model.failure.message));
2953
+ return;
2954
+ }
2429
2955
  yield* transition("leaving");
2430
2956
  const confirmed = yield* leaveIsConfirmed();
2431
2957
  yield* stopPorts();
@@ -2434,11 +2960,27 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2434
2960
  yield* transition("left");
2435
2961
  if (!confirmed) return yield* Effect2.fail(lifecycleFailure("leave_unconfirmed", false, "The Episode left locally without a durable Leave acknowledgement"));
2436
2962
  });
2963
+ const cancelPendingAdmission = () => Effect2.gen(function* () {
2964
+ const grant = access.currentUnsafe();
2965
+ if (!grant) return model.admissionCleanupConfirmed;
2966
+ const cancel = platform.cancelPendingAdmission;
2967
+ if (!cancel) return false;
2968
+ const result = yield* Effect2.exit(
2969
+ Effect2.tryPromise({
2970
+ try: (signal) => cancel(grant, signal),
2971
+ catch: (cause) => lifecycleFailure("internal_error", true, "Admission cancellation failed", cause)
2972
+ }).pipe(Effect2.timeout(3e3))
2973
+ );
2974
+ return result._tag === "Success" && result.value;
2975
+ });
2437
2976
  const leaveIsConfirmed = () => Effect2.gen(function* leaveIsConfirmedEffect() {
2438
2977
  const sync = model.sync;
2439
- if (!sync || model.episodeEndConfirmed || access.currentUnsafe() === null) return true;
2440
- const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2441
- return (yield* Effect2.exit(leave))._tag === "Success";
2978
+ if (model.episodeEndConfirmed) return true;
2979
+ if (sync && (sync.getSnapshot().connection.phase === "live" || sync.getSnapshot().control !== null)) {
2980
+ const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2981
+ if ((yield* Effect2.exit(leave))._tag === "Success") return true;
2982
+ }
2983
+ return yield* cancelPendingAdmission();
2442
2984
  });
2443
2985
  const publishFreshAccess = () => enqueue(
2444
2986
  Effect2.gen(function* () {
@@ -2652,7 +3194,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2652
3194
  if (snapshot.connection.phase === "idle" || snapshot.connection.phase === "stopped") return Effect2.succeed(true);
2653
3195
  if (snapshot.connection.phase !== "terminal") return Effect2.succeed(false);
2654
3196
  const failure2 = syncTerminalFailure(snapshot);
2655
- return failure2.recoverable ? Effect2.succeed(true) : Effect2.fail(failure2);
3197
+ return syncAutoRecoverable(snapshot, failure2) ? Effect2.succeed(true) : Effect2.fail(failure2);
2656
3198
  };
2657
3199
  const recoverSync = (attempt) => Effect2.gen(function* () {
2658
3200
  const sync = model.sync;
@@ -2696,6 +3238,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2696
3238
  else if (attempt.client === sync) attempt.started = true;
2697
3239
  })
2698
3240
  ).pipe(
3241
+ Effect2.andThen(waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4))),
3242
+ Effect2.andThen(Effect2.suspend(() => sync === model.sync && model.epoch === epoch && active(model) ? confirmSyncEntry(sync) : Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery was superseded")))),
2699
3243
  Effect2.tapError(
2700
3244
  () => enqueue(
2701
3245
  Effect2.gen(function* () {
@@ -2708,7 +3252,6 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2708
3252
  )
2709
3253
  )
2710
3254
  );
2711
- yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
2712
3255
  });
2713
3256
  const handleSyncSnapshot = (sync, snapshot) => Effect2.gen(function* handleSyncSnapshotEffect() {
2714
3257
  if (!isCurrentSyncSnapshot(sync, snapshot)) return;
@@ -2729,7 +3272,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2729
3272
  }
2730
3273
  if (snapshot.connection.phase !== "terminal") return false;
2731
3274
  const failure2 = syncTerminalFailure(snapshot);
2732
- if (failure2.recoverable) return false;
3275
+ if (syncAutoRecoverable(snapshot, failure2)) return false;
2733
3276
  yield* failForSnapshot(failure2);
2734
3277
  return true;
2735
3278
  });
@@ -2744,7 +3287,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2744
3287
  });
2745
3288
  const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
2746
3289
  const recordFailure = (failure2) => Effect2.sync(() => {
2747
- diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code });
3290
+ diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {} });
2748
3291
  });
2749
3292
  const failForSnapshot = (failure2) => Effect2.gen(function* failForSnapshotEffect() {
2750
3293
  model.failure = toFailure(failure2);
@@ -2845,8 +3388,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2845
3388
  },
2846
3389
  getDiagnostics: () => diagnostics.snapshot(),
2847
3390
  getJoinTrace: () => diagnostics.joinTrace(),
2848
- setInitialMedia: (provider, intent) => Effect2.sync(() => {
3391
+ setInitialMedia: (provider, intent, start) => Effect2.sync(() => {
2849
3392
  model.initialMedia = provider;
3393
+ model.startInitialMedia = start ?? null;
2850
3394
  model.initialMediaIntent = intent ?? null;
2851
3395
  }),
2852
3396
  configureJoin: (intent) => Effect2.sync(() => {
@@ -2907,7 +3451,7 @@ function snapshotFor(model, access) {
2907
3451
  state: model.state,
2908
3452
  subject: subjectFor(access),
2909
3453
  episode: episodeFor(access, control),
2910
- connection: Object.freeze({ sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
3454
+ connection: Object.freeze({ syncHealth: model.syncSnapshot?.connection.health, sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
2911
3455
  failure: model.failure ? Object.freeze({ ...model.failure }) : null,
2912
3456
  episodeEnded: model.episodeEndConfirmed,
2913
3457
  removedByHost: model.removedByHost
@@ -2935,7 +3479,7 @@ function lifecycleFailure(code, recoverable, message, cause) {
2935
3479
  return new ConnectionLifecycleFailure({ code, recoverable, message, ...cause === void 0 ? {} : { cause } });
2936
3480
  }
2937
3481
  function toFailure(value) {
2938
- return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message });
3482
+ return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message, ...value.sync ? { sync: value.sync } : {} });
2939
3483
  }
2940
3484
  function accessFailure(value) {
2941
3485
  return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? "Access was rejected" : "Could not reach the Space. Check your network and try again.", value.cause);
@@ -3027,10 +3571,20 @@ function mediaPhase(phase) {
3027
3571
  }
3028
3572
  function syncTerminalFailure(snapshot) {
3029
3573
  const reason = snapshot.connection.terminalReason;
3574
+ if (reason === "state_digest_recovery_failed") return lifecycleFailure("state_digest_recovery_failed", false, "The Episode state could not be recovered from an authoritative snapshot");
3030
3575
  if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
3031
- if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
3576
+ if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation" || reason === "access_rejected") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
3577
+ if (reason === "entry_timeout") return lifecycleFailure("sync_start_failed", true, "Call entry was not confirmed in time");
3032
3578
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
3033
3579
  }
3580
+ function syncStartupFailure(snapshot) {
3581
+ const sync = snapshot.connection.connectFailure ?? snapshot.connection.connectAttempt;
3582
+ const message = sync?.opened === false ? "Sync could not reach the call service" : "Sync could not connect";
3583
+ return new ConnectionLifecycleFailure({ code: "sync_hello_timeout", recoverable: true, message: `${message}${sync ? ` ${formatSyncConnectDiagnostic(sync)}` : " before the startup deadline"}`, ...sync ? { sync: Object.freeze({ ...sync }) } : {} });
3584
+ }
3585
+ function syncAutoRecoverable(snapshot, failure2) {
3586
+ return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
3587
+ }
3034
3588
 
3035
3589
  // src/space-client/errors.ts
3036
3590
  import { Data as Data3 } from "effect";
@@ -3045,6 +3599,8 @@ var CONNECTION_ERROR_CODE_MAP = {
3045
3599
  device_constraint_invalid: "media.device_constraint_invalid",
3046
3600
  media_capture_failed: "media.capture_failed",
3047
3601
  sync_start_failed: "connection.sync_start_failed",
3602
+ state_digest_recovery_failed: "connection.state_digest_recovery_failed",
3603
+ sync_hello_timeout: "connection.sync_hello_timeout",
3048
3604
  media_start_failed: "connection.media_start_failed",
3049
3605
  join_cleanup_unconfirmed: "connection.join_cleanup_unconfirmed",
3050
3606
  sync_recovery_exhausted: "connection.sync_recovery_exhausted",
@@ -3067,17 +3623,36 @@ var INVALID_PAYLOAD_CODE_BY_ACTION = /* @__PURE__ */ new Map([
3067
3623
  ["acceptMediaRequest", "media.request_invalid"],
3068
3624
  ["declineMediaRequest", "media.request_invalid"]
3069
3625
  ]);
3070
- function normalizeClientError(cause, fallback = "client.internal_error") {
3626
+ var MEDIA_TIMEOUT_FAILURE = { code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." };
3627
+ var SYNC_FAILURES = /* @__PURE__ */ new Map([
3628
+ ["sync_hello_timeout", { failure: { code: "connection.sync_hello_timeout", recoverable: true, message: "Sync could not reach the call service. Try again." } }],
3629
+ ["not_live", { failure: { code: "connection.invalid_state", recoverable: true, message: "Wait for the call to connect, then try again." } }],
3630
+ ["signaling_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3631
+ ["negotiation_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3632
+ ["media_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3633
+ ["retry_exhausted", { failure: MEDIA_TIMEOUT_FAILURE, scope: "media" }],
3634
+ ["reaction_ack_timeout", { failure: { code: "collaboration.unavailable", recoverable: true, message: "Couldn\u2019t confirm your reaction. Try again." } }],
3635
+ ["overloaded", { failure: { code: "collaboration.unavailable", recoverable: true, message: "The call is busy right now. Try again." } }],
3636
+ ["rate_limited", { failure: { code: "command.rate_limited", recoverable: true, message: "You are sending messages too fast. Wait a moment, then try again." } }]
3637
+ ]);
3638
+ function normalizeMediaError(cause) {
3639
+ return normalizeClientError(cause, "client.internal_error", "media");
3640
+ }
3641
+ function normalizeClientError(cause, fallback = "client.internal_error", scope) {
3071
3642
  if (cause instanceof SpaceClientError) return cause;
3072
- if (isConnectionError(cause)) {
3643
+ const chatFileError2 = normalizeChatFileError(cause);
3644
+ if (chatFileError2) return chatFileError2;
3645
+ if (cause instanceof ConnectionLifecycleFailure || isConnectionError(cause)) {
3073
3646
  return new SpaceClientError({
3074
- code: connectionErrorCode(cause.code, cause.action),
3647
+ code: connectionErrorCode(cause.code, cause instanceof ConnectionLifecycleFailure ? null : cause.action),
3075
3648
  recoverable: cause.recoverable,
3076
- message: cause.message
3649
+ message: cause.message,
3650
+ ...cause.sync ? { sync: cause.sync } : {}
3077
3651
  });
3078
3652
  }
3079
- if (cause instanceof V1SyncError && ["signaling_timeout", "negotiation_timeout", "media_timeout"].includes(cause.code)) {
3080
- return new SpaceClientError({ code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." });
3653
+ if (cause instanceof V1SyncError) {
3654
+ const policy = SYNC_FAILURES.get(cause.code);
3655
+ if (policy && (!policy.scope || policy.scope === scope)) return new SpaceClientError(policy.failure);
3081
3656
  }
3082
3657
  if (isAccessInvalid(cause)) {
3083
3658
  return new SpaceClientError({ code: "access.invalid", recoverable: true, message: "The access grant was rejected" });
@@ -3085,8 +3660,15 @@ function normalizeClientError(cause, fallback = "client.internal_error") {
3085
3660
  const message = cause instanceof Error ? cause.message : "The client operation failed";
3086
3661
  return new SpaceClientError({ code: fallback, recoverable: false, message });
3087
3662
  }
3663
+ function normalizeChatFileError(cause) {
3664
+ if (!(cause instanceof ChalkChatFileError) || cause.code !== "unavailable") return null;
3665
+ if (cause.stage === "token" && isAccessInvalid(cause.cause)) {
3666
+ return new SpaceClientError({ code: "access.invalid", recoverable: false, message: cause.cause instanceof Error ? cause.cause.message : "Access was rejected" });
3667
+ }
3668
+ return new SpaceClientError({ code: cause.stage === "token" ? "access.unavailable" : "collaboration.unavailable", recoverable: cause.recoverable, message: cause.message });
3669
+ }
3088
3670
  function failureFromError(error2) {
3089
- return Object.freeze({ code: error2.code, recoverable: error2.recoverable, message: error2.message });
3671
+ return Object.freeze({ code: error2.code, recoverable: error2.recoverable, message: error2.message, ...error2.sync ? { sync: error2.sync } : {} });
3090
3672
  }
3091
3673
  function isAccessInvalid(cause) {
3092
3674
  if (typeof cause !== "object" || cause === null || !("code" in cause)) return false;
@@ -3120,7 +3702,7 @@ var EMPTY = empty({
3120
3702
  reactions: empty({ active: empty([]) }),
3121
3703
  recording: empty({ current: null }),
3122
3704
  offline: empty({ pending: empty([]), decisionNeeded: false, policy: "send" }),
3123
- whiteboard: empty({ open: false, engine: empty({ status: "unsubscribed", sceneId: null, revision: null, presenting: false, error: null }) })
3705
+ whiteboard: empty({ open: false, engine: empty({ status: "unsubscribed", sceneId: null, revision: null, presenting: false, error: null }), access: empty({ canDraw: false, policy: null, permissions: empty([]) }) })
3124
3706
  });
3125
3707
  var SpaceStore = class _SpaceStore {
3126
3708
  #listeners = /* @__PURE__ */ new Set();
@@ -3146,6 +3728,7 @@ var SpaceStore = class _SpaceStore {
3146
3728
  const error2 = connectionError(snapshot.failure);
3147
3729
  const next = {
3148
3730
  status: connectionStatus(snapshot),
3731
+ syncHealth: snapshot.connection.syncHealth,
3149
3732
  episode: snapshot.episode,
3150
3733
  lastError: sameError(current.lastError, error2) ? current.lastError : error2,
3151
3734
  episodeEnded: snapshot.episodeEnded ?? false,
@@ -3199,27 +3782,15 @@ var SpaceStoreService = class extends Context4.Service()("@chalk/client/SpaceSto
3199
3782
  };
3200
3783
  var makeSpaceStoreLayer = Layer4.effect(SpaceStoreService, SpaceStore.make());
3201
3784
  var makeFakeSpaceStoreLayer = (store = new SpaceStore()) => Layer4.succeed(SpaceStoreService, store);
3202
- function mapCode(code) {
3203
- return {
3204
- invalid_access: "access.invalid",
3205
- access_unavailable: "access.unavailable",
3206
- episode_ended: "episode.ended",
3207
- invalid_payload: "chat.payload_invalid",
3208
- permission_denied: "media.permission_denied",
3209
- unsupported_environment: "environment.unsupported",
3210
- rate_limited: "command.rate_limited",
3211
- command_rejected: "command.rejected"
3212
- }[code] ?? "client.internal_error";
3213
- }
3214
3785
  function connectionError(failure2) {
3215
- return failure2 ? empty({ code: publicCode(failure2.code), recoverable: failure2.recoverable, message: failure2.message }) : null;
3786
+ return failure2 ? empty({ code: connectionErrorCode(failure2.code, failure2.action), recoverable: failure2.recoverable, message: failure2.message, ...failure2.sync ? { sync: failure2.sync } : {} }) : null;
3216
3787
  }
3217
3788
  function connectionStatus(snapshot) {
3218
3789
  const interrupted = INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.sync) || INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.media);
3219
3790
  return snapshot.state === "live" && interrupted ? "reconnecting" : snapshot.state;
3220
3791
  }
3221
3792
  function sameConnection(left, right) {
3222
- return left.status === right.status && sameEpisode(left.episode, right.episode) && left.lastError === right.lastError && left.episodeEnded === right.episodeEnded && left.removedByHost === right.removedByHost;
3793
+ return left.status === right.status && left.syncHealth === right.syncHealth && sameEpisode(left.episode, right.episode) && left.lastError === right.lastError && left.episodeEnded === right.episodeEnded && left.removedByHost === right.removedByHost;
3223
3794
  }
3224
3795
  function sameEpisode(left, right) {
3225
3796
  if (left === right) return true;
@@ -3231,20 +3802,6 @@ function sameError(left, right) {
3231
3802
  if (!left || !right) return false;
3232
3803
  return left.code === right.code && left.recoverable === right.recoverable && left.message === right.message;
3233
3804
  }
3234
- function publicCode(code) {
3235
- switch (code) {
3236
- case "device_not_found":
3237
- return "media.device_not_found";
3238
- case "device_busy":
3239
- return "media.device_busy";
3240
- case "device_constraint_invalid":
3241
- return "media.device_constraint_invalid";
3242
- case "media_capture_failed":
3243
- return "media.capture_failed";
3244
- default:
3245
- return mapCode(code);
3246
- }
3247
- }
3248
3805
 
3249
3806
  // src/space-client/core.ts
3250
3807
  import { Clock as Clock5, Context as Context13, Duration as Duration2, Effect as Effect12, Layer as Layer13 } from "effect";
@@ -3366,13 +3923,13 @@ var ChatControllerRuntime = class {
3366
3923
  #unsubscribeConnection = null;
3367
3924
  #unsubscribeEvents = null;
3368
3925
  #unsubscribeSnapshot = null;
3369
- constructor(connection, store, transport, apiBaseUrl, fetch, fork, offline) {
3926
+ constructor(connection, store, transport, apiBaseUrl, fetch2, fork, offline) {
3370
3927
  this.#offline = offline;
3371
3928
  this.#apiBaseUrl = apiBaseUrl.replace(/\/+$/u, "");
3372
3929
  this.#connection = connection;
3373
3930
  this.#store = store;
3374
3931
  this.#transport = transport;
3375
- this.#fetch = fetch;
3932
+ this.#fetch = fetch2;
3376
3933
  this.#fork = fork;
3377
3934
  this.#unsubscribeConnection = connection.subscribePorts((ports) => this.#bind(ports));
3378
3935
  }
@@ -3450,7 +4007,7 @@ var ChatControllerRuntime = class {
3450
4007
  });
3451
4008
  };
3452
4009
  upload = (file) => {
3453
- const fetch = this.#fetch;
4010
+ const fetch2 = this.#fetch;
3454
4011
  return Effect4.suspend(() => {
3455
4012
  if (!this.#transport) {
3456
4013
  return Effect4.fail(new SpaceClientError({ code: "collaboration.unavailable", recoverable: false, message: "Chat file upload is unavailable" }));
@@ -3465,7 +4022,10 @@ var ChatControllerRuntime = class {
3465
4022
  Effect4.flatMap(
3466
4023
  (upload) => Effect4.void.pipe(
3467
4024
  Effect4.andThen(
3468
- foreign(() => fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes })).pipe(
4025
+ Effect4.tryPromise({
4026
+ try: () => fetch2(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes }),
4027
+ catch: () => new SpaceClientError({ code: "collaboration.unavailable", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE })
4028
+ }).pipe(
3469
4029
  Effect4.flatMap((response) => {
3470
4030
  if (!response.ok) {
3471
4031
  return Effect4.fail(new SpaceClientError({ code: "command.rejected", recoverable: response.status >= 500, message: `Attachment upload failed with HTTP ${response.status}` }));
@@ -3786,8 +4346,11 @@ function chatError(message) {
3786
4346
  }
3787
4347
 
3788
4348
  // src/space-client/media-controller.ts
3789
- import { Clock as Clock3, Context as Context6, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
4349
+ import { Clock as Clock3, Context as Context6, Deferred as Deferred2, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
3790
4350
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4351
+ function sourceWorker(enabled) {
4352
+ return { desired: { enabled, revision: 0, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, sentRevision: 0, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null };
4353
+ }
3791
4354
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
3792
4355
  };
3793
4356
  var makeMediaController = (connection, store, selection) => Effect5.gen(function* () {
@@ -3812,7 +4375,8 @@ var makeMediaController = (connection, store, selection) => Effect5.gen(function
3812
4375
  );
3813
4376
  yield* connection.setInitialMedia(
3814
4377
  (intent) => controller.captureInitial(intent),
3815
- () => controller.initialIntent()
4378
+ () => controller.initialIntent(),
4379
+ (media, stream) => controller.startInitial(media, stream)
3816
4380
  );
3817
4381
  yield* Effect5.addFinalizer(() => Effect5.sync(() => controller.dispose()));
3818
4382
  controller.refreshDevicesInBackground();
@@ -3826,17 +4390,17 @@ var MediaControllerRuntime = class {
3826
4390
  #now;
3827
4391
  #fork;
3828
4392
  #tracks = /* @__PURE__ */ new Map();
3829
- #observedPublicationIds = /* @__PURE__ */ new Map();
3830
- #intentRevisions = /* @__PURE__ */ new Map();
3831
- #pendingEnables = /* @__PURE__ */ new Map();
4393
+ #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
4394
+ #syncLive = false;
3832
4395
  #pendingSources = /* @__PURE__ */ new Set();
4396
+ #pendingCaptures = /* @__PURE__ */ new Set();
3833
4397
  #requestGenerations = /* @__PURE__ */ new Map();
3834
- #intent = { microphone: true, camera: true };
4398
+ #camera = { status: "idle" };
3835
4399
  #requests = Object.freeze([]);
3836
4400
  #ports = null;
3837
- #screenEndedPending = false;
3838
4401
  #screenAudioTrack = null;
3839
4402
  #unsubscribeMedia = null;
4403
+ #unsubscribePause = null;
3840
4404
  #unsubscribeRequests = null;
3841
4405
  #unsubscribeSync = null;
3842
4406
  #unsubscribeConnection = null;
@@ -3854,328 +4418,396 @@ var MediaControllerRuntime = class {
3854
4418
  this.#unsubscribePorts = connection.subscribePorts((ports) => this.#bind(ports));
3855
4419
  }
3856
4420
  configure(intent) {
3857
- if (intent.microphone !== void 0) this.#intent.microphone = intent.microphone;
3858
- if (intent.camera !== void 0) this.#intent.camera = intent.camera;
4421
+ if (intent.microphone !== void 0) this.#recordIntent("microphone", intent.microphone);
4422
+ if (intent.camera !== void 0) this.#recordIntent("camera", intent.camera);
3859
4423
  }
3860
- setMicrophoneEnabled = (enabled) => this.#set("microphone", enabled);
3861
- setCameraEnabled = (enabled) => this.#set("camera", enabled);
3862
- setScreenShareEnabled = (enabled, options) => enabled ? this.#startScreen(options) : this.#stopScreen();
4424
+ setMicrophoneEnabled = (enabled) => this.#mediaAction(enabled, this.#set("microphone", enabled));
4425
+ setCameraEnabled = (enabled) => this.#mediaAction(enabled, this.#set("camera", enabled));
4426
+ captureCameraPreview = () => this.#cameraSource("preview").pipe(Effect5.mapError(normalizeMediaError));
4427
+ setScreenShareEnabled = (enabled, options) => enabled ? this.#mediaAction(true, this.#startScreen(options)) : this.#stopScreen();
3863
4428
  selectMicrophone = (deviceId) => Effect5.sync(() => {
3864
4429
  this.#selection.selectCapture("microphone", deviceId);
3865
4430
  this.#store.select("microphone", deviceId);
3866
- }).pipe(Effect5.mapError(normalizeClientError));
4431
+ }).pipe(Effect5.mapError(normalizeMediaError));
3867
4432
  selectCamera = (deviceId) => Effect5.sync(() => {
4433
+ const changed = this.#store.getSnapshot().media.selection.camera !== deviceId;
3868
4434
  this.#selection.selectCapture("camera", deviceId);
4435
+ if (changed && (!this.#sources.camera.localEnabled || !this.#hasTrack("camera"))) {
4436
+ this.#transitionCamera({ status: "ended" });
4437
+ }
3869
4438
  this.#store.select("camera", deviceId);
3870
- }).pipe(Effect5.mapError(normalizeClientError));
4439
+ }).pipe(Effect5.mapError(normalizeMediaError));
3871
4440
  selectSpeaker = (deviceId) => foreign2(() => this.#selection.selectSpeaker(deviceId)).pipe(
3872
4441
  Effect5.tap(() => Effect5.sync(() => this.#store.select("speaker", deviceId))),
3873
- Effect5.mapError(normalizeClientError)
4442
+ Effect5.mapError(normalizeMediaError)
3874
4443
  );
3875
4444
  acceptRequest = (requestId) => {
3876
4445
  return Effect5.suspend(() => {
3877
4446
  const request = this.#requests.find((candidate) => candidate.requestId === requestId);
3878
4447
  if (!request) return Effect5.fail(new SpaceClientError({ code: "media.request_invalid", recoverable: false, message: "The media request is no longer active" }));
3879
- return (request.kind === "unmute" ? this.#set("microphone", true) : this.#set("camera", true)).pipe(
4448
+ return (request.kind === "unmute" ? this.setMicrophoneEnabled(true) : this.setCameraEnabled(true)).pipe(
3880
4449
  Effect5.tap(
3881
4450
  () => Effect5.sync(() => {
3882
4451
  this.#removeRequest(requestId);
3883
4452
  })
3884
4453
  )
3885
4454
  );
3886
- }).pipe(Effect5.mapError(normalizeClientError));
4455
+ }).pipe(Effect5.mapError(normalizeMediaError));
3887
4456
  };
3888
4457
  declineRequest = (requestId) => {
3889
4458
  return Effect5.sync(() => {
3890
4459
  this.#removeRequest(requestId);
3891
- }).pipe(Effect5.mapError(normalizeClientError));
4460
+ }).pipe(Effect5.mapError(normalizeMediaError));
3892
4461
  };
3893
4462
  initialIntent() {
3894
- return { ...this.#intent };
4463
+ return { microphone: this.#sources.microphone.desired.enabled, camera: this.#sources.camera.desired.enabled };
3895
4464
  }
3896
4465
  captureInitial(intent) {
4466
+ for (const source of ["microphone", "camera"]) this.#sources[source].initialRevision = this.#sources[source].desired.revision;
4467
+ if (!intent.camera) return this.#captureInitial(intent);
4468
+ return this.#useCamera(
4469
+ (track) => Effect5.sync(() => this.#setTrack("camera", track)).pipe(
4470
+ Effect5.andThen(Effect5.suspend(() => intent.microphone && !this.#hasTrack("microphone") ? this.#captureSource("microphone").pipe(Effect5.tap((audio) => Effect5.sync(() => this.#setTrack("microphone", audio)))) : Effect5.void)),
4471
+ Effect5.map(() => {
4472
+ this.#pauseInitialTracks();
4473
+ this.#publish();
4474
+ return streamFromTracks([...this.#localTracks().values()]);
4475
+ })
4476
+ ),
4477
+ (signal) => this.#captureInitial(intent, signal).pipe(
4478
+ Effect5.map((stream) => {
4479
+ const track = stream.getTracks().find((track2) => track2.kind === "video");
4480
+ if (!track) throw new TypeError("Initial capture did not return a camera track");
4481
+ return track;
4482
+ })
4483
+ )
4484
+ );
4485
+ }
4486
+ #captureInitial(intent, signal) {
3897
4487
  if (!intent.microphone && !intent.camera) {
3898
- this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
4488
+ this.#pauseInitialTracks();
3899
4489
  this.#publish();
3900
- return Effect5.succeed(streamFromTracks([...this.#tracks.values()]));
4490
+ return Effect5.succeed(streamFromTracks([...this.#localTracks().values()]));
3901
4491
  }
3902
- return foreign2(() => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera })).pipe(
3903
- Effect5.flatMap(
3904
- (stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
3905
- Effect5.tap(
3906
- (tracks) => Effect5.sync(() => {
3907
- for (const [source, track] of tracks) this.#tracks.set(source, track);
3908
- this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
3909
- this.#publish();
3910
- })
3911
- ),
3912
- Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
3913
- Effect5.map(() => streamFromTracks([...this.#tracks.values()])),
3914
- Effect5.catch(
3915
- (cause) => Effect5.sync(() => {
3916
- stopStream(stream);
3917
- this.#publish();
3918
- }).pipe(Effect5.andThen(Effect5.fail(captureError(cause, "join", "Camera or microphone permission was denied"))))
3919
- )
4492
+ return this.#captureStream(
4493
+ () => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera }),
4494
+ (stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
4495
+ Effect5.tap(
4496
+ (tracks) => Effect5.sync(() => {
4497
+ for (const [source, track] of tracks) this.#setTrack(source, track);
4498
+ this.#pauseInitialTracks();
4499
+ this.#publish();
4500
+ })
4501
+ ),
4502
+ Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
4503
+ Effect5.map(() => streamFromTracks([...this.#localTracks().values()])),
4504
+ Effect5.catch(
4505
+ (cause) => Effect5.sync(() => {
4506
+ stopStream(stream);
4507
+ this.#publish();
4508
+ }).pipe(Effect5.andThen(Effect5.fail(captureError(cause, "join", "Camera or microphone permission was denied"))))
3920
4509
  )
3921
4510
  ),
3922
- Effect5.catch((cause) => Effect5.fail(cause instanceof ConnectionError ? cause : captureError(cause, "join", "Camera or microphone permission was denied")))
3923
- );
4511
+ (cause) => cause,
4512
+ signal
4513
+ ).pipe(Effect5.catch((cause) => Effect5.fail(cause instanceof ConnectionError ? cause : captureError(cause, "join", "Camera or microphone permission was denied"))));
3924
4514
  }
3925
4515
  refreshDevicesInBackground() {
3926
4516
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
3927
4517
  }
3928
4518
  dispose() {
3929
- this.#unsubscribeMedia?.();
3930
- this.#unsubscribeRequests?.();
3931
- this.#unsubscribeSync?.();
4519
+ this.#cancelCaptures();
4520
+ for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
3932
4521
  this.#unsubscribeConnection?.();
3933
4522
  this.#unsubscribeScreenEnded?.();
3934
4523
  this.#unsubscribePorts?.();
3935
4524
  this.#unsubscribeMedia = null;
3936
4525
  this.#unsubscribeRequests = null;
4526
+ this.#unsubscribePause = null;
3937
4527
  this.#unsubscribeSync = null;
3938
4528
  this.#unsubscribeConnection = null;
3939
4529
  this.#unsubscribeScreenEnded = null;
3940
4530
  this.#unsubscribePorts = null;
3941
4531
  this.#ports = null;
4532
+ this.#resetObservations();
3942
4533
  this.#screenAudioTrack?.stop();
3943
4534
  this.#screenAudioTrack = null;
3944
4535
  for (const track of this.#tracks.values()) track.stop();
3945
- this.#tracks.clear();
4536
+ this.#clearTracks();
3946
4537
  this.#clearRequests();
3947
4538
  this.#publish();
3948
4539
  }
4540
+ #mediaAction(enabling, operation) {
4541
+ return Effect5.suspend(() => !enabling || this.#store.getSnapshot().connection.status === "live" ? operation : Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
4542
+ }
3949
4543
  #set(source, enabled) {
3950
- let previousIntent = this.#intent[source];
3951
- let intentRevision = 0;
3952
- let earlyPause = null;
3953
- const localPause = Effect5.suspend(() => {
3954
- previousIntent = this.#intent[source];
3955
- intentRevision = (this.#intentRevisions.get(source) ?? 0) + 1;
3956
- this.#intentRevisions.set(source, intentRevision);
3957
- this.#intent[source] = enabled;
3958
- if (enabled) this.#pendingEnables.set(source, intentRevision);
3959
- this.#ports?.media.setLocalSourceIntent?.(source, enabled);
3960
- if (!enabled && this.#ports) earlyPause = this.#beginLocalPause(this.#ports, source);
3961
- return Effect5.void;
3962
- });
3963
- return localPause.pipe(
3964
- Effect5.andThen(
3965
- this.#serialize(
3966
- source,
3967
- this.#connection.runCommand((ports) => {
3968
- let captured = null;
3969
- let prepared = false;
3970
- const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
3971
- return Effect5.suspend(() => {
3972
- if (!enabled) {
3973
- let paused = false;
3974
- return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, source, false)).pipe(
3975
- Effect5.tap(
3976
- () => Effect5.sync(() => {
3977
- paused = true;
3978
- })
3979
- ),
3980
- Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(false)) : foreign2(() => ports.sync.setCameraEnabled(false))),
3981
- Effect5.asVoid,
3982
- Effect5.catch((cause) => {
3983
- if (paused && !isSyncRejection(cause) || this.#intentRevisions.get(source) !== intentRevision) return Effect5.fail(cause);
3984
- this.#intent[source] = previousIntent;
3985
- ports.media.setLocalSourceIntent?.(source, previousIntent);
3986
- return this.#localTarget(ports, source, previousIntent).pipe(
3987
- Effect5.catch(() => Effect5.void),
3988
- Effect5.andThen(Effect5.fail(cause))
3989
- );
3990
- })
3991
- );
3992
- }
3993
- const acquire = enabled && !this.#tracks.has(source) ? this.#captureSource(source).pipe(
3994
- Effect5.tap(
3995
- (track) => Effect5.sync(() => {
3996
- captured = track;
3997
- try {
3998
- this.#assertActivePorts(ports, action);
3999
- } catch (cause) {
4000
- track.stop();
4001
- throw cause;
4002
- }
4003
- this.#tracks.set(source, track);
4004
- ports.media.prepareLocalTrack(source, track);
4005
- prepared = true;
4006
- this.#publish();
4007
- })
4008
- )
4009
- ) : Effect5.void;
4010
- return acquire.pipe(
4011
- Effect5.asVoid,
4012
- Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(enabled)) : foreign2(() => ports.sync.setCameraEnabled(enabled))),
4013
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, action))),
4014
- Effect5.asVoid,
4015
- Effect5.catch(
4016
- (cause) => this.#rollbackCapture({
4017
- action,
4018
- cause,
4019
- fallback: Effect5.void,
4020
- media: ports.media,
4021
- permissionMessage: `${source} permission was denied`,
4022
- prepared,
4023
- restoreIntent: () => {
4024
- if (this.#intentRevisions.get(source) === intentRevision) this.#intent[source] = previousIntent;
4025
- },
4026
- source,
4027
- track: captured
4028
- })
4029
- )
4030
- );
4031
- });
4032
- })
4033
- )
4034
- )
4035
- ).pipe(
4036
- Effect5.mapError(normalizeClientError),
4037
- Effect5.ensuring(
4038
- Effect5.sync(() => {
4039
- if (this.#pendingEnables.get(source) === intentRevision) this.#pendingEnables.delete(source);
4040
- })
4041
- )
4042
- );
4544
+ return this.#act(source, enabled);
4043
4545
  }
4044
4546
  #startScreen(options) {
4045
- let intentRevision = 0;
4046
- return Effect5.sync(() => {
4047
- intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
4048
- this.#intentRevisions.set("screen", intentRevision);
4049
- this.#pendingEnables.set("screen", intentRevision);
4050
- this.#ports?.media.setLocalSourceIntent?.("screen", true);
4051
- }).pipe(
4052
- Effect5.andThen(
4053
- this.#serialize(
4054
- "screen",
4055
- this.#connection.runCommand((ports) => {
4056
- let track = null;
4057
- let prepared = false;
4058
- return Effect5.suspend(() => {
4059
- if (this.#tracks.has("screen") && options === void 0) {
4060
- return Effect5.void;
4061
- }
4062
- return Effect5.acquireUseRelease(
4063
- foreign2(() => this.#selection.getDisplayMedia(displayMediaConstraints(options))).pipe(Effect5.mapError((cause) => captureError(cause, "startScreenShare", "Screen sharing permission was denied"))),
4064
- (stream) => Effect5.sync(() => {
4065
- this.#assertActivePorts(ports, "startScreenShare");
4066
- track = requireDisplayVideoTrack(stream);
4067
- return { stream, track };
4068
- }).pipe(
4069
- Effect5.tap(() => {
4070
- const previous = this.#tracks.get("screen");
4071
- if (!previous) return Effect5.void;
4072
- return foreign2(() => ports.sync.setScreenShareEnabled(false)).pipe(
4073
- Effect5.andThen(this.#clearScreenAudio(ports.media)),
4074
- Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
4075
- Effect5.tap(
4076
- () => Effect5.sync(() => {
4077
- this.#assertActivePorts(ports, "startScreenShare");
4078
- this.#tracks.delete("screen");
4079
- previous.stop();
4080
- })
4081
- )
4082
- );
4083
- }),
4084
- Effect5.tap(
4085
- ({ stream: captured, track: screenTrack }) => Effect5.sync(() => {
4086
- const audio = captured.getAudioTracks()[0];
4087
- for (const extra of captured.getAudioTracks().slice(1)) extra.stop();
4088
- ports.media.prepareLocalTrack("screen", screenTrack);
4089
- prepared = true;
4090
- this.#tracks.set("screen", screenTrack);
4091
- this.#screenAudioTrack = audio ?? null;
4092
- if (audio) {
4093
- ports.media.prepareLocalTrack("screen_audio", audio);
4094
- audio.addEventListener("ended", () => this.#handleScreenAudioEnded(audio, ports));
4095
- }
4096
- screenTrack.addEventListener("ended", () => this.#handleScreenEnded(screenTrack));
4097
- this.#screenEndedPending = false;
4098
- this.#publish();
4099
- })
4100
- ),
4101
- Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(true))),
4102
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "startScreenShare"))),
4103
- Effect5.asVoid,
4104
- Effect5.catch(
4105
- (cause) => (prepared ? this.#clearScreenAudio(ports.media) : Effect5.void).pipe(
4106
- Effect5.ignore,
4107
- Effect5.andThen(this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
4108
- )
4109
- )
4110
- ),
4111
- (stream, exit) => Effect5.sync(() => {
4112
- if (Exit2.isFailure(exit)) stopStream(stream);
4113
- })
4114
- );
4115
- });
4547
+ return this.#act("screen", true, options);
4548
+ }
4549
+ #stopScreen() {
4550
+ return this.#act("screen", false);
4551
+ }
4552
+ #pauseTracks(source) {
4553
+ const track = this.#track(source);
4554
+ if (track) track.enabled = false;
4555
+ if (source === "screen" && this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
4556
+ }
4557
+ #recordIntent(source, enabled, options) {
4558
+ const state = this.#sources[source];
4559
+ const desired = { enabled, revision: state.desired.revision + 1, options, previousEnabled: state.localEnabled };
4560
+ state.desired = desired;
4561
+ if (!enabled) {
4562
+ state.localEnabled = false;
4563
+ this.#pauseTracks(source);
4564
+ if (source === "screen") {
4565
+ this.#track("screen")?.stop();
4566
+ this.#screenAudioTrack?.stop();
4567
+ }
4568
+ const ports = this.#ports;
4569
+ if (ports) {
4570
+ ports.media.setLocalSourceIntent?.(source, false);
4571
+ const previous = state.pause;
4572
+ const next = this.#beginLocalPause(ports, source);
4573
+ state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
4574
+ for (const result of results) if (result.status === "rejected") throw result.reason;
4575
+ }) : next;
4576
+ void state.pause.catch(() => void 0);
4577
+ }
4578
+ }
4579
+ return desired;
4580
+ }
4581
+ #act(source, enabled, options) {
4582
+ return Effect5.suspend(() => {
4583
+ const desired = this.#recordIntent(source, enabled, options);
4584
+ return this.#serialize(source, this.#work(source)).pipe(
4585
+ Effect5.andThen(
4586
+ Effect5.suspend(() => {
4587
+ if (enabled && this.#store.getSnapshot().connection.status !== "live") return this.#mediaAction(true, Effect5.void);
4588
+ const result = this.#sources[source].result;
4589
+ return result.revision === desired.revision && result.status === "failed" ? Effect5.fail(result.cause) : Effect5.void;
4116
4590
  })
4117
4591
  )
4118
- )
4119
- ).pipe(
4120
- Effect5.mapError(normalizeClientError),
4592
+ );
4593
+ }).pipe(Effect5.mapError(normalizeMediaError));
4594
+ }
4595
+ #work(source) {
4596
+ return Effect5.gen({ self: this }, function* () {
4597
+ const state = this.#sources[source];
4598
+ while (state.result.revision !== state.desired.revision || state.result.status === "failed" && state.result.cleanup) {
4599
+ const intent = state.desired;
4600
+ yield* this.#runRevision(source, intent);
4601
+ if (state.desired.revision === intent.revision) return;
4602
+ }
4603
+ });
4604
+ }
4605
+ #runRevision(source, intent) {
4606
+ return Effect5.gen({ self: this }, function* () {
4607
+ const state = this.#sources[source];
4608
+ const pause = state.pause;
4609
+ state.pause = null;
4610
+ const paused = yield* Effect5.exit(pause ? foreign2(() => pause) : Effect5.void);
4611
+ if (state.desired.revision !== intent.revision) return;
4612
+ const ports = this.#ports;
4613
+ const cleanup = source === "screen" && !intent.enabled ? yield* Effect5.exit(this.#stopScreenLocally(ports)) : Exit2.succeed(void 0);
4614
+ if (state.desired.revision !== intent.revision) return;
4615
+ if (ports && this.#syncLive) yield* this.#executeRevision(ports, source, intent);
4616
+ else if (intent.enabled) state.result = { revision: intent.revision, status: "failed", cause: new V1SyncError("Media enable requires a live call", "not_live"), cleanup: !intent.previousEnabled };
4617
+ yield* paused;
4618
+ yield* cleanup;
4619
+ });
4620
+ }
4621
+ #executeRevision(ports, source, intent) {
4622
+ return Effect5.gen({ self: this }, function* () {
4623
+ const state = this.#sources[source];
4624
+ if (state.result.revision === intent.revision && state.result.status === "failed") {
4625
+ yield* this.#connection.runCommand((p) => this.#send(p, source, intent, false));
4626
+ if (state.result.revision === intent.revision && state.result.status === "failed") state.result.cleanup = false;
4627
+ return;
4628
+ }
4629
+ const operation = source === "screen" ? this.#prepareScreen(ports, intent) : this.#prepareDevice(ports, source, intent);
4630
+ const result = yield* Effect5.exit(this.#connection.runCommand(() => operation));
4631
+ if (state.desired.revision !== intent.revision) return;
4632
+ if (Exit2.isSuccess(result)) {
4633
+ state.result = { revision: intent.revision, status: "confirmed" };
4634
+ state.localEnabled = state.forcedRevision === intent.revision ? false : intent.enabled;
4635
+ } else {
4636
+ const cause = yield* Effect5.flip(result);
4637
+ yield* this.#failRevision(ports, source, intent, cause);
4638
+ }
4639
+ });
4640
+ }
4641
+ #failRevision(ports, source, intent, cause) {
4642
+ return Effect5.sync(() => {
4643
+ const state = this.#sources[source];
4644
+ if (!intent.enabled && ports.sync.getSnapshot().connection.phase !== "live") return;
4645
+ const previous = intent.previousEnabled;
4646
+ if (state.desired.revision === intent.revision) state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
4647
+ });
4648
+ }
4649
+ #prepareDevice(ports, source, intent) {
4650
+ if (!intent.enabled) return this.#send(ports, source, intent, false);
4651
+ let captured = null;
4652
+ let prepared = false;
4653
+ const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
4654
+ return this.#mediaAction(
4655
+ true,
4656
+ Effect5.suspend(() => {
4657
+ this.#assertCurrentIntent(source, intent.revision);
4658
+ ports.media.setLocalSourceIntent?.(source, true);
4659
+ const retire = source === "microphone" ? this.#retireEndedMicrophone(ports.media) : Effect5.void;
4660
+ const acquire = Effect5.suspend(() => {
4661
+ this.#assertActivePorts(ports, action);
4662
+ this.#assertCurrentIntent(source, intent.revision);
4663
+ return source === "camera" && !intent.previousEnabled || !this.#hasTrack(source) ? this.#captureForPublish(
4664
+ source,
4665
+ ports.media,
4666
+ (track) => Effect5.try({
4667
+ try: () => {
4668
+ captured = track;
4669
+ this.#assertActivePorts(ports, action);
4670
+ this.#assertCurrentIntent(source, intent.revision);
4671
+ this.#setTrack(source, track);
4672
+ ports.media.prepareLocalTrack(source, track);
4673
+ prepared = true;
4674
+ this.#publish();
4675
+ },
4676
+ catch: (cause) => cause
4677
+ })
4678
+ ) : Effect5.void;
4679
+ });
4680
+ return retire.pipe(Effect5.andThen(acquire), Effect5.andThen(this.#send(ports, source, intent, true)));
4681
+ })
4682
+ ).pipe(Effect5.catch((cause) => this.#rollbackCapture({ action, cause, fallback: Effect5.void, media: ports.media, permissionMessage: `${source} permission was denied`, prepared, source, track: captured })));
4683
+ }
4684
+ #stopScreenLocally(ports) {
4685
+ const track = this.#track("screen");
4686
+ const clearVideo = ports ? foreign2(() => ports.media.clearPreparedLocalTrack("screen")) : Effect5.void;
4687
+ const clear = ports ? this.#clearScreenAudio(ports.media).pipe(Effect5.matchEffect({ onFailure: (cause) => clearVideo.pipe(Effect5.andThen(Effect5.fail(cause))), onSuccess: () => clearVideo })) : Effect5.void;
4688
+ return clear.pipe(
4121
4689
  Effect5.ensuring(
4122
4690
  Effect5.sync(() => {
4123
- if (this.#pendingEnables.get("screen") === intentRevision) this.#pendingEnables.delete("screen");
4691
+ if (this.#track("screen") === track) this.#deleteTrack("screen");
4692
+ track?.stop();
4693
+ this.#publish();
4124
4694
  })
4125
4695
  )
4126
4696
  );
4127
4697
  }
4128
- #stopScreen() {
4129
- let intentRevision = 0;
4130
- let earlyPause = null;
4131
- const localPause = Effect5.suspend(() => {
4132
- intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
4133
- this.#intentRevisions.set("screen", intentRevision);
4134
- this.#ports?.media.setLocalSourceIntent?.("screen", false);
4135
- if (this.#ports) earlyPause = this.#beginLocalPause(this.#ports, "screen");
4136
- return Effect5.void;
4698
+ #prepareScreen(ports, intent) {
4699
+ if (!intent.enabled) return this.#send(ports, "screen", intent, false);
4700
+ return this.#mediaAction(
4701
+ true,
4702
+ Effect5.suspend(() => {
4703
+ this.#assertCurrentIntent("screen", intent.revision);
4704
+ ports.media.setLocalSourceIntent?.("screen", true);
4705
+ if (this.#hasTrack("screen") && !intent.options) return this.#send(ports, "screen", intent, true);
4706
+ let track = null;
4707
+ let prepared = false;
4708
+ return this.#captureStream(
4709
+ () => this.#selection.getDisplayMedia(displayMediaConstraints(intent.options)),
4710
+ (stream) => Effect5.gen({ self: this }, function* () {
4711
+ this.#assertActivePorts(ports, "startScreenShare");
4712
+ this.#assertCurrentIntent("screen", intent.revision);
4713
+ track = requireDisplayVideoTrack(stream);
4714
+ const previous = this.#track("screen");
4715
+ if (previous) {
4716
+ yield* this.#send(ports, "screen", intent, false);
4717
+ yield* this.#clearScreenAudio(ports.media);
4718
+ yield* foreign2(() => ports.media.clearPreparedLocalTrack("screen"));
4719
+ this.#deleteTrack("screen");
4720
+ previous.stop();
4721
+ }
4722
+ this.#assertActivePorts(ports, "startScreenShare");
4723
+ this.#assertCurrentIntent("screen", intent.revision);
4724
+ const audio = stream.getAudioTracks()[0];
4725
+ for (const extra of stream.getAudioTracks().slice(1)) extra.stop();
4726
+ ports.media.prepareLocalTrack("screen", track);
4727
+ prepared = true;
4728
+ this.#setTrack("screen", track);
4729
+ this.#screenAudioTrack = audio ?? null;
4730
+ if (audio) {
4731
+ ports.media.prepareLocalTrack("screen_audio", audio);
4732
+ audio.addEventListener("ended", () => this.#handleScreenAudioEnded(audio, ports));
4733
+ }
4734
+ track.addEventListener("ended", () => this.#handleScreenEnded(track ?? void 0));
4735
+ this.#publish();
4736
+ yield* this.#send(ports, "screen", intent, true);
4737
+ }).pipe(
4738
+ Effect5.catch(
4739
+ (cause) => (prepared ? this.#clearScreenAudio(ports.media) : Effect5.void).pipe(
4740
+ Effect5.ignore,
4741
+ Effect5.andThen(this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
4742
+ )
4743
+ )
4744
+ ),
4745
+ (cause) => captureError(cause, "startScreenShare", "Screen sharing permission was denied")
4746
+ );
4747
+ })
4748
+ );
4749
+ }
4750
+ #assertCurrentIntent(source, revision) {
4751
+ if (this.#sources[source].desired.revision !== revision) throw cancelledCaptureError();
4752
+ }
4753
+ #resetObservations() {
4754
+ for (const source of MEDIA_SOURCES) {
4755
+ const state = this.#sources[source];
4756
+ state.result = { revision: state.desired.revision, status: "confirmed" };
4757
+ state.pause = null;
4758
+ state.serverSequence = -1;
4759
+ state.forcedRevision = null;
4760
+ }
4761
+ }
4762
+ #pauseInitialTracks() {
4763
+ for (const source of ["microphone", "camera"]) {
4764
+ const enabled = this.#sources[source].desired.enabled;
4765
+ const track = this.#track(source);
4766
+ if (track && !enabled) track.enabled = false;
4767
+ this.#sources[source].localEnabled = !!track && enabled;
4768
+ }
4769
+ }
4770
+ startInitial(media, stream) {
4771
+ const publish = foreign2(async () => {
4772
+ const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
4773
+ const tracks = stream.getTracks().filter((track) => {
4774
+ const source = track.kind === "audio" ? "microphone" : "camera";
4775
+ const state = this.#sources[source];
4776
+ track.enabled = state.desired.enabled;
4777
+ if (!state.desired.enabled) {
4778
+ this.#deleteTrack(source);
4779
+ if (source === "microphone") track.stop();
4780
+ }
4781
+ return state.desired.enabled;
4782
+ });
4783
+ for (const source of ["microphone", "camera"]) this.#sources[source].sentRevision = revisions[source];
4784
+ await media.start(streamFromTracks(tracks));
4785
+ for (const source of ["microphone", "camera"]) {
4786
+ const state = this.#sources[source];
4787
+ if (state.desired.revision === revisions[source]) state.result = { revision: revisions[source], status: "confirmed" };
4788
+ }
4137
4789
  });
4138
- return localPause.pipe(
4139
- Effect5.andThen(
4140
- this.#serialize(
4141
- "screen",
4142
- this.#connection.runCommand(
4143
- (ports) => Effect5.suspend(() => {
4144
- const track = this.#tracks.get("screen");
4145
- let paused = false;
4146
- return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, "screen", false)).pipe(
4147
- Effect5.tap(
4148
- () => Effect5.sync(() => {
4149
- paused = true;
4150
- })
4151
- ),
4152
- Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(false))),
4153
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "stopScreenShare"))),
4154
- Effect5.andThen(this.#clearScreenAudio(ports.media)),
4155
- Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
4156
- Effect5.tap(
4157
- () => Effect5.sync(() => {
4158
- this.#assertActivePorts(ports, "stopScreenShare");
4159
- if (this.#tracks.get("screen") === track) this.#tracks.delete("screen");
4160
- track?.stop();
4161
- this.#screenEndedPending = false;
4162
- this.#publish();
4163
- })
4164
- ),
4165
- Effect5.asVoid,
4166
- Effect5.catch(
4167
- (cause) => paused && !isSyncRejection(cause) || this.#intentRevisions.get("screen") !== intentRevision ? Effect5.fail(cause) : Effect5.sync(() => ports.media.setLocalSourceIntent?.("screen", true)).pipe(
4168
- Effect5.andThen(this.#localTarget(ports, "screen", true)),
4169
- Effect5.catch(() => Effect5.void),
4170
- Effect5.andThen(Effect5.fail(cause))
4171
- )
4172
- )
4173
- );
4174
- })
4175
- )
4176
- )
4177
- )
4178
- ).pipe(Effect5.mapError(normalizeClientError));
4790
+ return this.#serialize("microphone", this.#serialize("camera", publish));
4791
+ }
4792
+ #send(ports, source, intent, enabled) {
4793
+ return this.#mediaAction(
4794
+ enabled,
4795
+ foreign2(async () => {
4796
+ this.#assertCurrentIntent(source, intent.revision);
4797
+ this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
4798
+ const state = this.#sources[source];
4799
+ state.sentRevision = intent.revision;
4800
+ await (source === "microphone" ? ports.sync.setMicrophoneEnabled(enabled) : source === "camera" ? ports.sync.setCameraEnabled(enabled) : ports.sync.setScreenShareEnabled(enabled));
4801
+ this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
4802
+ })
4803
+ );
4804
+ }
4805
+ #reconcileIntents() {
4806
+ const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
4807
+ const resumed = live && !this.#syncLive;
4808
+ this.#syncLive = live;
4809
+ if (!resumed) return;
4810
+ for (const source of MEDIA_SOURCES) this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
4179
4811
  }
4180
4812
  #clearScreenAudio(media) {
4181
4813
  return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
@@ -4215,26 +4847,241 @@ var MediaControllerRuntime = class {
4215
4847
  )
4216
4848
  );
4217
4849
  }
4218
- #captureSource(source) {
4219
- return foreign2(() => this.#selection.getUserMedia({ audio: source === "microphone", video: source === "camera" })).pipe(
4220
- Effect5.mapError((cause) => captureError(cause, source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled", `${source} permission was denied`)),
4221
- Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
4222
- Effect5.flatMap(
4223
- (stream) => Effect5.try({
4224
- try: () => {
4225
- const track = stream.getTracks().find((candidate) => candidate.kind === (source === "microphone" ? "audio" : "video"));
4226
- if (!track) {
4227
- stopStream(stream);
4228
- throw new TypeError(`Media capture did not return a ${source} track`);
4229
- }
4230
- for (const candidate of stream.getTracks()) if (candidate !== track) candidate.stop();
4231
- return track;
4232
- },
4233
- catch: (cause) => cause
4850
+ #transitionCamera(next) {
4851
+ const previous = this.#camera;
4852
+ const sameOwner = "owner" in previous && "owner" in next && previous.owner === next.owner;
4853
+ if (previous.status === "live") previous.track.removeEventListener("ended", previous.onEnded);
4854
+ this.#camera = next;
4855
+ if (!sameOwner && "owner" in previous) {
4856
+ previous.owner.cancel();
4857
+ if (previous.status === "live") previous.track.stop();
4858
+ }
4859
+ if (next.status === "live") next.track.addEventListener("ended", next.onEnded, { once: true });
4860
+ this.#publish();
4861
+ this.#retireCameraPublication(previous, next);
4862
+ }
4863
+ #retireCameraPublication(previous, next) {
4864
+ if (previous.status !== "live" || previous.publication === null || previous.publication === "initial") return;
4865
+ if (next.status === "live" && next.track === previous.track && next.publication !== null) return;
4866
+ const media = previous.publication;
4867
+ void Promise.resolve().then(() => media.clearPreparedLocalTrack("camera", { stopTrack: false })).then(() => this.#enablePrivateCamera(previous.owner)).catch((cause) => Effect5.runSync(Effect5.logWarning("Camera publication cleanup failed", cause)));
4868
+ }
4869
+ #enablePrivateCamera(owner) {
4870
+ const state = this.#camera;
4871
+ if (state.status === "live" && state.owner === owner && state.publication === null) state.track.enabled = true;
4872
+ }
4873
+ #track(source) {
4874
+ if (source !== "camera") return this.#tracks.get(source);
4875
+ return this.#camera.status === "live" && this.#camera.publication !== null && this.#camera.track.readyState === "live" ? this.#camera.track : void 0;
4876
+ }
4877
+ #hasTrack(source) {
4878
+ return this.#track(source) !== void 0;
4879
+ }
4880
+ #localTracks() {
4881
+ const tracks = new Map(this.#tracks);
4882
+ const camera = this.#track("camera");
4883
+ if (camera) tracks.set("camera", camera);
4884
+ return tracks;
4885
+ }
4886
+ #setTrack(source, track) {
4887
+ if (source !== "camera") {
4888
+ this.#tracks.set(source, track);
4889
+ return;
4890
+ }
4891
+ const state = this.#camera;
4892
+ if (!("owner" in state)) throw cancelledCaptureError();
4893
+ if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
4894
+ const onEnded = () => {
4895
+ if (this.#camera.status === "live" && this.#camera.track === track) this.#transitionCamera({ status: "ended" });
4896
+ };
4897
+ this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
4898
+ }
4899
+ #deleteTrack(source) {
4900
+ if (source !== "camera") {
4901
+ this.#tracks.delete(source);
4902
+ return;
4903
+ }
4904
+ const state = this.#camera;
4905
+ if (state.status !== "live") return;
4906
+ if (state.owner.references > 0 && state.track.readyState === "live") {
4907
+ this.#transitionCamera({ ...state, publication: null });
4908
+ } else this.#transitionCamera({ status: "ended" });
4909
+ }
4910
+ #clearTracks() {
4911
+ this.#tracks.clear();
4912
+ this.#transitionCamera({ status: "idle" });
4913
+ }
4914
+ #cameraSource(purpose, acquire = (signal) => this.#captureSource("camera", signal)) {
4915
+ return Effect5.suspend(() => {
4916
+ this.#discardEndedCamera();
4917
+ const current = this.#camera;
4918
+ if (current.status === "live") return this.#liveCameraSource(current, purpose);
4919
+ if (current.status === "capturing") return this.#borrowCamera(current.owner);
4920
+ return this.#borrowCamera(this.#beginCameraCapture(acquire));
4921
+ });
4922
+ }
4923
+ #discardEndedCamera() {
4924
+ if (this.#camera.status === "live" && this.#camera.track.readyState !== "live") this.#transitionCamera({ status: "ended" });
4925
+ }
4926
+ #retireEndedMicrophone(media) {
4927
+ const track = this.#tracks.get("microphone");
4928
+ if (track?.readyState !== "ended") return Effect5.void;
4929
+ return foreign2(() => media.clearPreparedLocalTrack("microphone", { stopTrack: false })).pipe(
4930
+ Effect5.andThen(
4931
+ Effect5.sync(() => {
4932
+ if (this.#tracks.get("microphone") === track) this.#tracks.delete("microphone");
4933
+ })
4934
+ )
4935
+ );
4936
+ }
4937
+ #liveCameraSource(current, purpose) {
4938
+ if (current.publication !== null && !current.track.enabled && purpose === "preview") {
4939
+ const track = current.track.clone();
4940
+ track.enabled = true;
4941
+ const release = () => {
4942
+ this.#pendingCaptures.delete(release);
4943
+ track.stop();
4944
+ };
4945
+ this.#pendingCaptures.add(release);
4946
+ return Effect5.succeed({ track, release });
4947
+ }
4948
+ return this.#borrowCamera(current.owner);
4949
+ }
4950
+ #beginCameraCapture(acquire) {
4951
+ const abort = new AbortController();
4952
+ const owner = {
4953
+ result: Deferred2.makeUnsafe(),
4954
+ references: 0,
4955
+ cancel: () => {
4956
+ abort.abort();
4957
+ Deferred2.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
4958
+ }
4959
+ };
4960
+ this.#transitionCamera({ status: "capturing", owner });
4961
+ this.#fork(
4962
+ acquire(abort.signal).pipe(
4963
+ Effect5.tap(
4964
+ (track) => Effect5.try({
4965
+ try: () => {
4966
+ const state = this.#camera;
4967
+ if (abort.signal.aborted || !("owner" in state) || state.owner !== owner) {
4968
+ track.stop();
4969
+ throw cancelledCaptureError();
4970
+ }
4971
+ const onEnded = () => {
4972
+ if (this.#camera.status === "live" && this.#camera.owner === owner) this.#transitionCamera({ status: "ended" });
4973
+ };
4974
+ this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
4975
+ if (track.readyState !== "live") {
4976
+ this.#transitionCamera({ status: "ended" });
4977
+ throw cancelledCaptureError();
4978
+ }
4979
+ },
4980
+ catch: (cause) => cause
4981
+ })
4982
+ ),
4983
+ Effect5.onExit((exit) => Deferred2.done(owner.result, exit)),
4984
+ Effect5.ignore
4985
+ )
4986
+ );
4987
+ return owner;
4988
+ }
4989
+ #borrowCamera(owner) {
4990
+ owner.references++;
4991
+ let released = false;
4992
+ const release = () => {
4993
+ if (released) return;
4994
+ released = true;
4995
+ owner.references--;
4996
+ const current = this.#camera;
4997
+ if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
4998
+ };
4999
+ return Deferred2.await(owner.result).pipe(
5000
+ Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
5001
+ Effect5.onExit(
5002
+ (exit) => Effect5.sync(() => {
5003
+ if (Exit2.isFailure(exit)) release();
4234
5004
  })
4235
5005
  )
4236
5006
  );
4237
5007
  }
5008
+ #useCamera(use, acquire) {
5009
+ return Effect5.suspend(() => {
5010
+ let preview;
5011
+ return this.#cameraSource("publication", acquire).pipe(
5012
+ Effect5.tap(
5013
+ (value) => Effect5.sync(() => {
5014
+ preview = value;
5015
+ })
5016
+ ),
5017
+ Effect5.flatMap((value) => use(value.track)),
5018
+ Effect5.ensuring(Effect5.sync(() => preview?.release()))
5019
+ );
5020
+ });
5021
+ }
5022
+ #captureForPublish(source, media, prepare) {
5023
+ return source === "camera" ? foreign2(() => media.clearPreparedLocalTrack("camera", { stopTrack: false })).pipe(Effect5.retry({ times: 1 }), Effect5.andThen(this.#useCamera(prepare))) : this.#captureSource(source).pipe(Effect5.flatMap(prepare));
5024
+ }
5025
+ #captureStream(operation, use, onCaptureError = (cause) => cause, signal) {
5026
+ return Effect5.suspend(() => {
5027
+ let cancelled = false;
5028
+ let captured = null;
5029
+ const cancel = () => {
5030
+ cancelled = true;
5031
+ stopStream(captured);
5032
+ };
5033
+ this.#pendingCaptures.add(cancel);
5034
+ signal?.addEventListener("abort", cancel, { once: true });
5035
+ if (signal?.aborted) cancel();
5036
+ return foreign2(async () => {
5037
+ if (cancelled) throw cancelledCaptureError();
5038
+ const stream = await operation();
5039
+ captured = stream;
5040
+ if (cancelled) {
5041
+ stopStream(stream);
5042
+ throw cancelledCaptureError();
5043
+ }
5044
+ return stream;
5045
+ }).pipe(
5046
+ Effect5.mapError(onCaptureError),
5047
+ Effect5.flatMap(use),
5048
+ Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
5049
+ Effect5.onExit(
5050
+ (exit) => Effect5.sync(() => {
5051
+ this.#pendingCaptures.delete(cancel);
5052
+ signal?.removeEventListener("abort", cancel);
5053
+ if (Exit2.isFailure(exit)) cancel();
5054
+ })
5055
+ )
5056
+ );
5057
+ });
5058
+ }
5059
+ #cancelCaptures() {
5060
+ this.#transitionCamera({ status: "ended" });
5061
+ for (const cancel of this.#pendingCaptures) cancel();
5062
+ this.#pendingCaptures.clear();
5063
+ }
5064
+ #captureSource(source, signal) {
5065
+ return this.#captureStream(
5066
+ () => this.#selection.getUserMedia({ audio: source === "microphone", video: source === "camera" }),
5067
+ (stream) => this.#refreshDevices().pipe(
5068
+ Effect5.ignore,
5069
+ Effect5.andThen(
5070
+ Effect5.try({
5071
+ try: () => {
5072
+ const track = stream.getTracks().find((candidate) => candidate.kind === (source === "microphone" ? "audio" : "video"));
5073
+ if (!track) throw new TypeError(`Media capture did not return a ${source} track`);
5074
+ for (const candidate of stream.getTracks()) if (candidate !== track) candidate.stop();
5075
+ return track;
5076
+ },
5077
+ catch: (cause) => cause
5078
+ })
5079
+ )
5080
+ ),
5081
+ (cause) => captureError(cause, source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled", `${source} permission was denied`),
5082
+ signal
5083
+ );
5084
+ }
4238
5085
  #refreshDevices() {
4239
5086
  return foreign2(() => this.#selection.enumerateDevices()).pipe(
4240
5087
  Effect5.tap(
@@ -4248,11 +5095,15 @@ var MediaControllerRuntime = class {
4248
5095
  );
4249
5096
  }
4250
5097
  #discardCaptured(source, track, media, prepared) {
5098
+ if (source === "camera")
5099
+ return Effect5.sync(() => {
5100
+ if (this.#track(source) === track) this.#deleteTrack(source);
5101
+ });
4251
5102
  const clear = prepared ? foreign2(() => media.clearPreparedLocalTrack(source)).pipe(Effect5.ignore) : Effect5.void;
4252
5103
  return clear.pipe(
4253
5104
  Effect5.andThen(
4254
5105
  Effect5.sync(() => {
4255
- if (this.#tracks.get(source) === track) this.#tracks.delete(source);
5106
+ if (this.#track(source) === track) this.#deleteTrack(source);
4256
5107
  track.stop();
4257
5108
  })
4258
5109
  )
@@ -4260,7 +5111,6 @@ var MediaControllerRuntime = class {
4260
5111
  }
4261
5112
  #rollbackCapture(input) {
4262
5113
  if (isAccessInvalid2(input.cause)) return Effect5.fail(input.cause);
4263
- input.restoreIntent?.();
4264
5114
  const rollback = input.track ? this.#discardCaptured(input.source, input.track, input.media, input.prepared) : input.fallback;
4265
5115
  const captureWasUnavailable = input.action === "startScreenShare" && !input.prepared && input.track === null;
4266
5116
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
@@ -4270,21 +5120,33 @@ var MediaControllerRuntime = class {
4270
5120
  if (this.#connection.getSnapshot().state === "live" && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
4271
5121
  throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
4272
5122
  }
5123
+ #pausePublication(ports, source, revision) {
5124
+ const state = this.#sources[source];
5125
+ if (state.desired.revision !== revision || ports.sync.getSnapshot().control?.status === "ended") return;
5126
+ state.forcedRevision = revision;
5127
+ state.localEnabled = false;
5128
+ this.#pauseTracks(source);
5129
+ ports.media.setLocalSourceIntent?.(source, false);
5130
+ if (ports.media.closeForcedLocalPublication) this.#fork(foreign2(() => ports.media.closeForcedLocalPublication(source)).pipe(Effect5.ignore));
5131
+ this.#publish();
5132
+ }
4273
5133
  #bind(ports) {
4274
- this.#unsubscribeMedia?.();
4275
- this.#unsubscribeRequests?.();
4276
- this.#unsubscribeSync?.();
5134
+ if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
5135
+ for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
4277
5136
  this.#unsubscribeMedia = null;
4278
5137
  this.#unsubscribeRequests = null;
5138
+ this.#unsubscribePause = null;
4279
5139
  this.#unsubscribeSync = null;
4280
- if (this.#ports?.media !== ports?.media) this.#observedPublicationIds.clear();
4281
5140
  this.#ports = ports;
5141
+ this.#syncLive = false;
5142
+ this.#bindCamera(ports);
4282
5143
  if (!ports) {
5144
+ this.#resetObservations();
5145
+ this.#cancelCaptures();
4283
5146
  this.#screenAudioTrack?.stop();
4284
5147
  this.#screenAudioTrack = null;
4285
5148
  for (const track of this.#tracks.values()) track.stop();
4286
- this.#tracks.clear();
4287
- this.#screenEndedPending = false;
5149
+ this.#clearTracks();
4288
5150
  this.#clearRequests();
4289
5151
  this.#publish();
4290
5152
  return;
@@ -4293,56 +5155,63 @@ var MediaControllerRuntime = class {
4293
5155
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
4294
5156
  this.#publish();
4295
5157
  });
5158
+ this.#unsubscribePause = this.#subscribePauses(ports);
4296
5159
  this.#unsubscribeRequests = ports.sync.onDirectedRequest((request) => this.#request(request));
4297
- this.#unsubscribeSync = ports.sync.subscribe((snapshot) => {
4298
- this.#removeRequestsFromMissingParticipants(snapshot);
4299
- this.#applyRemoteMediaState(ports, snapshot);
4300
- this.#applyForcedMediaState(ports, snapshot);
4301
- });
5160
+ this.#unsubscribeSync = this.#subscribeSync(ports);
4302
5161
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
4303
- this.#applyForcedMediaState(ports, ports.sync.getSnapshot());
4304
5162
  this.#publish();
4305
5163
  }
4306
- #applyRemoteMediaState(ports, snapshot) {
4307
- for (const publication of ports.media.getSnapshot().remoteTracks) {
4308
- publication.track.enabled = snapshot.media?.items.some((item) => item.participantId === publication.participantId && item.source === (publication.source === "screen_audio" ? "screen" : publication.source) && item.enabled) ?? false;
4309
- }
5164
+ #subscribePauses(ports) {
5165
+ return ports.sync.onMediaPause?.((pause) => {
5166
+ const state = this.#sources[pause.source];
5167
+ if (pause.revision <= state.serverSequence) return;
5168
+ state.serverSequence = pause.revision;
5169
+ this.#pausePublication(ports, pause.source, state.sentRevision);
5170
+ }) ?? null;
4310
5171
  }
4311
- #applyForcedMediaState(ports, snapshot) {
4312
- if (!canApplyForcedMediaState(snapshot)) return;
4313
- for (const source of MEDIA_SOURCES) {
4314
- const projected = snapshot.media.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
4315
- if (projected?.publicationId) {
4316
- this.#observedPublicationIds.set(source, projected.publicationId);
4317
- continue;
4318
- }
4319
- const previousPublicationId = this.#observedPublicationIds.get(source);
4320
- if (!this.#observedPublicationIds.delete(source)) continue;
4321
- const local = ports.media.getSnapshot().localTracks.find((publication) => publication.source === source);
4322
- if (local && local.publicationId !== previousPublicationId) continue;
4323
- this.#pauseForcedSource(ports, source);
5172
+ #rememberPublications(snapshot) {
5173
+ const publications = /* @__PURE__ */ new Map();
5174
+ const own = (snapshot.media?.items ?? []).filter((item) => item.participantId === snapshot.participantId && item.enabled && item.publicationId !== null);
5175
+ for (const item of own) {
5176
+ if (item.source === "screen_audio" || item.publicationId === null) continue;
5177
+ const state = this.#sources[item.source];
5178
+ if (state.localEnabled && state.sentRevision === state.desired.revision) publications.set(item.source, { revision: state.sentRevision, publicationId: item.publicationId });
4324
5179
  }
5180
+ return publications;
4325
5181
  }
4326
- #pauseForcedSource(ports, source) {
4327
- const pendingEnable = this.#hasPendingEnable(source);
4328
- if (!pendingEnable) {
4329
- this.#clearForcedIntent(source);
5182
+ #pauseLostPublications(ports, snapshot, publications) {
5183
+ for (const [source, previous] of publications) {
5184
+ const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5185
+ if (!current || !current.enabled && (current.publicationId === previous.publicationId || current.publicationId === null)) this.#pausePublication(ports, source, previous.revision);
4330
5186
  }
4331
- const track = this.#tracks.get(source);
4332
- if (track) track.enabled = false;
4333
- ports.media.setLocalSourceIntent?.(source, false);
4334
- if (ports.media.closeForcedLocalPublication) void ports.media.closeForcedLocalPublication(source).catch(() => void 0);
4335
- if (pendingEnable) ports.media.setLocalSourceIntent?.(source, true);
4336
- this.#publish();
4337
5187
  }
4338
- #clearForcedIntent(source) {
4339
- this.#intentRevisions.set(source, (this.#intentRevisions.get(source) ?? 0) + 1);
4340
- if (source !== "screen") this.#intent[source] = false;
4341
- else if (this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
5188
+ #subscribeSync(ports) {
5189
+ let recovering = /* @__PURE__ */ new Map();
5190
+ let lastLive = ports.sync.getSnapshot();
5191
+ let wasLive = lastLive.connection.phase === "live";
5192
+ return ports.sync.subscribe((snapshot) => {
5193
+ const live = snapshot.connection.phase === "live";
5194
+ if (wasLive && !live) recovering = this.#rememberPublications(lastLive);
5195
+ if (live && !wasLive) {
5196
+ this.#pauseLostPublications(ports, snapshot, recovering);
5197
+ recovering.clear();
5198
+ }
5199
+ if (live) lastLive = snapshot;
5200
+ wasLive = live;
5201
+ this.#removeRequestsFromMissingParticipants(snapshot);
5202
+ this.#applyRemoteMediaState(ports, snapshot);
5203
+ this.#reconcileIntents();
5204
+ });
5205
+ }
5206
+ #bindCamera(ports) {
5207
+ if (!ports) return;
5208
+ const state = this.#camera;
5209
+ if (state.status === "live" && state.publication === "initial") this.#transitionCamera({ ...state, publication: ports.media });
4342
5210
  }
4343
- #hasPendingEnable(source) {
4344
- const revision = this.#pendingEnables.get(source);
4345
- return revision !== void 0 && revision === this.#intentRevisions.get(source);
5211
+ #applyRemoteMediaState(ports, snapshot) {
5212
+ for (const publication of ports.media.getSnapshot().remoteTracks) {
5213
+ publication.track.enabled = snapshot.media?.items.some((item) => item.participantId === publication.participantId && item.source === (publication.source === "screen_audio" ? "screen" : publication.source) && item.enabled) ?? false;
5214
+ }
4346
5215
  }
4347
5216
  #request(request) {
4348
5217
  if (request.expires_at_ms <= this.#now()) return;
@@ -4387,10 +5256,7 @@ var MediaControllerRuntime = class {
4387
5256
  this.#requests = Object.freeze([]);
4388
5257
  }
4389
5258
  #handleConnectionChange() {
4390
- if (this.#screenEndedPending && this.#connection.getSnapshot().state === "live") {
4391
- this.#screenEndedPending = false;
4392
- this.#fork(this.#stopScreen().pipe(Effect5.ignore));
4393
- }
5259
+ this.#reconcileIntents();
4394
5260
  this.#publish();
4395
5261
  }
4396
5262
  #handleScreenAudioEnded(track, ports) {
@@ -4404,12 +5270,8 @@ var MediaControllerRuntime = class {
4404
5270
  ).pipe(Effect5.ignore)
4405
5271
  );
4406
5272
  }
4407
- #handleScreenEnded(track = this.#tracks.get("screen")) {
4408
- if (!track || this.#tracks.get("screen") !== track) return;
4409
- if (this.#connection.getSnapshot().state === "reconnecting") {
4410
- this.#screenEndedPending = true;
4411
- return;
4412
- }
5273
+ #handleScreenEnded(track = this.#track("screen")) {
5274
+ if (!track || this.#track("screen") !== track) return;
4413
5275
  this.#fork(this.#stopScreen().pipe(Effect5.ignore));
4414
5276
  }
4415
5277
  #publish() {
@@ -4417,9 +5279,9 @@ var MediaControllerRuntime = class {
4417
5279
  const snapshot = this.#ports?.media.getSnapshot();
4418
5280
  const state = this.#connection.getSnapshot().state;
4419
5281
  const local = Object.freeze({
4420
- microphone: localMedia("microphone", this.#tracks, snapshot, this.#intent.microphone, state, this.#pendingSources.has("microphone")),
4421
- camera: localMedia("camera", this.#tracks, snapshot, this.#intent.camera, state, this.#pendingSources.has("camera")),
4422
- screen: localMedia("screen", this.#tracks, snapshot, false, state, this.#pendingSources.has("screen"))
5282
+ microphone: localMedia("microphone", this.#localTracks(), snapshot, this.#sources.microphone.localEnabled, state, this.#pendingSources.has("microphone")),
5283
+ camera: localMedia("camera", this.#localTracks(), snapshot, this.#sources.camera.localEnabled, state, this.#pendingSources.has("camera")),
5284
+ screen: localMedia("screen", this.#localTracks(), snapshot, false, state, this.#pendingSources.has("screen"))
4423
5285
  });
4424
5286
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
4425
5287
  const media = Object.freeze({
@@ -4436,6 +5298,9 @@ var MediaControllerRuntime = class {
4436
5298
  if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
4437
5299
  }
4438
5300
  };
5301
+ function cancelledCaptureError() {
5302
+ return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
5303
+ }
4439
5304
  function foreign2(operation) {
4440
5305
  return Effect5.tryPromise({ try: operation, catch: (cause) => cause });
4441
5306
  }
@@ -4535,12 +5400,6 @@ function isPermissionDenied(cause) {
4535
5400
  function isAccessInvalid2(cause) {
4536
5401
  return typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "access.invalid" || cause.code === "invalid_access");
4537
5402
  }
4538
- function isSyncRejection(cause) {
4539
- return isAccessInvalid2(cause) || cause instanceof V1SyncError && cause.code === "terminal_failure";
4540
- }
4541
- function canApplyForcedMediaState(snapshot) {
4542
- return !!snapshot.media && !!snapshot.participantId && snapshot.control?.status !== "ended" && snapshot.connection.phase !== "terminal";
4543
- }
4544
5403
 
4545
5404
  // src/space-client/offline-actions.ts
4546
5405
  import { Context as Context7, Effect as Effect6, Layer as Layer7 } from "effect";
@@ -4911,6 +5770,7 @@ var ParticipantsControllerRuntime = class {
4911
5770
  publish();
4912
5771
  }
4913
5772
  #publish(snapshot, collaborationCapabilities, media) {
5773
+ if (snapshot && !controlFor(snapshot)) return;
4914
5774
  const audioParticipants = new Set(media?.remoteTracks.filter((track) => track.source === "screen_audio" && track.track.readyState === "live").map((track) => track.participantId));
4915
5775
  if (snapshot?.participantId && media?.localTracks.some((track) => track.source === "screen_audio" && track.enabled && track.track.readyState === "live")) audioParticipants.add(snapshot.participantId);
4916
5776
  const projected = participantsFor(snapshot, collaborationCapabilities);
@@ -5245,7 +6105,7 @@ function foreign5(operation) {
5245
6105
 
5246
6106
  // src/space-client/whiteboard-controller.ts
5247
6107
  import { Context as Context11, Effect as Effect10, Layer as Layer11 } from "effect";
5248
- var EMPTY_SUMMARY = Object.freeze({ status: "unsubscribed", sceneId: null, revision: null, capabilities: Object.freeze([]), canDraw: false, canClear: false, presenting: false, error: null });
6108
+ var EMPTY_SUMMARY = Object.freeze({ status: "unsubscribed", sceneId: null, revision: null, capabilities: Object.freeze([]), canDraw: false, canClear: false, drawPolicy: null, drawPermissions: Object.freeze([]), presenting: false, error: null });
5249
6109
  var WhiteboardControllerService = class extends Context11.Service()("@chalk/client/WhiteboardController") {
5250
6110
  };
5251
6111
  var makeWhiteboardController = (connection, store, create) => Effect10.gen(function* () {
@@ -5268,13 +6128,22 @@ var WhiteboardControllerRuntime = class {
5268
6128
  #connected = false;
5269
6129
  #summary = EMPTY_SUMMARY;
5270
6130
  #unsubscribe = null;
6131
+ #unsubscribeRoster = null;
5271
6132
  constructor(connection, store, transport) {
5272
6133
  this.#store = store;
5273
6134
  this.#transport = transport;
6135
+ let roster = new Set(store.getSnapshot().participants.roster.map((person) => person.participantId));
6136
+ this.#unsubscribeRoster = store.subscribe(() => {
6137
+ const next = new Set(store.getSnapshot().participants.roster.map((person) => person.participantId));
6138
+ for (const id of roster) if (!next.has(id)) transport?.forgetParticipant?.(id);
6139
+ roster = next;
6140
+ });
5274
6141
  this.#unsubscribe = connection.subscribePorts((ports) => this.#bind(ports));
5275
6142
  }
5276
6143
  transport = () => this.#transport;
5277
6144
  dispose() {
6145
+ this.#unsubscribeRoster?.();
6146
+ this.#unsubscribeRoster = null;
5278
6147
  this.#unsubscribe?.();
5279
6148
  this.#unsubscribe = null;
5280
6149
  this.#bind(null);
@@ -5288,6 +6157,8 @@ var WhiteboardControllerRuntime = class {
5288
6157
  capabilities: Object.freeze([...summary.capabilities]),
5289
6158
  canDraw: summary.canDraw,
5290
6159
  canClear: summary.canClear,
6160
+ drawPolicy: summary.drawPolicy,
6161
+ drawPermissions: Object.freeze(summary.drawPermissions.map((permission) => Object.freeze({ ...permission }))),
5291
6162
  presenting: summary.presenting,
5292
6163
  error: summary.error ? Object.freeze({ ...summary.error }) : null
5293
6164
  });
@@ -5305,14 +6176,21 @@ var WhiteboardControllerRuntime = class {
5305
6176
  this.#publish();
5306
6177
  }
5307
6178
  #publish() {
5308
- const slice = Object.freeze({ open: this.#summary.status !== "unsubscribed", engine: Object.freeze({ status: this.#summary.status, sceneId: this.#summary.sceneId, revision: this.#summary.revision, presenting: this.#summary.presenting, error: this.#summary.error }) });
6179
+ const slice = Object.freeze({
6180
+ open: this.#summary.status !== "unsubscribed",
6181
+ engine: Object.freeze({ status: this.#summary.status, sceneId: this.#summary.sceneId, revision: this.#summary.revision, presenting: this.#summary.presenting, error: this.#summary.error }),
6182
+ access: Object.freeze({ canDraw: this.#summary.canDraw, policy: this.#summary.drawPolicy, permissions: this.#summary.drawPermissions })
6183
+ });
5309
6184
  const current = this.#store.getSnapshot().whiteboard;
5310
6185
  if (sameWhiteboard(current, slice)) return;
5311
6186
  this.#store.updateWhiteboard(slice);
5312
6187
  }
5313
6188
  };
5314
6189
  function sameWhiteboard(left, right) {
5315
- return left.open === right.open && left.engine.status === right.engine.status && left.engine.sceneId === right.engine.sceneId && left.engine.revision === right.engine.revision && left.engine.presenting === right.engine.presenting && sameError2(left.engine.error, right.engine.error);
6190
+ return left.open === right.open && left.engine.status === right.engine.status && left.engine.sceneId === right.engine.sceneId && left.engine.revision === right.engine.revision && left.engine.presenting === right.engine.presenting && sameError2(left.engine.error, right.engine.error) && sameAccess(left.access, right.access);
6191
+ }
6192
+ function sameAccess(left, right) {
6193
+ return left.canDraw === right.canDraw && left.policy === right.policy && left.permissions.length === right.permissions.length && left.permissions.every((permission, index) => permission.participantId === right.permissions[index]?.participantId && permission.canDraw === right.permissions[index]?.canDraw);
5316
6194
  }
5317
6195
  function sameError2(left, right) {
5318
6196
  if (left === right) return true;
@@ -5404,7 +6282,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
5404
6282
  diagnostics: {
5405
6283
  onEvent: (event) => {
5406
6284
  platform.onConnectionDiagnostic?.(event);
5407
- options.logger?.debug?.("SpaceClient connection event", { event: event.event, state: event.state, epoch: event.epoch, ...event.code ? { code: event.code } : {} });
6285
+ options.logger?.debug?.("SpaceClient connection event", { event: event.event, state: event.state, epoch: event.epoch, ...event.code ? { code: event.code } : {}, ...event.sync ? { sync: event.sync } : {} });
5408
6286
  }
5409
6287
  }
5410
6288
  });
@@ -5470,8 +6348,8 @@ var SpaceClientCore = class {
5470
6348
  subscribe = (listener) => this.#store.subscribe(listener);
5471
6349
  join(options = {}) {
5472
6350
  return Effect12.sync(() => this.#requireActive()).pipe(
5473
- Effect12.tap(() => Effect12.sync(() => this.controllers.media.configure(options))),
5474
6351
  Effect12.andThen(this.#connection.configureJoin(options)),
6352
+ Effect12.tap(() => Effect12.sync(() => this.controllers.media.configure(options))),
5475
6353
  Effect12.andThen(this.#connection.join()),
5476
6354
  Effect12.andThen(options.displayName === void 0 ? Effect12.void : this.controllers.participants.renameSelf(options.displayName)),
5477
6355
  Effect12.catchTag("ConnectionLifecycleFailure", (failure2) => Effect12.fail(failure2.code === "episode_ended" ? new SpaceClientError({ code: "episode.ended", recoverable: failure2.recoverable, message: failure2.message }) : failure2)),
@@ -5665,7 +6543,9 @@ export {
5665
6543
  CHALK_CHAT_ATTACHMENT_MIME_TYPES,
5666
6544
  ChalkChatFileError,
5667
6545
  createChalkChatFileHttpTransport,
6546
+ ChalkWhiteboardViewBounds,
5668
6547
  createChalkWhiteboardV1FileDownloadTransport,
6548
+ ChalkWhiteboardViewVersions,
5669
6549
  encodeWhiteboardV1ClientFrame,
5670
6550
  decodeWhiteboardV1ServerFrame,
5671
6551
  InMemoryChalkWhiteboardV1PendingOperationStore,