max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+474 insertions,
-67 deletions
| @@ -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 | + | } |