@m4l-jweb/wrapper 0.5.0 → 0.6.5
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/package.json +1 -1
- package/src/core.ts +512 -2
- package/src/max.d.ts +28 -0
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -76,21 +76,169 @@ function reload(): void {
|
|
|
76
76
|
*/
|
|
77
77
|
function anything(): void {}
|
|
78
78
|
|
|
79
|
+
/* ------------------------------------------------------------------ *
|
|
80
|
+
* Who to answer
|
|
81
|
+
*
|
|
82
|
+
* A reply normally goes out outlet 0, to the device's OWN [jweb]. But a floating
|
|
83
|
+
* window's [jweb] lives inside a subpatcher, so there is no cord to it - the
|
|
84
|
+
* wrapper reaches it BY NAME, through its [r window-read-<id>] (see loadWindows).
|
|
85
|
+
*
|
|
86
|
+
* So a handler must not hard-code outlet(0): when it is answering a WINDOW it has
|
|
87
|
+
* to route to that window's receiver instead. `window()` below sets replyWindow
|
|
88
|
+
* for the duration of a window message's dispatch, and reply() honours it - so the
|
|
89
|
+
* same get_state()/ui_ready() serve the device view AND any window, unchanged.
|
|
90
|
+
* ------------------------------------------------------------------ */
|
|
91
|
+
|
|
92
|
+
/** The window a reply should go to, or null for the device's own [jweb] (outlet 0). */
|
|
93
|
+
var replyWindow: string | null = null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* outlet(0, selector, value) that follows replyWindow - to a window's
|
|
97
|
+
* [r window-read-<id>] when one is set, to the device's own [jweb] otherwise.
|
|
98
|
+
*
|
|
99
|
+
* It takes a FIXED (selector, value) rather than a rest arg, and that is
|
|
100
|
+
* deliberate: `outlet` and `messnamed` are Max HOST functions, and calling
|
|
101
|
+
* `.apply` on them is not reliable across Max builds - when it fails it fails
|
|
102
|
+
* SILENTLY, and an exception here takes the whole ui_ready handshake (mode, build,
|
|
103
|
+
* the state resend) down with it, with no symptom but a device that never fills in
|
|
104
|
+
* its header. Every reply the wrapper sends is one selector and one value, so this
|
|
105
|
+
* is all it ever needs.
|
|
106
|
+
*/
|
|
107
|
+
function reply(selector: string, value: unknown): void {
|
|
108
|
+
if (replyWindow !== null) messnamed("window-read-" + replyWindow, selector, value);
|
|
109
|
+
else outlet(0, selector, value);
|
|
110
|
+
}
|
|
111
|
+
|
|
79
112
|
/**
|
|
80
113
|
* The UI announces it finished loading. The page loads asynchronously, so never
|
|
81
114
|
* assume it was listening when state last changed - resend all of it.
|
|
82
115
|
*/
|
|
83
116
|
function ui_ready(): void {
|
|
84
|
-
|
|
117
|
+
reply("mode", MODE);
|
|
85
118
|
// The UI shows this next to its own baked-in version: a mismatch means a
|
|
86
119
|
// mixed install (stale .amxd instance vs newer extracted UI, or vice versa).
|
|
87
|
-
|
|
120
|
+
reply("build", buildStamp());
|
|
88
121
|
sendCurrentTempo(); // liveapi.ts
|
|
89
122
|
// The device resends its own state here. The page loads asynchronously, so
|
|
90
123
|
// anything sent before it was listening is simply gone.
|
|
91
124
|
if (typeof onUiReady === "function") onUiReady();
|
|
92
125
|
}
|
|
93
126
|
|
|
127
|
+
/* ------------------------------------------------------------------ *
|
|
128
|
+
* State persistence
|
|
129
|
+
*
|
|
130
|
+
* A declared `state` slot is a named [dict] in the patcher with a [pattr] bound
|
|
131
|
+
* to it (see applyPersistence in surface.mjs). The pattr is what SAVES: Live
|
|
132
|
+
* stores a pattr's value in the set, and restores it before this script runs.
|
|
133
|
+
* The dict is where the app's JSON lives while the set is open.
|
|
134
|
+
*
|
|
135
|
+
* The app never touches the dict directly - it cannot; it is in the patcher. It
|
|
136
|
+
* asks for a slot (`get_state <id>`) and writes one (`sync_state <id> <json>`),
|
|
137
|
+
* and BOTH selectors carry the id as an ARGUMENT, not in the selector name.
|
|
138
|
+
* That matters: Max dispatches a message on its first word, so an app emitting
|
|
139
|
+
* `sync_state_config` looks for a `function sync_state_config()` that no device
|
|
140
|
+
* has, lands in anything(), and is silently swallowed. It did exactly that -
|
|
141
|
+
* every write was dropped, and the read path worked, so the state looked like it
|
|
142
|
+
* simply never persisted. The app side is @m4l-jweb/surface's stateStore().
|
|
143
|
+
* ------------------------------------------------------------------ */
|
|
144
|
+
|
|
145
|
+
/** The app asks for a slot. Reply on the inlet it binds: `state_<id> <json>`. */
|
|
146
|
+
function get_state(id: string): void {
|
|
147
|
+
try {
|
|
148
|
+
var d = new Dict("obj-state-" + id);
|
|
149
|
+
var json = d.stringify();
|
|
150
|
+
// Logged, because this is the ONLY moment that can tell you whether Live actually
|
|
151
|
+
// restored the slot: the app asks for it once the page is up, and what the dict
|
|
152
|
+
// hands back is what came out of the set. An empty "{}" here after a reopen means
|
|
153
|
+
// the [pattr] did not save - which is a failure with no other symptom.
|
|
154
|
+
post("m4l-jweb: get_state " + id + " -> " + json + "\n");
|
|
155
|
+
reply("state_" + id, json);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
post("m4l-jweb: get_state error for " + id + " - " + (e as Error).message + "\n");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The app writes a slot: `sync_state <id> <json...>`.
|
|
163
|
+
*
|
|
164
|
+
* The JSON arrives SPLIT: Max parses a message into atoms on whitespace, so
|
|
165
|
+
* anything the app stringified with spaces in it reaches us as several
|
|
166
|
+
* arguments. Join them back before parsing.
|
|
167
|
+
*/
|
|
168
|
+
function sync_state(id: string): void {
|
|
169
|
+
try {
|
|
170
|
+
var jsonParts: string[] = [];
|
|
171
|
+
for (var i = 1; i < arguments.length; i++) {
|
|
172
|
+
jsonParts.push(String(arguments[i]));
|
|
173
|
+
}
|
|
174
|
+
var json = jsonParts.join(" ");
|
|
175
|
+
var d = new Dict("obj-state-" + id);
|
|
176
|
+
d.parse(json);
|
|
177
|
+
// Read it straight back out. `parse` failing silently, or the dict box not
|
|
178
|
+
// existing at all (it was emitted with an invalid maxclass for a while, so [pattr]
|
|
179
|
+
// bound to nothing), both look exactly like a successful write from here. What the
|
|
180
|
+
// dict says it holds is the only evidence.
|
|
181
|
+
post("m4l-jweb: sync_state " + id + " <- " + json + " (dict now " + d.stringify() + ")\n");
|
|
182
|
+
// BROADCAST the new value to every OTHER view - the device UI and any other
|
|
183
|
+
// window - so a live edit in one page appears in the rest at once. Without this
|
|
184
|
+
// a second page only sees a slot when it next asks for it (on load), which is
|
|
185
|
+
// why an edit in the floating window never reached the device view. The WRITER
|
|
186
|
+
// is skipped: it already applied the value optimistically, and echoing it back
|
|
187
|
+
// could revert the control mid-typing (stateStore has no echo guard).
|
|
188
|
+
broadcastState(id, d.stringify());
|
|
189
|
+
} catch (e) {
|
|
190
|
+
post("m4l-jweb: sync_state error for " + id + " - " + (e as Error).message + "\n");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Push `state_<id> <json>` to every view except the one that wrote it (replyWindow:
|
|
196
|
+
* a window id, or null for the device view). A window is reached by name, the
|
|
197
|
+
* device view by outlet 0 - the same two paths reply() chooses between.
|
|
198
|
+
*/
|
|
199
|
+
function broadcastState(id: string, json: string): void {
|
|
200
|
+
if (replyWindow !== null) outlet(0, "state_" + id, json); // device view, unless it wrote
|
|
201
|
+
var ids = listWindowIds();
|
|
202
|
+
for (var i = 0; i < ids.length; i++) {
|
|
203
|
+
if (ids[i] !== replyWindow) messnamed("window-read-" + ids[i], "state_" + id, json);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------------------------ *
|
|
208
|
+
* Floating-window messages
|
|
209
|
+
*
|
|
210
|
+
* A window's page uses the ORDINARY bridge (`outlet(sel, ...)`), so it emits bare
|
|
211
|
+
* selectors just like the device view. They cannot arrive here bare, though: this
|
|
212
|
+
* [js] already has a get_state/ui_ready/etc, and a window's must not be mistaken
|
|
213
|
+
* for the device view's. So the subpatcher TAGS them - `[prepend window <id>]` on
|
|
214
|
+
* the window's [jweb] outlet (see applyWindows in surface.mjs) - and they land
|
|
215
|
+
* here as `window <id> <selector> <args...>`, dispatched on the first word.
|
|
216
|
+
*
|
|
217
|
+
* We then dispatch the INNER selector through the very same handlers, with
|
|
218
|
+
* replyWindow set so any reply routes back to the window and not the device view.
|
|
219
|
+
* That is what gives a window access to the device's state: `get_state`/
|
|
220
|
+
* `sync_state` reach the shared [dict], and `state_<id>` comes back to the window.
|
|
221
|
+
* Anything the library does not know goes to the device's own onWindowMessage().
|
|
222
|
+
* ------------------------------------------------------------------ */
|
|
223
|
+
|
|
224
|
+
function window(id: string): void {
|
|
225
|
+
var selector = String(arguments[1]);
|
|
226
|
+
var args: unknown[] = [];
|
|
227
|
+
for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
|
|
228
|
+
|
|
229
|
+
var prev = replyWindow;
|
|
230
|
+
replyWindow = id;
|
|
231
|
+
try {
|
|
232
|
+
if (selector === "ui_ready") ui_ready();
|
|
233
|
+
else if (selector === "get_state") get_state(String(args[0]));
|
|
234
|
+
else if (selector === "sync_state") (sync_state as any).apply(null, args);
|
|
235
|
+
else if (typeof onWindowMessage === "function") (onWindowMessage as any).apply(null, [id, selector].concat(args as any[]));
|
|
236
|
+
else post("m4l-jweb: window " + id + " sent unhandled '" + selector + "'\n");
|
|
237
|
+
} finally {
|
|
238
|
+
replyWindow = prev;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
94
242
|
/* ------------------------------------------------------------------ *
|
|
95
243
|
* The self-extracting UI payload
|
|
96
244
|
*
|
|
@@ -107,11 +255,58 @@ function loadWebview(): void {
|
|
|
107
255
|
if (!url) return;
|
|
108
256
|
outlet(0, "url", url);
|
|
109
257
|
post("m4l-jweb: sent url " + url + "\n");
|
|
258
|
+
|
|
259
|
+
loadWindows();
|
|
110
260
|
} catch (e) {
|
|
111
261
|
post("m4l-jweb: loadWebview error " + (e as Error).message + "\n");
|
|
112
262
|
}
|
|
113
263
|
}
|
|
114
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Point every floating window's [jweb] at its own extracted page.
|
|
267
|
+
*
|
|
268
|
+
* A window's [jweb] lives inside a subpatcher, so there is no cord from this
|
|
269
|
+
* [js] to it. `messnamed` reaches the [r window-read-<id>] the build put next to
|
|
270
|
+
* it - naming the receiver instead of the cord. The ids come from listWindowIds().
|
|
271
|
+
*/
|
|
272
|
+
function loadWindows(): void {
|
|
273
|
+
var folder = deviceFolder();
|
|
274
|
+
if (!folder) return;
|
|
275
|
+
|
|
276
|
+
var ids = listWindowIds();
|
|
277
|
+
var prefix = windowPrefix();
|
|
278
|
+
for (var i = 0; i < ids.length; i++) {
|
|
279
|
+
var winId = ids[i];
|
|
280
|
+
var winUrl = encodeURI("file:///" + folder + "/" + prefix + winId + ".html") + "?v=" + encodeURIComponent(buildStamp());
|
|
281
|
+
messnamed("window-read-" + winId, "url", winUrl);
|
|
282
|
+
post("m4l-jweb: window " + winId + " -> " + winUrl + "\n");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The `<device>_` prefix a window payload's name carries, so the device's own UI is not one. */
|
|
287
|
+
function windowPrefix(): string {
|
|
288
|
+
return typeof UI_PAYLOAD_NAME !== "undefined" ? UI_PAYLOAD_NAME.replace(/\.html$/, "") + "_" : "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The id of every floating window this device has, from its extracted payloads.
|
|
293
|
+
*
|
|
294
|
+
* Strip the `<device>_` prefix EXPLICITLY: a regex like /^.*_/ is greedy, so a
|
|
295
|
+
* window id with an underscore in it - `edit_grid` - would come back as `grid`.
|
|
296
|
+
*/
|
|
297
|
+
function listWindowIds(): string[] {
|
|
298
|
+
var ids: string[] = [];
|
|
299
|
+
if (typeof EXTRA_PAYLOAD_NAMES === "undefined") return ids;
|
|
300
|
+
var prefix = windowPrefix();
|
|
301
|
+
for (var i = 0; i < EXTRA_PAYLOAD_NAMES.length; i++) {
|
|
302
|
+
var name = EXTRA_PAYLOAD_NAMES[i];
|
|
303
|
+
if (name.slice(-5) !== ".html") continue; // a sample, a preset, ...: not a window
|
|
304
|
+
if (prefix && name.slice(0, prefix.length) !== prefix) continue;
|
|
305
|
+
ids.push(name.slice(prefix.length, name.length - 5));
|
|
306
|
+
}
|
|
307
|
+
return ids;
|
|
308
|
+
}
|
|
309
|
+
|
|
115
310
|
function resolveUiUrl(): string | null {
|
|
116
311
|
var folder = deviceFolder();
|
|
117
312
|
if (!folder) {
|
|
@@ -265,3 +460,318 @@ function b64decode(s: string): number[] {
|
|
|
265
460
|
}
|
|
266
461
|
return out;
|
|
267
462
|
}
|
|
463
|
+
|
|
464
|
+
/* ------------------------------------------------------------------ *
|
|
465
|
+
* Fetch-to-disk
|
|
466
|
+
*
|
|
467
|
+
* `fetchToFile(url, path)` in the app, [maxurl] in the patcher (the "download"
|
|
468
|
+
* chain), and this, orchestrating between them. Bulk data must never cross the
|
|
469
|
+
* Max message bridge, so the bytes go from libcurl STRAIGHT to disk and only the
|
|
470
|
+
* result comes back as a message.
|
|
471
|
+
*
|
|
472
|
+
* ------------------------------------------------------------------------------
|
|
473
|
+
* THE THREE THINGS THAT MADE THIS LOOK IMPOSSIBLE, all in maxurl's request dict:
|
|
474
|
+
*
|
|
475
|
+
* 1. THE KEY IS `filename_out`. It was `downloadfilename` here, which is not a
|
|
476
|
+
* key maxurl has - and an unknown key in a request dict is IGNORED, not
|
|
477
|
+
* rejected. So the request succeeded (HTTP 200, every time), the body went
|
|
478
|
+
* into the response dict, and nothing was ever written to disk. A perfect
|
|
479
|
+
* success and an empty folder. It is `filename_out` in Max's own reference
|
|
480
|
+
* (docs/refpages/max-ref/maxurl.maxref.xml, the `dictionary` message) and in
|
|
481
|
+
* the maxurl.maxhelp that ships with Live. Do not guess these names.
|
|
482
|
+
*
|
|
483
|
+
* 2. `overwrite_output_file` DEFAULTS TO 0. maxurl refuses to overwrite a file
|
|
484
|
+
* that already exists - so even with the right key, the download works once
|
|
485
|
+
* and then silently stops working. Set it to 1.
|
|
486
|
+
*
|
|
487
|
+
* 3. THE PATH IS AN OS PATH. maxurl is libcurl, not a Max file object: it does
|
|
488
|
+
* not know `~/`, and it does not know Max's `Desktop:/` style. It needs an
|
|
489
|
+
* absolute path, which is why a relative one is resolved against the device
|
|
490
|
+
* folder below.
|
|
491
|
+
*
|
|
492
|
+
* `response_dict` names the reply dict, so what comes back on maxurl's outlets is
|
|
493
|
+
* identifiable rather than a dict called "output" shared with anyone else's request.
|
|
494
|
+
*
|
|
495
|
+
* ------------------------------------------------------------------------------
|
|
496
|
+
* DOWNLOAD, THEN PLACE - why every fetch is TWO maxurl requests.
|
|
497
|
+
*
|
|
498
|
+
* A 404 is a RESPONSE, and maxurl writes it: point `filename_out` at a file you
|
|
499
|
+
* already rely on and a missing URL replaces it with the error page. Measured, not
|
|
500
|
+
* feared - see doc/ARCHITECTURE.md, where a 404 destroyed a good 1.2 MB cached .wav
|
|
501
|
+
* and reported status 404 while doing it. `overwrite_output_file` does not care what
|
|
502
|
+
* the status was.
|
|
503
|
+
*
|
|
504
|
+
* Everywhere else in computing the answer is "write to a temp path and move it into
|
|
505
|
+
* place on success". Max's [js] cannot: its `File` object has open, close, and the
|
|
506
|
+
* read/write family, and NOTHING ELSE. No rename. No delete. (Confirmed twice - in
|
|
507
|
+
* Cycling '74's reference, and by asking the live object what members it has.)
|
|
508
|
+
*
|
|
509
|
+
* So MAXURL DOES THE MOVE. It is libcurl, and libcurl speaks `file://`: a GET of
|
|
510
|
+
* `file:///<temp>` with `filename_out` set to the destination is a native, streaming
|
|
511
|
+
* file copy, on maxurl's own thread, with not one byte passing through [js]. Measured
|
|
512
|
+
* at 6 ms for 1 MB. The download therefore goes:
|
|
513
|
+
*
|
|
514
|
+
* 1. GET <url> -> filename_out <dest>.part (a bad response lands HERE)
|
|
515
|
+
* 2. validate status 2xx, no `error` key, bytes on disk > 0
|
|
516
|
+
* 3. GET file://<part> -> filename_out <dest> (only now is <dest> touched)
|
|
517
|
+
*
|
|
518
|
+
* A failure at 1 or 2 leaves the destination UNTOUCHED, which is the entire point.
|
|
519
|
+
*
|
|
520
|
+
* TWO TRAPS IN STEP 3, both of which look like success:
|
|
521
|
+
*
|
|
522
|
+
* - A file:// reply has NO HTTP STATUS. It comes back `status 0`, which the 2xx
|
|
523
|
+
* check in step 2 would reject as a failure. The copy is validated on BYTES - the
|
|
524
|
+
* destination is the same size as the part file - which is the honest check anyway
|
|
525
|
+
* and the only one that survives both schemes.
|
|
526
|
+
* - The .part file cannot be DELETED afterwards (no unlink, see above), so it is
|
|
527
|
+
* TRUNCATED to zero bytes instead. It costs an inode, not a megabyte, and the next
|
|
528
|
+
* fetch to the same destination overwrites it.
|
|
529
|
+
* ------------------------------------------------------------------ */
|
|
530
|
+
|
|
531
|
+
/** The reply dicts. Named, so nothing else can be mistaken for them - and so the two phases cannot be mistaken for each other. */
|
|
532
|
+
var FETCH_RESPONSE_DICT = "m4ljweb_fetch_response";
|
|
533
|
+
var PLACE_RESPONSE_DICT = "m4ljweb_place_response";
|
|
534
|
+
|
|
535
|
+
/** Where a download lands before it has earned its destination. */
|
|
536
|
+
function partPath(destPath: string): string {
|
|
537
|
+
return destPath + ".part";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface ActiveFetch {
|
|
541
|
+
requestId: string;
|
|
542
|
+
url: string;
|
|
543
|
+
destPath: string;
|
|
544
|
+
/** Bytes in the .part file, carried from the download phase into the place phase. */
|
|
545
|
+
partBytes: number;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// One at a time: [maxurl] can run several, but a queue keeps `currentFetch`
|
|
549
|
+
// unambiguous, and a device downloading its samples wants them in order anyway.
|
|
550
|
+
var fetchQueue: ActiveFetch[] = [];
|
|
551
|
+
var currentFetch: ActiveFetch | null = null;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* An absolute OS path for libcurl, from whatever the app asked for.
|
|
555
|
+
*
|
|
556
|
+
* A relative path is resolved against the DEVICE's folder - the one place a
|
|
557
|
+
* device can always write, and the same folder the UI payload is extracted into.
|
|
558
|
+
* An absolute path (POSIX `/...`, or Windows `C:/...`) is passed through.
|
|
559
|
+
*/
|
|
560
|
+
function resolveFetchPath(destPath: string): string {
|
|
561
|
+
var isAbsolute = destPath.indexOf("/") === 0 || destPath.indexOf(":") === 1;
|
|
562
|
+
if (isAbsolute) return destPath;
|
|
563
|
+
var folder = deviceFolder();
|
|
564
|
+
return folder ? folder + "/" + destPath : destPath;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** PHASE 1: download the URL to `<dest>.part`. Nothing touches `<dest>` yet. */
|
|
568
|
+
function processNextFetch(): void {
|
|
569
|
+
if (currentFetch || fetchQueue.length === 0) return;
|
|
570
|
+
var next = fetchQueue.shift();
|
|
571
|
+
if (!next) return;
|
|
572
|
+
currentFetch = next;
|
|
573
|
+
next.destPath = resolveFetchPath(next.destPath);
|
|
574
|
+
|
|
575
|
+
var reqDict = new Dict();
|
|
576
|
+
reqDict.set("url", next.url);
|
|
577
|
+
reqDict.set("http_method", "get");
|
|
578
|
+
reqDict.set("filename_out", partPath(next.destPath)); // NOT the destination - see above
|
|
579
|
+
reqDict.set("overwrite_output_file", 1); // ...or it downloads exactly once, ever
|
|
580
|
+
reqDict.set("response_dict", FETCH_RESPONSE_DICT);
|
|
581
|
+
|
|
582
|
+
post("m4l-jweb: fetch " + next.url + " -> " + next.destPath + "\n");
|
|
583
|
+
// Outlet 1 is the aux outlet; the "download" chain routes `maxurl` off it.
|
|
584
|
+
outlet(1, "maxurl", "dictionary", reqDict.name);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* PHASE 3: ask libcurl to copy the validated .part file over the destination.
|
|
589
|
+
*
|
|
590
|
+
* This is the "move" that [js] cannot do. `file://` is a scheme libcurl handles, so
|
|
591
|
+
* maxurl streams the file on its own thread and nothing crosses the message bridge.
|
|
592
|
+
*/
|
|
593
|
+
function placeFetch(fetched: ActiveFetch): void {
|
|
594
|
+
var reqDict = new Dict();
|
|
595
|
+
reqDict.set("url", encodeURI("file:///" + partPath(fetched.destPath)));
|
|
596
|
+
reqDict.set("http_method", "get");
|
|
597
|
+
reqDict.set("filename_out", fetched.destPath);
|
|
598
|
+
reqDict.set("overwrite_output_file", 1);
|
|
599
|
+
reqDict.set("response_dict", PLACE_RESPONSE_DICT);
|
|
600
|
+
outlet(1, "maxurl", "dictionary", reqDict.name);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ------------------------------------------------------------------ *
|
|
604
|
+
* Samples - the `samples` chain
|
|
605
|
+
* ------------------------------------------------------------------ */
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The app: `buffer_load <slot> <path>` - read a file into that slot's [buffer~].
|
|
609
|
+
*
|
|
610
|
+
* WHY THIS GOES THROUGH [js] AT ALL, when the chain could route it straight to the
|
|
611
|
+
* buffer: because the path the app wrote is not a path [buffer~] can open, and it
|
|
612
|
+
* fails in the two ways this file exists to prevent.
|
|
613
|
+
*
|
|
614
|
+
* A RELATIVE path is resolved against the device's folder - the same resolution
|
|
615
|
+
* `fetch_to_file` does, and it has to be the same one or the app downloads a file to
|
|
616
|
+
* one place and loads it from another. [buffer~] does not resolve it that way: a bare
|
|
617
|
+
* name is looked up in MAX's SEARCH PATH, which does not contain the device's folder,
|
|
618
|
+
* so `preview.wav` - freshly downloaded, right there next to the .amxd - reports
|
|
619
|
+
* "can't open" and the promise times out.
|
|
620
|
+
*
|
|
621
|
+
* ...and the resolved path CONTAINS SPACES on a normal Live install ("Ableton
|
|
622
|
+
* Library", "Max For Live"). A message travelling through the patcher as text would
|
|
623
|
+
* split there into three atoms and [buffer~] would open the first one. Handed out of
|
|
624
|
+
* [js] as a string, it stays ONE symbol all the way to `replace`.
|
|
625
|
+
*
|
|
626
|
+
* The file is checked before the buffer is asked for it, because a missing file makes
|
|
627
|
+
* [buffer~] print to the Max console and stay silent - there is no failure bang to
|
|
628
|
+
* bind to, and the app would learn nothing until the timeout. `buffer_error` says so
|
|
629
|
+
* at once.
|
|
630
|
+
*/
|
|
631
|
+
function buffer_load(slot: string, path: string): void {
|
|
632
|
+
var resolved = resolveFetchPath(path); // the same folder the download wrote to
|
|
633
|
+
var f: File | null = null;
|
|
634
|
+
try {
|
|
635
|
+
f = new File(resolved, "read");
|
|
636
|
+
} catch (e) {
|
|
637
|
+
f = null;
|
|
638
|
+
}
|
|
639
|
+
if (!f || !f.isopen) {
|
|
640
|
+
outlet(0, "buffer_error", slot, "no file at " + resolved);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
var bytes = f.eof;
|
|
644
|
+
f.close();
|
|
645
|
+
if (!bytes) {
|
|
646
|
+
outlet(0, "buffer_error", slot, "empty file at " + resolved);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Outlet 1 is the aux outlet; the `samples` chain routes `buffer_replace` off it,
|
|
651
|
+
// exactly as the `download` chain routes `maxurl`. Outlet 0 belongs to [jweb].
|
|
652
|
+
post("m4l-jweb: buffer_load " + slot + " -> " + resolved + "\n");
|
|
653
|
+
outlet(1, "buffer_replace", slot, resolved);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The app: `fetch_to_file <requestId> <url> <destPath>`. */
|
|
657
|
+
function fetch_to_file(requestId: string, url: string, destPath: string): void {
|
|
658
|
+
fetchQueue.push({ requestId: requestId, url: url, destPath: destPath, partBytes: 0 });
|
|
659
|
+
processNextFetch();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* maxurl finished: `maxurl_done dictionary <name>` (outlet 0, via [prepend]).
|
|
664
|
+
*
|
|
665
|
+
* Both phases land here, told apart by the response dict they asked for.
|
|
666
|
+
*/
|
|
667
|
+
function maxurl_done(msgType: string, dictName: string): void {
|
|
668
|
+
if (msgType !== "dictionary") return;
|
|
669
|
+
// A device's own wrapper/device.ts may drive [maxurl] itself (a cache, a spike) and
|
|
670
|
+
// its replies come back down this same cord. Offer it the reply first: without this
|
|
671
|
+
// hook, a request the device made lands here, finds no `currentFetch`, and is
|
|
672
|
+
// dropped in silence - which looks exactly like maxurl never answering.
|
|
673
|
+
if (typeof onMaxurlReply === "function" && onMaxurlReply(dictName)) return;
|
|
674
|
+
if (!currentFetch) return;
|
|
675
|
+
var fetched = currentFetch;
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
if (dictName === PLACE_RESPONSE_DICT) finishPlace(fetched);
|
|
679
|
+
else finishDownload(fetched, dictName);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
post("m4l-jweb: maxurl_done error - " + (e as Error).message + "\n");
|
|
682
|
+
failFetch(fetched, "wrapper error: " + (e as Error).message);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* PHASE 2: did the download earn its destination?
|
|
688
|
+
*
|
|
689
|
+
* All three checks, and all three are load-bearing: a filesystem failure comes back as
|
|
690
|
+
* status 200 with an `error` key, and a `filename_out` maxurl ignored comes back as a
|
|
691
|
+
* clean 200 with nothing on disk. Only the file itself proves the file.
|
|
692
|
+
*/
|
|
693
|
+
function finishDownload(fetched: ActiveFetch, dictName: string): void {
|
|
694
|
+
var d = new Dict(dictName);
|
|
695
|
+
var status = Number(d.get("status"));
|
|
696
|
+
var errorMsg = d.get("error");
|
|
697
|
+
var bytes = fileSize(partPath(fetched.destPath));
|
|
698
|
+
|
|
699
|
+
if (errorMsg) return failFetch(fetched, String(errorMsg));
|
|
700
|
+
if (status < 200 || status >= 300) return failFetch(fetched, "HTTP " + status);
|
|
701
|
+
if (bytes <= 0) return failFetch(fetched, "HTTP " + status + " but nothing was written to disk");
|
|
702
|
+
|
|
703
|
+
fetched.partBytes = bytes;
|
|
704
|
+
placeFetch(fetched); // ...and only now does the destination get touched
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* PHASE 3 (reply): validate the copy ON BYTES, not on status.
|
|
709
|
+
*
|
|
710
|
+
* A `file://` request has no HTTP status - it comes back as `status 0`, measured - so
|
|
711
|
+
* the 2xx check that guards the download would reject a perfectly good copy here. The
|
|
712
|
+
* size is the honest check for both schemes anyway.
|
|
713
|
+
*/
|
|
714
|
+
function finishPlace(fetched: ActiveFetch): void {
|
|
715
|
+
var placed = fileSize(fetched.destPath);
|
|
716
|
+
if (placed !== fetched.partBytes) {
|
|
717
|
+
return failFetch(fetched, "could not place the download: " + placed + " bytes at the destination, expected " + fetched.partBytes);
|
|
718
|
+
}
|
|
719
|
+
truncate(partPath(fetched.destPath)); // [js] cannot DELETE it; zero it instead
|
|
720
|
+
post("m4l-jweb: fetched " + placed + " bytes to " + fetched.destPath + "\n");
|
|
721
|
+
outlet(0, "fetch_done", fetched.requestId, placed);
|
|
722
|
+
currentFetch = null;
|
|
723
|
+
processNextFetch();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The destination is untouched whenever this is called from phase 1 or 2. That is the point. */
|
|
727
|
+
function failFetch(fetched: ActiveFetch, message: string): void {
|
|
728
|
+
post("m4l-jweb: fetch failed - " + message + "\n");
|
|
729
|
+
outlet(0, "fetch_error", fetched.requestId, message);
|
|
730
|
+
currentFetch = null;
|
|
731
|
+
processNextFetch();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Bytes on disk, or -1 if there is no readable file there. */
|
|
735
|
+
function fileSize(p: string): number {
|
|
736
|
+
try {
|
|
737
|
+
var f = new File(p);
|
|
738
|
+
if (!f.isopen) return -1;
|
|
739
|
+
var n = f.eof;
|
|
740
|
+
f.close();
|
|
741
|
+
return n;
|
|
742
|
+
} catch (e) {
|
|
743
|
+
return -1;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Zero a file. The closest thing to `delete` that [js] has - it has no unlink. */
|
|
748
|
+
function truncate(p: string): void {
|
|
749
|
+
try {
|
|
750
|
+
var f = new File(p, "write");
|
|
751
|
+
if (!f.isopen) f.open();
|
|
752
|
+
if (!f.isopen) return;
|
|
753
|
+
f.eof = 0;
|
|
754
|
+
f.close();
|
|
755
|
+
} catch (e) {
|
|
756
|
+
/* a leftover .part is untidy, not a failure */
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* maxurl's progress outlet, via [prepend maxurl_progress]:
|
|
762
|
+
*
|
|
763
|
+
* maxurl_progress <response-dict-name> <dl total> <dl now> <ul total> <ul now>
|
|
764
|
+
*
|
|
765
|
+
* FIVE atoms, starting with the dict NAME - per the reference (outlet 1). This
|
|
766
|
+
* used to be read as `(downloaded, total, percent)`, so the "bytes downloaded"
|
|
767
|
+
* the UI showed was actually a symbol, and the total was the download total. And
|
|
768
|
+
* note the reference's warning: not every server sends a content length, so the
|
|
769
|
+
* TOTAL can legitimately be 0. Do not divide by it.
|
|
770
|
+
*/
|
|
771
|
+
function maxurl_progress(dictName: string, dlTotal: number, dlNow: number): void {
|
|
772
|
+
if (!currentFetch) return;
|
|
773
|
+
// The local file:// copy reports progress too. It is not the download, and a UI that
|
|
774
|
+
// showed it would run its progress bar twice - the second time in a few milliseconds.
|
|
775
|
+
if (dictName !== FETCH_RESPONSE_DICT) return;
|
|
776
|
+
outlet(0, "fetch_progress", currentFetch.requestId, dlNow, dlTotal);
|
|
777
|
+
}
|
package/src/max.d.ts
CHANGED
|
@@ -16,6 +16,16 @@ declare function outlet(n: number, ...args: unknown[]): void;
|
|
|
16
16
|
/** Collect the `arguments` of a Max message handler into a real array. */
|
|
17
17
|
declare function arrayfromargs(args: IArguments): unknown[];
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Send a message to every [receive <name>] / [r <name>] in the patcher - the
|
|
21
|
+
* only way [js] can reach a box it has no patch cord to.
|
|
22
|
+
*
|
|
23
|
+
* The wrapper uses it for what CANNOT be wired: a floating window's [jweb] lives
|
|
24
|
+
* inside a subpatcher, so there is no cord from here to there. `messnamed` names
|
|
25
|
+
* the receiver instead of the cord.
|
|
26
|
+
*/
|
|
27
|
+
declare function messnamed(receiveName: string, ...args: unknown[]): void;
|
|
28
|
+
|
|
19
29
|
/** Arguments given to the object box, e.g. `js wrapper.js midi` -> ["midi"]. */
|
|
20
30
|
declare const jsarguments: unknown[];
|
|
21
31
|
|
|
@@ -166,5 +176,23 @@ declare function onUiReady(): void;
|
|
|
166
176
|
/** Every transport poll (20 Hz), after the packaged wrapper has sent its tick. */
|
|
167
177
|
declare function onTick(playing: number, beats: number): void;
|
|
168
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A reply from [maxurl] arrived. Return true if it was YOURS.
|
|
181
|
+
*
|
|
182
|
+
* Only for a device that drives [maxurl] itself - its replies travel the same cord as
|
|
183
|
+
* `fetchToFile()`'s, and the wrapper would otherwise drop them (no `currentFetch`).
|
|
184
|
+
* Match on the `response_dict` name you asked for.
|
|
185
|
+
*/
|
|
186
|
+
declare function onMaxurlReply(responseDictName: string): boolean;
|
|
187
|
+
|
|
169
188
|
/** Live's tempo changed (and once on attach). */
|
|
170
189
|
declare function onTempoChange(bpm: number): void;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A floating window's page sent a selector the library does not handle itself
|
|
193
|
+
* (i.e. not ui_ready/get_state/sync_state). `windowId` is which window; reply with
|
|
194
|
+
* `outlet(0, ...)` and it routes back to that window automatically (see reply() in
|
|
195
|
+
* core.ts). This is how a window editor - a drum map, a browser - talks to the
|
|
196
|
+
* device beyond shared state.
|
|
197
|
+
*/
|
|
198
|
+
declare function onWindowMessage(windowId: string, selector: string, ...args: unknown[]): void;
|