@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.
- package/CHANGELOG.md +159 -0
- package/README.md +39 -0
- package/dist/access/base64-url.d.ts +0 -1
- package/dist/access/grant.d.ts +0 -1
- package/dist/access/manager.d.ts +0 -1
- package/dist/chat-files/http-transport.d.ts +0 -1
- package/dist/chat-files/index.d.ts +0 -1
- package/dist/chat-files/types.d.ts +3 -1
- package/dist/{chunk-IPAN4LUZ.js → chunk-BCO3AZUB.js} +1463 -583
- package/dist/{chunk-AVXJ4HQU.js → chunk-D2GXPX2P.js} +298 -47
- package/dist/{chunk-CMVO7USN.js → chunk-DHGAKJXP.js} +1 -1
- package/dist/{chunk-O2NOZKJP.js → chunk-DWKTGBHR.js} +20 -8
- package/dist/{chunk-2HJIJVE2.js → chunk-HO4745TX.js} +591 -5
- package/dist/client.d.ts +277 -1
- package/dist/collaboration/index.d.ts +0 -1
- package/dist/collaboration/types.d.ts +0 -1
- package/dist/collaboration/wire.d.ts +0 -1
- package/dist/connection/dependencies.d.ts +8 -3
- package/dist/connection/diagnostics.d.ts +3 -3
- package/dist/connection/index.d.ts +1 -1
- package/dist/connection/lifecycle.d.ts +3 -3
- package/dist/connection/media-devices.d.ts +0 -1
- package/dist/connection/production.d.ts +0 -1
- package/dist/connection/types.d.ts +4 -2
- package/dist/effect.d.ts +0 -1
- package/dist/effect.js +146 -4
- package/dist/foreground-deadline.d.ts +0 -1
- package/dist/generated/http-api.d.ts +273 -1
- package/dist/generated/schemas.d.ts +732 -7
- package/dist/generated/sync.d.ts +74 -4
- package/dist/generated/whiteboard-v1.d.ts +114 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +21 -15
- package/dist/media/camera-uplink.d.ts +0 -1
- package/dist/media/capture-error.d.ts +0 -1
- package/dist/media/client.d.ts +3 -2
- package/dist/media/cloudflare-sfu.d.ts +0 -1
- package/dist/media/index.d.ts +0 -1
- package/dist/media/index.js +1 -1
- package/dist/media/observers.d.ts +0 -1
- package/dist/media/plane.d.ts +0 -1
- package/dist/media/remote-track-response.d.ts +0 -1
- package/dist/media/target.d.ts +0 -1
- package/dist/media/tracks.d.ts +0 -1
- package/dist/media/transport.d.ts +0 -1
- package/dist/media/types.d.ts +0 -1
- package/dist/public-invites/client.d.ts +0 -1
- package/dist/public-invites/index.d.ts +0 -1
- package/dist/public-invites/index.js +2 -2
- package/dist/public-invites/types.d.ts +0 -1
- package/dist/server/access-refresh.d.ts +0 -1
- package/dist/server/{chunk-C4XITY5U.js → chunk-XQOQTCUL.js} +22 -0
- package/dist/server/client.d.ts +0 -1
- package/dist/server/errors.d.cts +8 -1
- package/dist/server/errors.d.ts +8 -2
- package/dist/server/index.cjs +31 -7
- package/dist/server/index.d.cts +1 -1
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +10 -7
- package/dist/server/server-only.d.ts +0 -1
- package/dist/server/server-only.js +1 -1
- package/dist/server/transport.d.ts +0 -1
- package/dist/server/types.d.ts +0 -1
- package/dist/space-client/chat-controller-helpers.d.ts +0 -1
- package/dist/space-client/chat-controller.d.ts +0 -1
- package/dist/space-client/controller-effects.d.ts +0 -1
- package/dist/space-client/core.d.ts +0 -1
- package/dist/space-client/effect.d.ts +0 -1
- package/dist/space-client/errors.d.ts +4 -2
- package/dist/space-client/index.d.ts +1 -2
- package/dist/space-client/media-controller.d.ts +3 -2
- package/dist/space-client/media-device-selection.d.ts +0 -1
- package/dist/space-client/offline-actions-storage.d.ts +0 -1
- package/dist/space-client/offline-actions.d.ts +0 -1
- package/dist/space-client/participants-controller.d.ts +0 -1
- package/dist/space-client/promise-facade.d.ts +0 -1
- package/dist/space-client/reactions-controller.d.ts +0 -1
- package/dist/space-client/recording-controller.d.ts +0 -1
- package/dist/space-client/reload-rejoin.d.ts +1 -2
- package/dist/space-client/space-client.d.ts +0 -1
- package/dist/space-client/store.d.ts +0 -1
- package/dist/space-client/types.d.ts +15 -3
- package/dist/space-client/whiteboard-controller.d.ts +0 -1
- package/dist/sync/browser.d.ts +0 -1
- package/dist/sync/canonical.d.ts +0 -1
- package/dist/sync/connect-diagnostics.d.ts +10 -0
- package/dist/sync/dial-deadline.d.ts +3 -0
- package/dist/sync/errors.d.ts +0 -1
- package/dist/sync/index.d.ts +0 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/react-native.d.ts +0 -1
- package/dist/sync/types.d.ts +1 -1
- package/dist/sync/v1-client.d.ts +4 -2
- package/dist/sync/v1-codec.d.ts +0 -1
- package/dist/sync/v1-collaboration-state.d.ts +1 -1
- package/dist/sync/v1-command-scheduler.d.ts +0 -1
- package/dist/sync/v1-create.d.ts +0 -1
- package/dist/sync/v1-deferred.d.ts +0 -1
- package/dist/sync/v1-error.d.ts +0 -1
- package/dist/sync/v1-frame-signature.d.ts +0 -1
- package/dist/sync/v1-health.d.ts +15 -0
- package/dist/sync/v1-live-target-coordinator.d.ts +0 -1
- package/dist/sync/v1-persistence.d.ts +0 -1
- package/dist/sync/v1-platform-persistence.d.ts +0 -1
- package/dist/sync/v1-reducer.d.ts +3 -1
- package/dist/sync/v1-types.d.ts +14 -1
- package/dist/webhooks/errors.d.ts +0 -1
- package/dist/webhooks/generated/event-v1.d.ts +0 -1
- package/dist/webhooks/generated/fixtures-v1.d.ts +0 -1
- package/dist/webhooks/index.d.ts +0 -1
- package/dist/webhooks/processor.d.ts +0 -1
- package/dist/webhooks/server-only.d.ts +0 -1
- package/dist/webhooks/test.d.ts +0 -1
- package/dist/webhooks/types.d.ts +0 -1
- package/dist/webhooks/validate.d.ts +0 -1
- package/dist/webhooks/verify.d.ts +0 -1
- package/dist/whiteboard/file-download-transport.d.ts +0 -1
- package/dist/whiteboard/index.d.ts +2 -1
- package/dist/whiteboard/types.d.ts +54 -5
- package/dist/whiteboard/v1-client.d.ts +5 -2
- package/dist/whiteboard/v1-codec.d.ts +5 -2
- package/dist/whiteboard/v1-create.d.ts +0 -1
- package/dist/whiteboard/v1-multipart.d.ts +1 -2
- package/dist/whiteboard/v1-persistence.d.ts +0 -1
- package/dist/whiteboard/view-bounds.d.ts +5 -0
- package/dist/whiteboard/view-versions.d.ts +9 -0
- package/package.json +4 -2
- package/dist/access/base64-url.d.ts.map +0 -1
- package/dist/access/grant.d.ts.map +0 -1
- package/dist/access/grant.test.helpers.d.ts +0 -10
- package/dist/access/grant.test.helpers.d.ts.map +0 -1
- package/dist/access/manager.d.ts.map +0 -1
- package/dist/chat-files/http-transport.d.ts.map +0 -1
- package/dist/chat-files/index.d.ts.map +0 -1
- package/dist/chat-files/types.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/collaboration/index.d.ts.map +0 -1
- package/dist/collaboration/types.d.ts.map +0 -1
- package/dist/collaboration/wire.d.ts.map +0 -1
- package/dist/connection/dependencies.d.ts.map +0 -1
- package/dist/connection/diagnostics.d.ts.map +0 -1
- package/dist/connection/index.d.ts.map +0 -1
- package/dist/connection/lifecycle.d.ts.map +0 -1
- package/dist/connection/media-devices.d.ts.map +0 -1
- package/dist/connection/production.d.ts.map +0 -1
- package/dist/connection/types.d.ts.map +0 -1
- package/dist/effect.d.ts.map +0 -1
- package/dist/foreground-deadline.d.ts.map +0 -1
- package/dist/generated/http-api.d.ts.map +0 -1
- package/dist/generated/schemas.d.ts.map +0 -1
- package/dist/generated/sync.d.ts.map +0 -1
- package/dist/generated/whiteboard-v1.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/media/camera-uplink.d.ts.map +0 -1
- package/dist/media/capture-error.d.ts.map +0 -1
- package/dist/media/client.d.ts.map +0 -1
- package/dist/media/cloudflare-sfu.d.ts.map +0 -1
- package/dist/media/index.d.ts.map +0 -1
- package/dist/media/observers.d.ts.map +0 -1
- package/dist/media/plane.d.ts.map +0 -1
- package/dist/media/remote-track-response.d.ts.map +0 -1
- package/dist/media/target.d.ts.map +0 -1
- package/dist/media/tracks.d.ts.map +0 -1
- package/dist/media/transport.d.ts.map +0 -1
- package/dist/media/types.d.ts.map +0 -1
- package/dist/public-invites/client.d.ts.map +0 -1
- package/dist/public-invites/index.d.ts.map +0 -1
- package/dist/public-invites/types.d.ts.map +0 -1
- package/dist/server/access-refresh.d.ts.map +0 -1
- package/dist/server/client.d.ts.map +0 -1
- package/dist/server/errors.d.ts.map +0 -1
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/server-only.d.ts.map +0 -1
- package/dist/server/transport.d.ts.map +0 -1
- package/dist/server/types.d.ts.map +0 -1
- package/dist/space-client/chat-controller-helpers.d.ts.map +0 -1
- package/dist/space-client/chat-controller.d.ts.map +0 -1
- package/dist/space-client/controller-effects.d.ts.map +0 -1
- package/dist/space-client/core.d.ts.map +0 -1
- package/dist/space-client/core.test.helpers.d.ts +0 -15
- package/dist/space-client/core.test.helpers.d.ts.map +0 -1
- package/dist/space-client/effect.d.ts.map +0 -1
- package/dist/space-client/errors.d.ts.map +0 -1
- package/dist/space-client/index.d.ts.map +0 -1
- package/dist/space-client/media-controller.d.ts.map +0 -1
- package/dist/space-client/media-device-selection.d.ts.map +0 -1
- package/dist/space-client/offline-actions-storage.d.ts.map +0 -1
- package/dist/space-client/offline-actions.d.ts.map +0 -1
- package/dist/space-client/participants-controller.d.ts.map +0 -1
- package/dist/space-client/promise-facade.d.ts.map +0 -1
- package/dist/space-client/reactions-controller.d.ts.map +0 -1
- package/dist/space-client/recording-controller.d.ts.map +0 -1
- package/dist/space-client/reload-rejoin.d.ts.map +0 -1
- package/dist/space-client/space-client.d.ts.map +0 -1
- package/dist/space-client/store.d.ts.map +0 -1
- package/dist/space-client/terminal.test.helpers.d.ts +0 -136
- package/dist/space-client/terminal.test.helpers.d.ts.map +0 -1
- package/dist/space-client/types.d.ts.map +0 -1
- package/dist/space-client/whiteboard-controller.d.ts.map +0 -1
- package/dist/sync/browser.d.ts.map +0 -1
- package/dist/sync/canonical.d.ts.map +0 -1
- package/dist/sync/errors.d.ts.map +0 -1
- package/dist/sync/index.d.ts.map +0 -1
- package/dist/sync/react-native.d.ts.map +0 -1
- package/dist/sync/types.d.ts.map +0 -1
- package/dist/sync/v1-client.d.ts.map +0 -1
- package/dist/sync/v1-codec.d.ts.map +0 -1
- package/dist/sync/v1-collaboration-state.d.ts.map +0 -1
- package/dist/sync/v1-command-scheduler.d.ts.map +0 -1
- package/dist/sync/v1-create.d.ts.map +0 -1
- package/dist/sync/v1-deferred.d.ts.map +0 -1
- package/dist/sync/v1-error.d.ts.map +0 -1
- package/dist/sync/v1-frame-signature.d.ts.map +0 -1
- package/dist/sync/v1-live-target-coordinator.d.ts.map +0 -1
- package/dist/sync/v1-persistence.d.ts.map +0 -1
- package/dist/sync/v1-platform-persistence.d.ts.map +0 -1
- package/dist/sync/v1-reducer.d.ts.map +0 -1
- package/dist/sync/v1-types.d.ts.map +0 -1
- package/dist/webhooks/errors.d.ts.map +0 -1
- package/dist/webhooks/generated/event-v1.d.ts.map +0 -1
- package/dist/webhooks/generated/fixtures-v1.d.ts.map +0 -1
- package/dist/webhooks/index.d.ts.map +0 -1
- package/dist/webhooks/processor.d.ts.map +0 -1
- package/dist/webhooks/server-only.d.ts.map +0 -1
- package/dist/webhooks/test.d.ts.map +0 -1
- package/dist/webhooks/types.d.ts.map +0 -1
- package/dist/webhooks/validate.d.ts.map +0 -1
- package/dist/webhooks/verify.d.ts.map +0 -1
- package/dist/whiteboard/file-download-transport.d.ts.map +0 -1
- package/dist/whiteboard/index.d.ts.map +0 -1
- package/dist/whiteboard/types.d.ts.map +0 -1
- package/dist/whiteboard/v1-client.d.ts.map +0 -1
- package/dist/whiteboard/v1-codec.d.ts.map +0 -1
- package/dist/whiteboard/v1-create.d.ts.map +0 -1
- package/dist/whiteboard/v1-multipart.d.ts.map +0 -1
- 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-
|
|
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
|
-
|
|
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
|
|
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") =>
|
|
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") =>
|
|
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) =>
|
|
52
|
-
refreshAfterRejection: () =>
|
|
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:
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) &&
|
|
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
|
|
642
|
-
return
|
|
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
|
-
#
|
|
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.#
|
|
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
|
-
|
|
1012
|
-
|
|
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
|
|
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.#
|
|
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
|
|
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.#
|
|
1107
|
-
this.#
|
|
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
|
-
|
|
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.#
|
|
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 =
|
|
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.#
|
|
1564
|
+
const refused = !drawPolicyNegotiated(this.#hello) && this.#dropPolicyChanges();
|
|
1565
|
+
this.#publishSummary("loading", refused ? policyUnavailable() : null);
|
|
1273
1566
|
this.#clearHeartbeat();
|
|
1274
|
-
|
|
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
|
-
|
|
1278
|
-
for (const entry of pendingOperations) {
|
|
1279
|
-
this.#sendOperation(entry.pending.frame);
|
|
1280
|
-
}
|
|
1575
|
+
this.#sendInCreationOrder([...this.#operations.values()]);
|
|
1281
1576
|
this.#requestSnapshotAfterOperations();
|
|
1282
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
1732
|
+
const entry = this.#sentEntry(frame.operation_id);
|
|
1388
1733
|
if (!entry) return;
|
|
1389
|
-
this.#
|
|
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.#
|
|
1752
|
+
const entry = this.#sentEntry(frame.correlation_id);
|
|
1406
1753
|
if (entry) {
|
|
1407
|
-
|
|
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 (
|
|
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
|
-
|
|
1435
|
-
|
|
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.#
|
|
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
|
|
1855
|
+
const phase = this.#phase;
|
|
1453
1856
|
this.#resetDisconnectedState();
|
|
1454
1857
|
if (!this.#started || !this.#transportAvailable) return;
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
this.#
|
|
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
|
-
#
|
|
1467
|
-
|
|
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.#
|
|
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.#
|
|
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,
|
|
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(
|
|
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"
|
|
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 (
|
|
2440
|
-
|
|
2441
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3080
|
-
|
|
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:
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
3830
|
-
#
|
|
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
|
-
#
|
|
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.#
|
|
3858
|
-
if (intent.camera !== void 0) this.#
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
4460
|
+
}).pipe(Effect5.mapError(normalizeMediaError));
|
|
3892
4461
|
};
|
|
3893
4462
|
initialIntent() {
|
|
3894
|
-
return {
|
|
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.#
|
|
4488
|
+
this.#pauseInitialTracks();
|
|
3899
4489
|
this.#publish();
|
|
3900
|
-
return Effect5.succeed(streamFromTracks([...this.#
|
|
4490
|
+
return Effect5.succeed(streamFromTracks([...this.#localTracks().values()]));
|
|
3901
4491
|
}
|
|
3902
|
-
return
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
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
|
-
|
|
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.#
|
|
4691
|
+
if (this.#track("screen") === track) this.#deleteTrack("screen");
|
|
4692
|
+
track?.stop();
|
|
4693
|
+
this.#publish();
|
|
4124
4694
|
})
|
|
4125
4695
|
)
|
|
4126
4696
|
);
|
|
4127
4697
|
}
|
|
4128
|
-
#
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
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
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
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
|
-
#
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
|
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
|
-
#
|
|
4307
|
-
|
|
4308
|
-
|
|
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
|
-
#
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
if (
|
|
4316
|
-
|
|
4317
|
-
|
|
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
|
-
#
|
|
4327
|
-
const
|
|
4328
|
-
|
|
4329
|
-
this.#
|
|
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
|
-
#
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
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
|
-
#
|
|
4344
|
-
const
|
|
4345
|
-
|
|
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
|
-
|
|
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.#
|
|
4408
|
-
if (!track || this.#
|
|
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.#
|
|
4421
|
-
camera: localMedia("camera", this.#
|
|
4422
|
-
screen: localMedia("screen", this.#
|
|
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({
|
|
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,
|