effect-browser 0.2.0-beta.9 → 0.3.0-beta.11
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/README.md +861 -1161
- package/dist/Browser-B4qncxe7.mjs +628 -0
- package/dist/Browser-B4qncxe7.mjs.map +1 -0
- package/dist/Browser.d.mts +129 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +211 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +297 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-iMlcuzVD.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-Di2PxyF7.d.mts +119 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +125 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-B5cjaUp5.mjs +618 -0
- package/dist/Page-B5cjaUp5.mjs.map +1 -0
- package/dist/Page-B7X6DQUn.d.mts +595 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-Akcac63g.d.mts +209 -0
- package/dist/Plan-D1wrJVs8.mjs +508 -0
- package/dist/Plan-D1wrJVs8.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +64 -0
- package/dist/Stage.mjs +237 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-B6xr8nVi.mjs +261 -0
- package/dist/context-B6xr8nVi.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-BAf__WKX.mjs +5075 -0
- package/dist/page-BAf__WKX.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-Cd04Okid.mjs +264 -0
- package/dist/url-Cd04Okid.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +672 -0
- package/src/BrowserError.ts +234 -0
- package/src/BrowserEvent.ts +296 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +132 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +522 -0
- package/src/Plan.ts +663 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +467 -0
- package/src/Supervisor.ts +599 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +610 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/gate.ts +224 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +184 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +376 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +195 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +391 -0
- package/src/internal/page/context.ts +314 -0
- package/src/internal/page/identity.ts +35 -0
- package/src/internal/page/lane.ts +311 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +165 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +776 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/counts.ts +92 -0
- package/src/internal/pictures/pictures.ts +521 -0
- package/src/internal/pictures/transport.ts +243 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +164 -0
- package/src/internal/reading/ready.ts +111 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +416 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +94 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-BF9_sORP.d.mts +0 -388
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
- package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
- package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-yCG9BmRU.mjs +0 -76
- package/dist/Images-yCG9BmRU.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-Dyxu2q8C.mjs +0 -543
- package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
- package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One CDP screencast per page, with bounded transport replies and per-reader loss accounting.
|
|
3
|
+
* Native callbacks own synchronous bookkeeping; the page scope owns startup and teardown.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
Clock,
|
|
7
|
+
Deferred,
|
|
8
|
+
Duration,
|
|
9
|
+
Effect,
|
|
10
|
+
Exit,
|
|
11
|
+
Option,
|
|
12
|
+
Queue,
|
|
13
|
+
type Scope,
|
|
14
|
+
Semaphore,
|
|
15
|
+
Stream,
|
|
16
|
+
} from "effect";
|
|
17
|
+
|
|
18
|
+
import { type BrowserError, InvalidRequest } from "../../BrowserError.ts";
|
|
19
|
+
import { BrowserPaint, CaptureStats, Frame, type ScreencastOptions } from "../../Frame.ts";
|
|
20
|
+
import { undispatched } from "../page/context.ts";
|
|
21
|
+
import { type Estimate, toHostTime, uncertaintyAt } from "./clock.ts";
|
|
22
|
+
import { tally } from "./counts.ts";
|
|
23
|
+
import type { NativeFrame, Transport } from "./transport.ts";
|
|
24
|
+
|
|
25
|
+
export { countsKept } from "./counts.ts";
|
|
26
|
+
|
|
27
|
+
export interface Size {
|
|
28
|
+
readonly width: number;
|
|
29
|
+
readonly height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Options {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** Where each capture's screencast runs, found as it starts. */
|
|
35
|
+
readonly transport: Effect.Effect<Transport, BrowserError>;
|
|
36
|
+
readonly clock: Clock.Clock;
|
|
37
|
+
/** The estimate a capture starts with, once the page can be captured. */
|
|
38
|
+
readonly calibrate: Effect.Effect<Estimate, BrowserError>;
|
|
39
|
+
/** The browser's newest estimate, which times each frame. */
|
|
40
|
+
readonly latest: () => Estimate | undefined;
|
|
41
|
+
/** Measure the clock again if it is due, while frames flow. */
|
|
42
|
+
readonly renew: Effect.Effect<void>;
|
|
43
|
+
/** What the page needs from its own session while frames flow, sent as a capture starts. */
|
|
44
|
+
readonly watch: Effect.Effect<unknown, BrowserError>;
|
|
45
|
+
/** How long frames are kept, measured back from the newest. */
|
|
46
|
+
readonly frameHistory: Duration.Duration;
|
|
47
|
+
/** The viewport in CSS pixels; a capture is scaled to fit it, as screenshots are. */
|
|
48
|
+
readonly viewport: Effect.Effect<Size, BrowserError>;
|
|
49
|
+
readonly imageSize: (data: Uint8Array) => Size | undefined;
|
|
50
|
+
readonly error: (cause: unknown) => BrowserError;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Envelope =
|
|
54
|
+
| { readonly _tag: "Frame"; readonly sequence: number; readonly frame: Frame }
|
|
55
|
+
| { readonly _tag: "Failure"; readonly error: BrowserError };
|
|
56
|
+
|
|
57
|
+
interface Held {
|
|
58
|
+
readonly frame: Frame;
|
|
59
|
+
readonly timestamp: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Consecutive frames of one device size other than the expected one, oldest first. */
|
|
63
|
+
interface Run {
|
|
64
|
+
readonly device: Size;
|
|
65
|
+
/** When its first frame arrived, on the owner's monotonic clock. */
|
|
66
|
+
readonly since: number;
|
|
67
|
+
readonly frames: Array<Held>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Generation {
|
|
71
|
+
readonly subscribers: Set<Queue.Queue<Envelope>>;
|
|
72
|
+
readonly transport: Transport;
|
|
73
|
+
/** Stops handing the transport's frames to this generation. */
|
|
74
|
+
unlisten: () => void;
|
|
75
|
+
readonly calibration: Estimate;
|
|
76
|
+
/** The native capture's settings; every reader of this generation shares them. */
|
|
77
|
+
readonly quality: number;
|
|
78
|
+
readonly size: Size | null;
|
|
79
|
+
readonly onFrame: (frame: NativeFrame) => void;
|
|
80
|
+
accepting: boolean;
|
|
81
|
+
predecessor: number | undefined;
|
|
82
|
+
failure: BrowserError | undefined;
|
|
83
|
+
stopReply: Promise<void> | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Controller {
|
|
87
|
+
/** Acquire a running capture now; its stream waits independently for the first frame. */
|
|
88
|
+
readonly open: (options?: ScreencastOptions) => Effect.Effect<
|
|
89
|
+
{
|
|
90
|
+
readonly stream: Stream.Stream<Frame, BrowserError>;
|
|
91
|
+
/** Current before this reader subscribed, never duplicated in its stream. */
|
|
92
|
+
readonly current: Frame | undefined;
|
|
93
|
+
},
|
|
94
|
+
BrowserError,
|
|
95
|
+
Scope.Scope
|
|
96
|
+
>;
|
|
97
|
+
readonly stream: (options?: ScreencastOptions) => Stream.Stream<Frame, BrowserError>;
|
|
98
|
+
readonly latest: Effect.Effect<Option.Option<Frame>>;
|
|
99
|
+
readonly recent: Effect.Effect<ReadonlyArray<Frame>>;
|
|
100
|
+
/** Lifetime counts, or those of the latest `windowMillis`, at most `countsKept`. */
|
|
101
|
+
readonly stats: (windowMillis?: number) => Effect.Effect<CaptureStats>;
|
|
102
|
+
readonly active: Effect.Effect<boolean>;
|
|
103
|
+
/** Once every frame the running capture's connection sent before now has arrived. */
|
|
104
|
+
readonly sync: Effect.Effect<void, BrowserError>;
|
|
105
|
+
/** This exact frame still belongs to the active capture and its current document. */
|
|
106
|
+
readonly current: (frame: Frame) => boolean;
|
|
107
|
+
/** Confirm this exact frame remains current after the capture connection's round trip. */
|
|
108
|
+
readonly confirm: (frame: Frame) => Effect.Effect<boolean, BrowserError>;
|
|
109
|
+
/** Run a clipped or scaled picture of the page, keeping what it draws out of the capture. */
|
|
110
|
+
readonly excluding: <A, E, R>(
|
|
111
|
+
picture: Effect.Effect<A, E, R>,
|
|
112
|
+
resized?: boolean,
|
|
113
|
+
) => Effect.Effect<A, E, R>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** When the library took a clipped picture: from its call to its reply, in host milliseconds. */
|
|
117
|
+
interface Excluded {
|
|
118
|
+
readonly from: number;
|
|
119
|
+
until: number;
|
|
120
|
+
readonly resized: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const replyCapacity = 32;
|
|
124
|
+
const subscriberCapacity = 16;
|
|
125
|
+
const deadline = Duration.seconds(2);
|
|
126
|
+
|
|
127
|
+
// Device sizes can be fractional at some scale factors; the page reports whole pixels.
|
|
128
|
+
const sameSize = (left: Size, right: Size) =>
|
|
129
|
+
Math.abs(left.width - right.width) < 1 && Math.abs(left.height - right.height) < 1;
|
|
130
|
+
|
|
131
|
+
// A frame is its device scaled to fit, so it keeps the device's shape to within a pixel a side.
|
|
132
|
+
const sameShape = (image: Size, device: Size) =>
|
|
133
|
+
Math.abs(image.width * device.height - image.height * device.width) <
|
|
134
|
+
device.width + device.height;
|
|
135
|
+
|
|
136
|
+
// Another capture's frames last only as long as it does; a run of frames that lasts longer is
|
|
137
|
+
// the page's own.
|
|
138
|
+
const outlasting = Duration.seconds(1);
|
|
139
|
+
|
|
140
|
+
// No frame painted during a picture arrives this long after it.
|
|
141
|
+
const excludedFor = Duration.seconds(10);
|
|
142
|
+
|
|
143
|
+
// Every frame of a crop is painted before Chromium answers the picture, yet each test of it can
|
|
144
|
+
// land past the reply: its stamp, because Chromium stamps frames on another clock than the one the
|
|
145
|
+
// calibration reads (2 to 3 ms late on an idle machine here), and its arrival, because a frame is
|
|
146
|
+
// encoded after it is painted (3 to 6 ms). A frame is kept only when both land this long after.
|
|
147
|
+
const settling = Duration.millis(50);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Chromium draws the library's own clipped pictures into the running screencast. Where Playwright
|
|
151
|
+
* emulates the viewport they keep the page's device size, and its shape when the crop has the
|
|
152
|
+
* viewport's proportions, so nothing in a frame tells them apart: a frame painted, or arriving,
|
|
153
|
+
* while one is taken is left out, the page's own included. A crop on the page's own session is
|
|
154
|
+
* drawn at the crop's size, so there only a frame of another size than the page's is left out.
|
|
155
|
+
*/
|
|
156
|
+
const pictureWindows = (now: () => number) => {
|
|
157
|
+
let windows: ReadonlyArray<Excluded> = [];
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
drawn: ({ hostTime, timing, receivedAt }: Frame, foreign: boolean) =>
|
|
161
|
+
windows.some((window) => {
|
|
162
|
+
const until = window.until + Duration.toMillis(settling);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
(foreign || !window.resized) &&
|
|
166
|
+
((hostTime + timing.uncertaintyMillis >= window.from &&
|
|
167
|
+
hostTime - timing.uncertaintyMillis <= until) ||
|
|
168
|
+
(receivedAt >= window.from && receivedAt <= until))
|
|
169
|
+
);
|
|
170
|
+
}),
|
|
171
|
+
excluding: <A, E, R>(picture: Effect.Effect<A, E, R>, resized = false) =>
|
|
172
|
+
Effect.acquireUseRelease(
|
|
173
|
+
Effect.sync(() => {
|
|
174
|
+
const window = { from: now(), until: Number.POSITIVE_INFINITY, resized };
|
|
175
|
+
const since = window.from - Duration.toMillis(excludedFor);
|
|
176
|
+
|
|
177
|
+
windows = [...windows.filter((kept) => kept.until > since), window];
|
|
178
|
+
|
|
179
|
+
return window;
|
|
180
|
+
}),
|
|
181
|
+
() => picture,
|
|
182
|
+
(window) =>
|
|
183
|
+
Effect.sync(() => {
|
|
184
|
+
window.until = now();
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A native frame as readers get it: its picture's size, else the capture's or its device's, and
|
|
192
|
+
* its paint mapped by the browser's newest estimate, which may be older than the frame and says
|
|
193
|
+
* less about later paint.
|
|
194
|
+
*/
|
|
195
|
+
const timed = (
|
|
196
|
+
options: Options,
|
|
197
|
+
native: NativeFrame,
|
|
198
|
+
timestamp: number,
|
|
199
|
+
generation: Pick<Generation, "calibration" | "size" | "transport">,
|
|
200
|
+
) => {
|
|
201
|
+
const data = Buffer.from(native.data, "base64");
|
|
202
|
+
const image = options.imageSize(data);
|
|
203
|
+
const device = { width: native.metadata.deviceWidth, height: native.metadata.deviceHeight };
|
|
204
|
+
const size = image ?? generation.size ?? device;
|
|
205
|
+
const estimate = options.latest() ?? generation.calibration;
|
|
206
|
+
const hostTime = toHostTime(estimate, timestamp);
|
|
207
|
+
|
|
208
|
+
const frame = new Frame({
|
|
209
|
+
page: options.id,
|
|
210
|
+
data,
|
|
211
|
+
timing: new BrowserPaint({
|
|
212
|
+
timestamp,
|
|
213
|
+
hostTime,
|
|
214
|
+
uncertaintyMillis: uncertaintyAt(estimate, hostTime),
|
|
215
|
+
}),
|
|
216
|
+
receivedAt: Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6,
|
|
217
|
+
width: size.width,
|
|
218
|
+
height: size.height,
|
|
219
|
+
...generation.transport.frameTag(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return { frame, image, device };
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// A page too busy to report its viewport in time reads as null.
|
|
226
|
+
const readViewport = (options: Options) =>
|
|
227
|
+
options.viewport.pipe(
|
|
228
|
+
Effect.interruptible,
|
|
229
|
+
Effect.timeoutOrElse({ duration: deadline, orElse: () => Effect.succeed(null) }),
|
|
230
|
+
Effect.orElseSucceed(() => null),
|
|
231
|
+
Effect.provideService(Clock.Clock, options.clock),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What a new capture starts with: where it runs, the clock's estimate, and the viewport unless
|
|
236
|
+
* the reader gave a size. A capture connection attaches while the page's own session maps the
|
|
237
|
+
* clock, and a page too busy to report its viewport is still captured, at device size.
|
|
238
|
+
*/
|
|
239
|
+
const prepare = (options: Options, screencast: ScreencastOptions) =>
|
|
240
|
+
Effect.all(
|
|
241
|
+
[
|
|
242
|
+
options.transport.pipe(Effect.interruptible),
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const calibration = yield* options.calibrate.pipe(Effect.interruptible);
|
|
245
|
+
const viewport = screencast.size === undefined ? yield* readViewport(options) : null;
|
|
246
|
+
|
|
247
|
+
return { calibration, viewport };
|
|
248
|
+
}),
|
|
249
|
+
],
|
|
250
|
+
{ concurrency: 2 },
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Keeps other captures' frames from readers. A clipped or scaled screenshot of the page, taken
|
|
255
|
+
* from any session, draws frames into its running screencast without resizing the page, until
|
|
256
|
+
* Chromium returns to the page's size. Either their picture has another shape than their device,
|
|
257
|
+
* and they are dropped, or their device has another size than the page's frames.
|
|
258
|
+
*
|
|
259
|
+
* A frame of the expected device size goes to readers at once and drops any run of frames of
|
|
260
|
+
* another size before it, as another capture's. A run waits, in order, until the page reports a
|
|
261
|
+
* viewport of its size or until it outlasts any capture: then it was a real resize, so it goes to
|
|
262
|
+
* readers and its size is expected. The page's report only shortens the wait, so a page too busy
|
|
263
|
+
* to answer, or one measuring in other units as under browser zoom, never stalls the stream.
|
|
264
|
+
*/
|
|
265
|
+
const sizeFilter = (
|
|
266
|
+
options: Options,
|
|
267
|
+
deliver: (frame: Frame, timestamp: number) => void,
|
|
268
|
+
drop: (frames: number) => void,
|
|
269
|
+
) =>
|
|
270
|
+
Effect.gen(function* () {
|
|
271
|
+
// Until a capture reads its viewport or delivers a frame, its first frame stands in.
|
|
272
|
+
let expected: Size | null = null;
|
|
273
|
+
let run: Run | undefined;
|
|
274
|
+
// Wakes the decider for a new run or an ended one; wakes while it is busy coalesce.
|
|
275
|
+
const wake = yield* Queue.sliding<void>(1);
|
|
276
|
+
|
|
277
|
+
const end = () => {
|
|
278
|
+
if (run === undefined) return;
|
|
279
|
+
drop(run.frames.length);
|
|
280
|
+
run = undefined;
|
|
281
|
+
Queue.offerUnsafe(wake, undefined);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const now = () => Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6;
|
|
285
|
+
|
|
286
|
+
const adopt = (adopted: Run) => {
|
|
287
|
+
run = undefined;
|
|
288
|
+
expected = adopted.device;
|
|
289
|
+
for (const held of adopted.frames) deliver(held.frame, held.timestamp);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const decide: Effect.Effect<void> = Effect.suspend(() => {
|
|
293
|
+
const deciding = run;
|
|
294
|
+
|
|
295
|
+
if (deciding === undefined) return Effect.void;
|
|
296
|
+
|
|
297
|
+
return readViewport(options).pipe(
|
|
298
|
+
Effect.flatMap((viewport) => {
|
|
299
|
+
if (run !== deciding) return decide;
|
|
300
|
+
if (viewport !== null && sameSize(viewport, deciding.device))
|
|
301
|
+
return Effect.sync(() => adopt(deciding));
|
|
302
|
+
|
|
303
|
+
return outlast(deciding);
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Waits until the run outlasts any capture, unless a newer frame decides it first.
|
|
309
|
+
const outlast = (waiting: Run): Effect.Effect<void> =>
|
|
310
|
+
Effect.suspend(() => {
|
|
311
|
+
if (run !== waiting) return decide;
|
|
312
|
+
const remaining = waiting.since + Duration.toMillis(outlasting) - now();
|
|
313
|
+
|
|
314
|
+
if (remaining <= 0) return Effect.sync(() => adopt(waiting));
|
|
315
|
+
|
|
316
|
+
return Effect.sleep(Duration.millis(remaining)).pipe(
|
|
317
|
+
Effect.provideService(Clock.Clock, options.clock),
|
|
318
|
+
Effect.race(Queue.take(wake)),
|
|
319
|
+
Effect.andThen(outlast(waiting)),
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
yield* Queue.take(wake).pipe(Effect.andThen(decide), Effect.forever, Effect.forkScoped);
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
/** Drops the frames still waiting; their capture has stopped. */
|
|
327
|
+
end,
|
|
328
|
+
/** A new capture expects the viewport it read, if any. */
|
|
329
|
+
restart: (viewport: Size | null) => {
|
|
330
|
+
end();
|
|
331
|
+
expected = viewport;
|
|
332
|
+
},
|
|
333
|
+
/** Whether a frame of this device size shows the page, as far as the capture knows. */
|
|
334
|
+
expects: (device: Size) => expected !== null && sameSize(device, expected),
|
|
335
|
+
offer: (frame: Frame, timestamp: number, image: Size | undefined, device: Size) => {
|
|
336
|
+
if (image !== undefined && !sameShape(image, device)) {
|
|
337
|
+
drop(1);
|
|
338
|
+
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
expected ??= device;
|
|
342
|
+
if (sameSize(device, expected)) {
|
|
343
|
+
end();
|
|
344
|
+
deliver(frame, timestamp);
|
|
345
|
+
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (run !== undefined && !sameSize(run.device, device)) end();
|
|
349
|
+
if (run === undefined) {
|
|
350
|
+
run = { device, since: now(), frames: [] };
|
|
351
|
+
Queue.offerUnsafe(wake, undefined);
|
|
352
|
+
}
|
|
353
|
+
run.frames.push({ frame, timestamp });
|
|
354
|
+
if (run.frames.length > subscriberCapacity) {
|
|
355
|
+
run.frames.shift();
|
|
356
|
+
drop(1);
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A running capture's failure, told once its page's fate is known. A capture can fail before the
|
|
364
|
+
* page's own session hears why: a capture connection hears the browser's end before the connection
|
|
365
|
+
* that drives the page does, and an acknowledgement can be refused before the browser's loss is
|
|
366
|
+
* known. So the failure waits for the page's scope to close, `closed`, and is then the page's own
|
|
367
|
+
* end or its browser's loss, as `gone` reads it; or, if the page still stands after `deadline`,
|
|
368
|
+
* the capture's own.
|
|
369
|
+
*/
|
|
370
|
+
const told =
|
|
371
|
+
(closed: Deferred.Deferred<void>, gone: () => BrowserError, clock: Clock.Clock) =>
|
|
372
|
+
(failure: BrowserError) =>
|
|
373
|
+
Deferred.await(closed).pipe(
|
|
374
|
+
Effect.map(gone),
|
|
375
|
+
Effect.timeoutOrElse({ duration: deadline, orElse: () => Effect.succeed(failure) }),
|
|
376
|
+
Effect.provideService(Clock.Clock, clock),
|
|
377
|
+
Effect.flatMap(Effect.fail),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Once every frame the running capture's transport sent before now has arrived; a capture that
|
|
382
|
+
* has failed, or whose round trip fails, fails as `why` tells it.
|
|
383
|
+
*/
|
|
384
|
+
const synced = (
|
|
385
|
+
current: Generation | undefined,
|
|
386
|
+
why: (failure: BrowserError) => Effect.Effect<never, BrowserError>,
|
|
387
|
+
error: (cause: unknown) => BrowserError,
|
|
388
|
+
): Effect.Effect<void, BrowserError> => {
|
|
389
|
+
if (current === undefined) return Effect.void;
|
|
390
|
+
if (current.failure !== undefined) return why(current.failure);
|
|
391
|
+
|
|
392
|
+
return Effect.tryPromise({ try: () => current.transport.sync(), catch: error }).pipe(
|
|
393
|
+
Effect.catch(why),
|
|
394
|
+
Effect.asVoid,
|
|
395
|
+
);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const confirmation =
|
|
399
|
+
(
|
|
400
|
+
read: () => {
|
|
401
|
+
readonly generation: Generation | undefined;
|
|
402
|
+
readonly replies: number;
|
|
403
|
+
},
|
|
404
|
+
current: Controller["current"],
|
|
405
|
+
why: (failure: BrowserError) => Effect.Effect<never, BrowserError>,
|
|
406
|
+
error: (cause: unknown) => BrowserError,
|
|
407
|
+
): Controller["confirm"] =>
|
|
408
|
+
(frame) =>
|
|
409
|
+
Effect.suspend(() => {
|
|
410
|
+
const { generation } = read();
|
|
411
|
+
|
|
412
|
+
if (generation === undefined) return Effect.succeed(false);
|
|
413
|
+
|
|
414
|
+
return synced(generation, why, error).pipe(
|
|
415
|
+
Effect.map(() => {
|
|
416
|
+
const after = read();
|
|
417
|
+
|
|
418
|
+
return after.generation === generation && after.replies === 0 && current(frame);
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const isCurrent = (
|
|
424
|
+
frame: Frame,
|
|
425
|
+
generation: Generation | undefined,
|
|
426
|
+
latest: Option.Option<Frame>,
|
|
427
|
+
) =>
|
|
428
|
+
generation !== undefined &&
|
|
429
|
+
generation.accepting &&
|
|
430
|
+
generation.failure === undefined &&
|
|
431
|
+
Option.getOrUndefined(latest) === frame &&
|
|
432
|
+
generation.transport.frameTag().document === frame.document;
|
|
433
|
+
|
|
434
|
+
const currentFrame = (generation: Generation, latest: Option.Option<Frame>) =>
|
|
435
|
+
Option.getOrUndefined(Option.filter(latest, (frame) => isCurrent(frame, generation, latest)));
|
|
436
|
+
|
|
437
|
+
/** One reader counts its own missed envelopes without hiding gaps inside stream chunks. */
|
|
438
|
+
const frames = (
|
|
439
|
+
subscription: Queue.Queue<Envelope>,
|
|
440
|
+
baseline: number,
|
|
441
|
+
why: (failure: BrowserError) => Effect.Effect<never, BrowserError>,
|
|
442
|
+
missed: (count: number) => void,
|
|
443
|
+
) => {
|
|
444
|
+
let previous = baseline;
|
|
445
|
+
|
|
446
|
+
// Pull one envelope at a time so taking a stream chunk cannot hide a slow reader's loss.
|
|
447
|
+
return Stream.fromEffectRepeat(
|
|
448
|
+
Queue.take(subscription).pipe(
|
|
449
|
+
Effect.flatMap((envelope) => {
|
|
450
|
+
if (envelope._tag === "Failure") return why(envelope.error);
|
|
451
|
+
|
|
452
|
+
return Effect.sync(() => {
|
|
453
|
+
if (envelope.sequence > previous + 1) missed(envelope.sequence - previous - 1);
|
|
454
|
+
previous = envelope.sequence;
|
|
455
|
+
|
|
456
|
+
return envelope.frame;
|
|
457
|
+
});
|
|
458
|
+
}),
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
export const make = (options: Options) =>
|
|
464
|
+
Effect.gen(function* () {
|
|
465
|
+
const lock = yield* Semaphore.make(1);
|
|
466
|
+
const replies = new Set<Promise<void>>();
|
|
467
|
+
let generation: Generation | undefined;
|
|
468
|
+
// Done as the page's scope closes, once the page has gone and its browser's loss, if any, is
|
|
469
|
+
// known.
|
|
470
|
+
const closed = yield* Deferred.make<void>();
|
|
471
|
+
// The latest stop whose reply has not settled. A new capture waits for it rather than
|
|
472
|
+
// assuming it was lost; it is never submitted again.
|
|
473
|
+
let unsettledStop: Promise<void> | undefined;
|
|
474
|
+
let latest = Option.none<Frame>();
|
|
475
|
+
let history: ReadonlyArray<Frame> = [];
|
|
476
|
+
const keepMillis = Duration.toMillis(options.frameHistory);
|
|
477
|
+
const now = () => Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6;
|
|
478
|
+
const counts = tally(now);
|
|
479
|
+
// Frames delivered, which number them so that a reader can tell what it missed.
|
|
480
|
+
let delivered = 0;
|
|
481
|
+
const pictures = pictureWindows(now);
|
|
482
|
+
|
|
483
|
+
const current = (frame: Frame) => isCurrent(frame, generation, latest);
|
|
484
|
+
|
|
485
|
+
// Frames that show the page go to the running capture's readers.
|
|
486
|
+
const deliver = (frame: Frame, timestamp: number) => {
|
|
487
|
+
const current = generation;
|
|
488
|
+
|
|
489
|
+
if (current === undefined) return;
|
|
490
|
+
// Held frames are checked again: frames encoded out of order are dropped, never reordered.
|
|
491
|
+
if (current.predecessor !== undefined && timestamp <= current.predecessor)
|
|
492
|
+
return counts.count("late");
|
|
493
|
+
if (current.predecessor !== undefined) counts.gap(timestamp - current.predecessor);
|
|
494
|
+
current.predecessor = timestamp;
|
|
495
|
+
delivered++;
|
|
496
|
+
counts.count("accepted");
|
|
497
|
+
latest = Option.some(frame);
|
|
498
|
+
history = [...history.filter((kept) => kept.hostTime >= frame.hostTime - keepMillis), frame];
|
|
499
|
+
for (const subscriber of current.subscribers)
|
|
500
|
+
Queue.offerUnsafe(subscriber, { _tag: "Frame", sequence: delivered, frame });
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const sizes = yield* sizeFilter(options, deliver, (frames) =>
|
|
504
|
+
counts.count("foreignSize", frames),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// On air a capture runs for hours, and its frames are timed by the browser's newest estimate,
|
|
508
|
+
// so arriving frames renew it: a check at most each second, a measurement once it is due.
|
|
509
|
+
const renewing = yield* Queue.sliding<void>(1);
|
|
510
|
+
|
|
511
|
+
yield* Queue.take(renewing).pipe(
|
|
512
|
+
Effect.andThen(options.renew),
|
|
513
|
+
Effect.andThen(Effect.sleep(Duration.seconds(1))),
|
|
514
|
+
Effect.provideService(Clock.Clock, options.clock),
|
|
515
|
+
Effect.forever,
|
|
516
|
+
Effect.forkScoped,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const notifyFailure = (current: Generation, failure: BrowserError) => {
|
|
520
|
+
if (current.failure !== undefined) return;
|
|
521
|
+
current.failure = failure;
|
|
522
|
+
for (const subscriber of current.subscribers)
|
|
523
|
+
Queue.offerUnsafe(subscriber, { _tag: "Failure", error: failure });
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const stop = (current: Generation) => {
|
|
527
|
+
if (!current.accepting) return;
|
|
528
|
+
current.accepting = false;
|
|
529
|
+
sizes.end();
|
|
530
|
+
current.unlisten();
|
|
531
|
+
|
|
532
|
+
// Attach both handlers in the callback turn. Even a caller that leaves immediately never
|
|
533
|
+
// abandons a rejected native reply, and an uncertain stop is never submitted twice.
|
|
534
|
+
const reply = current.transport.stop().then(
|
|
535
|
+
() => undefined,
|
|
536
|
+
(cause: unknown) => {
|
|
537
|
+
notifyFailure(current, options.error(cause));
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
current.stopReply = reply;
|
|
542
|
+
unsettledStop = reply;
|
|
543
|
+
void reply.then(() => {
|
|
544
|
+
if (unsettledStop === reply) unsettledStop = undefined;
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const fail = (current: Generation, failure: BrowserError) => {
|
|
549
|
+
notifyFailure(current, failure);
|
|
550
|
+
stop(current);
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const gone = () => options.error(new Error("Target page has been closed"));
|
|
554
|
+
const stopDeadline = () => options.error(new Error("screencast stop exceeded its deadline"));
|
|
555
|
+
const why = told(closed, gone, options.clock);
|
|
556
|
+
|
|
557
|
+
// A stop's reply, awaited for at most `deadline`; `late` is what a stop past it means.
|
|
558
|
+
const settled = <E>(reply: () => Promise<void> | undefined, late: Effect.Effect<void, E>) =>
|
|
559
|
+
Effect.suspend(() => {
|
|
560
|
+
const pending = reply();
|
|
561
|
+
|
|
562
|
+
return pending === undefined
|
|
563
|
+
? Effect.void
|
|
564
|
+
: Effect.promise(() => pending).pipe(
|
|
565
|
+
Effect.interruptible,
|
|
566
|
+
Effect.timeoutOrElse({ duration: deadline, orElse: () => late }),
|
|
567
|
+
);
|
|
568
|
+
}).pipe(Effect.provideService(Clock.Clock, options.clock));
|
|
569
|
+
|
|
570
|
+
const awaitStop = (current: Generation) =>
|
|
571
|
+
settled(
|
|
572
|
+
() => current.stopReply,
|
|
573
|
+
Effect.sync(() => notifyFailure(current, stopDeadline())),
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// A late stop only delays the next capture; once its reply settles, capture can start again.
|
|
577
|
+
const awaitPreviousStop = settled(() => unsettledStop, Effect.fail(stopDeadline()));
|
|
578
|
+
|
|
579
|
+
// A reader leaves; the last one stops its generation.
|
|
580
|
+
const leave = (current: Generation, subscription: Queue.Queue<Envelope>) =>
|
|
581
|
+
Effect.gen(function* () {
|
|
582
|
+
current.subscribers.delete(subscription);
|
|
583
|
+
yield* Queue.shutdown(subscription);
|
|
584
|
+
if (current.subscribers.size !== 0) return;
|
|
585
|
+
yield* Effect.sync(() => stop(current));
|
|
586
|
+
yield* awaitStop(current);
|
|
587
|
+
if (generation === current) generation = undefined;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const acquire = (screencast: ScreencastOptions) =>
|
|
591
|
+
lock.withPermits(1)(
|
|
592
|
+
Effect.gen(function* () {
|
|
593
|
+
if (Deferred.isDoneUnsafe(closed)) return yield* gone();
|
|
594
|
+
let current = generation;
|
|
595
|
+
const starting = current === undefined;
|
|
596
|
+
|
|
597
|
+
if (current === undefined) {
|
|
598
|
+
yield* awaitPreviousStop;
|
|
599
|
+
const [transport, { calibration, viewport }] = yield* prepare(options, screencast);
|
|
600
|
+
const size = screencast.size ?? viewport;
|
|
601
|
+
|
|
602
|
+
if (Deferred.isDoneUnsafe(closed)) return yield* gone();
|
|
603
|
+
|
|
604
|
+
const created: Generation = {
|
|
605
|
+
subscribers: new Set(),
|
|
606
|
+
transport,
|
|
607
|
+
unlisten: () => undefined,
|
|
608
|
+
calibration,
|
|
609
|
+
quality: screencast.quality ?? 80,
|
|
610
|
+
size,
|
|
611
|
+
accepting: true,
|
|
612
|
+
predecessor: undefined,
|
|
613
|
+
failure: undefined,
|
|
614
|
+
stopReply: undefined,
|
|
615
|
+
onFrame: (native) => {
|
|
616
|
+
if (!created.accepting) return;
|
|
617
|
+
counts.count("received");
|
|
618
|
+
if (replies.size >= replyCapacity) {
|
|
619
|
+
fail(
|
|
620
|
+
created,
|
|
621
|
+
options.error(new Error("screencast acknowledgement capacity exceeded")),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const reply = created.transport.acknowledge(native.sessionId).then(
|
|
628
|
+
() => {
|
|
629
|
+
replies.delete(reply);
|
|
630
|
+
},
|
|
631
|
+
(cause: unknown) => {
|
|
632
|
+
replies.delete(reply);
|
|
633
|
+
if (created.accepting) fail(created, options.error(cause));
|
|
634
|
+
},
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
replies.add(reply);
|
|
638
|
+
const browserSeconds = native.metadata.timestamp;
|
|
639
|
+
const timestamp = browserSeconds === undefined ? Number.NaN : browserSeconds * 1000;
|
|
640
|
+
|
|
641
|
+
if (!Number.isFinite(timestamp)) return counts.count("missingTimestamp");
|
|
642
|
+
if (created.predecessor !== undefined && timestamp <= created.predecessor)
|
|
643
|
+
return counts.count("late");
|
|
644
|
+
Queue.offerUnsafe(renewing, undefined);
|
|
645
|
+
const { frame, image, device } = timed(options, native, timestamp, created);
|
|
646
|
+
|
|
647
|
+
if (pictures.drawn(frame, !sizes.expects(device)))
|
|
648
|
+
return counts.count("duringPictures");
|
|
649
|
+
sizes.offer(frame, timestamp, image, device);
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
current = created;
|
|
654
|
+
generation = current;
|
|
655
|
+
latest = Option.none();
|
|
656
|
+
sizes.restart(viewport);
|
|
657
|
+
} else if (
|
|
658
|
+
(screencast.quality !== undefined && screencast.quality !== current.quality) ||
|
|
659
|
+
(screencast.size !== undefined &&
|
|
660
|
+
(screencast.size.width !== current.size?.width ||
|
|
661
|
+
screencast.size.height !== current.size.height))
|
|
662
|
+
)
|
|
663
|
+
// One native capture serves every reader; silently giving a reader other settings
|
|
664
|
+
// than it asked for would misstate its frames.
|
|
665
|
+
return yield* undispatched(
|
|
666
|
+
"screencast",
|
|
667
|
+
new InvalidRequest({
|
|
668
|
+
detail: `a screencast with quality ${current.quality}${
|
|
669
|
+
current.size === null
|
|
670
|
+
? ""
|
|
671
|
+
: ` and size ${current.size.width}x${current.size.height}`
|
|
672
|
+
} is already running on this page; read it without options or with the same ones`,
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
// The callback API must apply sliding synchronously; PubSub.publishUnsafe skips it.
|
|
676
|
+
const subscription = yield* Queue.sliding<Envelope>(subscriberCapacity);
|
|
677
|
+
|
|
678
|
+
current.subscribers.add(subscription);
|
|
679
|
+
// A reader that joins a capture that has failed is told as its other readers are.
|
|
680
|
+
if (current.failure !== undefined)
|
|
681
|
+
Queue.offerUnsafe(subscription, { _tag: "Failure", error: current.failure });
|
|
682
|
+
const baseline = delivered;
|
|
683
|
+
const frame = currentFrame(current, latest);
|
|
684
|
+
|
|
685
|
+
if (starting) {
|
|
686
|
+
const { transport, quality, size } = current;
|
|
687
|
+
const listening = current;
|
|
688
|
+
|
|
689
|
+
current.unlisten = transport.listen({
|
|
690
|
+
frame: current.onFrame,
|
|
691
|
+
lost: (error) => fail(listening, error),
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const screencast = Effect.tryPromise({
|
|
695
|
+
try: () =>
|
|
696
|
+
transport.start({
|
|
697
|
+
format: "jpeg",
|
|
698
|
+
quality,
|
|
699
|
+
...(size === null ? {} : { maxWidth: size.width, maxHeight: size.height }),
|
|
700
|
+
}),
|
|
701
|
+
catch: options.error,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const started = yield* Effect.all([options.watch, screencast], {
|
|
705
|
+
concurrency: 2,
|
|
706
|
+
}).pipe(
|
|
707
|
+
Effect.interruptible,
|
|
708
|
+
Effect.timeoutOrElse({
|
|
709
|
+
duration: deadline,
|
|
710
|
+
orElse: () =>
|
|
711
|
+
Effect.fail(options.error(new Error("screencast start exceeded its deadline"))),
|
|
712
|
+
}),
|
|
713
|
+
Effect.provideService(Clock.Clock, options.clock),
|
|
714
|
+
Effect.exit,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
if (Exit.isFailure(started)) {
|
|
718
|
+
yield* leave(current, subscription);
|
|
719
|
+
|
|
720
|
+
return yield* Effect.failCause(started.cause);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return { current, subscription, baseline, frame };
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
const open: Controller["open"] = (screencast = {}) =>
|
|
729
|
+
Effect.acquireRelease(acquire(screencast), ({ current, subscription }) =>
|
|
730
|
+
lock.withPermits(1)(leave(current, subscription)),
|
|
731
|
+
).pipe(
|
|
732
|
+
Effect.map((lease) => ({
|
|
733
|
+
current: lease.frame,
|
|
734
|
+
stream: frames(lease.subscription, lease.baseline, why, (count) =>
|
|
735
|
+
counts.count("lost", count),
|
|
736
|
+
),
|
|
737
|
+
})),
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
// The page's scope closes once the page has gone, when its browser's loss, if any, is known.
|
|
741
|
+
yield* Effect.addFinalizer(() =>
|
|
742
|
+
lock.withPermits(1)(
|
|
743
|
+
Effect.suspend(() => {
|
|
744
|
+
const current = generation;
|
|
745
|
+
|
|
746
|
+
Deferred.doneUnsafe(closed, Exit.void);
|
|
747
|
+
if (current === undefined) return Effect.void;
|
|
748
|
+
fail(current, gone());
|
|
749
|
+
|
|
750
|
+
return awaitStop(current);
|
|
751
|
+
}),
|
|
752
|
+
),
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
open,
|
|
757
|
+
stream: (options?: ScreencastOptions) =>
|
|
758
|
+
Stream.unwrap(Effect.map(open(options), ({ stream }) => stream)),
|
|
759
|
+
latest: Effect.sync(() => latest),
|
|
760
|
+
recent: Effect.sync(() => history),
|
|
761
|
+
active: Effect.sync(() => generation?.accepting === true),
|
|
762
|
+
sync: Effect.suspend(() => synced(generation, why, options.error)),
|
|
763
|
+
current,
|
|
764
|
+
confirm: confirmation(
|
|
765
|
+
() => ({ generation, replies: replies.size }),
|
|
766
|
+
current,
|
|
767
|
+
why,
|
|
768
|
+
options.error,
|
|
769
|
+
),
|
|
770
|
+
excluding: pictures.excluding,
|
|
771
|
+
stats: (windowMillis?: number) =>
|
|
772
|
+
Effect.sync(
|
|
773
|
+
() => new CaptureStats({ ...counts.read(windowMillis), ackBacklog: replies.size }),
|
|
774
|
+
),
|
|
775
|
+
} satisfies Controller;
|
|
776
|
+
});
|