@q9labsai/chalk-client 5.3.0 → 5.4.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/dist/access/grant.d.ts +2 -0
- package/dist/{chunk-GOSWSXTH.js → chunk-MHJ4UPM3.js} +162 -5
- package/dist/chunk-N4IPKRS6.js +76 -0
- package/dist/{chunk-DHUHTIU7.js → chunk-TA2BJXI7.js} +243 -45
- package/dist/{chunk-3TGVTP6Z.js → chunk-UVWRTXNO.js} +894 -72
- package/dist/{chunk-O26BHHMI.js → chunk-VAJ6XNAA.js} +1 -1
- package/dist/{chunk-XSB2GDRW.js → chunk-XF567GPH.js} +198 -26
- package/dist/client.d.ts +180 -0
- package/dist/connection/dependencies.d.ts +5 -0
- package/dist/connection/production.d.ts +1 -1
- package/dist/effect.js +29 -4
- package/dist/generated/http-api.d.ts +178 -0
- package/dist/generated/schemas.d.ts +287 -0
- package/dist/generated/sync.d.ts +53 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +33 -15
- package/dist/media/call-stats.d.ts +27 -0
- package/dist/media/client.d.ts +1 -0
- package/dist/media/ice-servers.d.ts +3 -0
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.js +4 -1
- package/dist/media/plane.d.ts +1 -0
- package/dist/media/receive-stall.d.ts +11 -2
- package/dist/media/types.d.ts +19 -0
- package/dist/media/video-render-size.d.ts +7 -0
- package/dist/media/viewer-layer.d.ts +48 -0
- package/dist/public-invites/index.js +3 -2
- package/dist/server/index.cjs +18 -4
- package/dist/server/index.js +18 -4
- package/dist/session/index.d.ts +18 -0
- package/dist/sync/index.js +2 -1
- package/dist/sync/link-watch.d.ts +67 -0
- package/dist/sync/v1-client.d.ts +5 -0
- package/dist/sync/v1-command-scheduler.d.ts +4 -0
- package/dist/sync/v1-types.d.ts +12 -1
- package/package.json +2 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccessGrantError,
|
|
3
|
+
requireParsedAccessGrant
|
|
4
|
+
} from "./chunk-MHJ4UPM3.js";
|
|
1
5
|
import {
|
|
2
6
|
CloudflareSFUClient,
|
|
3
7
|
CloudflareSFUError,
|
|
@@ -6,11 +10,7 @@ import {
|
|
|
6
10
|
describeMediaCaptureError,
|
|
7
11
|
grantedMediaFromStream,
|
|
8
12
|
markMediaFailure
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import {
|
|
11
|
-
AccessGrantError,
|
|
12
|
-
requireParsedAccessGrant
|
|
13
|
-
} from "./chunk-GOSWSXTH.js";
|
|
13
|
+
} from "./chunk-UVWRTXNO.js";
|
|
14
14
|
import {
|
|
15
15
|
ChatAttachmentMimeTypeSchema,
|
|
16
16
|
ChatReactionSetFrameSchema,
|
|
@@ -24,7 +24,10 @@ import {
|
|
|
24
24
|
formatSyncConnectDiagnostic,
|
|
25
25
|
scheduleSyncDialDeadline,
|
|
26
26
|
syncConnectFailureCode
|
|
27
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-XF567GPH.js";
|
|
28
|
+
import {
|
|
29
|
+
withRequestContext
|
|
30
|
+
} from "./chunk-N4IPKRS6.js";
|
|
28
31
|
|
|
29
32
|
// src/access/manager.ts
|
|
30
33
|
import { Clock, Context, Data, Deferred, Effect, Layer, SynchronizedRef } from "effect";
|
|
@@ -56,31 +59,31 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
|
|
|
56
59
|
});
|
|
57
60
|
});
|
|
58
61
|
const automaticMediaReplacementListeners = /* @__PURE__ */ new Set();
|
|
59
|
-
const
|
|
62
|
+
const fetch = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs, replacementGate })).pipe(
|
|
60
63
|
Effect.catchTag(
|
|
61
64
|
"MediaReplacementPending",
|
|
62
65
|
({ done }) => Deferred.await(done).pipe(
|
|
63
|
-
Effect.flatMap(() =>
|
|
66
|
+
Effect.flatMap(() => fetch(reason, replaceMediaConnection, force)),
|
|
64
67
|
Effect.map((access) => ({ access, automaticMediaReplacement: false }))
|
|
65
68
|
)
|
|
66
69
|
),
|
|
67
70
|
Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
|
|
68
71
|
Effect.map((result) => result.access)
|
|
69
72
|
);
|
|
70
|
-
const initialize = (reason = "join") =>
|
|
73
|
+
const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure2) => failure2.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch(reason, false, true) : Effect.fail(failure2)));
|
|
71
74
|
const millisecondsUntilRefresh = Effect.gen(function* () {
|
|
72
75
|
const access = yield* SynchronizedRef.get(state);
|
|
73
76
|
if (!access.access) return null;
|
|
74
77
|
return millisecondsUntil(access.access, yield* Clock.currentTimeMillis, refreshWindowMs);
|
|
75
78
|
});
|
|
76
|
-
const ensureFresh = (reason = "scheduled_refresh") =>
|
|
79
|
+
const ensureFresh = (reason = "scheduled_refresh") => fetch(reason, false, false);
|
|
77
80
|
return {
|
|
78
81
|
current: SynchronizedRef.get(state).pipe(Effect.map((value) => value.access)),
|
|
79
82
|
currentUnsafe: () => SynchronizedRef.getUnsafe(state).access,
|
|
80
83
|
initialize,
|
|
81
84
|
ensureFresh,
|
|
82
|
-
refresh: (reason, replaceMediaConnection) =>
|
|
83
|
-
refreshAfterRejection: () =>
|
|
85
|
+
refresh: (reason, replaceMediaConnection) => fetch(reason, replaceMediaConnection, true),
|
|
86
|
+
refreshAfterRejection: () => fetch("access_retry", true, true),
|
|
84
87
|
getSyncToken: (reason = "sync_recovery") => ensureFresh(reason).pipe(Effect.map((access) => access.sync.token)),
|
|
85
88
|
getMediaToken: () => ensureFresh("scheduled_refresh").pipe(Effect.map((access) => access.media.token)),
|
|
86
89
|
subscribeAutomaticMediaReplacement: (listener) => Effect.sync(() => {
|
|
@@ -291,8 +294,9 @@ function invalidUpload(message) {
|
|
|
291
294
|
throw chatFileError("initiate_upload", "invalid_payload", false, message);
|
|
292
295
|
}
|
|
293
296
|
function createChatFileRequest(options) {
|
|
294
|
-
const fetchImplementation = options.fetch
|
|
295
|
-
|
|
297
|
+
const fetchImplementation = withRequestContext(options.fetch);
|
|
298
|
+
let baseUrl = options.baseUrl;
|
|
299
|
+
while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
|
|
296
300
|
return async function request(operation, path, init, accepts) {
|
|
297
301
|
const headers = new Headers(init.headers);
|
|
298
302
|
try {
|
|
@@ -786,7 +790,7 @@ var ChalkWhiteboardViewBounds = {
|
|
|
786
790
|
|
|
787
791
|
// src/whiteboard/file-download-transport.ts
|
|
788
792
|
function createChalkWhiteboardV1FileDownloadTransport(options) {
|
|
789
|
-
const fetchImplementation = options.fetch
|
|
793
|
+
const fetchImplementation = withRequestContext(options.fetch);
|
|
790
794
|
let baseUrlEnd = options.baseUrl.length;
|
|
791
795
|
while (baseUrlEnd > 0 && options.baseUrl[baseUrlEnd - 1] === "/") baseUrlEnd -= 1;
|
|
792
796
|
const baseUrl = options.baseUrl.slice(0, baseUrlEnd);
|
|
@@ -2565,6 +2569,7 @@ function screenShareCapture(track) {
|
|
|
2565
2569
|
}
|
|
2566
2570
|
|
|
2567
2571
|
// src/connection/production.ts
|
|
2572
|
+
var apiFetch = withRequestContext();
|
|
2568
2573
|
function createDefaultConnectionDependencies(options) {
|
|
2569
2574
|
return {
|
|
2570
2575
|
clock: {
|
|
@@ -2585,7 +2590,7 @@ function createDefaultConnectionDependencies(options) {
|
|
|
2585
2590
|
return () => globalThis.window?.removeEventListener("online", listener);
|
|
2586
2591
|
},
|
|
2587
2592
|
send: async (grant, report, signal, traceparent) => {
|
|
2588
|
-
const response = await
|
|
2593
|
+
const response = await apiFetch(`${options.apiBaseURL.replace(/\/$/, "")}/v1/tenants/${encodeURIComponent(grant.subject.tenantId)}/connection-reports`, {
|
|
2589
2594
|
method: "POST",
|
|
2590
2595
|
headers: { Authorization: `Bearer ${grant.media.token}`, "Content-Type": "application/json", traceparent },
|
|
2591
2596
|
body: JSON.stringify(report),
|
|
@@ -2601,7 +2606,7 @@ function createDefaultConnectionDependencies(options) {
|
|
|
2601
2606
|
getEpisodeStatus: async (access, signal) => {
|
|
2602
2607
|
const { tenantId, spaceId, episodeId, participantId } = access.subject;
|
|
2603
2608
|
const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
|
|
2604
|
-
const response = await
|
|
2609
|
+
const response = await apiFetch(`${options.apiBaseURL.replace(/\/$/, "")}/v1/tenants/${path[0]}/spaces/${path[1]}/episodes/${path[2]}/participants/${path[3]}/episode-status`, {
|
|
2605
2610
|
headers: { Authorization: `Bearer ${access.media.token}` },
|
|
2606
2611
|
signal,
|
|
2607
2612
|
cache: "no-store",
|
|
@@ -2618,7 +2623,7 @@ function createDefaultConnectionDependencies(options) {
|
|
|
2618
2623
|
let baseEnd = options.apiBaseURL.length;
|
|
2619
2624
|
while (baseEnd > 0 && options.apiBaseURL[baseEnd - 1] === "/") baseEnd--;
|
|
2620
2625
|
const baseURL = options.apiBaseURL.slice(0, baseEnd);
|
|
2621
|
-
const response = await
|
|
2626
|
+
const response = await apiFetch(`${baseURL}/v1/tenants/${path[0]}/spaces/${path[1]}/episodes/${path[2]}/participants/${path[3]}/cancel-join`, {
|
|
2622
2627
|
method: "POST",
|
|
2623
2628
|
headers: { Authorization: `Bearer ${access.media.token}` },
|
|
2624
2629
|
signal
|
|
@@ -2707,6 +2712,7 @@ function bindConnectionMediaClient(client, restart) {
|
|
|
2707
2712
|
...client.remoteParticipantsChanged ? { remoteParticipantsChanged: client.remoteParticipantsChanged.bind(client) } : {},
|
|
2708
2713
|
...client.remotePublicationsChanged ? { remotePublicationsChanged: client.remotePublicationsChanged.bind(client) } : {},
|
|
2709
2714
|
...client.remotePublicationResumed ? { remotePublicationResumed: client.remotePublicationResumed.bind(client) } : {},
|
|
2715
|
+
...client.localPublicationWithdrawn ? { localPublicationWithdrawn: client.localPublicationWithdrawn.bind(client) } : {},
|
|
2710
2716
|
start: client.start.bind(client),
|
|
2711
2717
|
stop: client.stop.bind(client),
|
|
2712
2718
|
restart,
|
|
@@ -3777,9 +3783,14 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3777
3783
|
yield* publish();
|
|
3778
3784
|
});
|
|
3779
3785
|
const handleMediaFailure = (media, cause, binding) => {
|
|
3786
|
+
if (model.state === "leaving") return Effect2.void;
|
|
3780
3787
|
if (media !== model.media || binding !== access.currentUnsafe()?.media.clientPayload.connectionId) return Effect2.void;
|
|
3781
|
-
|
|
3782
|
-
if (sfuAccessRejected(cause)
|
|
3788
|
+
const failure2 = failureFromCause(cause, "media_start_failed", "Media transport needs recovery");
|
|
3789
|
+
if (sfuAccessRejected(cause)) {
|
|
3790
|
+
stopRejectedMedia();
|
|
3791
|
+
return failForSnapshot(failure2);
|
|
3792
|
+
}
|
|
3793
|
+
if (cause instanceof CloudflareSFUError && cause.options.status === 410 || active(model) && connectionSuperseded(cause)) return failForSnapshot(failure2);
|
|
3783
3794
|
return handleMediaSnapshot(media, media.getSnapshot());
|
|
3784
3795
|
};
|
|
3785
3796
|
const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
|
|
@@ -4461,13 +4472,13 @@ var ChatControllerRuntime = class {
|
|
|
4461
4472
|
#unsubscribeConnection = null;
|
|
4462
4473
|
#unsubscribeEvents = null;
|
|
4463
4474
|
#unsubscribeSnapshot = null;
|
|
4464
|
-
constructor(connection, store, transport, apiBaseUrl,
|
|
4475
|
+
constructor(connection, store, transport, apiBaseUrl, fetch, fork, offline) {
|
|
4465
4476
|
this.#offline = offline;
|
|
4466
4477
|
this.#apiBaseUrl = apiBaseUrl.replace(/\/+$/u, "");
|
|
4467
4478
|
this.#connection = connection;
|
|
4468
4479
|
this.#store = store;
|
|
4469
4480
|
this.#transport = transport;
|
|
4470
|
-
this.#fetch =
|
|
4481
|
+
this.#fetch = fetch;
|
|
4471
4482
|
this.#fork = fork;
|
|
4472
4483
|
this.#unsubscribeConnection = connection.subscribePorts((ports) => this.#bind(ports));
|
|
4473
4484
|
}
|
|
@@ -4545,7 +4556,7 @@ var ChatControllerRuntime = class {
|
|
|
4545
4556
|
});
|
|
4546
4557
|
};
|
|
4547
4558
|
upload = (file) => {
|
|
4548
|
-
const
|
|
4559
|
+
const fetch = this.#fetch;
|
|
4549
4560
|
return Effect4.suspend(() => {
|
|
4550
4561
|
if (!this.#transport) {
|
|
4551
4562
|
return Effect4.fail(new SpaceClientError({ code: "collaboration.unavailable", recoverable: false, message: "Chat file upload is unavailable" }));
|
|
@@ -4561,7 +4572,7 @@ var ChatControllerRuntime = class {
|
|
|
4561
4572
|
(upload) => Effect4.void.pipe(
|
|
4562
4573
|
Effect4.andThen(
|
|
4563
4574
|
Effect4.tryPromise({
|
|
4564
|
-
try: () =>
|
|
4575
|
+
try: () => fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes }),
|
|
4565
4576
|
catch: () => new SpaceClientError({ code: "collaboration.unavailable", recoverable: true, message: CHAT_FILE_REACHABILITY_MESSAGE })
|
|
4566
4577
|
}).pipe(
|
|
4567
4578
|
Effect4.flatMap((response) => {
|
|
@@ -4969,8 +4980,35 @@ var MediaProjectionMonitor = class {
|
|
|
4969
4980
|
// src/space-client/media-controller.ts
|
|
4970
4981
|
var MEDIA_SOURCES = ["microphone", "camera", "screen"];
|
|
4971
4982
|
var MEDIA_READY_TIMEOUT_MS = 1e4;
|
|
4983
|
+
var OFF_RETRY_INITIAL_MS = 1e3;
|
|
4984
|
+
var OFF_RETRY_MAX_MS = 3e4;
|
|
4985
|
+
function locallyOff(state) {
|
|
4986
|
+
return !state.desired.enabled || !state.localEnabled;
|
|
4987
|
+
}
|
|
4988
|
+
function owesRemoteOff(state) {
|
|
4989
|
+
return state.offDebt === "owed" && locallyOff(state);
|
|
4990
|
+
}
|
|
4972
4991
|
function sourceWorker(enabled) {
|
|
4973
|
-
return {
|
|
4992
|
+
return {
|
|
4993
|
+
desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled },
|
|
4994
|
+
result: { revision: 0, status: "confirmed" },
|
|
4995
|
+
initialRevision: 0,
|
|
4996
|
+
localEnabled: enabled,
|
|
4997
|
+
pause: null,
|
|
4998
|
+
offDebt: "none",
|
|
4999
|
+
offRetry: { timer: null, delayMs: OFF_RETRY_INITIAL_MS, running: false },
|
|
5000
|
+
serverSequence: -1,
|
|
5001
|
+
forcedRevision: null,
|
|
5002
|
+
pausedPublicationId: null,
|
|
5003
|
+
capturePause: "none",
|
|
5004
|
+
awaitingLive: null
|
|
5005
|
+
};
|
|
5006
|
+
}
|
|
5007
|
+
function rejectOffline(state, intent) {
|
|
5008
|
+
if (intent.enabled) state.result = { revision: intent.revision, status: "failed", cause: new V1SyncError("Media enable requires a live call", "not_live"), cleanup: !intent.previousEnabled };
|
|
5009
|
+
}
|
|
5010
|
+
function ownProjection(snapshot, source) {
|
|
5011
|
+
return snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
|
|
4974
5012
|
}
|
|
4975
5013
|
var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
|
|
4976
5014
|
};
|
|
@@ -5014,7 +5052,10 @@ var MediaControllerRuntime = class {
|
|
|
5014
5052
|
#sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
|
|
5015
5053
|
#syncLive = false;
|
|
5016
5054
|
#disposed = false;
|
|
5055
|
+
/** The wanted-on sources when recovery began, with the intent revision each one had then. */
|
|
5017
5056
|
#recovering = null;
|
|
5057
|
+
/** The Sync client's last proof of contact when the current recovery began: where its drop is measured from. */
|
|
5058
|
+
#proof = null;
|
|
5018
5059
|
#pendingSources = /* @__PURE__ */ new Map();
|
|
5019
5060
|
#pendingCaptures = /* @__PURE__ */ new Set();
|
|
5020
5061
|
#readinessChecks = /* @__PURE__ */ new Set();
|
|
@@ -5157,6 +5198,8 @@ var MediaControllerRuntime = class {
|
|
|
5157
5198
|
}
|
|
5158
5199
|
dispose() {
|
|
5159
5200
|
this.#disposed = true;
|
|
5201
|
+
this.#cancelOffRetries();
|
|
5202
|
+
for (const source of MEDIA_SOURCES) this.#sources[source].offDebt = "none";
|
|
5160
5203
|
this.#projection.reset();
|
|
5161
5204
|
for (const check of this.#readinessChecks) check(normalizeMediaError(this.#cancelledCaptureError()));
|
|
5162
5205
|
this.#cancelCaptures();
|
|
@@ -5210,8 +5253,27 @@ var MediaControllerRuntime = class {
|
|
|
5210
5253
|
}).pipe(Effect5.ensuring(Effect5.sync(() => cleanup())));
|
|
5211
5254
|
});
|
|
5212
5255
|
}
|
|
5256
|
+
/** While a user enable waits for the call, its revision belongs to that wait; another source worker must not run it. */
|
|
5257
|
+
#awaitLive(source, revision) {
|
|
5258
|
+
const state = this.#sources[source];
|
|
5259
|
+
return Effect5.suspend(() => {
|
|
5260
|
+
state.awaitingLive = revision;
|
|
5261
|
+
return this.#waitForLive(source, revision);
|
|
5262
|
+
}).pipe(
|
|
5263
|
+
Effect5.ensuring(
|
|
5264
|
+
Effect5.sync(() => {
|
|
5265
|
+
if (state.awaitingLive === revision) state.awaitingLive = null;
|
|
5266
|
+
})
|
|
5267
|
+
)
|
|
5268
|
+
);
|
|
5269
|
+
}
|
|
5213
5270
|
#set(source, enabled) {
|
|
5214
|
-
return Effect5.suspend(() => this.#store.getSnapshot().connection.status === "joining" ? this.#act(source, enabled) : this.#mediaAction(enabled, this.#act(source, enabled)));
|
|
5271
|
+
return Effect5.suspend(() => this.#store.getSnapshot().connection.status === "joining" || this.#replacingMediaConnection() ? this.#act(source, enabled) : this.#mediaAction(enabled, this.#act(source, enabled)));
|
|
5272
|
+
}
|
|
5273
|
+
/** Sync is live and only the media connection is being replaced, e.g. while another source moves to a fresh SFU connection. */
|
|
5274
|
+
#replacingMediaConnection() {
|
|
5275
|
+
const { state, connection } = this.#connection.getSnapshot();
|
|
5276
|
+
return (state === "live" || state === "reconnecting") && connection.sync === "healthy" && (connection.media === "connecting" || connection.media === "recovering");
|
|
5215
5277
|
}
|
|
5216
5278
|
#startScreen(options) {
|
|
5217
5279
|
return this.#act("screen", true, options);
|
|
@@ -5258,7 +5320,9 @@ var MediaControllerRuntime = class {
|
|
|
5258
5320
|
return Effect5.suspend(() => {
|
|
5259
5321
|
if (this.#ports?.media.getSnapshot().connection.phase === "stopped") return Effect5.void;
|
|
5260
5322
|
const desired = this.#recordIntent(source, enabled, options);
|
|
5261
|
-
|
|
5323
|
+
if (!enabled) this.#sources[source].offDebt = "owed";
|
|
5324
|
+
else this.#cancelOffRetry(source);
|
|
5325
|
+
const ready = enabled && source !== "screen" && this.#store.getSnapshot().connection.status !== "live" ? this.#awaitLive(source, desired.revision).pipe(
|
|
5262
5326
|
Effect5.tapError(
|
|
5263
5327
|
(cause) => Effect5.sync(() => {
|
|
5264
5328
|
if (this.#sources[source].desired.revision === desired.revision) {
|
|
@@ -5293,12 +5357,12 @@ var MediaControllerRuntime = class {
|
|
|
5293
5357
|
#work(source) {
|
|
5294
5358
|
return Effect5.gen({ self: this }, function* () {
|
|
5295
5359
|
const state = this.#sources[source];
|
|
5296
|
-
while (state.result.revision !== state.desired.revision || state.result.status === "failed" && state.result.cleanup) {
|
|
5360
|
+
while (state.result.revision !== state.desired.revision || state.result.status === "failed" && state.result.cleanup || owesRemoteOff(state)) {
|
|
5297
5361
|
const intent = state.desired;
|
|
5298
5362
|
yield* this.#runRevision(source, intent);
|
|
5299
5363
|
if (state.desired.revision === intent.revision) return;
|
|
5300
5364
|
}
|
|
5301
|
-
});
|
|
5365
|
+
}).pipe(Effect5.ensuring(Effect5.sync(() => this.#scheduleOffRetry(source))));
|
|
5302
5366
|
}
|
|
5303
5367
|
#runRevision(source, intent) {
|
|
5304
5368
|
return Effect5.gen({ self: this }, function* () {
|
|
@@ -5307,11 +5371,12 @@ var MediaControllerRuntime = class {
|
|
|
5307
5371
|
state.pause = null;
|
|
5308
5372
|
const paused = yield* Effect5.exit(pause ? foreign2(() => pause) : Effect5.void);
|
|
5309
5373
|
if (state.desired.revision !== intent.revision) return;
|
|
5374
|
+
if (state.awaitingLive === intent.revision) return yield* paused;
|
|
5310
5375
|
const ports = this.#ports;
|
|
5311
5376
|
const cleanup = source === "screen" && !intent.enabled ? yield* Effect5.exit(this.#stopScreenLocally(ports)) : Exit2.succeed(void 0);
|
|
5312
5377
|
if (state.desired.revision !== intent.revision) return;
|
|
5313
5378
|
if (ports && this.#syncLive) yield* this.#executeRevision(ports, source, intent);
|
|
5314
|
-
else
|
|
5379
|
+
else rejectOffline(state, intent);
|
|
5315
5380
|
yield* paused;
|
|
5316
5381
|
yield* cleanup;
|
|
5317
5382
|
});
|
|
@@ -5319,16 +5384,13 @@ var MediaControllerRuntime = class {
|
|
|
5319
5384
|
#executeRevision(ports, source, intent) {
|
|
5320
5385
|
return Effect5.gen({ self: this }, function* () {
|
|
5321
5386
|
const state = this.#sources[source];
|
|
5322
|
-
if (state.result.revision === intent.revision && state.result.status === "failed")
|
|
5323
|
-
yield* this.#connection.runCommand((p) => this.#send(p, source, intent, false));
|
|
5324
|
-
if (state.result.revision === intent.revision && state.result.status === "failed") state.result.cleanup = false;
|
|
5325
|
-
return;
|
|
5326
|
-
}
|
|
5387
|
+
if (state.result.revision === intent.revision && (state.result.status === "failed" || owesRemoteOff(state))) return yield* this.#sendOwedOff(source, intent);
|
|
5327
5388
|
const operation = source === "screen" ? this.#prepareScreen(ports, intent) : this.#prepareDevice(ports, source, intent);
|
|
5328
5389
|
const result = yield* Effect5.exit(this.#connection.runCommand(() => operation));
|
|
5329
5390
|
if (state.desired.revision !== intent.revision) return;
|
|
5330
5391
|
if (Exit2.isSuccess(result)) {
|
|
5331
5392
|
state.result = { revision: intent.revision, status: "confirmed" };
|
|
5393
|
+
state.offDebt = intent.enabled ? "none" : "sent";
|
|
5332
5394
|
state.localEnabled = state.forcedRevision === intent.revision ? false : intent.enabled;
|
|
5333
5395
|
} else {
|
|
5334
5396
|
const cause = yield* Effect5.flip(result);
|
|
@@ -5336,6 +5398,16 @@ var MediaControllerRuntime = class {
|
|
|
5336
5398
|
}
|
|
5337
5399
|
});
|
|
5338
5400
|
}
|
|
5401
|
+
#sendOwedOff(source, intent) {
|
|
5402
|
+
return Effect5.gen({ self: this }, function* () {
|
|
5403
|
+
const state = this.#sources[source];
|
|
5404
|
+
yield* this.#connection.runCommand((p) => this.#send(p, source, intent, false));
|
|
5405
|
+
state.offDebt = "sent";
|
|
5406
|
+
if (state.result.revision !== intent.revision || state.result.status !== "failed") return;
|
|
5407
|
+
if (intent.enabled) state.result.cleanup = false;
|
|
5408
|
+
else state.result = { revision: intent.revision, status: "confirmed" };
|
|
5409
|
+
});
|
|
5410
|
+
}
|
|
5339
5411
|
#failRevision(ports, source, intent, cause) {
|
|
5340
5412
|
return Effect5.sync(() => {
|
|
5341
5413
|
const state = this.#sources[source];
|
|
@@ -5469,7 +5541,10 @@ var MediaControllerRuntime = class {
|
|
|
5469
5541
|
return state.desired.enabled && state.forcedRevision !== state.desired.revision;
|
|
5470
5542
|
}
|
|
5471
5543
|
#resetObservations() {
|
|
5472
|
-
if (this.#connection.getSnapshot().state !== "reconnecting")
|
|
5544
|
+
if (this.#connection.getSnapshot().state !== "reconnecting") {
|
|
5545
|
+
this.#recovering = null;
|
|
5546
|
+
this.#proof = null;
|
|
5547
|
+
}
|
|
5473
5548
|
for (const source of MEDIA_SOURCES) {
|
|
5474
5549
|
const state = this.#sources[source];
|
|
5475
5550
|
state.result = { revision: state.desired.revision, status: "confirmed" };
|
|
@@ -5552,19 +5627,97 @@ var MediaControllerRuntime = class {
|
|
|
5552
5627
|
}
|
|
5553
5628
|
#reconcileIntents() {
|
|
5554
5629
|
const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
|
|
5555
|
-
if (this.#syncLive && !live)
|
|
5630
|
+
if (this.#syncLive && !live) {
|
|
5631
|
+
this.#rememberRecoveryIntents();
|
|
5632
|
+
this.#cancelOffRetries();
|
|
5633
|
+
}
|
|
5556
5634
|
const resumed = live && !this.#syncLive;
|
|
5557
5635
|
this.#syncLive = live;
|
|
5558
5636
|
if (!resumed) return;
|
|
5559
|
-
if (this.#ports)
|
|
5637
|
+
if (this.#ports) {
|
|
5638
|
+
const snapshot = this.#ports.sync.getSnapshot();
|
|
5639
|
+
this.#applyRecoveryRule(this.#ports, snapshot);
|
|
5640
|
+
this.#oweOffsSyncStillShows(snapshot);
|
|
5641
|
+
}
|
|
5560
5642
|
this.#resumePendingActions();
|
|
5561
5643
|
}
|
|
5562
5644
|
#resumePendingActions() {
|
|
5645
|
+
for (const source of MEDIA_SOURCES) {
|
|
5646
|
+
this.#sources[source].offRetry.delayMs = OFF_RETRY_INITIAL_MS;
|
|
5647
|
+
if (source === "screen" && !owesRemoteOff(this.#sources[source])) this.#runWork(source);
|
|
5648
|
+
else this.#resendOwedOff(source);
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5651
|
+
/** After a reconnect, Sync's snapshot decides: a source the device turned off but Sync still shows on, with the publication the device paused, is an off to resend. */
|
|
5652
|
+
#oweOffsSyncStillShows(snapshot) {
|
|
5563
5653
|
for (const source of MEDIA_SOURCES) {
|
|
5564
5654
|
const state = this.#sources[source];
|
|
5565
|
-
if (
|
|
5566
|
-
|
|
5655
|
+
if (state.offDebt !== "sent" || state.pausedPublicationId === null) continue;
|
|
5656
|
+
if (ownProjection(snapshot, source)?.publicationId === state.pausedPublicationId) state.offDebt = "owed";
|
|
5657
|
+
}
|
|
5658
|
+
}
|
|
5659
|
+
/** Sync needs no off from this device: it shows the source off, or on with a publication this device never paused (another device's). */
|
|
5660
|
+
#syncNeedsNoOff(source) {
|
|
5661
|
+
const snapshot = this.#ports?.sync.getSnapshot();
|
|
5662
|
+
if (!snapshot?.media) return false;
|
|
5663
|
+
const item = ownProjection(snapshot, source);
|
|
5664
|
+
if (!item) return false;
|
|
5665
|
+
return !item.enabled || this.#sources[source].pausedPublicationId !== null && item.publicationId !== this.#sources[source].pausedPublicationId;
|
|
5666
|
+
}
|
|
5667
|
+
/** The one place an owed off is resent after the user's own attempt. At most one is in flight per source, and none while a user action on the source is pending. */
|
|
5668
|
+
#resendOwedOff(source) {
|
|
5669
|
+
const state = this.#sources[source];
|
|
5670
|
+
if (!this.#syncLive || this.#disposed || !owesRemoteOff(state) || state.offRetry.running || this.#pendingSources.has(source)) return;
|
|
5671
|
+
this.#cancelOffRetry(source);
|
|
5672
|
+
if (this.#syncNeedsNoOff(source)) {
|
|
5673
|
+
state.offDebt = "none";
|
|
5674
|
+
state.result = { revision: state.desired.revision, status: "confirmed" };
|
|
5675
|
+
return;
|
|
5676
|
+
}
|
|
5677
|
+
this.#runWork(source);
|
|
5678
|
+
}
|
|
5679
|
+
/** A Sync snapshot repays an owed off now, unless an attempt for that source is already queued or running. */
|
|
5680
|
+
#retryOffs() {
|
|
5681
|
+
for (const source of MEDIA_SOURCES) this.#resendOwedOff(source);
|
|
5682
|
+
}
|
|
5683
|
+
#runWork(source) {
|
|
5684
|
+
const retry = this.#sources[source].offRetry;
|
|
5685
|
+
retry.running = true;
|
|
5686
|
+
this.#fork(
|
|
5687
|
+
this.#serialize(source, this.#work(source)).pipe(
|
|
5688
|
+
Effect5.ignore,
|
|
5689
|
+
Effect5.ensuring(
|
|
5690
|
+
Effect5.sync(() => {
|
|
5691
|
+
retry.running = false;
|
|
5692
|
+
})
|
|
5693
|
+
)
|
|
5694
|
+
)
|
|
5695
|
+
);
|
|
5696
|
+
}
|
|
5697
|
+
/** An off that Sync still has not shown is retried on a timer with doubling backoff, one timer per source, so a quiet healthy connection repairs itself. */
|
|
5698
|
+
#scheduleOffRetry(source) {
|
|
5699
|
+
const state = this.#sources[source];
|
|
5700
|
+
const retry = state.offRetry;
|
|
5701
|
+
if (!owesRemoteOff(state)) {
|
|
5702
|
+
this.#cancelOffRetry(source);
|
|
5703
|
+
retry.delayMs = OFF_RETRY_INITIAL_MS;
|
|
5704
|
+
return;
|
|
5567
5705
|
}
|
|
5706
|
+
if (!this.#syncLive || this.#disposed || retry.timer !== null) return;
|
|
5707
|
+
retry.timer = this.#connection.scheduleUnsafe(() => {
|
|
5708
|
+
retry.timer = null;
|
|
5709
|
+
this.#resendOwedOff(source);
|
|
5710
|
+
}, retry.delayMs);
|
|
5711
|
+
retry.delayMs = Math.min(retry.delayMs * 2, OFF_RETRY_MAX_MS);
|
|
5712
|
+
}
|
|
5713
|
+
#cancelOffRetry(source) {
|
|
5714
|
+
const retry = this.#sources[source].offRetry;
|
|
5715
|
+
if (retry.timer === null) return;
|
|
5716
|
+
this.#connection.cancelScheduleUnsafe(retry.timer);
|
|
5717
|
+
retry.timer = null;
|
|
5718
|
+
}
|
|
5719
|
+
#cancelOffRetries() {
|
|
5720
|
+
for (const source of MEDIA_SOURCES) this.#cancelOffRetry(source);
|
|
5568
5721
|
}
|
|
5569
5722
|
#clearScreenAudio(media) {
|
|
5570
5723
|
return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
|
|
@@ -5971,6 +6124,7 @@ var MediaControllerRuntime = class {
|
|
|
5971
6124
|
this.#ports = ports;
|
|
5972
6125
|
this.#projection.reset();
|
|
5973
6126
|
this.#syncLive = false;
|
|
6127
|
+
this.#cancelOffRetries();
|
|
5974
6128
|
this.#bindCamera(ports);
|
|
5975
6129
|
if (!ports) {
|
|
5976
6130
|
this.#resetObservations();
|
|
@@ -6002,21 +6156,65 @@ var MediaControllerRuntime = class {
|
|
|
6002
6156
|
#rememberRecoveryIntents() {
|
|
6003
6157
|
for (const state of Object.values(this.#sources)) if (state.capturePause === "announced") state.capturePause = "pending";
|
|
6004
6158
|
if (!this.#ports) return;
|
|
6159
|
+
if (this.#recovering === null) this.#proof = this.#ports.sync.link?.proof() ?? null;
|
|
6005
6160
|
this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
|
|
6006
6161
|
}
|
|
6007
|
-
|
|
6008
|
-
|
|
6162
|
+
/**
|
|
6163
|
+
* The rule depends on what the connected Sync advertised. Sync's snapshot items carry `off_reason` only when its welcome said
|
|
6164
|
+
* `media_off_reason`, and only then can recovery tell a host stop from a publication that is merely missing. Without it the rule
|
|
6165
|
+
* is the one this SDK had before: a wanted-on source whose own item is missing or not enabled is paused (#597).
|
|
6166
|
+
*/
|
|
6167
|
+
#applyRecoveryRule(ports, snapshot) {
|
|
6168
|
+
const recovering = this.#recovering ?? /* @__PURE__ */ new Map();
|
|
6169
|
+
const proof = this.#proof;
|
|
6009
6170
|
this.#recovering = null;
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6171
|
+
this.#proof = null;
|
|
6172
|
+
if (ports.sync.offReasonNegotiated?.() === true) this.#pauseOnEvidence(ports, snapshot, recovering, ports.sync.link?.longSince(proof) ?? true);
|
|
6173
|
+
else this.#pauseWhenSyncLostIt(ports, snapshot, recovering);
|
|
6174
|
+
}
|
|
6175
|
+
#pauseWhenSyncLostIt(ports, snapshot, recovering) {
|
|
6176
|
+
for (const [source, revision] of recovering) {
|
|
6177
|
+
if (!ownProjection(snapshot, source)?.enabled) this.#pausePublication(ports, source, revision, "sync_recovery_loss");
|
|
6013
6178
|
}
|
|
6014
6179
|
}
|
|
6180
|
+
/**
|
|
6181
|
+
* A wanted-on source is paused when Sync's item says the host stopped it after the user's intent (`off_reason: "host"` with a newer
|
|
6182
|
+
* `off_revision`; a replayed stop event reaches `#subscribePauses` the same way). This holds for every source that is wanted on when
|
|
6183
|
+
* the snapshot arrives, including one the user turned on after recovery began, and for every source recovery began with. With no such
|
|
6184
|
+
* evidence the drop decides, whatever Sync lists: the source stays wanted-on after a drop proven short and turns off, like a tap, after
|
|
6185
|
+
* any other. The drop cutoff applies only to the intent the source had when recovery began (`recovering`), so a newer intent from the
|
|
6186
|
+
* user is left alone.
|
|
6187
|
+
*/
|
|
6188
|
+
#pauseOnEvidence(ports, snapshot, recovering, longDrop) {
|
|
6189
|
+
for (const source of MEDIA_SOURCES) {
|
|
6190
|
+
const intentRevision = recovering.get(source);
|
|
6191
|
+
if (intentRevision === void 0 && !this.#sources[source].desired.enabled) continue;
|
|
6192
|
+
const item = ownProjection(snapshot, source);
|
|
6193
|
+
const stopRevision = this.#hostStopNewerThanIntent(source, item);
|
|
6194
|
+
if (stopRevision !== null) this.#pauseForHostStop(ports, source, stopRevision);
|
|
6195
|
+
else if (longDrop && intentRevision !== void 0 && this.#sources[source].desired.revision === intentRevision) this.#turnOffLikeATap(source);
|
|
6196
|
+
}
|
|
6197
|
+
}
|
|
6198
|
+
#hostStopNewerThanIntent(source, item) {
|
|
6199
|
+
if (item?.offReason !== "host" || item.offRevision === void 0) return null;
|
|
6200
|
+
return item.offRevision > this.#sources[source].desired.controlRevision ? item.offRevision : null;
|
|
6201
|
+
}
|
|
6202
|
+
#pauseForHostStop(ports, source, stopRevision) {
|
|
6203
|
+
const state = this.#sources[source];
|
|
6204
|
+
state.serverSequence = Math.max(state.serverSequence, stopRevision);
|
|
6205
|
+
this.#pausePublication(ports, source, state.desired.revision, "moderation_pause");
|
|
6206
|
+
}
|
|
6207
|
+
// The intent becomes off exactly as a tap makes it, so the UI shows it off and the user can tap to turn it on.
|
|
6208
|
+
#turnOffLikeATap(source) {
|
|
6209
|
+
this.#recordIntent(source, false);
|
|
6210
|
+
this.#sources[source].offDebt = "owed";
|
|
6211
|
+
}
|
|
6015
6212
|
#subscribeSync(ports) {
|
|
6016
6213
|
return ports.sync.subscribe((snapshot) => {
|
|
6017
6214
|
this.#removeRequestsFromMissingParticipants(snapshot);
|
|
6018
6215
|
this.#applyRemoteMediaState(ports, snapshot);
|
|
6019
6216
|
this.#reconcileIntents();
|
|
6217
|
+
this.#retryOffs();
|
|
6020
6218
|
this.#notifyCapturePauses(ports);
|
|
6021
6219
|
this.#publish();
|
|
6022
6220
|
});
|