Skip to main content

max / goingson

Serve every script quasi-webview ships, and take quasi 0.54 Shell links quasi-download.js by default and build.rs was writing two of the four scripts out, so every described document has been linking a 404 since that script shipped -- an export control would have pasted the file's contents into a region. The new quasi-fill.js (f35aafee) would have been the second. A loop over the crate's constants rather than one fs::write per name, and the assets match plus its test gain both addresses. A script the renderer starts shipping and this file forgets is a feature that silently does nothing, which is what the loop and the served-list test are between them for. Nothing here uses Act::fills yet. The pin moves because quasi did.
Author: Max Johnson <me@maxj.phd> · 2026-08-22 17:44 UTC
Signed with PGP, not checked
Commit: e37d99720411d0ac77bc8e595d2ff4fad9b99f63
Parent: 078a44d
6 files changed, +267 insertions, -54 deletions
M Cargo.lock +31 -31
@@ -1387,7 +1387,7 @@
1387 1387 "libc",
1388 1388 "option-ext",
1389 1389 "redox_users",
1390 - "windows-sys 0.61.2",
1390 + "windows-sys 0.60.2",
1391 1391 ]
1392 1392
1393 1393 [[package]]
@@ -1628,7 +1628,7 @@
1628 1628 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1629 1629 dependencies = [
1630 1630 "libc",
1631 - "windows-sys 0.61.2",
1631 + "windows-sys 0.60.2",
1632 1632 ]
1633 1633
1634 1634 [[package]]
@@ -3639,7 +3639,7 @@
3639 3639 "png 0.18.1",
3640 3640 "serde",
3641 3641 "thiserror 2.0.20",
3642 - "windows-sys 0.61.2",
3642 + "windows-sys 0.60.2",
3643 3643 ]
3644 3644
3645 3645 [[package]]
@@ -3772,7 +3772,7 @@
3772 3772 source = "registry+https://github.com/rust-lang/crates.io-index"
3773 3773 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3774 3774 dependencies = [
3775 - "windows-sys 0.61.2",
3775 + "windows-sys 0.60.2",
3776 3776 ]
3777 3777
3778 3778 [[package]]
@@ -4706,7 +4706,7 @@
4706 4706
4707 4707 [[package]]
4708 4708 name = "quasi-http"
4709 - version = "0.53.0"
4709 + version = "0.54.0"
4710 4710 dependencies = [
4711 4711 "form_urlencoded",
4712 4712 "http",
@@ -4715,7 +4715,7 @@
4715 4715
4716 4716 [[package]]
4717 4717 name = "quasi-notifs"
4718 - version = "0.53.0"
4718 + version = "0.54.0"
4719 4719 dependencies = [
4720 4720 "quasi-router",
4721 4721 "synckit-config",
@@ -4723,14 +4723,14 @@
4723 4723
4724 4724 [[package]]
4725 4725 name = "quasi-router"
4726 - version = "0.53.0"
4726 + version = "0.54.0"
4727 4727 dependencies = [
4728 4728 "makeover-layout",
4729 4729 ]
4730 4730
4731 4731 [[package]]
4732 4732 name = "quasi-tauri"
4733 - version = "0.53.0"
4733 + version = "0.54.0"
4734 4734 dependencies = [
4735 4735 "http",
4736 4736 "quasi-http",
@@ -4756,7 +4756,7 @@
4756 4756
4757 4757 [[package]]
4758 4758 name = "quasi-webview"
4759 - version = "0.53.0"
4759 + version = "0.54.0"
4760 4760 dependencies = [
4761 4761 "docengine",
4762 4762 "makeover-layout",
@@ -5195,7 +5195,7 @@
5195 5195 "errno",
5196 5196 "libc",
5197 5197 "linux-raw-sys",
5198 - "windows-sys 0.61.2",
5198 + "windows-sys 0.60.2",
5199 5199 ]
5200 5200
5201 5201 [[package]]
@@ -5252,7 +5252,7 @@
5252 5252 "security-framework",
5253 5253 "security-framework-sys",
5254 5254 "webpki-root-certs",
5255 - "windows-sys 0.61.2",
5255 + "windows-sys 0.60.2",
5256 5256 ]
5257 5257
5258 5258 [[package]]
@@ -5792,7 +5792,7 @@
5792 5792 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5793 5793 dependencies = [
5794 5794 "libc",
5795 - "windows-sys 0.61.2",
5795 + "windows-sys 0.60.2",
5796 5796 ]
5797 5797
5798 5798 [[package]]
@@ -6567,7 +6567,7 @@
6567 6567 "getrandom 0.4.3",
6568 6568 "once_cell",
6569 6569 "rustix",
6570 - "windows-sys 0.61.2",
6570 + "windows-sys 0.60.2",
6571 6571 ]
6572 6572
6573 6573 [[package]]
@@ -6589,7 +6589,7 @@
6589 6589 "parking_lot",
6590 6590 "rustix",
6591 6591 "signal-hook",
6592 - "windows-sys 0.61.2",
6592 + "windows-sys 0.60.2",
6593 6593 ]
6594 6594
6595 6595 [[package]]
@@ -7071,7 +7071,7 @@
7071 7071 "png 0.18.1",
7072 7072 "serde",
7073 7073 "thiserror 2.0.20",
7074 - "windows-sys 0.61.2",
7074 + "windows-sys 0.60.2",
7075 7075 ]
7076 7076
7077 7077 [[package]]
@@ -7106,7 +7106,7 @@
7106 7106 dependencies = [
7107 7107 "memoffset",
7108 7108 "tempfile",
7109 - "windows-sys 0.61.2",
7109 + "windows-sys 0.60.2",
7110 7110 ]
7111 7111
7112 7112 [[package]]
@@ -7636,7 +7636,7 @@
7636 7636 source = "registry+https://github.com/rust-lang/crates.io-index"
7637 7637 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7638 7638 dependencies = [
7639 - "windows-sys 0.61.2",
7639 + "windows-sys 0.60.2",
7640 7640 ]
7641 7641
7642 7642 [[package]]
@@ -8494,21 +8494,21 @@
8494 8494 ]
8495 8495
8496 8496 [[patch.unused]]
8497 - name = "quasi-axum"
8498 - version = "0.53.0"
8499 -
8500 - [[patch.unused]]
8501 - name = "quasi-basics"
8502 - version = "0.53.0"
8503 -
8504 - [[patch.unused]]
8505 - name = "quasi-immediate"
8506 - version = "0.53.0"
8507 -
8508 - [[patch.unused]]
8509 - name = "quasi-store"
8497 + name = "ops-status"
8510 8498 version = "0.1.0"
8511 8499
8512 8500 [[patch.unused]]
8513 - name = "ops-status"
8501 + name = "quasi-axum"
8502 + version = "0.54.0"
8503 +
8504 + [[patch.unused]]
8505 + name = "quasi-basics"
8506 + version = "0.54.0"
8507 +
8508 + [[patch.unused]]
8509 + name = "quasi-immediate"
8510 + version = "0.54.0"
8511 +
8512 + [[patch.unused]]
8513 + name = "quasi-store"
8514 8514 version = "0.1.0"
@@ -36,7 +36,7 @@
36 36 makeover-geometry = "0.7"
37 37 # Only to write its selection and clock scripts into the frontend. Those two are
38 38 # the whole of `frontend/js/` since the swap.
39 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" }
40 40
41 41 [dependencies]
42 42 goingson-core = { workspace = true }
@@ -54,10 +54,10 @@
54 54 # dependency's source even with the feature off -- so a public clone could not
55 55 # build at all against a URL it could not reach. quasi went public that day and
56 56 # the URL moved here with it.
57 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" }
58 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" }
59 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" }
60 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" }
61 61
62 62 # The notification declaration, and it is NOT behind the `quasi` feature: the
63 63 # watcher is desktop app behaviour rather than a described screen, and the
@@ -74,7 +74,7 @@
74 74 # before it has ported its screens cannot take it. `notifications::Notifier` is
75 75 # the eight lines meanwhile, and it goes when the flip makes quasi-router
76 76 # unconditional here.
77 - quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.53", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.54", features = ["synckit", "describe"] }
78 78
79 79 # Tauri
80 80 tauri = { workspace = true, features = ["image-png", "devtools"] }
M src-tauri/build.rs +20 -16
@@ -471,7 +471,7 @@
471 471 /// Every file that can carry a class name.
472 472 fn markup_files(frontend: &Path) -> Vec<PathBuf> {
473 473 // `index.html` and `compose.html` were the first two entries until the
474 - // 2026-08-22 swap deleted them. What is left under `js/` is two generated
474 + // 2026-08-22 swap deleted them. What is left under `js/` is the generated
475 475 // scripts, and they are still read: `quasi-selection.js` names the classes
476 476 // quasi-webview emits, so a class it reaches for that the stylesheet does
477 477 // not define is the same defect this check was written to catch.
@@ -565,21 +565,25 @@
565 565 fs::write(frontend.join("js/tables.generated.js"), table_columns_js())
566 566 .expect("write js/tables.generated.js");
567 567
568 - // quasi-webview's selection script, written out rather than copied in, so
569 - // it cannot go stale against the markup that feeds it: it reads two hooks
570 - // that crate emits, and a fossilised copy here is a bulk bar that silently
571 - // stops counting.
572 - fs::write(
573 - frontend.join("js/quasi-selection.js"),
574 - quasi_webview::SELECTION_JS,
575 - )
576 - .expect("write js/quasi-selection.js");
577 -
578 - // The clock script, for the same reason: the running-timer band is drawn
579 - // from a description and ticks from this, and a stale copy is a band that
580 - // shows the time the screen was served.
581 - fs::write(frontend.join("js/quasi-clock.js"), quasi_webview::CLOCK_JS)
582 - .expect("write js/quasi-clock.js");
568 + // The scripts quasi-webview ships, written out rather than copied in, so
569 + // none of them can go stale against the markup that feeds it: each reads a
570 + // hook that crate emits, and a fossilised copy here is a bulk bar that
571 + // silently stops counting, a band showing the time the screen was served,
572 + // an export pasted into the page, or a picker that writes nothing.
573 + //
574 + // A loop rather than four calls, because the shell links every one of these
575 + // by default: a script the renderer starts shipping and this file forgets
576 + // is a 404 per document with nothing in the log. `quasi-download.js` was
577 + // exactly that until 2026-08-22.
578 + for (name, source) in [
579 + ("quasi-selection.js", quasi_webview::SELECTION_JS),
580 + ("quasi-clock.js", quasi_webview::CLOCK_JS),
581 + ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
582 + ("quasi-fill.js", quasi_webview::FILL_JS),
583 + ] {
584 + fs::write(frontend.join("js").join(name), source)
585 + .unwrap_or_else(|error| panic!("write js/{name}: {error}"));
586 + }
583 587
584 588 // The generated files above cannot drift from SizeClass. The hand-written
585 589 // ones can, so they are checked rather than trusted.
@@ -35,13 +35,17 @@
35 35 // descriptions run the same runtime. Out of `js/` because the
36 36 // design-system lint polices that directory as our own code and a
37 37 // minified library calls `confirm()` inside itself.
38 - // The other two are written out by `build.rs` from the renderer crate's
38 + // The rest are written out by `build.rs` from the renderer crate's
39 39 // own consts, so they cannot fossilise against the markup they read.
40 + // Every one the shell links has to be here: an address the shell names
41 + // and this match does not is a 404 per document.
40 42 "/static/htmx.min.js" => (JS, include_bytes!("../../frontend/vendor/htmx.min.js")),
41 43 "/static/quasi-selection.js" => {
42 44 (JS, include_bytes!("../../frontend/js/quasi-selection.js"))
43 45 }
44 46 "/static/quasi-clock.js" => (JS, include_bytes!("../../frontend/js/quasi-clock.js")),
47 + "/static/quasi-download.js" => (JS, include_bytes!("../../frontend/js/quasi-download.js")),
48 + "/static/quasi-fill.js" => (JS, include_bytes!("../../frontend/js/quasi-fill.js")),
45 49
46 50 // `/static/../fonts/X` off typography.css, which the URL parser has
47 51 // already resolved by the time it reaches here.
@@ -85,6 +89,8 @@
85 89 "/static/htmx.min.js",
86 90 "/static/quasi-selection.js",
87 91 "/static/quasi-clock.js",
92 + "/static/quasi-download.js",
93 + "/static/quasi-fill.js",
88 94 "/fonts/QuasiMono.woff2",
89 95 "/fonts/QuasiBody.woff2",
90 96 "/fonts/Reglo-Bold.woff2",
@@ -1,0 +1,108 @@
1 + // What a browser needs before a route's answer can become a saved file.
2 + //
3 + // `67881a88`. A route answers `Outcome::File` and this host puts the bytes
4 + // where downloads go. The header alone is enough for a plain link -- the
5 + // browser navigates, sees `Content-Disposition: attachment`, and saves -- but a
6 + // control this renderer emits reaches its route through htmx, and htmx reads
7 + // the answer into an XHR. An XHR that arrives is not a navigation, so nothing
8 + // is saved and the file is swapped into the page as text.
9 + //
10 + // So this cancels that swap and does the saving itself: a Blob of the body, an
11 + // anchor clicked, the object URL revoked. It is the browser's own download
12 + // path, reached from script because the request was made from script.
13 + //
14 + // Nothing here knows what the file is. It reads one thing, the
15 + // `Content-Disposition` the http adapter wrote, which is the same header the
16 + // no-script path relies on -- so there is one fact on the wire rather than a
17 + // second one invented for this file.
18 + (() => {
19 + "use strict";
20 +
21 + // The header the http adapter writes. RFC 6266 says a lot more than this
22 + // reads; the two forms below are the two it writes.
23 + const DISPOSITION = "content-disposition";
24 +
25 + /**
26 + * The file name the header suggests, or null when it suggests none.
27 + *
28 + * `filename*` first, because it carries the real characters and the quoted
29 + * `filename` beside it is the ASCII fallback for agents that cannot read
30 + * one. Both are written for every download, so preferring the richer one is
31 + * always available and never a guess.
32 + */
33 + const named = (header) => {
34 + const extended = /filename\*=UTF-8''([^;]+)/i.exec(header);
35 + if (extended) {
36 + try {
37 + return decodeURIComponent(extended[1]);
38 + } catch {
39 + // A name we cannot decode is a name we do not use. Falling
40 + // through to the quoted form is better than saving a file
41 + // called `%E2%9C`.
42 + }
43 + }
44 + const quoted = /filename="([^"]*)"/i.exec(header);
45 + return quoted ? quoted[1] : null;
46 + };
47 +
48 + /** Hand the bytes to the browser under this name. */
49 + const save = (blob, name) => {
50 + const url = URL.createObjectURL(blob);
51 + const link = document.createElement("a");
52 + link.href = url;
53 + link.download = name;
54 + // Firefox will not follow a click on an element outside the document,
55 + // which is the one reason this touches the DOM at all.
56 + document.body.append(link);
57 + link.click();
58 + link.remove();
59 + // Not revoked synchronously: the click starts the download
60 + // asynchronously and revoking first cancels it in some browsers. A task
61 + // later is after the download has taken its reference.
62 + setTimeout(() => URL.revokeObjectURL(url), 0);
63 + };
64 +
65 + // `htmx:beforeSwap` is the last event that can still stop the body reaching
66 + // the page, and it carries the XHR, which is where both the header and the
67 + // bytes are. Anything later has already destroyed a region.
68 + document.addEventListener("htmx:beforeSwap", (event) => {
69 + const xhr = event.detail?.xhr;
70 + if (!xhr) {
71 + return;
72 + }
73 + const header = xhr.getResponseHeader(DISPOSITION);
74 + if (!header || !/^\s*attachment/i.test(header)) {
75 + return;
76 + }
77 +
78 + // Nothing goes into the document. The screen the user pressed the
79 + // control on is the screen they keep, which is what the router said by
80 + // answering a file rather than a fragment.
81 + event.detail.shouldSwap = false;
82 + event.detail.isError = false;
83 +
84 + const type = xhr.getResponseHeader("content-type") || "application/octet-stream";
85 + // THIS IS TEXT-ONLY, AND IT IS A REAL LIMIT RATHER THAN AN OVERSIGHT.
86 + //
87 + // htmx leaves `responseType` unset, so the browser has already decoded
88 + // the body as UTF-8 by the time this runs and `xhr.response` is a
89 + // string. That round-trips losslessly for the measured sites -- JSON,
90 + // CSV, ICS, every export in the tree -- and mangles any byte sequence
91 + // that is not valid UTF-8, because the replacement characters were
92 + // substituted before this file could see them.
93 + //
94 + // The fixes all cost more than the case is worth today. Setting
95 + // `responseType = "blob"` up front breaks every ordinary swap, since
96 + // htmx reads the same field to get its markup. `overrideMimeType` with
97 + // `x-user-defined` does the same damage to every UTF-8 page. Refetching
98 + // the URL as a blob is correct and runs the route a second time, which
99 + // is wrong for a POST. The route this serves is scoped to a task
100 + // database by its own ruling; a binary payload over htmx is filed
101 + // rather than guessed at.
102 + //
103 + // Nothing here is wrong for a plain link, which never reaches this file
104 + // at all: the browser navigates, reads the same header, and saves the
105 + // bytes as they arrived.
106 + save(new Blob([xhr.response], { type }), named(header) || "download");
107 + });
108 + })();
@@ -1,0 +1,95 @@
1 + // Putting a chosen value into the box the reader was already typing in.
2 + //
3 + // `f35aafee`. An `Act` can name a field on the same screen that receives its
4 + // value, and this is the browser's half of "the renderer decides where in it
5 + // the value lands". Here that is the selection: a reader who has put the caret
6 + // in the middle of a paragraph and opened a picker means to insert there, and
7 + // that is what every editor they have ever used does.
8 + //
9 + // It reads two attributes the node emitter writes and nothing else: `data-fills`
10 + // names the field, `data-fill` carries the value. Both are ordinary escaped
11 + // attributes, so nothing here is a program built out of app text -- which is why
12 + // the behaviour is a script rather than one of the emitted _hyperscript
13 + // programs, whose one rule is that no program is written from text a user typed.
14 + // A media file called `"; alert(1); "` is a value in an attribute here and could
15 + // not be one in a program.
16 + //
17 + // What it does NOT do is call anything. The act's own action is emitted beside
18 + // this and htmx performs it as it would any other; an act that only fills says
19 + // so with `Destination::Local`, which emits no transport at all.
20 + (() => {
21 + "use strict";
22 +
23 + /** The field the act writes into. */
24 + const FIELD = "data-fills";
25 + /** What lands there. */
26 + const VALUE = "data-fill";
27 +
28 + /**
29 + * The box on this document under that name.
30 + *
31 + * By `name` rather than by `id`, because `name` is what the description
32 + * carries: it is what a submit sends the value under and what `Field::changes`
33 + * names. An id is scoped per form instance by the emitter and is therefore
34 + * not the string the description wrote.
35 + *
36 + * Searched from the pressed control outward: the nearest enclosing form
37 + * first, then the document. A screen showing the same form twice -- an
38 + * edit modal over a list -- otherwise gets the first copy in the document
39 + * whichever one the reader is in.
40 + */
41 + const box = (control, name) => {
42 + const selector = `[name="${CSS.escape(name)}"]`;
43 + return control.closest("form")?.querySelector(selector)
44 + ?? document.querySelector(selector);
45 + };
46 +
47 + /**
48 + * Put `text` where the caret is, and leave the caret after it.
49 + *
50 + * `selectionStart` is null on an input whose type has no text selection --
51 + * a colour or a date -- and on anything that is not a text control at all.
52 + * Appending is the honest fallback there: the value still arrives, which is
53 + * what the description asked for, and the position was never described.
54 + */
55 + const insert = (target, text) => {
56 + const value = target.value ?? "";
57 + const at = typeof target.selectionStart === "number" ? target.selectionStart : value.length;
58 + const stop = typeof target.selectionEnd === "number" ? target.selectionEnd : at;
59 +
60 + target.value = value.slice(0, at) + text + value.slice(stop);
61 + const after = at + text.length;
62 + try {
63 + target.setSelectionRange(after, after);
64 + } catch {
65 + // Same class of control as the null above: it holds a value and has
66 + // no selection to set. The text is in it either way.
67 + }
68 + // What a browser sends when a control's value settles, so an autosave
69 + // debounce, a word count and a `Field::changes` route all see this the
70 + // way they see typing. Without it the three surfaces this was measured
71 + // on would show the reference and save a document without it.
72 + target.dispatchEvent(new Event("input", { bubbles: true }));
73 + };
74 +
75 + // One delegated listener rather than one per control. A picker draws a card
76 + // per media file and its markup arrives in a swap, so binding per element
77 + // would mean rebinding on every swap; the document has been listening the
78 + // whole time instead.
79 + document.addEventListener("click", (event) => {
80 + const control = event.target?.closest?.(`[${FIELD}]`);
81 + if (!control) {
82 + return;
83 + }
84 + const target = box(control, control.getAttribute(FIELD));
85 + if (!target) {
86 + // A description naming a field that is not on the screen. Nothing
87 + // is written and nothing is broken: the act's own action still
88 + // runs, which is the same shape as a renderer ignoring a key it
89 + // does not know.
90 + return;
91 + }
92 + target.focus();
93 + insert(target, control.getAttribute(VALUE) ?? "");
94 + });
95 + })();