@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/core.ts +512 -2
  3. package/src/max.d.ts +28 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.5.0",
3
+ "version": "0.6.5",
4
4
  "description": "m4l-jweb: the Max for Live glue layer connecting a device to LiveAPI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- outlet(0, "mode", MODE);
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
- outlet(0, "build", buildStamp());
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;