Skip to main content

max / goingson

Compose gets a window of its own, and the description asks for it Finishes 3fb2526a. The screen has been built since f8eab8d and attachments since 9b37c84; what was left was the second mount, and the reason it was left is that nothing could ask for one. WHAT WAS MISSING WAS A WORD, NOT A WINDOW. Frame { verbs, status } landed in quasi 0.54 and says what a mount puts around a screen. Nothing said how anything asks for the second mount, so the only ways left were a bespoke branch in host.js or a menu item no screen can see -- both of which put the compose window outside the description, where no other renderer could ever have one. Max ruled (b) 2026-08-22: grow the vocabulary. That is Action::elsewhere, quasi 0.55.0, and this is its first consumer. A SECOND SCHEME, BECAUSE A PROTOCOL IS ONE RENDERER. quasi_tauri::Protocol is a scheme, a router, a state and one renderer, and a renderer is where the frame lives, so two frames is two schemes. compose://localhost/compose/{id} shares quasi::router() -- the same description, which is the entire point. Two things differ and both are the mount's: compose_shell has no nav and no running-timer band, and the frame reports. THE FRAME OFFERS NO VERBS, and that is deliberate rather than unfinished. Queue, Queue later, Discard and Take it back are on the screen, so both mounts get them from one place; a frame carrying them too would draw each verb twice in this window. Frame::verbs is for what a mount adds, and this mount adds a place to speak rather than something to press. WHICH CLOSES THE MEASURED TABLE. 3fb2526a found five divergences between the shipped modal and the shipped window. Four were gone before this commit: the attachments bar became a header row, progressive disclosure dissolved with Eudora's shape, and the verb placement and reply indicator were the accident rather than the answer. The fifth is where a mount says what happened, and Frame::holds is now what decides it -- a banner rests in the status line here and floats in the main window, from one description. open_compose_window comes back, aimed at an address rather than at compose.html, and its ComposeContext does not: a described route carries its reply context as a path and a query, so the three fields that struct existed to marshal are one string. The string is checked, because it arrives from the frontend and the frontend is where rendered email content is displayed -- joined onto the scheme so `..` resolves before the check rather than after, and refused unless it lands under /compose. Same reasoning as open_external_url one function down. A window is labelled per address, so two drafts are two windows and pressing the verb twice raises the one that is open. HOST.JS TAKES A SECOND JOB and its own rule survives, because the job is the same one in a different coat: a webview cannot put up a window any more than it can open a file dialog. What makes it not the thing that file forbids is that it answers a described word rather than inventing a behaviour. The test for a third job is written down beside it. Six tests. The two that would rot silently: that the verb carries the address and no transport (an href or an hx-get opens the screen in the window the control is already in), and that both mounts serve the same screen -- if this description ever learns which window it is in, it is back to the two code paths the measurement found. Takes quasi 0.55.0. Closes goingson 3fb2526a.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 20:31 UTC
Signed with PGP, not checked
Commit: 39adf6441d1622c32af322e1f6b2f05824591126
Parent: 9b37c84
9 files changed, +474 insertions, -67 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.60.2",
1390 + "windows-sys 0.61.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.60.2",
1631 + "windows-sys 0.61.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.60.2",
3642 + "windows-sys 0.61.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.60.2",
3775 + "windows-sys 0.61.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.54.0"
4709 + version = "0.55.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.54.0"
4718 + version = "0.55.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.54.0"
4726 + version = "0.55.0"
4727 4727 dependencies = [
4728 4728 "makeover-layout",
4729 4729 ]
4730 4730
4731 4731 [[package]]
4732 4732 name = "quasi-tauri"
4733 - version = "0.54.0"
4733 + version = "0.55.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.54.0"
4759 + version = "0.55.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.60.2",
5198 + "windows-sys 0.61.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.60.2",
5255 + "windows-sys 0.61.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.60.2",
5795 + "windows-sys 0.61.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.60.2",
6570 + "windows-sys 0.61.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.60.2",
6592 + "windows-sys 0.61.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.60.2",
7074 + "windows-sys 0.61.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.60.2",
7109 + "windows-sys 0.61.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.60.2",
7639 + "windows-sys 0.61.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.54.0"
8499 -
8500 - [[patch.unused]]
8501 - name = "quasi-basics"
8502 - version = "0.54.0"
8503 -
8504 - [[patch.unused]]
8505 - name = "quasi-immediate"
8506 - version = "0.54.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.55.0"
8503 +
8504 + [[patch.unused]]
8505 + name = "quasi-basics"
8506 + version = "0.55.0"
8507 +
8508 + [[patch.unused]]
8509 + name = "quasi-immediate"
8510 + version = "0.55.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.54" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.55" }
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.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" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.55" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.55" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.55" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.55" }
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.54", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.55", features = ["synckit", "describe"] }
78 78
79 79 # Tauri
80 80 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -240,6 +240,7 @@
240 240 // Window
241 241 $crate::commands::set_window_title,
242 242 $crate::commands::open_external_url,
243 + $crate::commands::open_compose_window,
243 244 // Search
244 245 $crate::commands::search,
245 246 // Daily Notes
@@ -378,6 +379,19 @@
378 379 late
379 380 };
380 381
382 + // The compose window's scheme. Registered on the same terms and skipped on
383 + // the same platform: this entry point serves mobile, where there is no
384 + // second window to put it in, but a scheme costs nothing unregistered-from
385 + // and leaving it out would make the two entry points differ in a way
386 + // nothing here needs. See `quasi::compose_protocol`.
387 + #[cfg(not(target_os = "android"))]
388 + let compose_state = {
389 + let (protocol, late) = quasi::compose_protocol();
390 + builder =
391 + builder.register_asynchronous_uri_scheme_protocol("compose", protocol.into_handler());
392 + late
393 + };
394 +
381 395 // Desktop-only plugins
382 396 #[cfg(not(any(target_os = "ios", target_os = "android")))]
383 397 {
@@ -414,6 +428,8 @@
414 428 // anything can put a window up.
415 429 #[cfg(not(target_os = "android"))]
416 430 quasi_state.set(Arc::clone(&state));
431 + #[cfg(not(target_os = "android"))]
432 + compose_state.set(Arc::clone(&state));
417 433 app_handle.manage(state);
418 434 });
419 435
@@ -140,6 +140,18 @@
140 140 (late, url)
141 141 };
142 142
143 + // The compose window's scheme, `compose://localhost/compose/{id}`: the same
144 + // router and the same screens, in a mount whose frame reports. A protocol
145 + // is one renderer and a renderer is where the frame lives, so a second
146 + // frame is a second scheme. See `quasi::compose_protocol` and goingson
147 + // `3fb2526a`.
148 + let compose_state = {
149 + let (protocol, late) = goingson_desktop::quasi::compose_protocol();
150 + builder =
151 + builder.register_asynchronous_uri_scheme_protocol("compose", protocol.into_handler());
152 + late
153 + };
154 +
143 155 // Desktop-only plugins
144 156 #[cfg(not(any(target_os = "ios", target_os = "android")))]
145 157 {
@@ -359,6 +371,11 @@
359 371 // Before the window is built, so the 503 window the deferred
360 372 // protocol opens closes here rather than at first request.
361 373 quasi_state.set(Arc::clone(&state));
374 + // Both schemes, from one state. A compose window can be opened
375 + // before the main window has finished anything, so leaving this
376 + // for later would be a 503 the first time somebody pressed the
377 + // verb.
378 + compose_state.set(Arc::clone(&state));
362 379 app_handle.manage(state);
363 380 });
364 381
@@ -25,6 +25,18 @@
25 25 * something here grows past picking a file and handing back what the host got,
26 26 * that is a described screen wearing a script, and the thing to do is describe
27 27 * it.
28 + *
29 + * THE SECOND JOB, added 2026-08-22, is `data-mount` and it is the same job in a
30 + * different coat: a webview cannot put up a window any more than it can open a
31 + * file dialog, so the description says where and this does it. What makes it
32 + * not the thing the paragraph above forbids is that it answers a word --
33 + * `Action::elsewhere`, quasi 0.55.0 -- rather than inventing a behaviour. Max
34 + * ruled that (goingson `3fb2526a`), against two cheaper options that would each
35 + * have put the second window outside the description: a bespoke branch here, or
36 + * a menu item no screen can see.
37 + *
38 + * The test for a third job is the same one: is there a described word it is
39 + * answering, and is the thing it does genuinely impossible for a renderer?
28 40 */
29 41 (() => {
30 42 /* The dialog filter, from the accept list the description carried.
@@ -79,14 +91,52 @@
79 91 });
80 92 }
81 93
94 + /* Put this address up in a mount of its own, which here is a window.
95 + *
96 + * `quasi-webview` emits `data-mount` with no transport at all -- no verb,
97 + * no trigger, no href -- for the same reason it does that for `data-sends`:
98 + * every one of them would put the answer in the mount the control is
99 + * already in, which is what the mark says not to do.
100 + *
101 + * The command takes the path and checks it before opening anything. This
102 + * file deliberately does no checking of its own: two answers to which
103 + * addresses may become a window is one answer too many, and the one that
104 + * matters is the one in Rust. */
105 + async function mount(element) {
106 + const path = element.getAttribute('data-mount');
107 + if (!path) return;
108 +
109 + const core = window.__TAURI__ && window.__TAURI__.core;
110 + if (!core) {
111 + /* Said out loud rather than silently doing nothing, for the reason
112 + * the missing dialog is: a control that looks pressable and is not
113 + * is the failure this whole file is fixing. */
114 + console.error('[host] no Tauri core available; cannot open a window');
115 + return;
116 + }
117 +
118 + try {
119 + await core.invoke('open_compose_window', { path });
120 + } catch (error) {
121 + console.error('[host] could not open a window on', path, error);
122 + }
123 + }
124 +
82 125 /* Delegated, because a fragment swap replaces the elements: a listener bound
83 126 * to each control at load would be gone the first time its region answered.
84 127 * The same reason the described screens use htmx rather than per-element
85 128 * wiring. */
86 129 document.addEventListener('click', (event) => {
87 - const element = event.target.closest('[data-sends]');
130 + const element = event.target.closest('[data-sends], [data-mount]');
88 131 if (!element) return;
89 132 event.preventDefault();
90 - perform(element);
133 + /* `data-sends` first. A control carrying both would be a description
134 + * asking the host to perform a call and to put its answer somewhere
135 + * else, and performing it is the half this app knows how to do. */
136 + if (element.hasAttribute('data-sends')) {
137 + perform(element);
138 + } else {
139 + mount(element);
140 + }
91 141 });
92 142 })();
@@ -7,19 +7,97 @@
7 7
8 8 use super::{ApiError, ResultApiError};
9 9
10 - // Commands
11 - // `open_compose_window` and its `ComposeContext` stood here until 2026-08-22.
12 - //
13 - // It built `compose.html?to=..&subject=..` and opened a second Tauri window on
14 - // it. The document swap (goingson@f4edefb) deleted `compose.html` along with
15 - // the rest of the JavaScript frontend, so the command pointed at nothing, and
16 - // the JavaScript that called it went in the same commit: it was unreachable and
17 - // broken at once.
18 - //
19 - // The second compose window comes back with the described compose screen,
20 - // goingson `3fb2526a`, aimed at an address on the quasi scheme rather than a
21 - // file. The reply context it used to encode into query parameters is what a
22 - // described address carries anyway.
10 + /// Put a described address up in a window of its own.
11 + ///
12 + /// goingson `3fb2526a`. The host half of
13 + /// [`Action::elsewhere`](quasi_router::Action::elsewhere): a webview cannot
14 + /// open a window, so the description says where and this does it.
15 + ///
16 + /// # What this used to be
17 + ///
18 + /// `open_compose_window` stood here until 2026-08-22 taking a `ComposeContext`,
19 + /// building `compose.html?to=..&subject=..` and opening a window on the file.
20 + /// The document swap (goingson@f4edefb) deleted `compose.html` along with the
21 + /// rest of the JavaScript frontend, so it pointed at nothing, and the script
22 + /// that called it went in the same commit: unreachable and broken at once.
23 + ///
24 + /// What replaces the `ComposeContext` is the address. A described route carries
25 + /// its reply context as a path and a query the way every other screen does, so
26 + /// the three fields the struct existed to marshal are one string now.
27 + ///
28 + /// # Why the path is checked
29 + ///
30 + /// `path` arrives from the frontend, and the frontend is where rendered email
31 + /// content is displayed. Joining an arbitrary string onto the scheme would let
32 + /// a scripting bug in a message body open a window on an address of its
33 + /// choosing, which is the same reasoning `open_external_url` is built on one
34 + /// function down. So it is joined onto `compose://localhost/` and refused
35 + /// unless it lands under `/compose`, which is the one screen this window is
36 + /// for. A rejected path is an error rather than a silently different window.
37 + #[tauri::command]
38 + #[instrument(skip_all)]
39 + pub async fn open_compose_window(
40 + #[allow(unused_variables)] app: tauri::AppHandle,
41 + #[allow(unused_variables)] path: String,
42 + ) -> Result<(), ApiError> {
43 + #[cfg(not(any(target_os = "ios", target_os = "android")))]
44 + {
45 + let url = compose_window_url(&path)
46 + .ok_or_else(|| ApiError::bad_request("Not an address a compose window can open"))?;
47 +
48 + // Named per address rather than "compose", so writing two messages at
49 + // once is two windows. Tauri refuses a duplicate label, and reusing one
50 + // would silently replace the message somebody was still writing.
51 + let label = compose_window_label(&path);
52 +
53 + // Already open: raise it rather than refusing. Pressing the verb twice
54 + // for the same draft means "show me that", not "fail".
55 + if let Some(existing) = tauri::Manager::get_webview_window(&app, &label) {
56 + existing
57 + .set_focus()
58 + .map_api_err("Failed to focus the compose window", ApiError::internal)?;
59 + return Ok(());
60 + }
61 +
62 + tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::External(url))
63 + .title("Compose")
64 + .inner_size(720.0, 640.0)
65 + .build()
66 + .map_api_err("Failed to open the compose window", ApiError::internal)?;
67 + }
68 +
69 + Ok(())
70 + }
71 +
72 + /// The address a compose window may open, or nothing.
73 + ///
74 + /// Joined onto the scheme rather than concatenated, so `..` segments are
75 + /// resolved before the check rather than after it, and refused unless the
76 + /// result is under `/compose`. See [`open_compose_window`].
77 + pub(crate) fn compose_window_url(path: &str) -> Option<tauri::Url> {
78 + let base: tauri::Url = "compose://localhost/".parse().ok()?;
79 + let url = base.join(path).ok()?;
80 + // Same origin, and the one screen this window exists for. `path()` is the
81 + // resolved path, so a `..` cannot walk out of it.
82 + (url.scheme() == "compose"
83 + && url.host_str() == Some("localhost")
84 + && (url.path() == "/compose" || url.path().starts_with("/compose/")))
85 + .then_some(url)
86 + }
87 +
88 + /// A window label unique to the address it shows.
89 + ///
90 + /// Tauri labels are restricted to alphanumerics, `-`, `/`, `:` and `_`, so the
91 + /// path is reduced to those and everything else becomes `_`. Collisions are
92 + /// possible in principle and harmless in practice: two addresses colliding
93 + /// would share a window, which is what two identical addresses already do.
94 + pub(crate) fn compose_window_label(path: &str) -> String {
95 + let body: String = path
96 + .chars()
97 + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
98 + .collect();
99 + format!("compose_{body}")
100 + }
23 101
24 102 #[tauri::command]
25 103 #[instrument(skip_all)]
@@ -82,7 +160,68 @@
82 160
83 161 #[cfg(test)]
84 162 mod tests {
85 - use super::is_external_http_url;
163 + use super::{compose_window_label, compose_window_url, is_external_http_url};
164 +
165 + #[test]
166 + fn a_compose_address_is_accepted() {
167 + for good in [
168 + "/compose",
169 + "/compose/4afaaf51-80b0-4cc3-a121-7d5b8b86a84c",
170 + "/compose/4afaaf51-80b0-4cc3-a121-7d5b8b86a84c?folder=drafts",
171 + ] {
172 + let url = compose_window_url(good).unwrap_or_else(|| panic!("should accept {good:?}"));
173 + assert_eq!(url.scheme(), "compose");
174 + assert_eq!(url.host_str(), Some("localhost"));
175 + }
176 + }
177 +
178 + #[test]
179 + fn nothing_else_becomes_a_window() {
180 + // `path` arrives from the frontend, and the frontend is where rendered
181 + // email content is displayed. The failure this guards is a scripting
182 + // bug in a message body opening a window on an address of its choosing,
183 + // which is `open_external_url`'s reasoning one function down.
184 + for bad in [
185 + // Another screen. This window is for one thing.
186 + "/settings",
187 + "/emails",
188 + // Off the scheme entirely.
189 + "https://evil.example.com/",
190 + "file:///etc/passwd",
191 + "javascript:alert(1)",
192 + // A prefix that is not the path. `/composer` is a different screen
193 + // and `starts_with("/compose")` alone would take it.
194 + "/composed-elsewhere",
195 + "/composer/1",
196 + // Walking out, which is why the path is joined and re-read rather
197 + // than string-checked.
198 + "/compose/../settings",
199 + "/compose/../../etc/passwd",
200 + "",
201 + ] {
202 + assert!(
203 + compose_window_url(bad).is_none(),
204 + "should refuse {bad:?}, got {:?}",
205 + compose_window_url(bad)
206 + );
207 + }
208 + }
209 +
210 + #[test]
211 + fn two_drafts_are_two_windows() {
212 + // A shared label would have Tauri refuse the second window, or worse,
213 + // replace a message somebody was still writing.
214 + assert_ne!(
215 + compose_window_label("/compose/aaaa"),
216 + compose_window_label("/compose/bbbb")
217 + );
218 + // And the label is something Tauri will take: alphanumerics and `_`.
219 + let label = compose_window_label("/compose/4afaaf51-80b0-4cc3-a121-7d5b8b86a84c?x=1");
220 + assert!(
221 + label.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
222 + "{label}"
223 + );
224 + }
86 225
87 226 #[test]
88 227 fn accepts_http_and_https() {
@@ -75,12 +75,28 @@
75 75 //! person walked away. Both checks stay: the send path is reached by more than
76 76 //! this screen.
77 77 //!
78 - //! # What is not here
78 + //! # A window of its own, and one description in both
79 79 //!
80 - //! **A window of its own.** [`Frame`](quasi_router::Frame) is what a mount puts
81 - //! around a screen, and a mount is a `Webview`; this app builds one. A compose
82 - //! window is a second mount carrying a frame whose verbs are these verbs, which
83 - //! is the other half of `3fb2526a`.
80 + //! `3fb2526a` measured five divergences between the shipped modal and the
81 + //! shipped window, and four of them were gone before this paragraph could be
82 + //! written: the attachments bar became a header row, progressive disclosure
83 + //! dissolved with Eudora's shape, and the verb placement and the reply
84 + //! indicator were the accident rather than the answer. The fifth is where a
85 + //! mount says what happened, and that is what the frame carries.
86 + //!
87 + //! So [`super::compose_protocol`] is a second scheme serving **this same
88 + //! screen** with no app chrome and a frame that reports. Nothing here is
89 + //! conditional on which window it is in, and nothing should be: a screen that
90 + //! knew would be the two code paths the measurement found.
91 + //!
92 + //! Asking for the window is [`Action::elsewhere`], added to the vocabulary for
93 + //! this (quasi 0.55.0). Max ruled it 2026-08-22 over two cheaper options --- a
94 + //! bespoke branch in `host.js`, or a menu item --- because both put the second
95 + //! window outside the description, where no other renderer could ever have one.
96 + //!
97 + //! The frame offers no verbs. Queue, Queue later, Discard and Take it back are
98 + //! on the screen, so both mounts get them from one place, and a frame carrying
99 + //! them too would draw each one twice here.
84 100
85 101 // Handlers take their request by value because `quasi_router::Handler` is a
86 102 // plain `fn(&S, Request)` pointer, so the signature is the router's.
@@ -300,11 +316,20 @@
300 316 .at_place(super::shell::EMAILS)
301 317 .with(header)
302 318 .with(
303 - Slot::new("compose-aside", RegionKind::Pane).with(Node::Act(
304 - Act::new("Discard", Action::post(format!("/compose/{id}/discard")))
305 - .tone(Tone::Danger)
306 - .confirm("Throw this message away?"),
307 - )),
319 + Slot::new("compose-aside", RegionKind::Pane)
320 + // The same address this screen is already at, put up in a mount
321 + // of its own. `Action::elsewhere` is the whole of it: this
322 + // screen does not know which window it is in, and that is what
323 + // makes one description serve both. See the module header.
324 + .with(Node::Act(Act::new(
325 + "Open in a window",
326 + Action::get(format!("/compose/{id}")).elsewhere(),
327 + )))
328 + .with(Node::Act(
329 + Act::new("Discard", Action::post(format!("/compose/{id}/discard")))
330 + .tone(Tone::Danger)
331 + .confirm("Throw this message away?"),
332 + )),
308 333 ))
309 334 }
310 335
@@ -338,9 +338,22 @@
338 338 // own rather than one the renderer ships. See `assets` and
339 339 // `frontend/js/host.js`.
340 340 .with_head("<script src=\"/static/host.js\" defer></script>")
341 - // The app's own furniture: where you can go, and the running-timer
342 - // band. See [`shell`].
343 - .with_chrome(shell::chrome())
341 + }
342 +
343 + /// The document the compose window is served inside.
344 + ///
345 + /// [`document_shell`] without [`shell::chrome`], which is the whole difference
346 + /// and is the point: a compose window has no mailbox nav and no running-timer
347 + /// band. Eudora's did not either. What surrounds the screen here is the
348 + /// [`Frame`](quasi_router::Frame) the mount supplies, which is what that member
349 + /// is for.
350 + ///
351 + /// Everything else is shared rather than copied. The stylesheets, their order
352 + /// and `host.js` are the same document furniture, and a second list of them
353 + /// would drift the first time one was added.
354 + #[must_use]
355 + pub fn compose_shell() -> quasi_webview::Shell {
356 + document_shell()
344 357 }
345 358
346 359 /// The custom protocol serving the screens inside the app, and the handle its
@@ -365,10 +378,71 @@
365 378 "quasi",
366 379 router(),
367 380 Arc::new(
368 - quasi_webview::Webview::under("quasi://localhost/static").with_shell(document_shell()),
381 + quasi_webview::Webview::under("quasi://localhost/static").with_shell(
382 + // The app's own furniture: where you can go, and the
383 + // running-timer band. Applied here rather than in
384 + // [`document_shell`] because the compose window is served the
385 + // same document without it. See [`shell`].
386 + document_shell().with_chrome(shell::chrome()),
387 + ),
369 388 ),
370 389 );
371 390 // A stylesheet is not a description. This runs before the router and wins,
372 391 // so the two address spaces are kept apart: see [`assets`].
373 392 (protocol.passthrough(assets::get), late)
374 393 }
394 +
395 + /// The compose window's scheme: the same screens, in a mount of its own.
396 + ///
397 + /// `compose://localhost/compose/{id}`. goingson `3fb2526a`, and the second half
398 + /// of what [`Frame`](quasi_router::Frame) was added for.
399 + ///
400 + /// # Why a second scheme rather than a second window on the first
401 + ///
402 + /// A [`quasi_tauri::Protocol`] is a scheme, a router, a state and **one**
403 + /// renderer, and a renderer is where the frame lives. Two mounts wanting two
404 + /// frames is therefore two protocols. They share [`router`] — the same
405 + /// description, which is the entire point: compose does not know which window
406 + /// it is in, and a screen that did would be back to the two code paths the
407 + /// measurement found.
408 + ///
409 + /// # What differs, and it is two things
410 + ///
411 + /// [`compose_shell`] rather than the app's, so there is no mailbox nav and no
412 + /// running-timer band. And a frame that reports: the compose window has a
413 + /// status line, which is the one divergence from `3fb2526a`'s measured table
414 + /// still standing. The shipped window said what happened with `setStatus()`
415 + /// into a status bar and the modal raised a toast, and
416 + /// [`Frame::holds`](quasi_router::Frame::holds) is now what decides that — a
417 + /// banner rests in the line here and floats in the main window, from one
418 + /// description.
419 + ///
420 + /// The frame offers **no verbs**, and that is deliberate rather than
421 + /// unfinished. Queue, Queue later, Discard and Take it back are on the screen,
422 + /// so both mounts get them from one place; a frame carrying them too would draw
423 + /// each verb twice in this window. `Frame::verbs` is for what a mount adds, and
424 + /// this mount adds a place to speak rather than something to press.
425 + ///
426 + /// # Every screen, not only compose
427 + ///
428 + /// The router is the whole app's, so this scheme will serve any address. That
429 + /// is a consequence of sharing one description and it is harmless: nothing
430 + /// links into this scheme except [`crate::commands::window::open_compose_window`],
431 + /// which builds the address itself. Narrowing it to one route would mean a
432 + /// second router to keep in step with the first.
433 + #[must_use]
434 + pub fn compose_protocol() -> (
435 + quasi_tauri::Protocol<AppState, quasi_webview::Webview>,
436 + quasi_tauri::Late<AppState>,
437 + ) {
438 + let (protocol, late) = quasi_tauri::Protocol::pending(
439 + "compose",
440 + router(),
441 + Arc::new(
442 + quasi_webview::Webview::under("compose://localhost/static")
443 + .with_shell(compose_shell())
444 + .with_frame(quasi_router::Frame::new().reporting()),
445 + ),
446 + );
447 + (protocol.passthrough(assets::get), late)
448 + }
@@ -503,3 +503,89 @@
503 503 assert!(blob.exists(), "the blob outlives the file");
504 504 assert_eq!(std::fs::read_to_string(&blob).unwrap(), "here for now");
505 505 }
506 +
507 + #[tokio::test]
508 + async fn compose_offers_itself_in_a_window_of_its_own() {
509 + // `3fb2526a`, the second-mount half. The verb is an ordinary described
510 + // control marked `elsewhere`, so what the markup must carry is the address
511 + // and no transport: an `href` or an `hx-get` would put the answer in the
512 + // window the control is already in, which is the one thing the mark says
513 + // not to do.
514 + let state = state().await;
515 + let id = started(&state);
516 +
517 + let markup = html(&state, &format!("/compose/{id}"));
518 +
519 + assert!(
520 + markup.contains(&format!(r#"data-mount="/compose/{id}""#)),
521 + "{markup}"
522 + );
523 + // No transport on it. A read would ordinarily be an anchor with an `href`
524 + // and an `hx-get`, and either would open the screen in the window the
525 + // control is already in -- the one thing the mark says not to do. Read off
526 + // the element itself, since the screen is full of controls that rightly
527 + // carry both.
528 + let verb = markup
529 + .split("<a ")
530 + .find(|piece| piece.starts_with("class=\"button\" data-act data-mount="))
531 + .expect("the verb is an element of its own");
532 + let verb = &verb[..verb.find('>').expect("a tag ends")];
533 + assert!(!verb.contains("href="), "{verb}");
534 + assert!(!verb.contains("hx-get"), "{verb}");
535 + // Not `data-sends`: `host.js` posts to one and opens a window on the other,
536 + // so conflating them is a message sent where a window was wanted.
537 + assert!(!verb.contains("data-sends"), "{verb}");
538 + }
539 +
540 + #[tokio::test]
541 + async fn the_compose_window_serves_the_same_screen_as_the_main_window() {
542 + // The whole point of the second mount, and the thing that would rot
543 + // silently: one description, two windows. If this screen ever learns which
544 + // mount it is in, it is back to the two code paths `3fb2526a` measured --
545 + // `compose-form.js` shared by two documents with two chrome arrangements.
546 + //
547 + // Asserted through the router rather than through the protocol, because
548 + // what differs between the mounts is the renderer and the frame, and the
549 + // description is what must not differ.
550 + let state = state().await;
551 + let id = started(&state);
552 +
553 + let main = html(&state, &format!("/compose/{id}"));
554 +
555 + let framed = {
556 + let response = router()
557 + .handle(
558 + &state,
559 + Request::get(format!("/compose/{id}")).carrying(Params::new()),
560 + )
561 + .expect("the route answers");
562 + let Outcome::Screen(screen) = &response.outcome else {
563 + panic!("compose is a screen");
564 + };
565 + // The compose window's renderer: the same screens, a frame that
566 + // reports. See `quasi::compose_protocol`.
567 + quasi_webview::Webview::new()
568 + .with_frame(quasi_router::Frame::new().reporting())
569 + .screen(screen)
570 + };
571 +
572 + // Everything the screen says is in both.
573 + for said in ["Subject", "Attached", "Queue", "Discard"] {
574 + assert!(main.contains(said), "main window lost {said}: {main}");
575 + assert!(
576 + framed.contains(said),
577 + "compose window lost {said}: {framed}"
578 + );
579 + }
580 +
581 + // And the frame adds a place to speak, which the main window has not. That
582 + // is the fifth divergence of `3fb2526a`'s table and the only one left.
583 + assert!(
584 + framed.contains(quasi_webview::frame::STATUS_ID),
585 + "the compose window has no status line: {framed}"
586 + );
587 + assert!(
588 + !main.contains(quasi_webview::frame::STATUS_ID),
589 + "the main window grew a status line: {main}"
590 + );
591 + }