From 24a5fe008b165f057db470149d7d8ea4fd24b6ce Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:22:52 +0900 Subject: [PATCH 001/224] Feat: Add an MSIX packager that versions and describes a package from app metadata dioxus-compose-msix stages a package directory from one executable (or a directory with the renderer beside it): AppxManifest.xml for a full-trust desktop application, the four tile and logo images drawn from the app icon, and a four-part version mapped from Cargo.toml (revision 0 for the Store, a growing build number for App Installer). Identity, publisher, name and description come from Dioxus.toml, with [windows.msix] holding the values Partner Center assigns. On Windows it packs .msix and .msixbundle with MakeAppx and can write an App Installer update feed. Tests are named for the proposed update packaging requirement, which is still a draft. --- Cargo.toml | 1 + tools/msix/Cargo.toml | 31 ++ tools/msix/examples/fixture_app.rs | 39 +++ tools/msix/src/appinstaller.rs | 127 ++++++++ tools/msix/src/assets.rs | 381 ++++++++++++++++++++++ tools/msix/src/lib.rs | 327 +++++++++++++++++++ tools/msix/src/main.rs | 211 +++++++++++++ tools/msix/src/manifest.rs | 287 +++++++++++++++++ tools/msix/src/metadata.rs | 492 +++++++++++++++++++++++++++++ tools/msix/src/sdk.rs | 105 ++++++ tools/msix/src/version.rs | 206 ++++++++++++ tools/msix/tests/stage.rs | 191 +++++++++++ 12 files changed, 2398 insertions(+) create mode 100644 tools/msix/Cargo.toml create mode 100644 tools/msix/examples/fixture_app.rs create mode 100644 tools/msix/src/appinstaller.rs create mode 100644 tools/msix/src/assets.rs create mode 100644 tools/msix/src/lib.rs create mode 100644 tools/msix/src/main.rs create mode 100644 tools/msix/src/manifest.rs create mode 100644 tools/msix/src/metadata.rs create mode 100644 tools/msix/src/sdk.rs create mode 100644 tools/msix/src/version.rs create mode 100644 tools/msix/tests/stage.rs diff --git a/Cargo.toml b/Cargo.toml index 516a9ae2..daf94437 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "samples/statistics", "samples/store", "samples/todo", + "tools/msix", ] resolver = "2" diff --git a/tools/msix/Cargo.toml b/tools/msix/Cargo.toml new file mode 100644 index 00000000..9c092457 --- /dev/null +++ b/tools/msix/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "dioxus-compose-msix" +version = "0.1.0" +description = "Packages a dioxus-compose desktop executable as an MSIX for the Microsoft Store and App Installer." +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +# The packager runs on the machine that ships the application, not inside it, so it is a +# plain command line tool. Everything it decides (identity, version, manifest, icons, +# update feed) is ordinary Rust and is tested on any host; only the last step, handing +# the staged directory to the Windows SDK's MakeAppx, needs Windows. +[dependencies] +png = "0.17" +toml = "0.8" + +[[bin]] +name = "dioxus-compose-msix" +path = "src/main.rs" +bench = false + +[lib] +bench = false + +# A tiny windowed program the pipeline can package when no application build is at hand. +# It shows a message box and waits, which is what the certification kit's launch test +# needs: a process that starts, draws a window and stays up until it is closed. +[[example]] +name = "fixture-app" +path = "examples/fixture_app.rs" diff --git a/tools/msix/examples/fixture_app.rs b/tools/msix/examples/fixture_app.rs new file mode 100644 index 00000000..9b23a5ee --- /dev/null +++ b/tools/msix/examples/fixture_app.rs @@ -0,0 +1,39 @@ +//! A stand-in application for proving the packaging pipeline when no application build +//! is at hand: it opens a window (a message box) and stays up until it is closed, which +//! is all the certification kit's launch checks ask of a program. +//! +//! Built for Windows with the GUI subsystem so that no console window appears; on other +//! hosts it only prints, so the example still compiles in the workspace's checks. + +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[cfg(windows)] +mod win { + use std::ffi::c_void; + + #[link(name = "user32")] + unsafe extern "system" { + fn MessageBoxW(hwnd: *mut c_void, text: *const u16, caption: *const u16, kind: u32) -> i32; + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + pub fn show() { + let text = wide("This program stands in for an application while its package is checked."); + let caption = wide("dioxus-compose packaging fixture"); + // SAFETY: both strings are NUL-terminated UTF-16 that outlive the call, and a + // null owner window is allowed. + unsafe { + MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), 0x40); + } + } +} + +fn main() { + #[cfg(windows)] + win::show(); + #[cfg(not(windows))] + println!("the packaging fixture only shows a window on Windows"); +} diff --git a/tools/msix/src/appinstaller.rs b/tools/msix/src/appinstaller.rs new file mode 100644 index 00000000..7126aa91 --- /dev/null +++ b/tools/msix/src/appinstaller.rs @@ -0,0 +1,127 @@ +//! The App Installer update feed. +//! +//! A package installed from the Store is updated by the Store. A package installed from +//! a download has no Store behind it, so it carries the address of a small XML file +//! instead. Windows reads that file when the application starts and in the background, +//! and when it names a greater version, downloads and installs it over the running +//! copy. Publishing an update is replacing two files on a web server: the bundle and +//! this one. +//! +//! The feed is written next to the bundle and names its own address, which is how +//! Windows knows where to look the next time. + +use crate::Error; +use crate::manifest::xml_escape; +use crate::metadata::AppMetadata; +use crate::version::PackageVersion; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Feed { + /// Where the feed itself is served, ending in `.appinstaller`. + pub feed_uri: String, + /// Where the bundle it points at is served. + pub bundle_uri: String, + /// How often, in hours, Windows checks the feed when the application starts. Zero + /// means every start. + pub hours_between_checks: u32, +} + +fn check_uri(what: &str, uri: &str) -> Result<(), Error> { + let ok = uri.starts_with("https://") + || uri.starts_with("http://") + || uri.starts_with("\\\\") + || uri.starts_with("file://"); + if !ok { + return Err(Error::new(format!( + "{what} `{uri}` has to be an https:// address (or a file share for testing)" + ))); + } + Ok(()) +} + +impl Feed { + /// A feed served from `base`, a directory URL, with the bundle beside it. + pub fn beside(base: &str, feed_file: &str, bundle_file: &str) -> Result { + // A file share is joined the way Windows writes paths, everything else as a URL. + let sep = if base.starts_with("\\\\") { '\\' } else { '/' }; + let base = base.trim_end_matches(['/', '\\']); + let feed = Feed { + feed_uri: format!("{base}{sep}{feed_file}"), + bundle_uri: format!("{base}{sep}{bundle_file}"), + hours_between_checks: 0, + }; + check_uri("feed address", &feed.feed_uri)?; + Ok(feed) + } + + pub fn render(&self, meta: &AppMetadata, version: PackageVersion) -> Result { + check_uri("feed address", &self.feed_uri)?; + check_uri("bundle address", &self.bundle_uri)?; + let e = xml_escape; + Ok(format!( + "\n\ + \n \ + \n \ + \n \ + \n \ + \n \ + \n\ + \n", + feed = e(&self.feed_uri), + bundle = e(&self.bundle_uri), + name = e(&meta.identity_name), + publisher = e(&meta.publisher), + hours = self.hours_between_checks, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::version::Channel; + + fn meta() -> AppMetadata { + let d: toml::Table = "[application]\nname = \"Ember\"\n[bundle]\nidentifier = \"dev.darkpyonix.ember\"\npublisher = \"DarkPyonix\"\n" + .parse() + .unwrap(); + let c: toml::Table = "[package]\nname = \"ember\"\nversion = \"1.0.0\"\n" + .parse() + .unwrap(); + AppMetadata::from_tables(std::path::Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap() + } + + #[test] + fn fr34_feed_points_at_the_bundle_and_at_itself() { + let v = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(12)).unwrap(); + let feed = Feed::beside( + "https://example.com/ember/", + "Ember.appinstaller", + "Ember_1.0.0.12.msixbundle", + ) + .unwrap(); + let xml = feed.render(&meta(), v).unwrap(); + assert!( + xml.contains("Uri=\"https://example.com/ember/Ember.appinstaller\""), + "{xml}" + ); + assert!(xml.contains( + "" + ), "{xml}"); + assert!(xml.contains("")); + assert!(xml.contains("")); + } + + #[test] + fn fr34_feed_on_a_file_share_uses_backslashes() { + let feed = Feed::beside("\\\\host\\share\\", "a.appinstaller", "b.msixbundle").unwrap(); + assert_eq!(feed.feed_uri, "\\\\host\\share\\a.appinstaller"); + assert_eq!(feed.bundle_uri, "\\\\host\\share\\b.msixbundle"); + } + + #[test] + fn fr34_feed_refuses_a_relative_address() { + assert!(Feed::beside("ember", "a.appinstaller", "b.msixbundle").is_err()); + } +} diff --git a/tools/msix/src/assets.rs b/tools/msix/src/assets.rs new file mode 100644 index 00000000..7cf65cee --- /dev/null +++ b/tools/msix/src/assets.rs @@ -0,0 +1,381 @@ +//! The images a package has to carry, drawn from the application's one icon. +//! +//! The manifest names four: the 44 pixel logo the taskbar and Start list use, the 150 +//! pixel medium tile, the 310 by 150 wide tile, and the 50 pixel Store logo. Each is +//! written at its exact size under the unqualified name the manifest uses, so the package +//! needs no resource index to find them. +//! +//! An icon smaller than the largest image is scaled up and the packager says so: the +//! Store accepts it, but a tile drawn from 64 pixels looks like one. + +use std::path::Path; + +use crate::Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rgba { + pub width: u32, + pub height: u32, + /// Straight (not premultiplied) RGBA, row major. + pub pixels: Vec, +} + +/// One image the manifest refers to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AssetSpec { + pub file: &'static str, + pub width: u32, + pub height: u32, +} + +pub const STORE_LOGO: AssetSpec = AssetSpec { + file: "StoreLogo.png", + width: 50, + height: 50, +}; +pub const SQUARE_44: AssetSpec = AssetSpec { + file: "Square44x44Logo.png", + width: 44, + height: 44, +}; +pub const SQUARE_150: AssetSpec = AssetSpec { + file: "Square150x150Logo.png", + width: 150, + height: 150, +}; +pub const WIDE_310: AssetSpec = AssetSpec { + file: "Wide310x150Logo.png", + width: 310, + height: 150, +}; + +pub const ALL: [AssetSpec; 4] = [STORE_LOGO, SQUARE_44, SQUARE_150, WIDE_310]; + +/// The directory inside the package the images live in. +pub const DIR: &str = "Assets"; + +impl Rgba { + pub fn decode(bytes: &[u8]) -> Result { + let mut decoder = png::Decoder::new(bytes); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder + .read_info() + .map_err(|e| Error::new(format!("icon is not a readable PNG: {e}")))?; + let mut buf = vec![0; reader.output_buffer_size()]; + let info = reader + .next_frame(&mut buf) + .map_err(|e| Error::new(format!("icon is not a readable PNG: {e}")))?; + let (w, h) = (info.width, info.height); + let src = &buf[..info.buffer_size()]; + let count = (w * h) as usize; + let mut pixels = Vec::with_capacity(count * 4); + match info.color_type { + png::ColorType::Rgba => pixels.extend_from_slice(src), + png::ColorType::Rgb => { + for p in src.chunks_exact(3) { + pixels.extend_from_slice(&[p[0], p[1], p[2], 255]); + } + } + png::ColorType::GrayscaleAlpha => { + for p in src.chunks_exact(2) { + pixels.extend_from_slice(&[p[0], p[0], p[0], p[1]]); + } + } + png::ColorType::Grayscale => { + for &g in src { + pixels.extend_from_slice(&[g, g, g, 255]); + } + } + png::ColorType::Indexed => { + return Err(Error::new("icon PNG kept its palette after expansion")); + } + } + if pixels.len() != count * 4 { + return Err(Error::new("icon PNG decoded to the wrong number of pixels")); + } + Ok(Rgba { + width: w, + height: h, + pixels, + }) + } + + pub fn encode(&self) -> Result, Error> { + let fail = |e: png::EncodingError| Error::new(format!("cannot encode PNG: {e}")); + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().map_err(fail)?; + writer.write_image_data(&self.pixels).map_err(fail)?; + writer.finish().map_err(fail)?; + Ok(out) + } + + pub fn transparent(width: u32, height: u32) -> Self { + Rgba { + width, + height, + pixels: vec![0; (width * height * 4) as usize], + } + } + + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + [ + self.pixels[i], + self.pixels[i + 1], + self.pixels[i + 2], + self.pixels[i + 3], + ] + } + + /// Scales to `width` by `height` with a triangle filter whose support widens when + /// shrinking, so a large icon is averaged rather than sampled. Colour is weighted by + /// alpha, which keeps the transparent surround from bleeding a dark fringe into the + /// edge of the mark. + pub fn resize(&self, width: u32, height: u32) -> Self { + let horizontal = weights(self.width, width); + let vertical = weights(self.height, height); + + // Premultiplied floats for the intermediate. + let src: Vec<[f32; 4]> = self + .pixels + .chunks_exact(4) + .map(|p| { + let a = p[3] as f32 / 255.0; + [ + p[0] as f32 * a, + p[1] as f32 * a, + p[2] as f32 * a, + p[3] as f32, + ] + }) + .collect(); + + let mut rows = vec![[0f32; 4]; (width * self.height) as usize]; + for y in 0..self.height { + for (x, taps) in horizontal.iter().enumerate() { + let mut acc = [0f32; 4]; + for &(sx, w) in taps { + let p = src[(y * self.width + sx) as usize]; + for (a, v) in acc.iter_mut().zip(p) { + *a += v * w; + } + } + rows[(y * width) as usize + x] = acc; + } + } + + let mut out = Rgba::transparent(width, height); + for (y, taps) in vertical.iter().enumerate() { + for x in 0..width { + let mut acc = [0f32; 4]; + for &(sy, w) in taps { + let p = rows[(sy * width + x) as usize]; + for (a, v) in acc.iter_mut().zip(p) { + *a += v * w; + } + } + let a = acc[3].clamp(0.0, 255.0); + let i = ((y as u32 * width + x) * 4) as usize; + if a > 0.0 { + let scale = 255.0 / a; + for (dst, v) in out.pixels[i..i + 3].iter_mut().zip(acc) { + *dst = (v * scale).round().clamp(0.0, 255.0) as u8; + } + } + out.pixels[i + 3] = a.round() as u8; + } + } + out + } + + /// Copies `other` onto this image with its top left corner at (`x`, `y`). + pub fn blit(&mut self, other: &Rgba, x: u32, y: u32) { + for oy in 0..other.height { + for ox in 0..other.width { + let (tx, ty) = (x + ox, y + oy); + if tx >= self.width || ty >= self.height { + continue; + } + let s = ((oy * other.width + ox) * 4) as usize; + let d = ((ty * self.width + tx) * 4) as usize; + self.pixels[d..d + 4].copy_from_slice(&other.pixels[s..s + 4]); + } + } + } +} + +/// For each destination index, the source indices and normalised weights that make it. +fn weights(src: u32, dst: u32) -> Vec> { + let scale = src as f32 / dst as f32; + let support = scale.max(1.0); + (0..dst) + .map(|d| { + let centre = (d as f32 + 0.5) * scale - 0.5; + let lo = (centre - support).floor().max(0.0) as u32; + let hi = ((centre + support).ceil() as u32).min(src - 1); + let mut taps: Vec<(u32, f32)> = (lo..=hi) + .map(|s| { + let w = (1.0 - (s as f32 - centre).abs() / support).max(0.0); + (s, w) + }) + .filter(|&(_, w)| w > 0.0) + .collect(); + if taps.is_empty() { + taps.push((centre.round().clamp(0.0, (src - 1) as f32) as u32, 1.0)); + } + let total: f32 = taps.iter().map(|&(_, w)| w).sum(); + for t in &mut taps { + t.1 /= total; + } + taps + }) + .collect() +} + +/// Draws one asset from the icon. Square assets are the icon at that size. The wide tile +/// is the icon at the tile's height, centred on a transparent field, which is how Windows +/// draws a tile that has no wide artwork of its own. +pub fn render(icon: &Rgba, spec: AssetSpec) -> Rgba { + if spec.width == spec.height { + return icon.resize(spec.width, spec.height); + } + let side = spec.width.min(spec.height); + let mark = icon.resize(side, side); + let mut canvas = Rgba::transparent(spec.width, spec.height); + canvas.blit(&mark, (spec.width - side) / 2, (spec.height - side) / 2); + canvas +} + +/// The largest side any asset needs. An icon smaller than this is being enlarged. +pub fn largest_side() -> u32 { + ALL.iter().map(|s| s.width.max(s.height)).max().unwrap_or(0) +} + +/// Writes every asset into `/Assets`. Returns a warning when the icon had +/// to be enlarged. +pub fn write_all(icon_png: &[u8], package_root: &Path) -> Result, Error> { + let icon = Rgba::decode(icon_png)?; + let dir = package_root.join(DIR); + std::fs::create_dir_all(&dir) + .map_err(|e| Error::new(format!("cannot create {}: {e}", dir.display())))?; + for spec in ALL { + let bytes = render(&icon, spec).encode()?; + let path = dir.join(spec.file); + std::fs::write(&path, bytes) + .map_err(|e| Error::new(format!("cannot write {}: {e}", path.display())))?; + } + let needed = 150; // the medium tile, the largest square the icon is drawn into + let warning = (icon.width.min(icon.height) < needed).then(|| { + format!( + "the icon is {}x{} and is enlarged for the {needed}x{needed} tile; give the application a 256x256 icon for sharp tiles", + icon.width, icon.height + ) + }); + Ok(warning) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, rgba: [u8; 4]) -> Rgba { + Rgba { + width: w, + height: h, + pixels: rgba + .iter() + .copied() + .cycle() + .take((w * h * 4) as usize) + .collect(), + } + } + + #[test] + fn fr34_every_asset_has_its_exact_size() { + let icon = solid(64, 64, [200, 80, 40, 255]); + for spec in ALL { + let img = render(&icon, spec); + assert_eq!( + (img.width, img.height), + (spec.width, spec.height), + "{}", + spec.file + ); + let decoded = Rgba::decode(&img.encode().unwrap()).unwrap(); + assert_eq!((decoded.width, decoded.height), (spec.width, spec.height)); + } + } + + #[test] + fn fr34_resizing_keeps_a_solid_colour_solid() { + let icon = solid(64, 64, [10, 120, 230, 255]); + for (w, h) in [(44, 44), (150, 150), (50, 50), (3, 3), (256, 256)] { + let r = icon.resize(w, h); + for y in 0..h { + for x in 0..w { + assert_eq!(r.pixel(x, y), [10, 120, 230, 255], "{w}x{h} at {x},{y}"); + } + } + } + } + + #[test] + fn fr34_transparent_surround_does_not_darken_the_edge() { + // Left half opaque red, right half fully transparent black. + let mut icon = solid(8, 8, [0, 0, 0, 0]); + for y in 0..8 { + for x in 0..4 { + let i = ((y * 8 + x) * 4) as usize; + icon.pixels[i..i + 4].copy_from_slice(&[255, 0, 0, 255]); + } + } + let r = icon.resize(3, 3); + let middle = r.pixel(1, 1); + assert!(middle[3] > 0 && middle[3] < 255, "{middle:?}"); + assert_eq!(&middle[..3], &[255, 0, 0], "edge colour bled: {middle:?}"); + } + + #[test] + fn fr34_wide_tile_centres_the_icon() { + let icon = solid(16, 16, [0, 255, 0, 255]); + let wide = render(&icon, WIDE_310); + assert_eq!(wide.pixel(0, 75)[3], 0); + assert_eq!(wide.pixel(309, 75)[3], 0); + assert_eq!(wide.pixel(155, 75), [0, 255, 0, 255]); + } + + #[test] + fn fr34_small_icons_are_reported() { + let dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples/notepad/assets/icon.png"); + let bytes = std::fs::read(dir).unwrap(); + let icon = Rgba::decode(&bytes).unwrap(); + assert_eq!((icon.width, icon.height), (64, 64)); + assert!(largest_side() >= 150); + } + + #[test] + fn fr34_gray_and_rgb_pngs_decode() { + for (color, channels) in [ + (png::ColorType::Rgb, 3usize), + (png::ColorType::Grayscale, 1), + (png::ColorType::GrayscaleAlpha, 2), + ] { + let mut out = Vec::new(); + { + let mut e = png::Encoder::new(&mut out, 2, 2); + e.set_color(color); + e.set_depth(png::BitDepth::Eight); + let mut w = e.write_header().unwrap(); + w.write_image_data(&vec![128u8; 4 * channels]).unwrap(); + } + let img = Rgba::decode(&out).unwrap(); + assert_eq!(img.pixels.len(), 16, "{color:?}"); + assert_eq!(img.pixel(0, 0)[0], 128); + } + } +} diff --git a/tools/msix/src/lib.rs b/tools/msix/src/lib.rs new file mode 100644 index 00000000..7f3f57c4 --- /dev/null +++ b/tools/msix/src/lib.rs @@ -0,0 +1,327 @@ +//! Packages a dioxus-compose desktop application as MSIX. +//! +//! The input is what a release build already produces: one executable, or a directory +//! holding the executable with the renderer beside it. The output is a package the +//! Microsoft Store accepts for submission, a bundle wrapping it, and optionally an App +//! Installer feed that keeps a downloaded copy current. Name, publisher, description, +//! icon and version come from the application's own `Dioxus.toml` and `Cargo.toml`. +//! +//! The steps are split so that every decision can be checked anywhere: [`stage`] builds +//! the package directory (payload, manifest, images) on any host, and [`pack`] hands that +//! directory to the Windows SDK's MakeAppx, which only exists on Windows. + +pub mod appinstaller; +pub mod assets; +pub mod manifest; +pub mod metadata; +pub mod sdk; +pub mod version; + +use std::ffi::OsStr; +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use manifest::Arch; +pub use metadata::AppMetadata; +pub use version::{Channel, PackageVersion}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error(String); + +impl Error { + pub fn new(message: impl Into) -> Self { + Error(message.into()) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +/// What goes into the package besides the manifest and images. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Payload { + /// One self-contained executable, placed at the root of the package. + Executable(PathBuf), + /// A directory copied whole, with the executable at `executable` inside it. This is + /// the shape a build with the renderer beside the program has (`bin\app.exe` next + /// to the renderer DLL, `lib` next to `bin`). + Directory { root: PathBuf, executable: PathBuf }, +} + +impl Payload { + /// The executable's path inside the package, with backslashes as the manifest wants. + pub fn executable_in_package(&self) -> Result { + let relative: PathBuf = match self { + Payload::Executable(file) => PathBuf::from( + file.file_name() + .ok_or_else(|| Error::new(format!("{} is not a file", file.display())))?, + ), + Payload::Directory { executable, .. } => executable.clone(), + }; + let parts: Vec = relative + .components() + .map(|c| match c { + std::path::Component::Normal(s) => Ok(s.to_string_lossy().into_owned()), + _ => Err(Error::new(format!( + "the executable `{}` has to be a plain path inside the payload", + relative.display() + ))), + }) + .collect::>()?; + if parts.is_empty() { + return Err(Error::new("the executable path is empty")); + } + let joined = parts.join("\\"); + if !joined.to_ascii_lowercase().ends_with(".exe") { + return Err(Error::new(format!( + "the executable `{joined}` does not end in .exe" + ))); + } + Ok(joined) + } +} + +/// Everything one packaging run needs to know. +#[derive(Clone, Debug)] +pub struct Plan { + pub meta: AppMetadata, + pub version: PackageVersion, + pub channel: Channel, + pub arch: Arch, + pub payload: Payload, + pub icon: Option, +} + +impl Plan { + /// `__.msix`, the name MakeAppx and the Store expect. + pub fn package_file_name(&self) -> String { + format!( + "{}_{}_{}.msix", + self.meta.identity_name, + self.version, + self.arch.as_str() + ) + } + + pub fn bundle_file_name(&self) -> String { + format!("{}_{}.msixbundle", self.meta.identity_name, self.version) + } + + pub fn feed_file_name(&self) -> String { + format!("{}.appinstaller", self.meta.identity_name) + } +} + +/// Names a package directory must keep for itself. +const RESERVED: &[&str] = &[ + "AppxManifest.xml", + "AppxBlockMap.xml", + "AppxSignature.p7x", + "[Content_Types].xml", + "AppxMetadata", +]; + +fn copy_dir(from: &Path, to: &Path) -> Result<(), Error> { + std::fs::create_dir_all(to) + .map_err(|e| Error::new(format!("cannot create {}: {e}", to.display())))?; + let entries = std::fs::read_dir(from) + .map_err(|e| Error::new(format!("cannot read {}: {e}", from.display())))?; + for entry in entries { + let entry = + entry.map_err(|e| Error::new(format!("cannot read {}: {e}", from.display())))?; + let src = entry.path(); + let dst = to.join(entry.file_name()); + let kind = entry + .file_type() + .map_err(|e| Error::new(format!("cannot inspect {}: {e}", src.display())))?; + if kind.is_dir() { + copy_dir(&src, &dst)?; + } else { + std::fs::copy(&src, &dst).map_err(|e| { + Error::new(format!( + "cannot copy {} to {}: {e}", + src.display(), + dst.display() + )) + })?; + } + } + Ok(()) +} + +/// Builds the package directory at `layout`: the payload, `AppxManifest.xml` and the +/// images. Runs anywhere. Returns warnings worth showing the person packaging. +pub fn stage(plan: &Plan, layout: &Path) -> Result, Error> { + let mut warnings = Vec::new(); + if layout.exists() { + std::fs::remove_dir_all(layout) + .map_err(|e| Error::new(format!("cannot clear {}: {e}", layout.display())))?; + } + std::fs::create_dir_all(layout) + .map_err(|e| Error::new(format!("cannot create {}: {e}", layout.display())))?; + + let executable = plan.payload.executable_in_package()?; + match &plan.payload { + Payload::Executable(file) => { + if !file.is_file() { + return Err(Error::new(format!("{} does not exist", file.display()))); + } + std::fs::copy(file, layout.join(&executable)) + .map_err(|e| Error::new(format!("cannot copy {}: {e}", file.display())))?; + } + Payload::Directory { + root, + executable: exe, + } => { + if !root.join(exe).is_file() { + return Err(Error::new(format!( + "{} is not in the payload directory {}", + exe.display(), + root.display() + ))); + } + for name in RESERVED.iter().chain([&assets::DIR]) { + if root.join(name).exists() { + return Err(Error::new(format!( + "the payload already has `{name}`, which the package writes itself" + ))); + } + } + copy_dir(root, layout)?; + } + } + + let xml = manifest::render(&plan.meta, plan.version, plan.arch, &executable); + std::fs::write(layout.join("AppxManifest.xml"), xml) + .map_err(|e| Error::new(format!("cannot write the manifest: {e}")))?; + + let icon_path = plan.icon.clone().or_else(|| plan.meta.icon.clone()).ok_or_else(|| { + Error::new( + "the application has no icon: name a PNG in [bundle] icon, or put one at /icon.png", + ) + })?; + let icon = std::fs::read(&icon_path) + .map_err(|e| Error::new(format!("cannot read the icon {}: {e}", icon_path.display())))?; + if let Some(w) = assets::write_all(&icon, layout)? { + warnings.push(w); + } + + if plan.channel == Channel::Store && !plan.meta.store_identity { + warnings.push(format!( + "the identity `{}` / `{}` was derived, not taken from [windows.msix]; Partner Center will refuse \ + this package until identity_name and publisher are set to the values it shows for the reserved name", + plan.meta.identity_name, plan.meta.publisher + )); + } + Ok(warnings) +} + +/// What [`pack`] produced. +#[derive(Clone, Debug)] +pub struct Outputs { + pub package: PathBuf, + pub bundle: PathBuf, + pub feed: Option, +} + +/// Turns a staged directory into `.msix` and `.msixbundle` with MakeAppx, and writes the +/// App Installer feed when `feed_base` is given. Windows only, because MakeAppx is. +pub fn pack( + plan: &Plan, + layout: &Path, + out_dir: &Path, + feed_base: Option<&str>, +) -> Result { + let makeappx = sdk::find_tool("makeappx.exe")?; + std::fs::create_dir_all(out_dir) + .map_err(|e| Error::new(format!("cannot create {}: {e}", out_dir.display())))?; + let package = out_dir.join(plan.package_file_name()); + sdk::run( + &makeappx, + &[ + OsStr::new("pack"), + OsStr::new("/o"), + OsStr::new("/d"), + layout.as_os_str(), + OsStr::new("/p"), + package.as_os_str(), + ], + )?; + + // MakeAppx bundles every package in a directory, so the package goes into one of + // its own. Other architectures join it here when they are built. + let bundle_src = out_dir.join("bundle-src"); + if bundle_src.exists() { + std::fs::remove_dir_all(&bundle_src) + .map_err(|e| Error::new(format!("cannot clear {}: {e}", bundle_src.display())))?; + } + std::fs::create_dir_all(&bundle_src) + .map_err(|e| Error::new(format!("cannot create {}: {e}", bundle_src.display())))?; + std::fs::copy(&package, bundle_src.join(plan.package_file_name())) + .map_err(|e| Error::new(format!("cannot copy the package: {e}")))?; + let bundle = out_dir.join(plan.bundle_file_name()); + let version = plan.version.to_string(); + sdk::run( + &makeappx, + &[ + OsStr::new("bundle"), + OsStr::new("/o"), + OsStr::new("/bv"), + OsStr::new(&version), + OsStr::new("/d"), + bundle_src.as_os_str(), + OsStr::new("/p"), + bundle.as_os_str(), + ], + )?; + std::fs::remove_dir_all(&bundle_src).ok(); + + let feed = match feed_base { + Some(base) => { + let feed = + appinstaller::Feed::beside(base, &plan.feed_file_name(), &plan.bundle_file_name())?; + let path = out_dir.join(plan.feed_file_name()); + std::fs::write(&path, feed.render(&plan.meta, plan.version)?) + .map_err(|e| Error::new(format!("cannot write {}: {e}", path.display())))?; + Some(path) + } + None => None, + }; + Ok(Outputs { + package, + bundle, + feed, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr34_executable_paths_use_backslashes() { + let p = Payload::Directory { + root: "/x".into(), + executable: Path::new("bin").join("app.exe"), + }; + assert_eq!(p.executable_in_package().unwrap(), "bin\\app.exe"); + let single = Payload::Executable(Path::new("/build/release/ember.exe").into()); + assert_eq!(single.executable_in_package().unwrap(), "ember.exe"); + } + + #[test] + fn fr34_executable_must_stay_inside_the_payload() { + let p = Payload::Directory { + root: "/x".into(), + executable: Path::new("..").join("app.exe"), + }; + assert!(p.executable_in_package().is_err()); + let not_exe = Payload::Executable("/build/ember".into()); + assert!(not_exe.executable_in_package().is_err()); + } +} diff --git a/tools/msix/src/main.rs b/tools/msix/src/main.rs new file mode 100644 index 00000000..80fc886d --- /dev/null +++ b/tools/msix/src/main.rs @@ -0,0 +1,211 @@ +//! `dioxus-compose-msix`: package a desktop build as MSIX. +//! +//! ```text +//! dioxus-compose-msix --project --exe --out [options] +//! ``` +//! +//! Run with `--help` for the options. + +use std::io::Write; +use std::path::PathBuf; +use std::process::ExitCode; + +use dioxus_compose_msix::{ + AppMetadata, Arch, Channel, Error, PackageVersion, Payload, Plan, pack, stage, +}; + +const HELP: &str = "\ +dioxus-compose-msix: package a dioxus-compose desktop build as MSIX + +USAGE: + dioxus-compose-msix --project --exe --out [options] + +REQUIRED: + --project The application's directory, holding Dioxus.toml and Cargo.toml + --exe The executable. With --payload-dir, a path inside that directory + --out Where the layout, .msix, .msixbundle and feed are written + +OPTIONS: + --payload-dir Package this whole directory (renderer beside the program) + --channel store (default) or sideload + --revision Fourth version part; sideload only (a build number that grows) + --version Use this instead of the version in Cargo.toml + --arch x64 (default) or arm64 + --icon Use this icon instead of the one Dioxus.toml names + --feed-base Write an App Installer feed for a bundle served from this URL + --test-identity Allow the Store channel with a derived identity (for certification runs) + --layout-only Stage the package directory and stop; runs on any host + --summary Append key=value lines describing the outputs (for CI) + -h, --help Show this text +"; + +struct Args { + project: PathBuf, + exe: PathBuf, + out: PathBuf, + payload_dir: Option, + channel: Channel, + revision: Option, + version: Option, + arch: Arch, + icon: Option, + feed_base: Option, + test_identity: bool, + layout_only: bool, + summary: Option, +} + +fn parse_args() -> Result, Error> { + let mut project = None; + let mut exe = None; + let mut out = None; + let mut payload_dir = None; + let mut channel = Channel::Store; + let mut revision = None; + let mut version = None; + let mut arch = Arch::X64; + let mut icon = None; + let mut feed_base = None; + let mut test_identity = false; + let mut layout_only = false; + let mut summary = None; + + let mut it = std::env::args().skip(1); + while let Some(flag) = it.next() { + let mut value = || { + it.next() + .ok_or_else(|| Error::new(format!("{flag} needs a value"))) + }; + match flag.as_str() { + "-h" | "--help" => return Ok(None), + "--project" => project = Some(PathBuf::from(value()?)), + "--exe" => exe = Some(PathBuf::from(value()?)), + "--out" => out = Some(PathBuf::from(value()?)), + "--payload-dir" => payload_dir = Some(PathBuf::from(value()?)), + "--channel" => channel = Channel::parse(&value()?)?, + "--revision" => { + let v = value()?; + revision = + Some(v.parse().map_err(|_| { + Error::new(format!("--revision `{v}` is not a whole number")) + })?); + } + "--version" => version = Some(value()?), + "--arch" => { + let v = value()?; + arch = Arch::parse(&v) + .ok_or_else(|| Error::new(format!("--arch `{v}`: use x64 or arm64")))?; + } + "--icon" => icon = Some(PathBuf::from(value()?)), + "--feed-base" => feed_base = Some(value()?), + "--test-identity" => test_identity = true, + "--layout-only" => layout_only = true, + "--summary" => summary = Some(PathBuf::from(value()?)), + other => return Err(Error::new(format!("unknown option `{other}`; see --help"))), + } + } + let need = |v: Option, name: &str| { + v.ok_or_else(|| Error::new(format!("{name} is required; see --help"))) + }; + Ok(Some(Args { + project: need(project, "--project")?, + exe: need(exe, "--exe")?, + out: need(out, "--out")?, + payload_dir, + channel, + revision, + version, + arch, + icon, + feed_base, + test_identity, + layout_only, + summary, + })) +} + +fn run() -> Result<(), Error> { + let Some(args) = parse_args()? else { + print!("{HELP}"); + return Ok(()); + }; + let meta = AppMetadata::load(&args.project)?; + if args.channel == Channel::Store + && !meta.store_identity + && !args.test_identity + && !args.layout_only + { + return Err(Error::new( + "the Store channel needs the identity Partner Center assigned: set identity_name and publisher \ + in [windows.msix] of Dioxus.toml, or pass --test-identity for a certification run", + )); + } + let semver = args.version.clone().unwrap_or_else(|| meta.version.clone()); + let version = PackageVersion::from_semver(&semver, args.channel, args.revision)?; + let payload = match &args.payload_dir { + Some(root) => Payload::Directory { + root: root.clone(), + executable: args.exe.clone(), + }, + None => Payload::Executable(args.exe.clone()), + }; + let plan = Plan { + meta, + version, + channel: args.channel, + arch: args.arch, + payload, + icon: args.icon.clone(), + }; + + let layout = args.out.join("layout"); + for warning in stage(&plan, &layout)? { + eprintln!("warning: {warning}"); + } + println!( + "staged {} {} at {}", + plan.meta.identity_name, + plan.version, + layout.display() + ); + + let mut lines = vec![ + format!("identity={}", plan.meta.identity_name), + format!("publisher={}", plan.meta.publisher), + format!("version={}", plan.version), + format!("layout={}", layout.display()), + ]; + if !args.layout_only { + let outputs = pack(&plan, &layout, &args.out, args.feed_base.as_deref())?; + println!("package {}", outputs.package.display()); + println!("bundle {}", outputs.bundle.display()); + lines.push(format!("package={}", outputs.package.display())); + lines.push(format!("bundle={}", outputs.bundle.display())); + if let Some(feed) = &outputs.feed { + println!("feed {}", feed.display()); + lines.push(format!("feed={}", feed.display())); + } + } + if let Some(path) = &args.summary { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| Error::new(format!("cannot open {}: {e}", path.display())))?; + for line in lines { + writeln!(file, "{line}") + .map_err(|e| Error::new(format!("cannot write {}: {e}", path.display())))?; + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/tools/msix/src/manifest.rs b/tools/msix/src/manifest.rs new file mode 100644 index 00000000..f72f141a --- /dev/null +++ b/tools/msix/src/manifest.rs @@ -0,0 +1,287 @@ +//! `AppxManifest.xml` for a packaged full-trust desktop application. +//! +//! The application is an ordinary Win32 executable, so the package declares it as one: +//! `Windows.FullTrustApplication` as the entry point and the `runFullTrust` restricted +//! capability. That capability is what lets the renderer load its DLL, read its font +//! configuration and open files the way it does unpackaged; the Store accepts it for +//! desktop applications, with a sentence of justification at submission. + +use crate::assets; +use crate::metadata::AppMetadata; +use crate::version::PackageVersion; + +/// Escapes text for an XML attribute or element. +pub fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +/// Capabilities declared in the `uap` namespace. Everything else in +/// `[windows] capabilities` is a foundation capability (`internetClient` and the like). +const UAP_CAPABILITIES: &[&str] = &[ + "appointments", + "blockedChatMessages", + "chat", + "contacts", + "documentsLibrary", + "enterpriseAuthentication", + "musicLibrary", + "objects3D", + "phoneCall", + "picturesLibrary", + "removableStorage", + "sharedUserCertificates", + "userAccountInformation", + "videosLibrary", + "voipCall", +]; + +/// The application id inside the package: a letter followed by letters and digits. +pub fn application_id(display_name: &str) -> String { + let id: String = display_name + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .take(64) + .collect(); + if id.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { + id + } else { + "App".to_owned() + } +} + +/// The CPU the executable was built for, as the manifest spells it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arch { + X64, + Arm64, +} + +impl Arch { + pub fn parse(text: &str) -> Option { + match text { + "x64" => Some(Arch::X64), + "arm64" => Some(Arch::Arm64), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Arch::X64 => "x64", + Arch::Arm64 => "arm64", + } + } +} + +/// `executable` is the path of the program inside the package, with backslashes. +pub fn render(meta: &AppMetadata, version: PackageVersion, arch: Arch, executable: &str) -> String { + let e = xml_escape; + let mut x = String::new(); + x.push_str("\n"); + x.push_str( + "\n", + ); + x.push_str(&format!( + " \n", + e(&meta.identity_name), + e(&meta.publisher), + version, + arch.as_str() + )); + x.push_str(" \n"); + x.push_str(&format!( + " {}\n", + e(&meta.display_name) + )); + x.push_str(&format!( + " {}\n", + e(&meta.publisher_display_name) + )); + x.push_str(&format!( + " {}\\{}\n", + assets::DIR, + assets::STORE_LOGO.file + )); + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(&format!( + " \n", + e(&meta.min_version), + e(&meta.max_version_tested) + )); + x.push_str(" \n"); + x.push_str(" \n"); + for lang in &meta.languages { + x.push_str(&format!(" \n", e(lang))); + } + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(&format!( + " \n", + application_id(&meta.display_name), + e(executable) + )); + x.push_str(&format!( + " \n", + e(&meta.display_name), + e(&meta.description), + e(&meta.background_color), + assets::SQUARE_150.file, + assets::SQUARE_44.file, + d = assets::DIR, + )); + x.push_str(&format!( + " \n", + assets::DIR, + assets::WIDE_310.file + )); + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(" \n"); + // The schema wants every Capability before any DeviceCapability. + for cap in meta + .capabilities + .iter() + .filter(|c| !UAP_CAPABILITIES.contains(&c.as_str())) + { + x.push_str(&format!(" \n", e(cap))); + } + for cap in meta + .capabilities + .iter() + .filter(|c| UAP_CAPABILITIES.contains(&c.as_str())) + { + x.push_str(&format!(" \n", e(cap))); + } + x.push_str(" \n"); + for cap in meta + .restricted_capabilities + .iter() + .filter(|c| c.as_str() != "runFullTrust") + { + x.push_str(&format!(" \n", e(cap))); + } + for cap in &meta.device_capabilities { + x.push_str(&format!(" \n", e(cap))); + } + x.push_str(" \n"); + x.push_str("\n"); + x +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::{DEFAULT_MAX_VERSION_TESTED, DEFAULT_MIN_VERSION}; + use crate::version::Channel; + + fn meta() -> AppMetadata { + AppMetadata { + display_name: "Ember".into(), + publisher_display_name: "DarkPyonix".into(), + description: "Notes & ".into(), + identity_name: "DarkPyonix.Ember".into(), + publisher: "CN=\"Dark, Pyonix\"".into(), + store_identity: true, + version: "1.2.3".into(), + executable: "ember.exe".into(), + icon: None, + languages: vec!["en-us".into(), "ko-kr".into()], + capabilities: vec!["internetClient".into(), "picturesLibrary".into()], + restricted_capabilities: vec![], + device_capabilities: vec!["microphone".into()], + min_version: DEFAULT_MIN_VERSION.into(), + max_version_tested: DEFAULT_MAX_VERSION_TESTED.into(), + background_color: "transparent".into(), + } + } + + fn version() -> PackageVersion { + PackageVersion::from_semver("1.2.3", Channel::Store, None).unwrap() + } + + #[test] + fn fr34_manifest_carries_identity_and_version() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + assert!(xml.contains( + "" + ), "{xml}"); + assert!(xml.contains("Ember")); + assert!(xml.contains("DarkPyonix")); + } + + #[test] + fn fr34_manifest_declares_a_full_trust_desktop_application() { + let xml = render(&meta(), version(), Arch::Arm64, "bin\\ember.exe"); + assert!(xml.contains("EntryPoint=\"Windows.FullTrustApplication\"")); + assert!(xml.contains("Executable=\"bin\\ember.exe\"")); + assert!(xml.contains("")); + assert!(xml.contains("Name=\"Windows.Desktop\"")); + assert!(xml.contains("ProcessorArchitecture=\"arm64\"")); + assert_eq!(xml.matches("runFullTrust").count(), 1); + } + + #[test] + fn fr34_manifest_escapes_text() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + assert!(xml.contains("Description=\"Notes & <things>\"")); + assert!(!xml.contains("Notes & <")); + } + + #[test] + fn fr34_manifest_names_every_asset_it_ships() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + for spec in assets::ALL { + assert!( + xml.contains(&format!("Assets\\{}", spec.file)), + "{} is generated but not referenced", + spec.file + ); + } + } + + #[test] + fn fr34_capabilities_are_ordered_and_namespaced() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + let foundation = xml.find("")); + assert!(xml.contains("")); + } + + #[test] + fn fr34_application_id_is_alphanumeric() { + assert_eq!(application_id("Ember"), "Ember"); + assert_eq!(application_id("My App 2"), "MyApp2"); + assert_eq!(application_id("2048"), "App"); + assert_eq!(application_id("노트"), "App"); + } +} diff --git a/tools/msix/src/metadata.rs b/tools/msix/src/metadata.rs new file mode 100644 index 00000000..2b867ae6 --- /dev/null +++ b/tools/msix/src/metadata.rs @@ -0,0 +1,492 @@ +//! What the package says about the application, read from the files the application +//! already has. +//! +//! An application describes itself once: its name, identifier, publisher and +//! description in `Dioxus.toml` (the file `dx` reads for every other bundle it makes), +//! and its version in `Cargo.toml`. The package is built from those, so a release only +//! ever changes one number in one place. +//! +//! The Store adds two values that nothing else has: the package identity name and the +//! publisher certificate subject that Partner Center assigns when the application's name +//! is reserved. They go in `[windows.msix]`, which `dx` does not read: +//! +//! ```toml +//! [windows.msix] +//! identity_name = "DarkPyonix.Ember" # Partner Center: Package/Identity/Name +//! publisher = "CN=01234567-89AB-..." # Partner Center: Package/Identity/Publisher +//! display_name = "Ember" # the reserved name, if it differs from [application] +//! languages = ["en-us", "ko-kr"] +//! ``` +//! +//! Without them the identity is derived from `[bundle] identifier` and the publisher +//! becomes `CN=`, which is enough to build, sign with a test certificate, +//! install and certify, but is not what the Store will accept for upload. + +use std::path::{Path, PathBuf}; + +use toml::{Table, Value}; + +use crate::Error; + +/// The oldest Windows a package installs on. 1809 is the first release that runs a +/// packaged full-trust application with everything this packager writes, and the +/// oldest the Store still offers desktop packages to. +pub const DEFAULT_MIN_VERSION: &str = "10.0.17763.0"; +/// The newest Windows the package was tested on. Windows uses it to decide which +/// compatibility behaviour applies, so it moves when a newer Windows has been tried. +pub const DEFAULT_MAX_VERSION_TESTED: &str = "10.0.26100.0"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppMetadata { + pub display_name: String, + pub publisher_display_name: String, + pub description: String, + pub identity_name: String, + pub publisher: String, + /// Whether the identity came from `[windows.msix]` rather than being derived. The + /// Store channel insists on it. + pub store_identity: bool, + pub version: String, + pub executable: String, + pub icon: Option, + pub languages: Vec, + pub capabilities: Vec, + pub restricted_capabilities: Vec, + pub device_capabilities: Vec, + pub min_version: String, + pub max_version_tested: String, + pub background_color: String, +} + +fn read_table(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| Error::new(format!("cannot read {}: {e}", path.display())))?; + text.parse::() + .map_err(|e| Error::new(format!("{} is not valid TOML: {e}", path.display()))) +} + +fn get<'a>(table: &'a Table, path: &[&str]) -> Option<&'a Value> { + let (last, parents) = path.split_last()?; + let mut current = table; + for key in parents { + current = current.get(*key)?.as_table()?; + } + current.get(*last) +} + +fn get_str(table: &Table, path: &[&str]) -> Option { + get(table, path) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +fn get_strings(table: &Table, path: &[&str]) -> Vec { + match get(table, path) { + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + Some(Value::String(one)) => vec![one.clone()], + _ => Vec::new(), + } +} + +/// The package version from `Cargo.toml`, following `version.workspace = true` up to +/// the workspace that defines it. +fn cargo_version(project: &Path, cargo: &Table) -> Result { + match get(cargo, &["package", "version"]) { + Some(Value::String(v)) => return Ok(v.clone()), + Some(Value::Table(t)) if t.get("workspace").and_then(Value::as_bool) == Some(true) => {} + None => { + return Err(Error::new(format!( + "{}/Cargo.toml has no [package] version", + project.display() + ))); + } + Some(other) => { + return Err(Error::new(format!( + "{}/Cargo.toml: [package] version is `{other}`, expected a string or `{{ workspace = true }}`", + project.display() + ))); + } + } + let mut dir = project.parent(); + while let Some(d) = dir { + let candidate = d.join("Cargo.toml"); + if candidate.is_file() { + let root = read_table(&candidate)?; + if root.contains_key("workspace") { + return get_str(&root, &["workspace", "package", "version"]).ok_or_else(|| { + Error::new(format!( + "{} inherits its version from the workspace, and {} has no [workspace.package] version", + project.display(), + candidate.display() + )) + }); + } + } + dir = d.parent(); + } + Err(Error::new(format!( + "{} inherits its version from a workspace, and no workspace was found above it", + project.display() + ))) +} + +/// Turns a reverse-DNS identifier into a valid package identity name. +/// +/// A package name is 3 to 50 characters of ASCII letters, digits, periods and hyphens. +/// Anything else is dropped (an underscore becomes a hyphen), and a name that is too long +/// keeps its last labels, which are the ones that tell applications from one publisher +/// apart. +pub fn identity_from_identifier(identifier: &str) -> Result { + let cleaned: String = identifier + .chars() + .filter_map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' => Some(c), + '_' => Some('-'), + _ => None, + }) + .collect(); + let labels: Vec<&str> = cleaned.split('.').filter(|l| !l.is_empty()).collect(); + let mut name = labels.join("."); + let mut start = 0; + while name.len() > 50 && start + 1 < labels.len() { + start += 1; + name = labels[start..].join("."); + } + if name.len() > 50 { + name.truncate(50); + } + validate_identity_name(&name).map(|()| name) +} + +pub fn validate_identity_name(name: &str) -> Result<(), Error> { + let ok_chars = name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'); + if !(3..=50).contains(&name.len()) || !ok_chars { + return Err(Error::new(format!( + "package identity name `{name}` must be 3 to 50 characters of letters, digits, `.` and `-`" + ))); + } + if name.starts_with('.') || name.ends_with('.') { + return Err(Error::new(format!( + "package identity name `{name}` cannot start or end with `.`" + ))); + } + Ok(()) +} + +/// Quotes a value for a distinguished name when it holds a character a DN treats as +/// syntax. +pub fn dn_value(value: &str) -> String { + let needs_quotes = value + .chars() + .any(|c| matches!(c, ',' | '+' | '=' | '"' | '\\' | '<' | '>' | ';' | '#')) + || value.starts_with(' ') + || value.ends_with(' '); + if needs_quotes { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_owned() + } +} + +pub fn validate_publisher(publisher: &str) -> Result<(), Error> { + if !publisher.starts_with("CN=") && !publisher.contains(", CN=") && !publisher.contains(",CN=") + { + return Err(Error::new(format!( + "publisher `{publisher}` is not a certificate subject: it has to be a distinguished name \ + with a CN, such as `CN=Contoso` or the value Partner Center shows under Package/Identity/Publisher" + ))); + } + if publisher.len() > 8192 { + return Err(Error::new("publisher is longer than 8192 characters")); + } + Ok(()) +} + +fn validate_label(what: &str, value: &str) -> Result<(), Error> { + if value.is_empty() || value.chars().count() > 256 { + return Err(Error::new(format!( + "{what} must be 1 to 256 characters, and is `{value}`" + ))); + } + Ok(()) +} + +impl AppMetadata { + /// Reads `/Dioxus.toml` and `/Cargo.toml`. + pub fn load(project: &Path) -> Result { + let dioxus_path = project.join("Dioxus.toml"); + let dioxus = read_table(&dioxus_path)?; + let cargo = read_table(&project.join("Cargo.toml"))?; + let version = cargo_version(project, &cargo)?; + Self::from_tables(project, &dioxus, &cargo, version) + } + + pub fn from_tables( + project: &Path, + dioxus: &Table, + cargo: &Table, + version: String, + ) -> Result { + let package_name = get_str(cargo, &["package", "name"]).ok_or_else(|| { + Error::new(format!( + "{}/Cargo.toml has no [package] name", + project.display() + )) + })?; + + let display_name = get_str(dioxus, &["windows", "msix", "display_name"]) + .or_else(|| get_str(dioxus, &["application", "name"])) + .unwrap_or_else(|| package_name.clone()); + let publisher_display_name = get_str(dioxus, &["windows", "publisher"]) + .or_else(|| get_str(dioxus, &["bundle", "publisher"])) + .ok_or_else(|| { + Error::new( + "Dioxus.toml has no publisher: set [bundle] publisher to the name the Store shows as the developer", + ) + })?; + let description = get_str(dioxus, &["windows", "short_description"]) + .or_else(|| get_str(dioxus, &["bundle", "short_description"])) + .unwrap_or_else(|| display_name.clone()); + + let explicit_identity = get_str(dioxus, &["windows", "msix", "identity_name"]); + let explicit_publisher = get_str(dioxus, &["windows", "msix", "publisher"]); + let store_identity = explicit_identity.is_some() && explicit_publisher.is_some(); + + let identity_name = match explicit_identity { + Some(name) => { + validate_identity_name(&name)?; + name + } + None => { + let identifier = get_str(dioxus, &["windows", "identifier"]) + .or_else(|| get_str(dioxus, &["bundle", "identifier"])) + .ok_or_else(|| { + Error::new( + "Dioxus.toml has neither [windows.msix] identity_name nor [bundle] identifier to derive one from", + ) + })?; + identity_from_identifier(&identifier)? + } + }; + let publisher = explicit_publisher + .unwrap_or_else(|| format!("CN={}", dn_value(&publisher_display_name))); + validate_publisher(&publisher)?; + validate_label("display name", &display_name)?; + validate_label("publisher display name", &publisher_display_name)?; + + // The executable is the binary Cargo builds for this package. A package with more + // than one binary names the one to launch in [windows.msix] executable. + let executable = get_str(dioxus, &["windows", "msix", "executable"]) + .or_else(|| { + cargo + .get("bin") + .and_then(Value::as_array) + .and_then(|bins| bins.first()) + .and_then(|b| b.get("name")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or(package_name); + let executable = if executable.to_ascii_lowercase().ends_with(".exe") { + executable + } else { + format!("{executable}.exe") + }; + + let icon = icon_path(project, dioxus); + + let mut languages = get_strings(dioxus, &["windows", "msix", "languages"]); + if languages.is_empty() { + languages.push("en-us".to_owned()); + } + + Ok(AppMetadata { + display_name, + publisher_display_name, + description, + identity_name, + publisher, + store_identity, + version, + executable, + icon, + languages, + capabilities: get_strings(dioxus, &["windows", "capabilities"]), + restricted_capabilities: get_strings(dioxus, &["windows", "restricted_capabilities"]), + device_capabilities: get_strings(dioxus, &["windows", "device_capabilities"]), + min_version: get_str(dioxus, &["windows", "msix", "min_version"]) + .unwrap_or_else(|| DEFAULT_MIN_VERSION.to_owned()), + max_version_tested: get_str(dioxus, &["windows", "msix", "max_version_tested"]) + .unwrap_or_else(|| DEFAULT_MAX_VERSION_TESTED.to_owned()), + background_color: get_str(dioxus, &["windows", "msix", "background_color"]) + .unwrap_or_else(|| "transparent".to_owned()), + }) + } +} + +/// The largest PNG the application names as its icon, or `/icon.png`. +fn icon_path(project: &Path, dioxus: &Table) -> Option { + let mut named = get_strings(dioxus, &["windows", "icon"]); + if named.is_empty() { + named = get_strings(dioxus, &["bundle", "icon"]); + } + let mut pngs: Vec = named + .iter() + .filter(|p| p.to_ascii_lowercase().ends_with(".png")) + .map(|p| project.join(p)) + .filter(|p| p.is_file()) + .collect(); + pngs.sort_by_key(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)); + if let Some(largest) = pngs.pop() { + return Some(largest); + } + let asset_dir = + get_str(dioxus, &["application", "asset_dir"]).unwrap_or_else(|| "assets".into()); + let fallback = project.join(asset_dir).join("icon.png"); + fallback.is_file().then_some(fallback) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tables(dioxus: &str, cargo: &str) -> (Table, Table) { + (dioxus.parse().unwrap(), cargo.parse().unwrap()) + } + + const NOTEPAD: &str = r#" +[application] +name = "Notepad" +asset_dir = "assets" + +[bundle] +identifier = "dev.darkpyonix.dioxus.compose.samples.notepad" +publisher = "DarkPyonix" +short_description = "A plain text editor built with dioxus-compose." +"#; + + const CARGO: &str = r#" +[package] +name = "sample-notepad" +version = "0.1.0" + +[[bin]] +name = "sample-notepad" +path = "src/main.rs" +"#; + + #[test] + fn fr34_metadata_comes_from_dioxus_toml_and_cargo_toml() { + let (d, c) = tables(NOTEPAD, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "0.1.0".into()).unwrap(); + assert_eq!(m.display_name, "Notepad"); + assert_eq!(m.publisher_display_name, "DarkPyonix"); + assert_eq!( + m.description, + "A plain text editor built with dioxus-compose." + ); + assert_eq!( + m.identity_name, + "dev.darkpyonix.dioxus.compose.samples.notepad" + ); + assert_eq!(m.publisher, "CN=DarkPyonix"); + assert!(!m.store_identity); + assert_eq!(m.executable, "sample-notepad.exe"); + assert_eq!(m.languages, vec!["en-us"]); + assert_eq!(m.min_version, DEFAULT_MIN_VERSION); + } + + #[test] + fn fr34_store_identity_overrides_the_derived_one() { + let dioxus = format!( + "{NOTEPAD}\n[windows.msix]\nidentity_name = \"DarkPyonix.Ember\"\npublisher = \"CN=1F2E3D4C-0000-1111-2222-333344445555\"\ndisplay_name = \"Ember\"\nlanguages = [\"en-us\", \"ko-kr\"]\n" + ); + let (d, c) = tables(&dioxus, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); + assert_eq!(m.identity_name, "DarkPyonix.Ember"); + assert_eq!(m.publisher, "CN=1F2E3D4C-0000-1111-2222-333344445555"); + assert_eq!(m.display_name, "Ember"); + assert!(m.store_identity); + assert_eq!(m.languages, vec!["en-us", "ko-kr"]); + } + + #[test] + fn fr34_windows_section_overrides_bundle() { + let dioxus = format!( + "{NOTEPAD}\n[windows]\npublisher = \"Dark Pyonix, Inc.\"\nshort_description = \"Windows text\"\ncapabilities = [\"internetClient\"]\n" + ); + let (d, c) = tables(&dioxus, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); + assert_eq!(m.publisher_display_name, "Dark Pyonix, Inc."); + assert_eq!(m.publisher, "CN=\"Dark Pyonix, Inc.\""); + assert_eq!(m.description, "Windows text"); + assert_eq!(m.capabilities, vec!["internetClient"]); + } + + #[test] + fn fr34_missing_publisher_is_an_error() { + let (d, c) = tables( + "[application]\nname = \"X\"\n[bundle]\nidentifier = \"a.b.c\"\n", + CARGO, + ); + let err = + AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap_err(); + assert!(err.to_string().contains("publisher"), "{err}"); + } + + #[test] + fn fr34_identity_names_are_made_valid() { + assert_eq!( + identity_from_identifier("com.example.my_app").unwrap(), + "com.example.my-app" + ); + assert_eq!( + identity_from_identifier("com.ex ample.app!").unwrap(), + "com.example.app" + ); + let long = "dev.darkpyonix.dioxus.compose.samples.a-very-long-application-name"; + let name = identity_from_identifier(long).unwrap(); + assert!(name.len() <= 50, "{name}"); + assert!(name.ends_with("a-very-long-application-name"), "{name}"); + assert!(identity_from_identifier("ab").is_err()); + } + + #[test] + fn fr34_publisher_must_be_a_distinguished_name() { + assert!(validate_publisher("CN=DarkPyonix").is_ok()); + assert!(validate_publisher("O=Org, CN=Name").is_ok()); + assert!(validate_publisher("DarkPyonix").is_err()); + } + + #[test] + fn fr34_executable_follows_the_explicit_override() { + let dioxus = format!("{NOTEPAD}\n[windows.msix]\nexecutable = \"ember\"\n"); + let (d, c) = tables(&dioxus, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); + assert_eq!(m.executable, "ember.exe"); + } + + #[test] + fn fr34_the_real_samples_produce_valid_metadata() { + let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); + let mut seen = 0; + for entry in std::fs::read_dir(&samples).unwrap() { + let dir = entry.unwrap().path(); + if !dir.join("Dioxus.toml").is_file() { + continue; + } + let m = AppMetadata::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + assert!(m.icon.is_some(), "{} has no icon", dir.display()); + seen += 1; + } + assert!(seen > 0, "no samples found under {}", samples.display()); + } +} diff --git a/tools/msix/src/sdk.rs b/tools/msix/src/sdk.rs new file mode 100644 index 00000000..8f195d17 --- /dev/null +++ b/tools/msix/src/sdk.rs @@ -0,0 +1,105 @@ +//! Finding and running the Windows SDK's packaging tools. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::Error; + +/// Parses `10.0.22621.0` into something that orders numerically. +fn sdk_version(name: &str) -> Option> { + let parts: Option> = name.split('.').map(|p| p.parse().ok()).collect(); + parts.filter(|p| p.len() == 4) +} + +/// The newest `\bin\\\` under `kits`. +pub fn newest_in_kits(kits: &Path, host: &str, tool: &str) -> Option { + let mut found: Vec<(Vec, PathBuf)> = std::fs::read_dir(kits.join("bin")) + .ok()? + .filter_map(|e| e.ok()) + .filter_map(|e| { + let version = sdk_version(&e.file_name().to_string_lossy())?; + let candidate = e.path().join(host).join(tool); + candidate.is_file().then_some((version, candidate)) + }) + .collect(); + found.sort(); + found.pop().map(|(_, p)| p) +} + +/// Finds an SDK tool: `$DXC_WINDOWS_SDK_BIN\` if set, else the newest installed +/// Windows 10/11 SDK, else whatever is on `PATH`. +pub fn find_tool(tool: &str) -> Result { + if let Some(dir) = std::env::var_os("DXC_WINDOWS_SDK_BIN") { + let candidate = Path::new(&dir).join(tool); + if candidate.is_file() { + return Ok(candidate); + } + return Err(Error::new(format!( + "DXC_WINDOWS_SDK_BIN is set, and {} is not there", + candidate.display() + ))); + } + let host = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + for root in ["ProgramFiles(x86)", "ProgramFiles"] { + if let Some(pf) = std::env::var_os(root) { + let kits = Path::new(&pf).join("Windows Kits").join("10"); + if let Some(found) = newest_in_kits(&kits, host, tool) { + return Ok(found); + } + } + } + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(tool); + if candidate.is_file() { + return Ok(candidate); + } + } + } + Err(Error::new(format!( + "{tool} was not found: install the Windows SDK, or set DXC_WINDOWS_SDK_BIN to the directory that holds it. \ + Packing needs Windows; `--layout-only` stages the package directory on any host" + ))) +} + +pub fn run(tool: &Path, args: &[&OsStr]) -> Result<(), Error> { + let status = Command::new(tool) + .args(args) + .status() + .map_err(|e| Error::new(format!("cannot start {}: {e}", tool.display())))?; + if status.success() { + Ok(()) + } else { + Err(Error::new(format!( + "{} {} failed with {status}", + tool.display(), + args.iter() + .map(|a| a.to_string_lossy()) + .collect::>() + .join(" ") + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr34_sdk_versions_order_numerically() { + let mut v = vec![ + sdk_version("10.0.9999.0").unwrap(), + sdk_version("10.0.22621.0").unwrap(), + sdk_version("10.0.19041.0").unwrap(), + ]; + v.sort(); + assert_eq!(v.last().unwrap(), &vec![10, 0, 22621, 0]); + assert!(sdk_version("wdf").is_none()); + assert!(sdk_version("10.0.1").is_none()); + } +} diff --git a/tools/msix/src/version.rs b/tools/msix/src/version.rs new file mode 100644 index 00000000..c78d748c --- /dev/null +++ b/tools/msix/src/version.rs @@ -0,0 +1,206 @@ +//! The four-part package version. +//! +//! An application states one version, the `version` in its `Cargo.toml`, and every +//! package is versioned from it. MSIX wants `Major.Minor.Build.Revision`, each part a +//! 16-bit number, and Windows only ever replaces an installed package with a strictly +//! greater one. Both delivery channels lean on that rule, so the mapping has to be +//! monotonic: a later release of the application must never produce a smaller version. + +use std::fmt; + +use crate::Error; + +/// Where a package is going. The two differ in who owns the fourth part of the version. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + /// Partner Center. The Store reserves the revision for its own use and rejects a + /// package whose revision is not zero, so a Store release is `Major.Minor.Patch.0`. + Store, + /// Direct download kept current by App Installer. The revision is a build number + /// that only grows, so successive builds of one release (or of a pre-release) each + /// install over the last. + Sideload, +} + +impl Channel { + pub fn parse(text: &str) -> Result { + match text { + "store" => Ok(Channel::Store), + "sideload" => Ok(Channel::Sideload), + other => Err(Error::new(format!( + "unknown channel `{other}`: use `store` for Partner Center or `sideload` for App Installer" + ))), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PackageVersion { + pub major: u16, + pub minor: u16, + pub build: u16, + pub revision: u16, +} + +impl fmt::Display for PackageVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}.{}.{}.{}", + self.major, self.minor, self.build, self.revision + ) + } +} + +fn part(name: &str, text: &str, whole: &str) -> Result { + let value: u64 = text.parse().map_err(|_| { + Error::new(format!( + "version `{whole}`: the {name} part `{text}` is not a number" + )) + })?; + u16::try_from(value).map_err(|_| { + Error::new(format!( + "version `{whole}`: the {name} part is {value}, and a package version part cannot exceed 65535" + )) + }) +} + +impl PackageVersion { + /// Maps an application's semantic version onto a package version for `channel`. + /// + /// `revision` is the build number for the sideload channel. It is refused for the + /// Store, where the revision has to stay zero. A pre-release (`1.2.0-beta.3`) is + /// refused for the Store as well: the Store orders packages by version alone, so + /// `1.2.0-beta.3` and `1.2.0` would both be `1.2.0.0` and the release could never + /// replace the beta. Ship a pre-release on the sideload channel, or give it its + /// own patch number. + pub fn from_semver( + semver: &str, + channel: Channel, + revision: Option, + ) -> Result { + let trimmed = semver.trim(); + // Build metadata never takes part in ordering, so it is dropped first. + let without_build = trimmed.split('+').next().unwrap_or(trimmed); + let (core, pre) = match without_build.split_once('-') { + Some((core, pre)) => (core, Some(pre)), + None => (without_build, None), + }; + let parts: Vec<&str> = core.split('.').collect(); + if parts.len() != 3 { + return Err(Error::new(format!( + "version `{trimmed}` is not MAJOR.MINOR.PATCH" + ))); + } + let major = part("major", parts[0], trimmed)?; + let minor = part("minor", parts[1], trimmed)?; + let build = part("patch", parts[2], trimmed)?; + + let revision = match channel { + Channel::Store => { + if let Some(pre) = pre { + return Err(Error::new(format!( + "version `{trimmed}` is a pre-release (`{pre}`), and the Store cannot tell it apart \ + from the release it precedes: both would be {major}.{minor}.{build}.0. \ + Package it for the sideload channel, or give it a patch number of its own" + ))); + } + if let Some(r) = revision.filter(|r| *r != 0) { + return Err(Error::new(format!( + "a Store package must have revision 0 (the Store reserves the fourth part), \ + and revision {r} was given" + ))); + } + 0 + } + Channel::Sideload => { + let r = revision.unwrap_or(0); + u16::try_from(r).map_err(|_| { + Error::new(format!( + "revision {r} cannot exceed 65535; use a counter that restarts with each release" + )) + })? + } + }; + + Ok(PackageVersion { + major, + minor, + build, + revision, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr34_store_version_is_the_crate_version_with_revision_zero() { + let v = PackageVersion::from_semver("1.4.2", Channel::Store, None).unwrap(); + assert_eq!(v.to_string(), "1.4.2.0"); + } + + #[test] + fn fr34_store_refuses_a_nonzero_revision() { + assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(7)).is_err()); + assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(0)).is_ok()); + } + + #[test] + fn fr34_store_refuses_a_prerelease() { + let err = PackageVersion::from_semver("1.4.2-beta.1", Channel::Store, None).unwrap_err(); + assert!(err.to_string().contains("pre-release"), "{err}"); + } + + #[test] + fn fr34_sideload_revision_is_the_build_number() { + let v = + PackageVersion::from_semver("1.4.2-beta.1+abc", Channel::Sideload, Some(31)).unwrap(); + assert_eq!(v.to_string(), "1.4.2.31"); + } + + #[test] + fn fr34_build_metadata_is_ignored() { + let v = PackageVersion::from_semver("2.0.1+git.abc", Channel::Store, None).unwrap(); + assert_eq!(v.to_string(), "2.0.1.0"); + } + + #[test] + fn fr34_parts_above_sixteen_bits_are_refused() { + assert!(PackageVersion::from_semver("1.70000.0", Channel::Store, None).is_err()); + assert!(PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(70_000)).is_err()); + } + + #[test] + fn fr34_malformed_versions_are_refused() { + for bad in ["1.0", "1.0.0.0", "a.b.c", "", "1..0"] { + assert!( + PackageVersion::from_semver(bad, Channel::Store, None).is_err(), + "{bad} was accepted" + ); + } + } + + #[test] + fn fr34_later_releases_map_to_greater_versions() { + let order = ["0.9.9", "1.0.0", "1.0.1", "1.1.0", "2.0.0"]; + let mapped: Vec<_> = order + .iter() + .map(|v| PackageVersion::from_semver(v, Channel::Store, None).unwrap()) + .collect(); + assert!(mapped.windows(2).all(|w| w[0] < w[1])); + + let a = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(4)).unwrap(); + let b = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(5)).unwrap(); + assert!(a < b); + } + + #[test] + fn fr34_channel_names_parse() { + assert_eq!(Channel::parse("store").unwrap(), Channel::Store); + assert_eq!(Channel::parse("sideload").unwrap(), Channel::Sideload); + assert!(Channel::parse("beta").is_err()); + } +} diff --git a/tools/msix/tests/stage.rs b/tools/msix/tests/stage.rs new file mode 100644 index 00000000..ed134131 --- /dev/null +++ b/tools/msix/tests/stage.rs @@ -0,0 +1,191 @@ +//! Staging a package directory from a real sample, through the public API. + +use std::path::{Path, PathBuf}; + +use dioxus_compose_msix::{ + AppMetadata, Arch, Channel, PackageVersion, Payload, Plan, assets, stage, +}; + +fn scratch(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("msix") + .join(name); + if dir.exists() { + std::fs::remove_dir_all(&dir).unwrap(); + } + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn notepad() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples/notepad") +} + +fn plan(meta: AppMetadata, payload: Payload) -> Plan { + let version = PackageVersion::from_semver(&meta.version, Channel::Store, None).unwrap(); + Plan { + meta, + version, + channel: Channel::Store, + arch: Arch::X64, + payload, + icon: None, + } +} + +#[test] +fn fr34_a_single_executable_is_staged_with_manifest_and_images() { + let dir = scratch("single"); + let exe = dir.join("sample-notepad.exe"); + std::fs::write(&exe, b"MZ not really").unwrap(); + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan(meta, Payload::Executable(exe)); + let layout = dir.join("layout"); + let warnings = stage(&p, &layout).unwrap(); + + assert!(layout.join("sample-notepad.exe").is_file()); + let manifest = std::fs::read_to_string(layout.join("AppxManifest.xml")).unwrap(); + assert!( + manifest.contains("Executable=\"sample-notepad.exe\""), + "{manifest}" + ); + assert!(manifest.contains("Version=\"0.1.0.0\""), "{manifest}"); + assert!(manifest.contains("Notepad")); + + for spec in assets::ALL { + let bytes = std::fs::read(layout.join("Assets").join(spec.file)).unwrap(); + let img = assets::Rgba::decode(&bytes).unwrap(); + assert_eq!( + (img.width, img.height), + (spec.width, spec.height), + "{}", + spec.file + ); + } + // The samples draw 64 pixel icons and the derived identity is not the Store's, so + // both are reported rather than passed over. + assert!( + warnings.iter().any(|w| w.contains("enlarged")), + "{warnings:?}" + ); + assert!( + warnings.iter().any(|w| w.contains("Partner Center")), + "{warnings:?}" + ); +} + +#[test] +fn fr34_a_payload_directory_is_copied_whole() { + let dir = scratch("directory"); + let payload = dir.join("payload"); + std::fs::create_dir_all(payload.join("bin")).unwrap(); + std::fs::create_dir_all(payload.join("lib").join("fonts")).unwrap(); + std::fs::write(payload.join("bin").join("sample-notepad.exe"), b"MZ").unwrap(); + std::fs::write(payload.join("bin").join("renderer.dll"), b"MZ").unwrap(); + std::fs::write( + payload + .join("lib") + .join("fonts") + .join("fontconfig.properties"), + b"x", + ) + .unwrap(); + + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan( + meta, + Payload::Directory { + root: payload, + executable: Path::new("bin").join("sample-notepad.exe"), + }, + ); + let layout = dir.join("layout"); + stage(&p, &layout).unwrap(); + assert!(layout.join("bin").join("renderer.dll").is_file()); + assert!( + layout + .join("lib") + .join("fonts") + .join("fontconfig.properties") + .is_file() + ); + let manifest = std::fs::read_to_string(layout.join("AppxManifest.xml")).unwrap(); + assert!( + manifest.contains("Executable=\"bin\\sample-notepad.exe\""), + "{manifest}" + ); +} + +#[test] +fn fr34_a_payload_that_already_has_a_manifest_is_refused() { + let dir = scratch("reserved"); + let payload = dir.join("payload"); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("app.exe"), b"MZ").unwrap(); + std::fs::write(payload.join("AppxManifest.xml"), b"").unwrap(); + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan( + meta, + Payload::Directory { + root: payload, + executable: "app.exe".into(), + }, + ); + let err = stage(&p, &dir.join("layout")).unwrap_err(); + assert!(err.to_string().contains("AppxManifest.xml"), "{err}"); +} + +#[test] +fn fr34_a_missing_executable_is_refused() { + let dir = scratch("missing"); + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan(meta, Payload::Executable(dir.join("nothing.exe"))); + assert!(stage(&p, &dir.join("layout")).is_err()); +} + +#[test] +fn fr34_workspace_version_is_followed() { + let root = scratch("workspace"); + let project = root.join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\n[workspace.package]\nversion = \"3.2.1\"\n", + ) + .unwrap(); + std::fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion.workspace = true\n", + ) + .unwrap(); + std::fs::write( + project.join("Dioxus.toml"), + "[application]\nname = \"App\"\n[bundle]\nidentifier = \"dev.example.app\"\npublisher = \"Example\"\n", + ) + .unwrap(); + let m = AppMetadata::load(&project).unwrap(); + assert_eq!(m.version, "3.2.1"); + assert_eq!(m.executable, "app.exe"); + assert!(m.icon.is_none()); +} + +#[test] +fn fr34_every_sample_stages() { + let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); + let dir = scratch("every"); + let mut staged = 0; + for entry in std::fs::read_dir(&samples).unwrap() { + let project = entry.unwrap().path(); + if !project.join("Dioxus.toml").is_file() { + continue; + } + let meta = AppMetadata::load(&project).unwrap(); + let exe = dir.join(&meta.executable); + std::fs::write(&exe, b"MZ").unwrap(); + let name = project.file_name().unwrap().to_owned(); + let p = plan(meta, Payload::Executable(exe)); + stage(&p, &dir.join(name)).unwrap(); + staged += 1; + } + assert!(staged >= 10, "only {staged} samples staged"); +} From d5cc3b1383dfb0ecdf107d25a2dffd12cf2e4b66 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:22:52 +0900 Subject: [PATCH 002/224] Chore: Package, sign and certify an MSIX on a Windows runner The workflow builds the packager and a small windowed stand-in, packages it with the notepad sample's metadata, signs it with a throwaway certificate, installs it, and runs the Windows App Certification Kit against the package and the bundle, uploading both reports. A second job publishes two builds to an App Installer feed on a file share and checks that the installed package sees and takes the update. A dispatch can package a real build from another run's artifact instead of the stand-in. --- .github/workflows/package-windows.yml | 259 ++++++++++++++++++++ tools/msix/scripts/common.ps1 | 31 +++ tools/msix/scripts/new-test-certificate.ps1 | 61 +++++ tools/msix/scripts/run-wack.ps1 | 68 +++++ tools/msix/scripts/sign.ps1 | 41 ++++ tools/msix/scripts/test-update.ps1 | 116 +++++++++ 6 files changed, 576 insertions(+) create mode 100644 .github/workflows/package-windows.yml create mode 100644 tools/msix/scripts/common.ps1 create mode 100644 tools/msix/scripts/new-test-certificate.ps1 create mode 100644 tools/msix/scripts/run-wack.ps1 create mode 100644 tools/msix/scripts/sign.ps1 create mode 100644 tools/msix/scripts/test-update.ps1 diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml new file mode 100644 index 00000000..c160a9b3 --- /dev/null +++ b/.github/workflows/package-windows.yml @@ -0,0 +1,259 @@ +# Windows package -- builds an MSIX and an .msixbundle from one executable, signs them +# with a throwaway certificate, runs the Windows App Certification Kit against them, and +# proves that a newer bundle published to an App Installer feed replaces an installed one. +# +# The kit is what Partner Center runs on a submission, so a package that passes here is +# not refused there for the same reasons. The Store signs what it takes in itself; the +# test certificate exists only so that this machine will install the package. +# +# What gets packaged: by default a tiny windowed stand-in built from this repository, +# described by the notepad sample's metadata, because the pipeline is what is under test +# and the renderer takes most of an hour to build. Dispatched with a run id, it packages +# a real build instead: an artifact from that run, unpacked, with the program and the +# renderer beside it. + +name: Windows package + +on: + push: + paths: + - 'tools/msix/**' + - '.github/workflows/package-windows.yml' + pull_request: + paths: + - 'tools/msix/**' + - '.github/workflows/package-windows.yml' + workflow_dispatch: + inputs: + project: + description: 'Application directory holding Dioxus.toml and Cargo.toml' + default: 'samples/notepad' + type: string + payload_run_id: + description: 'Run id whose artifact holds the build to package (empty: the stand-in)' + default: '' + type: string + payload_artifact: + description: 'Artifact name in that run' + default: 'samples-windows-x64' + type: string + payload_archive: + description: 'Archive inside the artifact' + default: 'notepad-windows-x64.tar.gz' + type: string + payload_root: + description: 'Directory inside the archive that becomes the package root' + default: 'notepad' + type: string + payload_exe: + description: 'The program, relative to that directory' + default: 'bin/sample-notepad.exe' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: package-windows-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + PROJECT: ${{ inputs.project || 'samples/notepad' }} + +jobs: + test: + name: Packager tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Format + run: cargo fmt -p dioxus-compose-msix -- --check + + - name: Clippy + run: cargo clippy -p dioxus-compose-msix --all-targets -- -D warnings + + - name: Tests + run: cargo test -p dioxus-compose-msix + + - name: Keep the resolved lock file + # The packager's dependencies are resolved here rather than on a developer + # machine, so the lock file that resolution produced is kept for committing. + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: cargo-lock + path: Cargo.lock + + package: + name: Package, sign and certify + needs: test + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Build the packager and the stand-in program + run: | + cargo build --release -p dioxus-compose-msix --bin dioxus-compose-msix --example fixture-app + + - name: Fetch the build to package + if: inputs.payload_run_id != '' + uses: actions/download-artifact@v4 + with: + run-id: ${{ inputs.payload_run_id }} + name: ${{ inputs.payload_artifact }} + path: payload-artifact + github-token: ${{ github.token }} + + - name: Package + id: pack + run: | + $ErrorActionPreference = 'Stop' + $tool = 'target\release\dioxus-compose-msix.exe' + $out = Join-Path $env:RUNNER_TEMP 'msix' + $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' + if ('${{ inputs.payload_run_id }}' -ne '') { + $unpacked = Join-Path $env:RUNNER_TEMP 'payload' + New-Item -ItemType Directory -Force -Path $unpacked | Out-Null + tar -xzf "payload-artifact\${{ inputs.payload_archive }}" -C $unpacked + & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` + --payload-dir (Join-Path $unpacked '${{ inputs.payload_root }}') ` + --exe '${{ inputs.payload_exe }}' + } else { + & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` + --exe 'target\release\examples\fixture-app.exe' + } + if ($LASTEXITCODE -ne 0) { throw "packaging failed" } + Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + Get-Content (Join-Path $out 'layout\AppxManifest.xml') + + - name: Create and trust a test certificate + id: cert + run: > + ./tools/msix/scripts/new-test-certificate.ps1 + -Publisher '${{ steps.pack.outputs.publisher }}' + -OutDir "$env:RUNNER_TEMP\cert" -Trust + + - name: Sign + run: > + ./tools/msix/scripts/sign.ps1 + -Path '${{ steps.pack.outputs.package }}','${{ steps.pack.outputs.bundle }}' + -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' + + - name: Install and uninstall the bundle + run: | + $ErrorActionPreference = 'Stop' + Add-AppxPackage -Path '${{ steps.pack.outputs.bundle }}' + $installed = Get-AppxPackage -Name '${{ steps.pack.outputs.identity }}' + if (-not $installed) { throw "the bundle did not install" } + Write-Host "installed $($installed.PackageFullName) at $($installed.InstallLocation)" + if ($installed.Version -ne '${{ steps.pack.outputs.version }}') { throw "installed $($installed.Version), packaged ${{ steps.pack.outputs.version }}" } + $installed | Remove-AppxPackage + + - name: Windows App Certification Kit (package) + run: > + ./tools/msix/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.package }}' + -Report "$env:RUNNER_TEMP\wack\package-report.xml" + + - name: Windows App Certification Kit (bundle) + run: > + ./tools/msix/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.bundle }}' + -Report "$env:RUNNER_TEMP\wack\bundle-report.xml" + + - name: Upload the reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: wack-reports + path: ${{ runner.temp }}/wack/ + if-no-files-found: warn + + - name: Upload the packages + if: always() + uses: actions/upload-artifact@v4 + with: + name: msix-packages + path: | + ${{ runner.temp }}/msix/*.msix + ${{ runner.temp }}/msix/*.msixbundle + ${{ runner.temp }}/cert/test-signing.cer + if-no-files-found: warn + + update: + name: App Installer update + needs: test + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Build the packager and the stand-in program + run: cargo build --release -p dioxus-compose-msix --bin dioxus-compose-msix --example fixture-app + + - name: Package two builds of one release for the sideload channel + id: pack + run: | + $ErrorActionPreference = 'Stop' + $tool = 'target\release\dioxus-compose-msix.exe' + foreach ($revision in 1, 2) { + $out = Join-Path $env:RUNNER_TEMP "sideload-$revision" + $summary = Join-Path $env:RUNNER_TEMP "sideload-$revision.txt" + & $tool --project $env:PROJECT --out $out --summary $summary ` + --channel sideload --revision $revision ` + --feed-base '\\localhost\msixfeed' ` + --exe 'target\release\examples\fixture-app.exe' + if ($LASTEXITCODE -ne 0) { throw "packaging revision $revision failed" } + Get-Content (Join-Path $out '*.appinstaller') + } + Get-Content (Join-Path $env:RUNNER_TEMP 'sideload-1.txt') | + Where-Object { $_ -match '^(identity|publisher|bundle)=' } | + Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + $bundle2 = (Get-Content (Join-Path $env:RUNNER_TEMP 'sideload-2.txt') | Where-Object { $_ -match '^bundle=' }) -replace '^bundle=', '' + Add-Content -Path $env:GITHUB_OUTPUT -Value "bundle2=$bundle2" -Encoding utf8 + + - name: Create and trust a test certificate + id: cert + run: > + ./tools/msix/scripts/new-test-certificate.ps1 + -Publisher '${{ steps.pack.outputs.publisher }}' + -OutDir "$env:RUNNER_TEMP\cert" -Trust + + - name: Sign both bundles + run: > + ./tools/msix/scripts/sign.ps1 + -Path '${{ steps.pack.outputs.bundle }}','${{ steps.pack.outputs.bundle2 }}' + -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' + + - name: Install from the feed, publish a newer build, update + shell: powershell + run: > + ./tools/msix/scripts/test-update.ps1 + -Older "$env:RUNNER_TEMP\sideload-1" + -Newer "$env:RUNNER_TEMP\sideload-2" + -Share "$env:RUNNER_TEMP\feed" + -ShareName msixfeed + -Name '${{ steps.pack.outputs.identity }}' diff --git a/tools/msix/scripts/common.ps1 b/tools/msix/scripts/common.ps1 new file mode 100644 index 00000000..2f8c811d --- /dev/null +++ b/tools/msix/scripts/common.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS +Helpers shared by the MSIX scripts: finding Windows SDK tools. +#> + +function Find-SdkTool { + param([Parameter(Mandatory)][string]$Name) + if ($env:DXC_WINDOWS_SDK_BIN) { + $candidate = Join-Path $env:DXC_WINDOWS_SDK_BIN $Name + if (Test-Path $candidate) { return $candidate } + throw "DXC_WINDOWS_SDK_BIN is set, and $candidate is not there" + } + $kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $found = Get-ChildItem -Path $kits -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\$Name" } | + Where-Object { Test-Path $_ } | + Select-Object -First 1 + if ($found) { return $found } + $onPath = Get-Command $Name -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + throw "$Name was not found: install the Windows SDK, or set DXC_WINDOWS_SDK_BIN" +} + +function Set-StepOutput { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Value) + if ($env:GITHUB_OUTPUT) { + Add-Content -Path $env:GITHUB_OUTPUT -Value "$Name=$Value" -Encoding utf8 + } +} diff --git a/tools/msix/scripts/new-test-certificate.ps1 b/tools/msix/scripts/new-test-certificate.ps1 new file mode 100644 index 00000000..84fbfc65 --- /dev/null +++ b/tools/msix/scripts/new-test-certificate.ps1 @@ -0,0 +1,61 @@ +<# +.SYNOPSIS +Creates a throwaway code-signing certificate whose subject is the package publisher. + +.DESCRIPTION +Windows installs a package only when its signature chains to a certificate the machine +trusts, and only when the certificate subject is exactly the Publisher in the manifest. +A release to the Store needs neither: Partner Center signs the package itself when it +takes it in. A test install and the certification kit need both, so this makes a +certificate that lives for a week, exports it, and with -Trust adds it to the machine's +TrustedPeople store. + +Never use this for anything a person downloads. A self-signed certificate asks every +user to trust it by hand, and teaching people to do that is the problem signing exists +to stop. + +.PARAMETER Publisher +The manifest's Publisher, for example CN=DarkPyonix. + +.PARAMETER OutDir +Where test-signing.pfx and test-signing.cer are written. + +.PARAMETER Trust +Also trust the certificate on this machine (needs an elevated shell). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Publisher, + [Parameter(Mandatory)][string]$OutDir, + [switch]$Trust +) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$cert = New-SelfSignedCertificate -Type Custom -Subject $Publisher ` + -KeyUsage DigitalSignature -KeyAlgorithm RSA -KeyLength 2048 ` + -FriendlyName 'dioxus-compose MSIX test signing' ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -NotAfter (Get-Date).AddDays(7) ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') + +$password = [guid]::NewGuid().ToString('N') +if ($env:GITHUB_ACTIONS) { Write-Host "::add-mask::$password" } +$secure = ConvertTo-SecureString -String $password -Force -AsPlainText +$pfx = Join-Path $OutDir 'test-signing.pfx' +$cer = Join-Path $OutDir 'test-signing.cer' +Export-PfxCertificate -Cert $cert -FilePath $pfx -Password $secure | Out-Null +Export-Certificate -Cert $cert -FilePath $cer | Out-Null + +if ($Trust) { + Import-Certificate -FilePath $cer -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' | Out-Null + Write-Host "trusted $($cert.Subject) ($($cert.Thumbprint)) on this machine" +} + +Set-StepOutput -Name pfx -Value $pfx +Set-StepOutput -Name cer -Value $cer +Set-StepOutput -Name password -Value $password +Write-Host "certificate $($cert.Subject), thumbprint $($cert.Thumbprint)" +Write-Host "pfx $pfx" +Write-Host "cer $cer" diff --git a/tools/msix/scripts/run-wack.ps1 b/tools/msix/scripts/run-wack.ps1 new file mode 100644 index 00000000..f08e7257 --- /dev/null +++ b/tools/msix/scripts/run-wack.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS +Runs the Windows App Certification Kit against a package and fails on a failed test. + +.DESCRIPTION +The kit is the check Partner Center runs when a package is submitted, so a package that +passes it here is not refused for the same reasons there. It installs the package, +launches it, inspects the binaries and the manifest, and writes an XML report. The +package has to be signed by a certificate the machine trusts, or the install step fails +before any test runs. + +Warnings do not fail the run; they are printed so that they are read. The Store accepts +a package with warnings, and some of them (a test that does not apply to a full-trust +application) cannot be avoided. + +.PARAMETER Package +The signed .msix or .msixbundle. + +.PARAMETER Report +Where the XML report is written. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Package, + [Parameter(Mandatory)][string]$Report +) +$ErrorActionPreference = 'Stop' + +$appcert = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\App Certification Kit\appcert.exe' +if (-not (Test-Path $appcert)) { + throw "the Windows App Certification Kit is not installed ($appcert); it ships with the Windows SDK" +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Report) | Out-Null +if (Test-Path $Report) { Remove-Item $Report } + +& $appcert reset +& $appcert test -appxpackagepath (Resolve-Path $Package).Path -reportoutputpath $Report +$exit = $LASTEXITCODE +if (-not (Test-Path $Report)) { + throw "the certification kit wrote no report (exit $exit)" +} + +[xml]$xml = Get-Content -Raw -Path $Report +$overall = $xml.REPORT.OVERALL_RESULT +$failed = @() +foreach ($test in $xml.SelectNodes('//TEST')) { + $resultNode = $test.SelectSingleNode('RESULT') + $result = if ($resultNode) { $resultNode.InnerText.Trim() } else { '' } + $line = '{0,-8} {1}' -f $result, $test.NAME + if ($result -eq 'FAIL') { + $failed += $test + Write-Host "::error title=WACK::$($test.NAME)" + } elseif ($result -eq 'WARNING') { + Write-Host "::warning title=WACK::$($test.NAME)" + } + Write-Host $line + foreach ($message in $test.SelectNodes('MESSAGES/MESSAGE')) { + Write-Host " $($message.TEXT)" + } +} +Write-Host "overall result: $overall (appcert exit $exit)" +if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "WACK on $(Split-Path -Leaf $Package): **$overall**" -Encoding utf8 +} +if ($overall -ne 'PASS' -and $overall -ne 'WARNING') { + throw "the certification kit reported $overall with $($failed.Count) failing test(s)" +} diff --git a/tools/msix/scripts/sign.ps1 b/tools/msix/scripts/sign.ps1 new file mode 100644 index 00000000..c44c1e76 --- /dev/null +++ b/tools/msix/scripts/sign.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS +Signs packages with SignTool. + +.DESCRIPTION +Signs each file given with a PFX, using SHA-256 as MSIX requires. A bundle is signed as +a whole; the packages inside it do not need signatures of their own. + +.PARAMETER Path +The .msix, .msixbundle or executable files to sign. + +.PARAMETER Pfx +The certificate with its private key. + +.PARAMETER Password +The PFX password. + +.PARAMETER TimestampUrl +An RFC 3161 timestamp server. A release signature should be timestamped so that it +outlives the certificate; a test signature does not need to be. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string[]]$Path, + [Parameter(Mandatory)][string]$Pfx, + [Parameter(Mandatory)][string]$Password, + [string]$TimestampUrl +) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$signtool = Find-SdkTool 'signtool.exe' +foreach ($file in $Path) { + $arguments = @('sign', '/fd', 'SHA256', '/f', $Pfx, '/p', $Password) + if ($TimestampUrl) { $arguments += @('/tr', $TimestampUrl, '/td', 'SHA256') } + $arguments += $file + & $signtool @arguments + if ($LASTEXITCODE -ne 0) { throw "signtool could not sign $file (exit $LASTEXITCODE)" } + & $signtool verify /pa $file + if ($LASTEXITCODE -ne 0) { throw "the signature on $file does not verify (exit $LASTEXITCODE)" } +} diff --git a/tools/msix/scripts/test-update.ps1 b/tools/msix/scripts/test-update.ps1 new file mode 100644 index 00000000..07fa6ad3 --- /dev/null +++ b/tools/msix/scripts/test-update.ps1 @@ -0,0 +1,116 @@ +<# +.SYNOPSIS +Installs a package from an App Installer feed, publishes a newer one to the same feed, +and checks that Windows sees the update and replaces the installed package with it. + +.DESCRIPTION +This is the update path a downloaded application takes, exercised end to end on one +machine. The feed lives on a file share (App Installer accepts a UNC path as well as an +https address), and the share is a directory this script fills: + + 1. the older bundle and its feed go on the share, and the feed is installed; + 2. the newer bundle goes on the share and its feed replaces the older one; + 3. the installed package is asked whether an update is available, the question + Windows asks on every launch, and has to answer yes; + 4. the feed is installed again, which is what Windows does with that answer, and the + installed package has to be the newer version, with the same identity, alone. + +The Store channel cannot be exercised here: only the Store delivers its updates. The +property it relies on is the one step 4 checks, that a package with the same name and +publisher and a greater version replaces the installed one. + +Must run in Windows PowerShell 5.1, which can call the Windows Runtime directly. + +.PARAMETER Older +Directory with the older .msixbundle and .appinstaller. + +.PARAMETER Newer +Directory with the newer .msixbundle and .appinstaller. + +.PARAMETER Share +Directory that backs the file share the feeds name. + +.PARAMETER ShareName +The share's name; the feeds have to have been written for \\localhost\. + +.PARAMETER Name +The package identity name. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Older, + [Parameter(Mandatory)][string]$Newer, + [Parameter(Mandatory)][string]$Share, + [Parameter(Mandatory)][string]$ShareName, + [Parameter(Mandatory)][string]$Name +) +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName System.Runtime.WindowsRuntime +$null = [Windows.Management.Deployment.PackageManager, Windows.Management.Deployment, ContentType = WindowsRuntime] +$null = [Windows.ApplicationModel.Package, Windows.ApplicationModel, ContentType = WindowsRuntime] +$asTaskGeneric = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { + $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and + $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' +} | Select-Object -First 1 + +function Wait-WinRt($operation, [Type]$resultType) { + $task = $asTaskGeneric.MakeGenericMethod($resultType).Invoke($null, @($operation)) + $task.Wait() | Out-Null + $task.Result +} + +function Get-Installed { + @(Get-AppxPackage -Name $Name) +} + +function Publish-Feed([string]$from) { + Copy-Item -Path (Join-Path $from '*.msixbundle') -Destination $Share -Force + Copy-Item -Path (Join-Path $from '*.appinstaller') -Destination $Share -Force +} + +New-Item -ItemType Directory -Force -Path $Share | Out-Null +if (-not (Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue)) { + New-SmbShare -Name $ShareName -Path $Share -ReadAccess 'Everyone' | Out-Null +} +Get-Installed | Remove-AppxPackage + +Publish-Feed $Older +$feedFile = Get-ChildItem -Path $Share -Filter '*.appinstaller' | Select-Object -First 1 +$feed = "\\localhost\$ShareName\$($feedFile.Name)" +Write-Host "installing from $feed" +Add-AppxPackage -AppInstallerFile $feed +$first = Get-Installed +if ($first.Count -ne 1) { throw "expected one installed $Name, found $($first.Count)" } +$firstVersion = [version]$first[0].Version +Write-Host "installed $Name $firstVersion" + +Publish-Feed $Newer +[xml]$newFeed = Get-Content -Raw -Path (Join-Path $Share $feedFile.Name) +$expected = [version]$newFeed.AppInstaller.MainBundle.Version +if ($expected -le $firstVersion) { throw "the newer feed names $expected, not above $firstVersion" } + +$manager = New-Object Windows.Management.Deployment.PackageManager +$package = $manager.FindPackagesForUser('') | Where-Object { $_.Id.Name -eq $Name } | Select-Object -First 1 +if (-not $package) { throw "the Windows Runtime does not see $Name" } +$info = $package.GetAppInstallerInfo() +if (-not $info) { throw "$Name was installed without its feed, so Windows would never look for an update" } +Write-Host "the installed package watches $($info.Uri)" +$availability = Wait-WinRt ($package.CheckUpdateAvailabilityAsync()) ([Windows.ApplicationModel.PackageUpdateAvailabilityResult]) +Write-Host "update availability: $($availability.Availability)" +if ($availability.Availability -notin @('Available', 'Required')) { + throw "Windows reports $($availability.Availability) after a newer bundle was published" +} + +Add-AppxPackage -AppInstallerFile $feed +$after = Get-Installed +if ($after.Count -ne 1) { throw "expected the update to replace $Name, found $($after.Count) installed" } +$afterVersion = [version]$after[0].Version +if ($afterVersion -ne $expected) { throw "after the update $Name is $afterVersion, expected $expected" } +if ($after[0].Publisher -ne $first[0].Publisher) { throw "the update changed the publisher" } +Write-Host "updated $Name $firstVersion -> $afterVersion" +if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "App Installer update: $Name $firstVersion -> **$afterVersion**" -Encoding utf8 +} + +Get-Installed | Remove-AppxPackage From b5262a07a584b6d239a48051aed0916107cc04de Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:24:58 +0900 Subject: [PATCH 003/224] Fix: Satisfy clippy in the SDK version ordering test --- tools/msix/src/sdk.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/msix/src/sdk.rs b/tools/msix/src/sdk.rs index 8f195d17..846def15 100644 --- a/tools/msix/src/sdk.rs +++ b/tools/msix/src/sdk.rs @@ -92,7 +92,7 @@ mod tests { #[test] fn fr34_sdk_versions_order_numerically() { - let mut v = vec![ + let mut v = [ sdk_version("10.0.9999.0").unwrap(), sdk_version("10.0.22621.0").unwrap(), sdk_version("10.0.19041.0").unwrap(), From 14cd650a19984bef1468fbc073c612c6abe2af40 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:30:24 +0900 Subject: [PATCH 004/224] Fix: Declare per-monitor DPI awareness in the packaging stand-in's manifest The certification kit reads DPI awareness from the embedded manifest and warned that the stand-in was not DPI aware. The build script shows the two linker arguments an application uses to embed the same manifest. --- tools/msix/build.rs | 23 +++++++++++++++++++++++ tools/msix/examples/fixture_app.manifest | 9 +++++++++ 2 files changed, 32 insertions(+) create mode 100644 tools/msix/build.rs create mode 100644 tools/msix/examples/fixture_app.manifest diff --git a/tools/msix/build.rs b/tools/msix/build.rs new file mode 100644 index 00000000..62bb0ac6 --- /dev/null +++ b/tools/msix/build.rs @@ -0,0 +1,23 @@ +//! Embeds an application manifest in the stand-in program. +//! +//! The certification kit reads DPI awareness from the executable's embedded manifest. A +//! program that only declares it at run time (as the renderer does, with +//! SetProcessDpiAwarenessContext) draws correctly but is reported as not DPI aware. An +//! application that wants a clean report embeds the same manifest the same way: these +//! two linker arguments in its own build script. + +fn main() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("fixture_app.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if windows && msvc { + println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-examples=/MANIFESTINPUT:{}", + manifest.display() + ); + } +} diff --git a/tools/msix/examples/fixture_app.manifest b/tools/msix/examples/fixture_app.manifest new file mode 100644 index 00000000..1ad43ed8 --- /dev/null +++ b/tools/msix/examples/fixture_app.manifest @@ -0,0 +1,9 @@ + + + + + true/pm + PerMonitorV2 + + + From a86f1fa267cc7e0d15215059ae41365e1f3cec3d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:30:24 +0900 Subject: [PATCH 005/224] Fix: Serve the App Installer test feed over local HTTP The file share the feed named was not reachable on the runner (0x80070003), so the update test now serves the feed and bundles from a local HTTP server. --- .github/workflows/package-windows.yml | 6 +- tools/msix/scripts/test-update.ps1 | 113 ++++++++++++++------------ 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index c160a9b3..7310011f 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -224,7 +224,7 @@ jobs: $summary = Join-Path $env:RUNNER_TEMP "sideload-$revision.txt" & $tool --project $env:PROJECT --out $out --summary $summary ` --channel sideload --revision $revision ` - --feed-base '\\localhost\msixfeed' ` + --feed-base 'http://localhost:8642' ` --exe 'target\release\examples\fixture-app.exe' if ($LASTEXITCODE -ne 0) { throw "packaging revision $revision failed" } Get-Content (Join-Path $out '*.appinstaller') @@ -254,6 +254,6 @@ jobs: ./tools/msix/scripts/test-update.ps1 -Older "$env:RUNNER_TEMP\sideload-1" -Newer "$env:RUNNER_TEMP\sideload-2" - -Share "$env:RUNNER_TEMP\feed" - -ShareName msixfeed + -Serve "$env:RUNNER_TEMP\feed" + -Port 8642 -Name '${{ steps.pack.outputs.identity }}' diff --git a/tools/msix/scripts/test-update.ps1 b/tools/msix/scripts/test-update.ps1 index 07fa6ad3..d0e1653d 100644 --- a/tools/msix/scripts/test-update.ps1 +++ b/tools/msix/scripts/test-update.ps1 @@ -5,8 +5,8 @@ and checks that Windows sees the update and replaces the installed package with .DESCRIPTION This is the update path a downloaded application takes, exercised end to end on one -machine. The feed lives on a file share (App Installer accepts a UNC path as well as an -https address), and the share is a directory this script fills: +machine. The feed is served over HTTP from a directory this script fills, standing in +for the web server a release would publish to: 1. the older bundle and its feed go on the share, and the feed is installed; 2. the newer bundle goes on the share and its feed replaces the older one; @@ -27,11 +27,11 @@ Directory with the older .msixbundle and .appinstaller. .PARAMETER Newer Directory with the newer .msixbundle and .appinstaller. -.PARAMETER Share -Directory that backs the file share the feeds name. +.PARAMETER Serve +Directory the feed and bundles are served from. -.PARAMETER ShareName -The share's name; the feeds have to have been written for \\localhost\. +.PARAMETER Port +The local port the feeds were written for (http://localhost:). .PARAMETER Name The package identity name. @@ -40,8 +40,8 @@ The package identity name. param( [Parameter(Mandatory)][string]$Older, [Parameter(Mandatory)][string]$Newer, - [Parameter(Mandatory)][string]$Share, - [Parameter(Mandatory)][string]$ShareName, + [Parameter(Mandatory)][string]$Serve, + [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][string]$Name ) $ErrorActionPreference = 'Stop' @@ -65,52 +65,57 @@ function Get-Installed { } function Publish-Feed([string]$from) { - Copy-Item -Path (Join-Path $from '*.msixbundle') -Destination $Share -Force - Copy-Item -Path (Join-Path $from '*.appinstaller') -Destination $Share -Force + Copy-Item -Path (Join-Path $from '*.msixbundle') -Destination $Serve -Force + Copy-Item -Path (Join-Path $from '*.appinstaller') -Destination $Serve -Force } -New-Item -ItemType Directory -Force -Path $Share | Out-Null -if (-not (Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue)) { - New-SmbShare -Name $ShareName -Path $Share -ReadAccess 'Everyone' | Out-Null +New-Item -ItemType Directory -Force -Path $Serve | Out-Null +$python = (Get-Command python -ErrorAction Stop).Source +$server = Start-Process -FilePath $python -PassThru -WindowStyle Hidden ` + -ArgumentList '-m', 'http.server', "$Port", '--bind', '127.0.0.1', '--directory', $Serve +try { + Start-Sleep -Seconds 2 + Get-Installed | Remove-AppxPackage + + Publish-Feed $Older + $feedFile = Get-ChildItem -Path $Serve -Filter '*.appinstaller' | Select-Object -First 1 + $feed = "http://localhost:$Port/$($feedFile.Name)" + Write-Host "installing from $feed" + Add-AppxPackage -AppInstallerFile $feed + $first = Get-Installed + if ($first.Count -ne 1) { throw "expected one installed $Name, found $($first.Count)" } + $firstVersion = [version]$first[0].Version + Write-Host "installed $Name $firstVersion" + + Publish-Feed $Newer + [xml]$newFeed = Get-Content -Raw -Path (Join-Path $Serve $feedFile.Name) + $expected = [version]$newFeed.AppInstaller.MainBundle.Version + if ($expected -le $firstVersion) { throw "the newer feed names $expected, not above $firstVersion" } + + $manager = New-Object Windows.Management.Deployment.PackageManager + $package = $manager.FindPackagesForUser('') | Where-Object { $_.Id.Name -eq $Name } | Select-Object -First 1 + if (-not $package) { throw "the Windows Runtime does not see $Name" } + $info = $package.GetAppInstallerInfo() + if (-not $info) { throw "$Name was installed without its feed, so Windows would never look for an update" } + Write-Host "the installed package watches $($info.Uri)" + $availability = Wait-WinRt ($package.CheckUpdateAvailabilityAsync()) ([Windows.ApplicationModel.PackageUpdateAvailabilityResult]) + Write-Host "update availability: $($availability.Availability)" + if ($availability.Availability -notin @('Available', 'Required')) { + throw "Windows reports $($availability.Availability) after a newer bundle was published" + } + + Add-AppxPackage -AppInstallerFile $feed + $after = Get-Installed + if ($after.Count -ne 1) { throw "expected the update to replace $Name, found $($after.Count) installed" } + $afterVersion = [version]$after[0].Version + if ($afterVersion -ne $expected) { throw "after the update $Name is $afterVersion, expected $expected" } + if ($after[0].Publisher -ne $first[0].Publisher) { throw "the update changed the publisher" } + Write-Host "updated $Name $firstVersion -> $afterVersion" + if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "App Installer update: $Name $firstVersion -> **$afterVersion**" -Encoding utf8 + } + + Get-Installed | Remove-AppxPackage +} finally { + Stop-Process -Id $server.Id -ErrorAction SilentlyContinue } -Get-Installed | Remove-AppxPackage - -Publish-Feed $Older -$feedFile = Get-ChildItem -Path $Share -Filter '*.appinstaller' | Select-Object -First 1 -$feed = "\\localhost\$ShareName\$($feedFile.Name)" -Write-Host "installing from $feed" -Add-AppxPackage -AppInstallerFile $feed -$first = Get-Installed -if ($first.Count -ne 1) { throw "expected one installed $Name, found $($first.Count)" } -$firstVersion = [version]$first[0].Version -Write-Host "installed $Name $firstVersion" - -Publish-Feed $Newer -[xml]$newFeed = Get-Content -Raw -Path (Join-Path $Share $feedFile.Name) -$expected = [version]$newFeed.AppInstaller.MainBundle.Version -if ($expected -le $firstVersion) { throw "the newer feed names $expected, not above $firstVersion" } - -$manager = New-Object Windows.Management.Deployment.PackageManager -$package = $manager.FindPackagesForUser('') | Where-Object { $_.Id.Name -eq $Name } | Select-Object -First 1 -if (-not $package) { throw "the Windows Runtime does not see $Name" } -$info = $package.GetAppInstallerInfo() -if (-not $info) { throw "$Name was installed without its feed, so Windows would never look for an update" } -Write-Host "the installed package watches $($info.Uri)" -$availability = Wait-WinRt ($package.CheckUpdateAvailabilityAsync()) ([Windows.ApplicationModel.PackageUpdateAvailabilityResult]) -Write-Host "update availability: $($availability.Availability)" -if ($availability.Availability -notin @('Available', 'Required')) { - throw "Windows reports $($availability.Availability) after a newer bundle was published" -} - -Add-AppxPackage -AppInstallerFile $feed -$after = Get-Installed -if ($after.Count -ne 1) { throw "expected the update to replace $Name, found $($after.Count) installed" } -$afterVersion = [version]$after[0].Version -if ($afterVersion -ne $expected) { throw "after the update $Name is $afterVersion, expected $expected" } -if ($after[0].Publisher -ne $first[0].Publisher) { throw "the update changed the publisher" } -Write-Host "updated $Name $firstVersion -> $afterVersion" -if ($env:GITHUB_STEP_SUMMARY) { - Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "App Installer update: $Name $firstVersion -> **$afterVersion**" -Encoding utf8 -} - -Get-Installed | Remove-AppxPackage From 6d32d4bac4ac943382448027c8fd9a9afa5a3536 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:30:50 +0900 Subject: [PATCH 006/224] Chore: Lock the MSIX packager's dependencies Resolved by the Windows package workflow, which runs the packager's build; nothing else in the lock file moved. --- Cargo.lock | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b9b3b84..a1e551f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -35,6 +41,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -163,6 +175,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.7.0" @@ -239,6 +260,14 @@ dependencies = [ "jni", ] +[[package]] +name = "dioxus-compose-msix" +version = "0.1.0" +dependencies = [ + "png", + "toml", +] + [[package]] name = "dioxus-core" version = "0.7.10" @@ -330,6 +359,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -340,6 +375,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -394,6 +449,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itertools" version = "0.13.0" @@ -546,6 +617,26 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -644,6 +735,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -700,7 +804,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -744,7 +848,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -922,6 +1026,21 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1044,6 +1163,47 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" @@ -1286,6 +1446,15 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "zerocopy" version = "0.8.57" @@ -1306,6 +1475,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" From 9dcf46aba7672776b58cf23ed43c2344caec84c9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:34:15 +0900 Subject: [PATCH 007/224] Feat: Let an AppImage update itself through appimageupdatetool dioxus_compose::update says how the running application was delivered (AppImage, Flatpak or neither), reads the AppImage's update information from its .upd_info ELF section, and checks for, applies and restarts into an update through the appimageupdatetool the packager places inside the AppImage. A Flatpak is reported as store-managed and never offered an update. Covers the self-update criteria of the draft packaging requirement (provisionally FR-34, Linux part). --- dioxus-compose/src/lib.rs | 1 + dioxus-compose/src/update.rs | 655 ++++++++++++++++++++++++ dioxus-compose/tests/appimage_update.rs | 416 +++++++++++++++ 3 files changed, 1072 insertions(+) create mode 100644 dioxus-compose/src/update.rs create mode 100644 dioxus-compose/tests/appimage_update.rs diff --git a/dioxus-compose/src/lib.rs b/dioxus-compose/src/lib.rs index 2d007b90..bfe492fb 100644 --- a/dioxus-compose/src/lib.rs +++ b/dioxus-compose/src/lib.rs @@ -27,6 +27,7 @@ pub mod renderer; pub mod schema; pub mod spans; pub mod tokens; +pub mod update; mod widgets; pub mod window; diff --git a/dioxus-compose/src/update.rs b/dioxus-compose/src/update.rs new file mode 100644 index 00000000..52d04fc9 --- /dev/null +++ b/dioxus-compose/src/update.rs @@ -0,0 +1,655 @@ +//! Updating an application that was delivered outside a store. +//! +//! Linux delivers an application one of two ways, and they update differently. +//! +//! - **A Flatpak is updated by whoever installed it**: Flathub through GNOME Software, +//! KDE Discover or `flatpak update`. The sandbox cannot write to its own installation, +//! and an application that tried would be fighting the store. [`delivery`] says +//! [`Delivery::Flatpak`] and nothing here offers to update it. +//! - **An AppImage is one file the person downloaded, and it updates itself.** The file +//! carries a line saying where newer versions are published (its *update information*, +//! in an ELF section named `.upd_info`). Each published version has a `.zsync` file +//! beside it, and `appimageupdatetool` reads the line, fetches the `.zsync`, and +//! downloads only the blocks of the new file that the old one does not already have. +//! +//! Nothing here runs on its own. The application decides when to check, asks the person, +//! and then applies. Checking reaches the network, so it belongs on a worker thread, never +//! on the thread that draws: +//! +//! ```no_run +//! use dioxus_compose::update::{UpdateCheck, delivery}; +//! +//! if let Ok(updater) = delivery().appimage_updater() { +//! std::thread::spawn(move || { +//! if let Ok(UpdateCheck::Available) = updater.check() { +//! // Tell the UI through a signal, ask the person, and only then: +//! updater.apply().expect("the update downloads and verifies"); +//! let error = updater.relaunch(std::env::args_os().skip(1)); +//! eprintln!("could not start the updated application: {error}"); +//! } +//! }); +//! } +//! ``` +//! +//! The work of updating belongs to `appimageupdatetool`, which the packager places inside +//! the AppImage. It verifies the result against the SHA-1 the `.zsync` file publishes, and +//! when the running AppImage is signed it refuses a new file that is not signed by the +//! same key. Reimplementing either here would only add a second place for them to be +//! wrong. + +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::str::FromStr; + +/// The ELF section an AppImage runtime reserves for the update information. +pub const UPDATE_INFORMATION_SECTION: &str = ".upd_info"; + +/// Names an `appimageupdatetool` to use instead of the one inside the AppImage. +pub const UPDATER_ENV: &str = "DIOXUS_COMPOSE_APPIMAGEUPDATETOOL"; + +/// Where the packager puts `appimageupdatetool`, relative to the AppImage's mount point. +pub const BUNDLED_UPDATER: &str = "usr/lib/appimageupdatetool/AppRun"; + +/// How the running application reached this machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Delivery { + /// Started from an AppImage. `path` is the AppImage file itself, which is what gets + /// replaced; `mount` is where its contents are visible while it runs. + AppImage { + path: PathBuf, + mount: Option, + }, + /// Running inside a Flatpak sandbox. Updates are the store's job. + Flatpak { app_id: String }, + /// Neither: a binary someone built or unpacked themselves. + Unpackaged, +} + +/// Finds out how the running application was delivered. +/// +/// Reads only the environment and one file: an AppImage runtime sets `APPIMAGE` and +/// `APPDIR` for the program it starts, and Flatpak sets `FLATPAK_ID` and places +/// `/.flatpak-info` in every sandbox. +pub fn delivery() -> Delivery { + delivery_from( + |name| std::env::var_os(name), + Path::new("/.flatpak-info").exists(), + ) +} + +/// [`delivery`] with the environment and the sandbox marker given rather than read, so a +/// caller (and a test) can ask what a given environment means. +pub fn delivery_from(env: impl Fn(&str) -> Option, flatpak_info: bool) -> Delivery { + let present = |value: Option| value.filter(|value| !value.is_empty()); + if let Some(app_id) = present(env("FLATPAK_ID")) { + return Delivery::Flatpak { + app_id: app_id.to_string_lossy().into_owned(), + }; + } + if flatpak_info { + return Delivery::Flatpak { + app_id: String::new(), + }; + } + if let Some(path) = present(env("APPIMAGE")) { + return Delivery::AppImage { + path: PathBuf::from(path), + mount: present(env("APPDIR")).map(PathBuf::from), + }; + } + Delivery::Unpackaged +} + +impl Delivery { + /// An updater for the running AppImage, found the way [`AppImageUpdater::new`] finds + /// one. Anything other than an AppImage answers with the reason it cannot update + /// itself. + pub fn appimage_updater(&self) -> Result { + match self { + Delivery::AppImage { path, mount } => AppImageUpdater::new(path, mount.as_deref()), + Delivery::Flatpak { app_id } => Err(UpdateError::ManagedByStore { + app_id: app_id.clone(), + }), + Delivery::Unpackaged => Err(UpdateError::NotAnAppImage), + } + } +} + +/// Where an AppImage's newer versions are published. +/// +/// The three forms are the ones the AppImage tools define. Each one ends at a `.zsync` +/// file; the `.zsync` file then names the AppImage it describes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateInformation { + /// `zsync|`. + Zsync { url: String }, + /// `gh-releases-zsync||||`. + /// + /// `release` is `latest` (the newest release that is not a pre-release) or a tag. The + /// pattern may use `*`, which is how one line keeps matching files whose names carry + /// a version. + GitHubReleasesZsync { + owner: String, + repository: String, + release: String, + file_name: String, + }, + /// `pling-v1-zsync||`, for the Pling store. + PlingV1Zsync { + product_id: String, + file_name: String, + }, +} + +impl FromStr for UpdateInformation { + type Err = UpdateError; + + fn from_str(line: &str) -> Result { + let invalid = |why: &str| UpdateError::InvalidUpdateInformation { + line: line.to_owned(), + reason: why.to_owned(), + }; + let fields: Vec<&str> = line.split('|').collect(); + let nonempty = |fields: &[&str]| fields.iter().all(|field| !field.trim().is_empty()); + match fields.as_slice() { + ["zsync", url] if nonempty(&[*url]) => Ok(UpdateInformation::Zsync { + url: (*url).to_owned(), + }), + ["zsync", ..] => Err(invalid("`zsync` takes exactly one field, the URL")), + ["gh-releases-zsync", owner, repository, release, file_name] + if nonempty(&[*owner, *repository, *release, *file_name]) => + { + Ok(UpdateInformation::GitHubReleasesZsync { + owner: (*owner).to_owned(), + repository: (*repository).to_owned(), + release: (*release).to_owned(), + file_name: (*file_name).to_owned(), + }) + } + ["gh-releases-zsync", ..] => Err(invalid( + "`gh-releases-zsync` takes four fields: owner, repository, release and file name", + )), + ["pling-v1-zsync", product_id, file_name] if nonempty(&[*product_id, *file_name]) => { + Ok(UpdateInformation::PlingV1Zsync { + product_id: (*product_id).to_owned(), + file_name: (*file_name).to_owned(), + }) + } + ["pling-v1-zsync", ..] => Err(invalid( + "`pling-v1-zsync` takes two fields: product id and file name", + )), + _ => Err(invalid( + "the first field must be `zsync`, `gh-releases-zsync` or `pling-v1-zsync`", + )), + } + } +} + +impl fmt::Display for UpdateInformation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UpdateInformation::Zsync { url } => write!(f, "zsync|{url}"), + UpdateInformation::GitHubReleasesZsync { + owner, + repository, + release, + file_name, + } => write!( + f, + "gh-releases-zsync|{owner}|{repository}|{release}|{file_name}" + ), + UpdateInformation::PlingV1Zsync { + product_id, + file_name, + } => write!(f, "pling-v1-zsync|{product_id}|{file_name}"), + } + } +} + +/// Reads the update information an AppImage carries, without reading the whole file. +/// +/// `Ok(None)` is an AppImage built without any: the runtime always reserves the section, +/// and an empty one means nobody filled it in. +pub fn read_update_information( + file: &mut (impl Read + Seek), +) -> Result, UpdateError> { + let Some(bytes) = read_elf_section(file, UPDATE_INFORMATION_SECTION)? else { + return Ok(None); + }; + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + let text = std::str::from_utf8(&bytes[..end]).map_err(|_| { + UpdateError::NotElf("the update information section is not UTF-8 text".to_owned()) + })?; + let text = text.trim(); + Ok((!text.is_empty()).then(|| text.to_owned())) +} + +/// The update information of the AppImage at `path`, parsed. +pub fn update_information_of(path: &Path) -> Result, UpdateError> { + let mut file = File::open(path).map_err(|source| UpdateError::Io { + action: format!("open {}", path.display()), + source, + })?; + read_update_information(&mut file)? + .map(|line| line.parse()) + .transpose() +} + +/// Section headers are read whole, so a corrupt count must not turn into a huge read. +const MAX_SECTIONS: u64 = 1 << 16; +/// The runtime reserves 1024 bytes for update information; anything near this is not one. +const MAX_SECTION_BYTES: u64 = 1 << 20; + +fn read_elf_section( + file: &mut (impl Read + Seek), + wanted: &str, +) -> Result>, UpdateError> { + let not_elf = |why: &str| UpdateError::NotElf(why.to_owned()); + let mut ident = [0u8; 64]; + file.seek(SeekFrom::Start(0)).map_err(UpdateError::read)?; + let read = read_up_to(file, &mut ident).map_err(UpdateError::read)?; + if read < 52 || &ident[..4] != b"\x7fELF" { + return Err(not_elf("the file does not start with an ELF header")); + } + let wide = match ident[4] { + 1 => false, + 2 => true, + _ => return Err(not_elf("the ELF class is neither 32 nor 64 bit")), + }; + let big = match ident[5] { + 1 => false, + 2 => true, + _ => { + return Err(not_elf( + "the ELF byte order is neither little nor big endian", + )); + } + }; + let int = Endian { big }; + let (shoff, shentsize, shnum, shstrndx) = if wide { + if read < 64 { + return Err(not_elf("the 64-bit ELF header is cut short")); + } + ( + int.u64(&ident[0x28..0x30]), + int.u16(&ident[0x3a..0x3c]), + int.u16(&ident[0x3c..0x3e]), + int.u16(&ident[0x3e..0x40]), + ) + } else { + ( + int.u32(&ident[0x20..0x24]) as u64, + int.u16(&ident[0x2e..0x30]), + int.u16(&ident[0x30..0x32]), + int.u16(&ident[0x32..0x34]), + ) + }; + let minimum = if wide { 64 } else { 40 }; + if shoff == 0 || shnum == 0 { + return Ok(None); + } + if (shentsize as usize) < minimum || shnum as u64 > MAX_SECTIONS || shstrndx >= shnum { + return Err(not_elf("the section header table is malformed")); + } + let mut table = vec![0u8; shentsize as usize * shnum as usize]; + file.seek(SeekFrom::Start(shoff)) + .map_err(UpdateError::read)?; + file.read_exact(&mut table) + .map_err(|_| not_elf("the section header table runs past the end of the file"))?; + let section = |index: usize| { + let header = &table[index * shentsize as usize..][..shentsize as usize]; + if wide { + ( + int.u32(&header[0..4]), + int.u64(&header[24..32]), + int.u64(&header[32..40]), + ) + } else { + ( + int.u32(&header[0..4]), + int.u32(&header[16..20]) as u64, + int.u32(&header[20..24]) as u64, + ) + } + }; + let (_, names_offset, names_size) = section(shstrndx as usize); + let names = read_at(file, names_offset, names_size)?; + for index in 0..shnum as usize { + let (name, offset, size) = section(index); + let Some(rest) = names.get(name as usize..) else { + continue; + }; + let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len()); + if &rest[..end] == wanted.as_bytes() { + return read_at(file, offset, size).map(Some); + } + } + Ok(None) +} + +fn read_at(file: &mut (impl Read + Seek), offset: u64, size: u64) -> Result, UpdateError> { + if size > MAX_SECTION_BYTES { + return Err(UpdateError::NotElf( + "a section is larger than any this reader expects".to_owned(), + )); + } + let mut bytes = vec![0u8; size as usize]; + file.seek(SeekFrom::Start(offset)) + .map_err(UpdateError::read)?; + file.read_exact(&mut bytes) + .map_err(|_| UpdateError::NotElf("a section runs past the end of the file".to_owned()))?; + Ok(bytes) +} + +fn read_up_to(file: &mut impl Read, buffer: &mut [u8]) -> io::Result { + let mut filled = 0; + while filled < buffer.len() { + match file.read(&mut buffer[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + Ok(filled) +} + +#[derive(Clone, Copy)] +struct Endian { + big: bool, +} + +impl Endian { + fn u16(self, bytes: &[u8]) -> u16 { + let bytes = [bytes[0], bytes[1]]; + if self.big { + u16::from_be_bytes(bytes) + } else { + u16::from_le_bytes(bytes) + } + } + + fn u32(self, bytes: &[u8]) -> u32 { + let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; + if self.big { + u32::from_be_bytes(bytes) + } else { + u32::from_le_bytes(bytes) + } + } + + fn u64(self, bytes: &[u8]) -> u64 { + let mut array = [0u8; 8]; + array.copy_from_slice(&bytes[..8]); + if self.big { + u64::from_be_bytes(array) + } else { + u64::from_le_bytes(array) + } + } +} + +/// What a check found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateCheck { + /// The published file differs from the running one. + Available, + /// The published file is the one that is running. + UpToDate, +} + +/// Checks for and applies updates to one AppImage, through `appimageupdatetool`. +#[derive(Debug, Clone)] +pub struct AppImageUpdater { + appimage: PathBuf, + information: UpdateInformation, + tool: PathBuf, +} + +impl AppImageUpdater { + /// An updater for the AppImage at `appimage`, whose contents are mounted at `mount`. + /// + /// The tool is looked for in three places, first match wins: the path in + /// `DIOXUS_COMPOSE_APPIMAGEUPDATETOOL`, the copy the packager places inside the + /// AppImage, and `appimageupdatetool` on `PATH`. + pub fn new(appimage: &Path, mount: Option<&Path>) -> Result { + let tool = find_tool( + std::env::var_os(UPDATER_ENV).as_deref(), + mount, + std::env::var_os("PATH").as_deref(), + ) + .ok_or(UpdateError::UpdaterMissing)?; + Self::with_tool(appimage, &tool) + } + + /// An updater that runs the given `appimageupdatetool`. + pub fn with_tool(appimage: &Path, tool: &Path) -> Result { + let information = + update_information_of(appimage)?.ok_or(UpdateError::NoUpdateInformation)?; + Ok(Self { + appimage: appimage.to_owned(), + information, + tool: tool.to_owned(), + }) + } + + /// The AppImage this updater replaces. + pub fn appimage(&self) -> &Path { + &self.appimage + } + + /// Where the AppImage says its newer versions are. + pub fn update_information(&self) -> &UpdateInformation { + &self.information + } + + /// Asks whether the published file differs from the one on disk. Reaches the + /// network, so call it from a worker thread. + pub fn check(&self) -> Result { + let output = self + .command() + .arg("--check-for-update") + .arg(&self.appimage) + .output(); + let output = output.map_err(|source| UpdateError::Io { + action: format!("run {}", self.tool.display()), + source, + })?; + // The tool's own contract: 1 when the published file differs, 0 when it does + // not, anything else when it could not tell. + match output.status.code() { + Some(0) => Ok(UpdateCheck::UpToDate), + Some(1) => Ok(UpdateCheck::Available), + code => Err(UpdateError::UpdaterFailed { + code, + output: describe_output(&output.stdout, &output.stderr), + }), + } + } + + /// Downloads the new version and puts it where the running AppImage is. + /// + /// Only the blocks that changed are downloaded. The file is replaced, not edited in + /// place, so the running process keeps reading the version it started from until it + /// exits or [`relaunch`](Self::relaunch)es. + pub fn apply(&self) -> Result<(), UpdateError> { + let output = self + .command() + .arg("--overwrite") + .arg(&self.appimage) + .output(); + let output = output.map_err(|source| UpdateError::Io { + action: format!("run {}", self.tool.display()), + source, + })?; + if output.status.success() { + Ok(()) + } else { + Err(UpdateError::UpdaterFailed { + code: output.status.code(), + output: describe_output(&output.stdout, &output.stderr), + }) + } + } + + /// Starts the AppImage on disk in place of this process. Returns only on failure. + pub fn relaunch(&self, args: I) -> io::Error + where + I: IntoIterator, + S: AsRef, + { + let mut command = Command::new(&self.appimage); + command.args(args); + // Each runtime sets these for the program it starts. Left in place, the new + // process would report the old mount point until its own runtime replaced them. + for name in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] { + command.env_remove(name); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.exec() + } + #[cfg(not(unix))] + { + match command.spawn() { + Ok(_) => std::process::exit(0), + Err(error) => error, + } + } + } + + fn command(&self) -> Command { + let mut command = Command::new(&self.tool); + // The tool is itself an extracted AppImage. It must see its own directory, not + // the mount point of the application that started it. + for name in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] { + command.env_remove(name); + } + if self.tool.file_name() == Some(OsStr::new("AppRun")) { + if let Some(dir) = self.tool.parent() { + command.env("APPDIR", dir); + } + } + command + } +} + +/// Where `appimageupdatetool` is, given the override, the AppImage's mount point and +/// `PATH`, first match wins. +pub fn find_tool( + override_path: Option<&OsStr>, + mount: Option<&Path>, + path: Option<&OsStr>, +) -> Option { + if let Some(explicit) = override_path.filter(|value| !value.is_empty()) { + return Some(PathBuf::from(explicit)); + } + if let Some(bundled) = mount.map(|mount| mount.join(BUNDLED_UPDATER)) { + if bundled.is_file() { + return Some(bundled); + } + } + std::env::split_paths(path?) + .map(|dir| dir.join("appimageupdatetool")) + .find(|candidate| candidate.is_file()) +} + +fn describe_output(stdout: &[u8], stderr: &[u8]) -> String { + let mut text = String::from_utf8_lossy(stdout).trim().to_owned(); + let stderr = String::from_utf8_lossy(stderr); + if !stderr.trim().is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(stderr.trim()); + } + text +} + +/// Why an update could not be checked for or applied. +#[derive(Debug)] +pub enum UpdateError { + /// The application was not started from an AppImage, so there is no file to replace. + NotAnAppImage, + /// A store installed this application and updates it; replacing it from inside would + /// fight the store. + ManagedByStore { app_id: String }, + /// The AppImage was built without update information, so it does not know where its + /// newer versions are. + NoUpdateInformation, + /// The update information is not in a form the AppImage tools define. + InvalidUpdateInformation { line: String, reason: String }, + /// The file is not an ELF executable, or its section table is damaged. + NotElf(String), + /// No `appimageupdatetool` inside the AppImage, in the override variable, or on `PATH`. + UpdaterMissing, + /// `appimageupdatetool` ran and reported a failure. + UpdaterFailed { code: Option, output: String }, + /// Reading the AppImage or starting the tool failed. + Io { action: String, source: io::Error }, +} + +impl UpdateError { + fn read(source: io::Error) -> Self { + UpdateError::Io { + action: "read the AppImage".to_owned(), + source, + } + } +} + +impl fmt::Display for UpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UpdateError::NotAnAppImage => write!( + f, + "this application was not started from an AppImage, so there is no file to update" + ), + UpdateError::ManagedByStore { app_id } if app_id.is_empty() => write!( + f, + "this application runs in a Flatpak sandbox; the store that installed it updates it" + ), + UpdateError::ManagedByStore { app_id } => write!( + f, + "this application is the Flatpak {app_id}; the store that installed it updates it" + ), + UpdateError::NoUpdateInformation => write!( + f, + "this AppImage was built without update information, so it does not know where newer versions are published" + ), + UpdateError::InvalidUpdateInformation { line, reason } => { + write!(f, "the update information `{line}` is not usable: {reason}") + } + UpdateError::NotElf(reason) => write!(f, "not a readable AppImage: {reason}"), + UpdateError::UpdaterMissing => write!( + f, + "appimageupdatetool was not found inside the AppImage ({BUNDLED_UPDATER}), in {UPDATER_ENV}, or on PATH" + ), + UpdateError::UpdaterFailed { code, output } => { + match code { + Some(code) => write!(f, "appimageupdatetool exited with status {code}")?, + None => write!(f, "appimageupdatetool was stopped by a signal")?, + } + if !output.is_empty() { + write!(f, ":\n{output}")?; + } + Ok(()) + } + UpdateError::Io { action, source } => write!(f, "could not {action}: {source}"), + } + } +} + +impl std::error::Error for UpdateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + UpdateError::Io { source, .. } => Some(source), + _ => None, + } + } +} diff --git a/dioxus-compose/tests/appimage_update.rs b/dioxus-compose/tests/appimage_update.rs new file mode 100644 index 00000000..f15c2d04 --- /dev/null +++ b/dioxus-compose/tests/appimage_update.rs @@ -0,0 +1,416 @@ +//! An AppImage updates itself; a Flatpak leaves updating to its store. +//! +//! The AppImages here are synthetic ELF files with exactly the sections that matter, and +//! `appimageupdatetool` is a shell script that answers the way the real one does. The real +//! pair runs in `.github/workflows/packager-linux.yml`, which updates a built AppImage +//! against `.zsync` files served on the runner. + +use std::ffi::OsString; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use dioxus_compose::update::{ + AppImageUpdater, BUNDLED_UPDATER, Delivery, UpdateCheck, UpdateError, UpdateInformation, + delivery_from, find_tool, read_update_information, update_information_of, +}; + +/// An ELF file with a null section, the given sections and a section name table. +fn elf(wide: bool, big: bool, sections: &[(&str, &[u8])]) -> Vec { + let put16 = |out: &mut Vec, v: u16| { + out.extend_from_slice(&if big { + v.to_be_bytes() + } else { + v.to_le_bytes() + }) + }; + let put32 = |out: &mut Vec, v: u32| { + out.extend_from_slice(&if big { + v.to_be_bytes() + } else { + v.to_le_bytes() + }) + }; + let put64 = |out: &mut Vec, v: u64| { + out.extend_from_slice(&if big { + v.to_be_bytes() + } else { + v.to_le_bytes() + }) + }; + let header_size = if wide { 64 } else { 52 }; + let entry_size: u16 = if wide { 64 } else { 40 }; + + let mut names = vec![0u8]; + let mut name_offsets = Vec::new(); + for (name, _) in sections { + name_offsets.push(names.len() as u32); + names.extend_from_slice(name.as_bytes()); + names.push(0); + } + let names_name = names.len() as u32; + names.extend_from_slice(b".shstrtab\0"); + + let mut body = Vec::new(); + let mut placed = Vec::new(); + for (_, data) in sections { + placed.push(((header_size + body.len()) as u64, data.len() as u64)); + body.extend_from_slice(data); + } + let names_at = (header_size + body.len()) as u64; + body.extend_from_slice(&names); + let table_at = (header_size + body.len()) as u64; + let count = sections.len() as u16 + 2; + + let mut out = Vec::new(); + out.extend_from_slice(b"\x7fELF"); + out.push(if wide { 2 } else { 1 }); + out.push(if big { 2 } else { 1 }); + out.push(1); + out.extend_from_slice(&[0; 9]); + put16(&mut out, 2); // executable + put16(&mut out, if wide { 62 } else { 3 }); + put32(&mut out, 1); + if wide { + put64(&mut out, 0); // entry + put64(&mut out, 0); // program headers + put64(&mut out, table_at); + } else { + put32(&mut out, 0); + put32(&mut out, 0); + put32(&mut out, table_at as u32); + } + put32(&mut out, 0); // flags + put16(&mut out, header_size as u16); + put16(&mut out, 0); + put16(&mut out, 0); + put16(&mut out, entry_size); + put16(&mut out, count); + put16(&mut out, count - 1); + assert_eq!(out.len(), header_size); + out.extend_from_slice(&body); + + let entry = |out: &mut Vec, name: u32, offset: u64, size: u64| { + let start = out.len(); + put32(out, name); + put32(out, if name == 0 && size == 0 { 0 } else { 1 }); + if wide { + put64(out, 0); // flags + put64(out, 0); // address + put64(out, offset); + put64(out, size); + } else { + put32(out, 0); + put32(out, 0); + put32(out, offset as u32); + put32(out, size as u32); + } + out.resize(start + entry_size as usize, 0); + }; + entry(&mut out, 0, 0, 0); + for (name, (offset, size)) in name_offsets.iter().zip(&placed) { + entry(&mut out, *name, *offset, *size); + } + entry(&mut out, names_name, names_at, names.len() as u64); + out +} + +/// The section as the AppImage runtime reserves it: 1024 bytes, filled from the start. +fn upd_info(line: &str) -> Vec { + let mut bytes = line.as_bytes().to_vec(); + bytes.resize(1024, 0); + bytes +} + +fn scratch(test: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("appimage_update") + .join(test); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +const LOCAL: &str = "zsync|http://127.0.0.1:8080/Demo-x86_64.AppImage.zsync"; + +#[test] +fn fr34_reads_update_information_from_every_elf_layout() { + for wide in [true, false] { + for big in [false, true] { + let file = elf( + wide, + big, + &[ + (".text", b"\x90\x90"), + (".upd_info", &upd_info(LOCAL)), + (".sha256_sig", &[0; 1024]), + ], + ); + let found = read_update_information(&mut Cursor::new(file)).unwrap(); + assert_eq!( + found.as_deref(), + Some(LOCAL), + "wide={wide} big_endian={big}" + ); + } + } +} + +#[test] +fn fr34_an_empty_or_missing_section_means_no_update_information() { + let reserved = elf(true, false, &[(".upd_info", &[0; 1024])]); + assert_eq!( + read_update_information(&mut Cursor::new(reserved)).unwrap(), + None + ); + let absent = elf(true, false, &[(".text", b"\x90")]); + assert_eq!( + read_update_information(&mut Cursor::new(absent)).unwrap(), + None + ); +} + +#[test] +fn fr34_a_file_that_is_not_elf_is_an_error_not_a_panic() { + for bytes in [ + b"#!/bin/sh\necho hi\n".to_vec(), + Vec::new(), + b"\x7fELF".to_vec(), + ] { + let error = read_update_information(&mut Cursor::new(bytes)).unwrap_err(); + assert!(matches!(error, UpdateError::NotElf(_)), "{error}"); + } + // A section table that points past the end of the file. + let mut truncated = elf(true, false, &[(".upd_info", &upd_info(LOCAL))]); + truncated.truncate(truncated.len() - 10); + assert!(read_update_information(&mut Cursor::new(truncated)).is_err()); +} + +#[test] +fn fr34_update_information_parses_the_three_forms_and_prints_them_back() { + let lines = [ + LOCAL, + "gh-releases-zsync|darkpyonix|compose-rust|latest|Calculator-*-x86_64.AppImage.zsync", + "pling-v1-zsync|1234567|Calculator-*-x86_64.AppImage", + ]; + for line in lines { + let parsed: UpdateInformation = line.parse().unwrap(); + assert_eq!(parsed.to_string(), line); + } + let parsed: UpdateInformation = lines[1].parse().unwrap(); + assert_eq!( + parsed, + UpdateInformation::GitHubReleasesZsync { + owner: "darkpyonix".into(), + repository: "compose-rust".into(), + release: "latest".into(), + file_name: "Calculator-*-x86_64.AppImage.zsync".into(), + } + ); +} + +#[test] +fn fr34_malformed_update_information_says_what_is_wrong() { + for line in [ + "", + "zsync|", + "zsync|a|b", + "gh-releases-zsync|owner|repo|latest", + "gh-releases-zsync|owner||latest|file", + "bintray-zsync|a|b|c|d", + ] { + let error = line.parse::().unwrap_err(); + assert!( + matches!(error, UpdateError::InvalidUpdateInformation { .. }), + "{line}: {error}" + ); + assert!(error.to_string().contains("is not usable"), "{error}"); + } +} + +fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| { + pairs + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| OsString::from(value)) + } +} + +#[test] +fn fr34_delivery_tells_an_appimage_from_a_flatpak_from_neither() { + assert_eq!( + delivery_from( + env_of(&[ + ("APPIMAGE", "/home/a/Demo.AppImage"), + ("APPDIR", "/tmp/.mount_Demo") + ]), + false + ), + Delivery::AppImage { + path: "/home/a/Demo.AppImage".into(), + mount: Some("/tmp/.mount_Demo".into()), + } + ); + assert_eq!( + delivery_from(env_of(&[("FLATPAK_ID", "dev.example.Demo")]), true), + Delivery::Flatpak { + app_id: "dev.example.Demo".into() + } + ); + assert_eq!(delivery_from(env_of(&[]), false), Delivery::Unpackaged); + assert_eq!( + delivery_from(env_of(&[("APPIMAGE", "")]), false), + Delivery::Unpackaged + ); +} + +#[test] +fn fr34_a_flatpak_never_offers_to_update_itself() { + // Even an AppImage variable leaking into the sandbox does not make it one. + let inside = delivery_from( + env_of(&[ + ("FLATPAK_ID", "dev.example.Demo"), + ("APPIMAGE", "/x.AppImage"), + ]), + true, + ); + let error = inside.appimage_updater().unwrap_err(); + assert!( + matches!(error, UpdateError::ManagedByStore { .. }), + "{error}" + ); + assert!(error.to_string().contains("store"), "{error}"); + + let error = Delivery::Unpackaged.appimage_updater().unwrap_err(); + assert!(matches!(error, UpdateError::NotAnAppImage), "{error}"); +} + +#[test] +fn fr34_the_bundled_updater_wins_over_path_and_the_override_wins_over_both() { + let dir = scratch("find_tool"); + let mount = dir.join("mount"); + let bin = dir.join("bin"); + std::fs::create_dir_all(mount.join(BUNDLED_UPDATER).parent().unwrap()).unwrap(); + std::fs::write(mount.join(BUNDLED_UPDATER), b"").unwrap(); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("appimageupdatetool"), b"").unwrap(); + let path = std::env::join_paths([&bin]).unwrap(); + + assert_eq!( + find_tool(None, Some(&mount), Some(&path)), + Some(mount.join(BUNDLED_UPDATER)) + ); + assert_eq!( + find_tool(None, Some(&dir.join("elsewhere")), Some(&path)), + Some(bin.join("appimageupdatetool")) + ); + assert_eq!( + find_tool(Some("/opt/tool".as_ref()), Some(&mount), Some(&path)), + Some(PathBuf::from("/opt/tool")) + ); + assert_eq!(find_tool(None, None, None), None); +} + +#[test] +fn fr34_an_appimage_without_update_information_cannot_be_updated() { + let dir = scratch("no_information"); + let appimage = dir.join("Demo.AppImage"); + std::fs::write(&appimage, elf(true, false, &[(".upd_info", &[0; 1024])])).unwrap(); + let error = AppImageUpdater::with_tool(&appimage, Path::new("/bin/true")).unwrap_err(); + assert!(matches!(error, UpdateError::NoUpdateInformation), "{error}"); + assert_eq!(update_information_of(&appimage).unwrap(), None); +} + +#[cfg(unix)] +mod with_a_fake_tool { + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// Answers like `appimageupdatetool`: 1 from a check while `new` differs from the + /// target, 0 once they match, and `--overwrite` copies `new` over the target. + const FAKE_TOOL: &str = r#"#!/bin/sh +here="$(dirname "$0")" +echo "APPDIR=${APPDIR:-} APPIMAGE=${APPIMAGE:-} $*" >> "$here/calls.log" +case "$1" in + --check-for-update) cmp -s "$here/new" "$2" && exit 0 || exit 1 ;; + --overwrite) cp "$here/new" "$2" && exit 0 ;; +esac +echo "unexpected arguments: $*" >&2 +exit 2 +"#; + + fn setup(test: &str) -> (PathBuf, PathBuf, PathBuf) { + let dir = scratch(test); + let tooldir = dir.join("appimageupdatetool"); + std::fs::create_dir_all(&tooldir).unwrap(); + let tool = tooldir.join("AppRun"); + std::fs::write(&tool, FAKE_TOOL).unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let appimage = dir.join("Demo.AppImage"); + std::fs::write( + &appimage, + elf( + true, + false, + &[(".upd_info", &upd_info(LOCAL)), (".v", b"1")], + ), + ) + .unwrap(); + std::fs::write( + tooldir.join("new"), + elf( + true, + false, + &[(".upd_info", &upd_info(LOCAL)), (".v", b"2")], + ), + ) + .unwrap(); + (dir, appimage, tool) + } + + #[test] + fn fr34_check_then_apply_replaces_the_appimage_with_the_published_one() { + let (_dir, appimage, tool) = setup("check_apply"); + let updater = AppImageUpdater::with_tool(&appimage, &tool).unwrap(); + assert_eq!(updater.update_information().to_string(), LOCAL); + assert_eq!(updater.check().unwrap(), UpdateCheck::Available); + updater.apply().unwrap(); + assert_eq!( + std::fs::read(&appimage).unwrap(), + std::fs::read(tool.parent().unwrap().join("new")).unwrap() + ); + assert_eq!(updater.check().unwrap(), UpdateCheck::UpToDate); + + // The tool sees its own directory as APPDIR and never the application's AppImage. + let calls = std::fs::read_to_string(tool.parent().unwrap().join("calls.log")).unwrap(); + for line in calls.lines() { + assert!( + line.starts_with(&format!( + "APPDIR={} APPIMAGE= ", + tool.parent().unwrap().display() + )), + "{line}" + ); + } + } + + #[test] + fn fr34_a_failing_updater_is_reported_with_its_output() { + let (_dir, appimage, tool) = setup("failure"); + std::fs::write( + &tool, + "#!/bin/sh\necho 'signature does not match' >&2\nexit 3\n", + ) + .unwrap(); + let updater = AppImageUpdater::with_tool(&appimage, &tool).unwrap(); + for error in [updater.check().unwrap_err(), updater.apply().unwrap_err()] { + match &error { + UpdateError::UpdaterFailed { code, output } => { + assert_eq!(*code, Some(3)); + assert!(output.contains("signature does not match"), "{output}"); + } + other => panic!("expected UpdaterFailed, got {other}"), + } + } + } +} From 755f840c2aa07b43030c79719097989dcce70217 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:34:15 +0900 Subject: [PATCH 008/224] Feat: Generate Linux package metadata from Dioxus.toml packager-linux reads dx's [application], [bundle] and [linux] tables plus a [linux.store] table for what store listings need, and writes the desktop entry, AppStream metainfo, hicolor icons, an AppDir carrying appimageupdatetool, AppImage update information (gh-releases-zsync or zsync) and a flatpak-builder manifest (prebuilt archive or an offline build from source with vendored crates). The demo crate is the small program the packaging workflow builds in two versions to show each channel updating an installed build. Covers the metadata criteria of the draft packaging requirement (provisionally FR-34, Linux part). --- Cargo.toml | 2 + tools/packager-linux/Cargo.toml | 21 + tools/packager-linux/demo/Cargo.toml | 20 + tools/packager-linux/demo/Dioxus.toml | 33 ++ tools/packager-linux/demo/icon.png | Bin 0 -> 1178 bytes tools/packager-linux/demo/src/main.rs | 136 +++++ tools/packager-linux/samples.toml | 13 + tools/packager-linux/src/appimage.rs | 285 ++++++++++ tools/packager-linux/src/appstream.rs | 157 ++++++ tools/packager-linux/src/desktop.rs | 66 +++ tools/packager-linux/src/flatpak.rs | 333 ++++++++++++ tools/packager-linux/src/icons.rs | 145 ++++++ tools/packager-linux/src/lib.rs | 16 + tools/packager-linux/src/main.rs | 365 +++++++++++++ tools/packager-linux/src/metadata.rs | 509 ++++++++++++++++++ tools/packager-linux/tests/metadata.rs | 696 +++++++++++++++++++++++++ 16 files changed, 2797 insertions(+) create mode 100644 tools/packager-linux/Cargo.toml create mode 100644 tools/packager-linux/demo/Cargo.toml create mode 100644 tools/packager-linux/demo/Dioxus.toml create mode 100644 tools/packager-linux/demo/icon.png create mode 100644 tools/packager-linux/demo/src/main.rs create mode 100644 tools/packager-linux/samples.toml create mode 100644 tools/packager-linux/src/appimage.rs create mode 100644 tools/packager-linux/src/appstream.rs create mode 100644 tools/packager-linux/src/desktop.rs create mode 100644 tools/packager-linux/src/flatpak.rs create mode 100644 tools/packager-linux/src/icons.rs create mode 100644 tools/packager-linux/src/lib.rs create mode 100644 tools/packager-linux/src/main.rs create mode 100644 tools/packager-linux/src/metadata.rs create mode 100644 tools/packager-linux/tests/metadata.rs diff --git a/Cargo.toml b/Cargo.toml index 516a9ae2..19986126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ members = [ "samples/statistics", "samples/store", "samples/todo", + "tools/packager-linux", + "tools/packager-linux/demo", ] resolver = "2" diff --git a/tools/packager-linux/Cargo.toml b/tools/packager-linux/Cargo.toml new file mode 100644 index 00000000..150e224b --- /dev/null +++ b/tools/packager-linux/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "packager-linux" +# Tooling for this repository's releases, not a library anyone depends on. +version = "0.0.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "Wraps a built dioxus-compose application as an AppImage and a Flatpak" + +[lib] +bench = false + +[[bin]] +name = "packager-linux" +path = "src/main.rs" +bench = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = { version = "1", default-features = false, features = ["std", "parse", "serde"] } diff --git a/tools/packager-linux/demo/Cargo.toml b/tools/packager-linux/demo/Cargo.toml new file mode 100644 index 00000000..5892f954 --- /dev/null +++ b/tools/packager-linux/demo/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "packager-linux-demo" +# The packaging workflow builds this twice with different versions in +# DIOXUS_COMPOSE_APP_VERSION and shows the older one updating itself to the newer. +version = "0.0.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "A small program that shows a packaged dioxus-compose application updating itself" + +[[bin]] +name = "update-demo" +path = "src/main.rs" +bench = false + +[dependencies] +# No renderer: this program draws nothing, so it needs neither the renderer download nor +# a display, and it builds inside a Flatpak sandbox with no network. +dioxus-compose = { path = "../../../dioxus-compose", default-features = false } diff --git a/tools/packager-linux/demo/Dioxus.toml b/tools/packager-linux/demo/Dioxus.toml new file mode 100644 index 00000000..d2a5918b --- /dev/null +++ b/tools/packager-linux/demo/Dioxus.toml @@ -0,0 +1,33 @@ +# What the packaging workflow wraps: a program that updates itself, described the way an +# application describes itself to dx, plus the [linux.store] facts a store listing needs. + +[application] +name = "UpdateDemo" + +[bundle] +identifier = "dev.darkpyonix.compose.UpdateDemo" +publisher = "DarkPyonix" +icon = ["icon.png"] +short_description = "Shows a packaged application updating itself" +long_description = """ +A small program built with dioxus-compose that checks for a newer version of itself, asks, +downloads only the parts that changed, and restarts into it. + +Delivered as an AppImage it updates itself from the .zsync file published beside each +release. Delivered as a Flatpak it leaves updating to the store that installed it.""" + +[linux] +categories = ["Development"] +keywords = ["update", "appimage", "flatpak"] + +[linux.store] +exec = "update-demo" +license = "Apache-2.0" +homepage = "https://github.com/darkpyonix/compose-rust" +bugtracker = "https://github.com/darkpyonix/compose-rust/issues" +vcs_browser = "https://github.com/darkpyonix/compose-rust" +brand_light = "#dbe7ff" +brand_dark = "#1d2b4f" +screenshots = [ + { url = "https://raw.githubusercontent.com/darkpyonix/compose-rust/develop/tools/packager-linux/demo/screenshot.png", caption = "Checking for an update" }, +] diff --git a/tools/packager-linux/demo/icon.png b/tools/packager-linux/demo/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..24c96d9a6cdc1d531077731b546739d2aa7f9fc4 GIT binary patch literal 1178 zcmc&zO=uHQ5dPlVO|y-p*+`_Jw9U3gY{Y653O$r&*CsZpQ0h_;L}= z(NLPT{v8ThZ41_eXFkvm&-$Bt7}y&3yajd$Ti-Z_W?& z_c|R74gk(rG^_)RAcIsx?%3&DCjfCc7S;w6E1zF#P9x~CJ#5g}@sdwegdD4#c-}PI zQF=DFFZsRw)7ieEj?sL(mMiQ~y!S<6;c7fy(VKZ@SEQw6>N3mhBoMcNpFmLeFjs5D zU6L)JMBF2ZK-w5TbXaUWnrW7$fJ0&hqZtuw^eWEs;w&W&=9ieOTbsv#lkraLgL_V!ziQer$g zXZ@#Mq?}{G^bkEwc?+kU8j#;BVZE58!k0aeA%dx!bbtz4BJFAbP-ls_8tQM(D&Krj z)CH|-Yx+foVqM|1s{@I8NZ$8zRYjgLEw=zvtk3Vhtt= zL7IVgFbq#~2!3>$gSJ1R0eu^!=$o>jrUkG-R}#@vP?SlMbfEhmA5xU0u3AdhX(mh> z3B#I*MH&c&=79&gN2O@q#h2H;9G!9rPd^MR3tyR5vpPO8{q5c=f0gUHHnscGob^1h LNPqawf%w=@cU2DU literal 0 HcmV?d00001 diff --git a/tools/packager-linux/demo/src/main.rs b/tools/packager-linux/demo/src/main.rs new file mode 100644 index 00000000..f0091780 --- /dev/null +++ b/tools/packager-linux/demo/src/main.rs @@ -0,0 +1,136 @@ +//! A packaged application that checks for, asks about, applies and restarts into an +//! update, the way a real one would, with standard input in place of a dialog. +//! +//! The packaging workflow builds it twice with different versions, wraps both as +//! AppImages, serves the newer one's `.zsync` on the runner, and runs the older one with +//! `--update`. The same program inside a Flatpak says the store updates it and changes +//! nothing. +//! +//! ```text +//! update-demo --version print the version +//! update-demo --delivery say how this copy was delivered +//! update-demo --check exit 0 when up to date, 10 when an update is available +//! update-demo --update check, ask, apply, and restart into the new version +//! [--yes] answer yes without asking +//! ``` + +use std::io::{BufRead, Write}; +use std::process::ExitCode; + +use dioxus_compose::update::{Delivery, UpdateCheck, delivery}; + +/// Set by the build, so two builds of the same source differ the way two releases do. +const VERSION: &str = match option_env!("DIOXUS_COMPOSE_APP_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + +const UPDATE_AVAILABLE: u8 = 10; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let yes = args.iter().any(|a| a == "--yes"); + match args.first().map(String::as_str) { + Some("--version") | None => { + println!("update-demo {VERSION}"); + ExitCode::SUCCESS + } + Some("--delivery") => { + match delivery() { + Delivery::AppImage { path, mount } => println!( + "AppImage {} mounted at {}", + path.display(), + mount.map_or("(unknown)".into(), |m| m.display().to_string()) + ), + Delivery::Flatpak { app_id } => println!("Flatpak {app_id}"), + Delivery::Unpackaged => println!("unpackaged"), + } + ExitCode::SUCCESS + } + Some("--check") => check(), + Some("--update") => update(yes), + Some(other) => { + eprintln!("unknown argument {other}; see the comment at the top of main.rs"); + ExitCode::from(2) + } + } +} + +fn check() -> ExitCode { + let updater = match delivery().appimage_updater() { + Ok(updater) => updater, + Err(error) => { + println!("no self-update: {error}"); + return ExitCode::SUCCESS; + } + }; + println!("update information: {}", updater.update_information()); + match updater.check() { + Ok(UpdateCheck::UpToDate) => { + println!("update-demo {VERSION} is up to date"); + ExitCode::SUCCESS + } + Ok(UpdateCheck::Available) => { + println!("update-demo {VERSION}: an update is available"); + ExitCode::from(UPDATE_AVAILABLE) + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn update(yes: bool) -> ExitCode { + let updater = match delivery().appimage_updater() { + Ok(updater) => updater, + Err(error) => { + println!("no self-update: {error}"); + return ExitCode::SUCCESS; + } + }; + println!("update-demo {VERSION} at {}", updater.appimage().display()); + println!("update information: {}", updater.update_information()); + + // A real application does this on a worker thread and tells the UI through a signal. + let checked = std::thread::spawn({ + let updater = updater.clone(); + move || updater.check() + }) + .join() + .expect("the check thread does not panic"); + match checked { + Ok(UpdateCheck::UpToDate) => { + println!("already up to date"); + return ExitCode::SUCCESS; + } + Ok(UpdateCheck::Available) => println!("an update is available"), + Err(error) => { + eprintln!("error: {error}"); + return ExitCode::FAILURE; + } + } + + if !yes && !ask("Install it and restart? [y/N] ") { + println!("not updating"); + return ExitCode::SUCCESS; + } + if let Err(error) = updater.apply() { + eprintln!("error: {error}"); + return ExitCode::FAILURE; + } + println!("updated; restarting"); + let error = updater.relaunch(["--version"]); + eprintln!("error: could not restart: {error}"); + ExitCode::FAILURE +} + +fn ask(question: &str) -> bool { + print!("{question}"); + let _ = std::io::stdout().flush(); + let mut answer = String::new(); + if std::io::stdin().lock().read_line(&mut answer).is_err() { + return false; + } + matches!(answer.trim(), "y" | "Y" | "yes") +} diff --git a/tools/packager-linux/samples.toml b/tools/packager-linux/samples.toml new file mode 100644 index 00000000..0e687ae4 --- /dev/null +++ b/tools/packager-linux/samples.toml @@ -0,0 +1,13 @@ +# Overlay for the samples' Dioxus.toml files: the store facts that are the same for every +# sample and that a sample's own file, which is written to be copied, should not carry. +# Pass with --overlay, and the executable with --exec sample-. + +[linux] +categories = ["Utility"] + +[linux.store] +license = "Apache-2.0" +homepage = "https://github.com/darkpyonix/compose-rust" +bugtracker = "https://github.com/darkpyonix/compose-rust/issues" +vcs_browser = "https://github.com/darkpyonix/compose-rust" +developer_id = "dev.darkpyonix" diff --git a/tools/packager-linux/src/appimage.rs b/tools/packager-linux/src/appimage.rs new file mode 100644 index 00000000..81cdd8bf --- /dev/null +++ b/tools/packager-linux/src/appimage.rs @@ -0,0 +1,285 @@ +//! The AppImage channel: an AppDir laid out for `appimagetool`, and the update +//! information line that lets the finished file update itself. +//! +//! The line is written into the AppImage by `appimagetool -u`, which also asks +//! `zsyncmake` for the `.zsync` file that is published beside it. An installed AppImage +//! reads the line back, finds the newest `.zsync`, and downloads only the blocks that +//! changed. + +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::appstream::{appdata_file_name, metainfo}; +use crate::desktop::{desktop_entry, desktop_file_name}; +use crate::icons::{Icon, best}; +use crate::metadata::AppMetadata; + +/// Where the packager puts an extracted `appimageupdatetool`, relative to the AppDir. The +/// application looks for it at the same place under the mount point. +pub const UPDATER_DIR: &str = "usr/lib/appimageupdatetool"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppImageError(pub String); + +impl fmt::Display for AppImageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for AppImageError {} + +/// The architecture name AppImage files and tools use for a Rust target architecture. +pub fn appimage_arch(rust_arch: &str) -> Option<&'static str> { + match rust_arch { + "x86_64" => Some("x86_64"), + "aarch64" => Some("aarch64"), + "x86" | "i686" => Some("i686"), + "arm" | "armv7" => Some("armhf"), + _ => None, + } +} + +/// The application name as it appears in file names: anything but letters, digits, `.`, +/// `_` and `-` becomes `_`. +pub fn file_safe(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect() +} + +/// `--.AppImage`, the shape the AppImage tools expect and the one +/// the update information's pattern matches. +pub fn appimage_file_name(meta: &AppMetadata, arch: &str) -> String { + format!("{}-{}-{arch}.AppImage", file_safe(&meta.name), meta.version) +} + +/// Where newer versions are published. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateChannel { + /// The newest non-prerelease GitHub release of `owner/repository` whose assets include + /// a `.zsync` for this application and architecture. + GitHubReleases { owner: String, repository: String }, + /// One fixed URL of a `.zsync` file, replaced by every release. + Zsync { url: String }, +} + +impl UpdateChannel { + /// Parses `owner/repository`. + pub fn github(slug: &str) -> Result { + let parts: Vec<&str> = slug.split('/').collect(); + match parts.as_slice() { + [owner, repository] if valid_field(owner) && valid_field(repository) => { + Ok(UpdateChannel::GitHubReleases { + owner: (*owner).to_owned(), + repository: (*repository).to_owned(), + }) + } + _ => Err(AppImageError(format!("`{slug}` is not owner/repository"))), + } + } + + pub fn zsync(url: &str) -> Result { + if !(url.starts_with("https://") || url.starts_with("http://")) + || url.contains('|') + || url.contains(' ') + || !url.ends_with(".zsync") + { + return Err(AppImageError(format!( + "`{url}` is not an http(s) URL of a .zsync file" + ))); + } + Ok(UpdateChannel::Zsync { + url: url.to_owned(), + }) + } + + /// The update information line for this application on this architecture. + pub fn update_information(&self, meta: &AppMetadata, arch: &str) -> String { + match self { + UpdateChannel::GitHubReleases { owner, repository } => format!( + "gh-releases-zsync|{owner}|{repository}|latest|{}-*-{arch}.AppImage.zsync", + file_safe(&meta.name) + ), + UpdateChannel::Zsync { url } => format!("zsync|{url}"), + } + } +} + +fn valid_field(field: &str) -> bool { + !field.is_empty() + && field + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +/// The script the runtime starts. It runs the application from the directory it was +/// copied into, so the renderer and its companions sit beside it the way they did when +/// it was built. +pub fn apprun(meta: &AppMetadata) -> String { + format!( + "#!/bin/sh\n\ + # Written by packager-linux. Starts {name} from inside the AppImage.\n\ + # The runtime sets APPDIR to where the image is mounted. Falling back to this\n\ + # file's directory lets the unpacked AppDir run too.\n\ + APPDIR=\"${{APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}}\"\n\ + export APPDIR\n\ + exec \"$APPDIR/usr/lib/{exec}/{exec}\" \"$@\"\n", + name = meta.name.replace('\n', " "), + exec = meta.exec + ) +} + +/// What goes into an AppDir. +#[derive(Debug, Clone)] +pub struct AppDirInputs<'a> { + pub meta: &'a AppMetadata, + /// A directory holding the executable and whatever must sit beside it (the renderer + /// and its companions), or the executable alone. + pub payload: &'a Path, + pub icons: &'a [Icon], + /// An extracted `appimageupdatetool` (the `squashfs-root` of its AppImage). + pub updater: Option<&'a Path>, +} + +/// Lays out an AppDir at `out`, which must not exist yet. Returns the files it wrote, +/// relative to `out`, in the order written. +pub fn assemble_appdir(inputs: &AppDirInputs, out: &Path) -> Result, AppImageError> { + let meta = inputs.meta; + if out.exists() { + return Err(AppImageError(format!( + "{} already exists; an AppDir is assembled from nothing", + out.display() + ))); + } + let io_error = |what: &str, error: io::Error| AppImageError(format!("{what}: {error}")); + let mut written = Vec::new(); + let mut write = |relative: &str, contents: &[u8], executable: bool| { + let path = out.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| io_error(&parent.display().to_string(), e))?; + } + fs::write(&path, contents).map_err(|e| io_error(&path.display().to_string(), e))?; + if executable { + set_executable(&path).map_err(|e| io_error(&path.display().to_string(), e))?; + } + written.push(PathBuf::from(relative)); + Ok::<(), AppImageError>(()) + }; + + let icon = best(inputs.icons).ok_or_else(|| { + AppImageError("no icon: an AppImage shows one in file managers and menus".into()) + })?; + + let desktop = desktop_entry(meta); + write("AppRun", apprun(meta).as_bytes(), true)?; + write(&desktop_file_name(meta), desktop.as_bytes(), false)?; + let icon_bytes = + fs::read(&icon.source).map_err(|e| io_error(&icon.source.display().to_string(), e))?; + write(&icon.root_name(&meta.id), &icon_bytes, false)?; + write( + &format!("usr/share/applications/{}", desktop_file_name(meta)), + desktop.as_bytes(), + false, + )?; + write( + &format!("usr/share/metainfo/{}", appdata_file_name(meta)), + metainfo(meta).as_bytes(), + false, + )?; + for icon in inputs.icons { + let bytes = + fs::read(&icon.source).map_err(|e| io_error(&icon.source.display().to_string(), e))?; + write( + &format!("usr/{}", icon.install_path(&meta.id)), + &bytes, + false, + )?; + } + + let lib = out.join("usr/lib").join(&meta.exec); + if inputs.payload.is_dir() { + copy_tree(inputs.payload, &lib) + .map_err(|e| io_error(&inputs.payload.display().to_string(), e))?; + } else { + fs::create_dir_all(&lib).map_err(|e| io_error(&lib.display().to_string(), e))?; + fs::copy(inputs.payload, lib.join(&meta.exec)) + .map_err(|e| io_error(&inputs.payload.display().to_string(), e))?; + } + let executable = lib.join(&meta.exec); + if !executable.is_file() { + return Err(AppImageError(format!( + "the payload has no {} at its top level", + meta.exec + ))); + } + set_executable(&executable).map_err(|e| io_error(&executable.display().to_string(), e))?; + written.push(PathBuf::from(format!("usr/lib/{0}/{0}", meta.exec))); + + if let Some(updater) = inputs.updater { + if !updater.join("AppRun").exists() { + return Err(AppImageError(format!( + "{} has no AppRun; pass the directory `appimageupdatetool --appimage-extract` made", + updater.display() + ))); + } + let target = out.join(UPDATER_DIR); + copy_tree(updater, &target).map_err(|e| io_error(&updater.display().to_string(), e))?; + written.push(PathBuf::from(format!("{UPDATER_DIR}/AppRun"))); + } + Ok(written) +} + +/// Copies a directory tree, keeping symbolic links as links and permissions as they are. +pub fn copy_tree(from: &Path, to: &Path) -> io::Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let kind = entry.file_type()?; + let target = to.join(entry.file_name()); + if kind.is_symlink() { + let link = fs::read_link(entry.path())?; + symlink(&link, &target, &entry.path())?; + } else if kind.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn symlink(link: &Path, target: &Path, _original: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(link, target) +} + +#[cfg(not(unix))] +fn symlink(_link: &Path, target: &Path, original: &Path) -> io::Result<()> { + if original.is_dir() { + copy_tree(original, target) + } else { + fs::copy(original, target).map(|_| ()) + } +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(permissions.mode() | 0o755); + fs::set_permissions(path, permissions) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> io::Result<()> { + Ok(()) +} diff --git a/tools/packager-linux/src/appstream.rs b/tools/packager-linux/src/appstream.rs new file mode 100644 index 00000000..52e923e7 --- /dev/null +++ b/tools/packager-linux/src/appstream.rs @@ -0,0 +1,157 @@ +//! The AppStream metainfo file: what a store page shows. Flathub builds its listing from +//! this file alone, and `appstreamcli validate` is what decides whether it is accepted. + +use crate::metadata::AppMetadata; + +/// The file name Flathub expects under `share/metainfo`. +pub fn metainfo_file_name(meta: &AppMetadata) -> String { + format!("{}.metainfo.xml", meta.id) +} + +/// The file name `appimagetool` looks for under `usr/share/metainfo`. It derives the name +/// from the desktop entry's and only knows the older suffix. +pub fn appdata_file_name(meta: &AppMetadata) -> String { + format!("{}.appdata.xml", meta.id) +} + +/// The metainfo XML document. +pub fn metainfo(meta: &AppMetadata) -> String { + let mut x = String::new(); + x.push_str("\n"); + x.push_str("\n"); + element(&mut x, 1, "id", &meta.id); + element(&mut x, 1, "metadata_license", &meta.metadata_license); + element(&mut x, 1, "project_license", &meta.license); + element(&mut x, 1, "name", &meta.name); + element(&mut x, 1, "summary", &meta.summary); + x.push_str(&format!( + " \n", + escape(&meta.developer_id) + )); + element(&mut x, 2, "name", &meta.developer_name); + x.push_str(" \n"); + + x.push_str(" \n"); + for paragraph in &meta.description { + element(&mut x, 2, "p", paragraph); + } + x.push_str(" \n"); + + x.push_str(&format!( + " {}.desktop\n", + escape(&meta.id) + )); + for (kind, url) in [ + ("homepage", Some(&meta.homepage)), + ("bugtracker", meta.bugtracker.as_ref()), + ("vcs-browser", meta.vcs_browser.as_ref()), + ("donation", meta.donation.as_ref()), + ] { + if let Some(url) = url { + x.push_str(&format!(" {}\n", escape(url))); + } + } + + if !meta.keywords.is_empty() { + x.push_str(" \n"); + for keyword in &meta.keywords { + element(&mut x, 2, "keyword", keyword); + } + x.push_str(" \n"); + } + + if !meta.screenshots.is_empty() { + x.push_str(" \n"); + for (index, shot) in meta.screenshots.iter().enumerate() { + if index == 0 { + x.push_str(" \n"); + } else { + x.push_str(" \n"); + } + element(&mut x, 3, "image", &shot.url); + if let Some(caption) = &shot.caption { + element(&mut x, 3, "caption", caption); + } + x.push_str(" \n"); + } + x.push_str(" \n"); + } + + if meta.brand_light.is_some() || meta.brand_dark.is_some() { + x.push_str(" \n"); + for (scheme, colour) in [ + ("light", meta.brand_light.as_ref()), + ("dark", meta.brand_dark.as_ref()), + ] { + if let Some(colour) = colour { + x.push_str(&format!( + " {}\n", + escape(colour) + )); + } + } + x.push_str(" \n"); + } + + if meta.content_rating.is_empty() { + x.push_str(" \n"); + } else { + x.push_str(" \n"); + for (attribute, value) in &meta.content_rating { + x.push_str(&format!( + " {}\n", + escape(attribute), + escape(value) + )); + } + x.push_str(" \n"); + } + + x.push_str(" \n"); + element(&mut x, 2, "binary", &meta.exec); + x.push_str(" \n"); + + x.push_str(" \n"); + let release = format!( + " \n"); + } else { + x.push_str(&release); + x.push_str(">\n \n"); + for note in &meta.release_notes { + element(&mut x, 4, "p", note); + } + x.push_str(" \n \n"); + } + x.push_str(" \n"); + x.push_str("\n"); + x +} + +fn element(x: &mut String, depth: usize, name: &str, text: &str) { + for _ in 0..depth { + x.push_str(" "); + } + x.push_str(&format!("<{name}>{}\n", escape(text))); +} + +/// Escapes text and attribute values. +pub fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} diff --git a/tools/packager-linux/src/desktop.rs b/tools/packager-linux/src/desktop.rs new file mode 100644 index 00000000..84b9ee21 --- /dev/null +++ b/tools/packager-linux/src/desktop.rs @@ -0,0 +1,66 @@ +//! The desktop entry: how a menu, a launcher and a store's install button start the +//! application. + +use crate::metadata::AppMetadata; + +/// The entry's file name. Flatpak and AppStream both require it to be the application +/// identifier. +pub fn desktop_file_name(meta: &AppMetadata) -> String { + format!("{}.desktop", meta.id) +} + +/// The `[Desktop Entry]` group. +/// +/// `Exec` is the bare executable name. Inside a Flatpak that name is on `PATH`; inside an +/// AppImage the runtime starts `AppRun` and never reads `Exec`, and the desktop +/// integration tools rewrite it to the AppImage's path when they install the entry. +pub fn desktop_entry(meta: &AppMetadata) -> String { + let mut out = String::from("[Desktop Entry]\n"); + let mut line = |key: &str, value: &str| { + out.push_str(key); + out.push('='); + out.push_str(value); + out.push('\n'); + }; + line("Type", "Application"); + line("Version", "1.5"); + line("Name", &escape(&meta.name)); + line("Comment", &escape(&meta.summary)); + line("Exec", &meta.exec); + line("Icon", &meta.id); + line("Terminal", "false"); + line("Categories", &list(&meta.categories)); + if !meta.keywords.is_empty() { + line("Keywords", &list(&meta.keywords)); + } + if !meta.mime_types.is_empty() { + line("MimeType", &list(&meta.mime_types)); + } + if let Some(class) = &meta.startup_wm_class { + line("StartupWMClass", &escape(class)); + } + out +} + +/// Escapes a value the way the desktop entry specification requires. +pub fn escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + c => out.push(c), + } + } + out +} + +/// A `;`-terminated list, with `;` inside an item escaped. +fn list(items: &[String]) -> String { + items + .iter() + .map(|item| format!("{};", escape(item).replace(';', "\\;"))) + .collect() +} diff --git a/tools/packager-linux/src/flatpak.rs b/tools/packager-linux/src/flatpak.rs new file mode 100644 index 00000000..acdd4844 --- /dev/null +++ b/tools/packager-linux/src/flatpak.rs @@ -0,0 +1,333 @@ +//! The Flatpak channel: a manifest `flatpak-builder` builds, in the shape Flathub reviews. +//! +//! Two kinds of payload, because Flathub accepts both and an application built with this +//! project can need either: +//! +//! - **From source.** The Rust program is compiled inside the sandbox from a source +//! archive, with every crate vendored by `flatpak-cargo-generator` (the build has no +//! network). This is what Flathub expects of an open source application. +//! - **Prebuilt.** An archive of the built program, fetched by URL and checked by SHA-256. +//! This is the path for the renderer: it is compiled by GraalVM native-image, which no +//! Flathub SDK extension provides, so it arrives as a release artifact either way. +//! +//! The window is AWT's on X11, so the sandbox gets an X11 socket, shared IPC (X11's +//! shared memory transport needs it, and without it every frame is copied through the +//! socket) and the GPU. Wayland sessions reach it through XWayland. A Wayland socket with +//! X11 only as a fallback is for a renderer that speaks Wayland itself, which this one +//! does not yet. + +use crate::appstream::metainfo_file_name; +use crate::desktop::desktop_file_name; +use crate::icons::{Icon, IconKind}; +use crate::metadata::AppMetadata; + +/// The runtime branch used when the application's file does not name one. +pub const DEFAULT_RUNTIME_VERSION: &str = "25.08"; + +/// Where a source comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceRef { + /// Fetched by `flatpak-builder`, checked against the digest. What Flathub requires. + Url { url: String, sha256: String }, + /// A file beside the manifest. For local builds and tests; Flathub refuses it for + /// anything but small packaging files. + Path(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Payload { + /// An archive whose top directory holds the executable and everything beside it. + Prebuilt { archive: SourceRef }, + /// A source archive of a Cargo workspace. + FromSource { + source: SourceRef, + /// The file `flatpak-cargo-generator` wrote, relative to the manifest. + cargo_sources: String, + package: String, + bin: String, + /// Extra arguments for `cargo build`, such as `--no-default-features`. + cargo_args: Vec, + /// The renderer's release archive, when the program draws. + renderer: Option, + }, +} + +#[derive(Debug, Clone)] +pub struct FlatpakOptions<'a> { + pub payload: Payload, + pub icons: &'a [Icon], + /// Give the sandbox a Wayland socket and keep X11 only as a fallback. + pub wayland: bool, +} + +/// The sandbox permissions: the window's own, then the application's extras. +pub fn finish_args(meta: &AppMetadata, wayland: bool) -> Vec { + let mut args: Vec = if wayland { + vec![ + "--share=ipc".into(), + "--socket=wayland".into(), + "--socket=fallback-x11".into(), + "--device=dri".into(), + ] + } else { + vec![ + "--share=ipc".into(), + "--socket=x11".into(), + "--device=dri".into(), + ] + }; + for extra in &meta.finish_args { + if !args.contains(extra) { + args.push(extra.clone()); + } + } + args +} + +/// Where a packaging file sits beside the manifest, and where the build finds it. +pub fn icon_source_path(meta: &AppMetadata, icon: &Icon) -> String { + match icon.kind { + IconKind::Png(size) => format!("icons/{size}x{size}/{}.png", meta.id), + IconKind::Svg => format!("icons/scalable/{}.svg", meta.id), + } +} + +/// The manifest, as JSON. +pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { + let module = meta.exec.clone(); + let lib = format!("/app/lib/{}", meta.exec); + let mut sources = Vec::new(); + let mut commands: Vec = Vec::new(); + let mut build_options: Vec<(String, Json)> = Vec::new(); + let mut top_extra: Vec<(String, Json)> = Vec::new(); + + match &options.payload { + Payload::Prebuilt { archive } => { + sources.push(archive_source(archive, Some("payload"))); + commands.push(format!("mkdir -p {lib} /app/bin")); + commands.push(format!("cp -a payload/. {lib}/")); + } + Payload::FromSource { + source, + cargo_sources, + package, + bin, + cargo_args, + renderer, + } => { + top_extra.push(( + "sdk-extensions".into(), + Json::Array(vec![Json::str("org.freedesktop.Sdk.Extension.rust-stable")]), + )); + build_options.push(( + "append-path".into(), + Json::str("/usr/lib/sdk/rust-stable/bin"), + )); + let mut env = vec![ + ( + "CARGO_HOME".to_owned(), + Json::str(&format!("/run/build/{module}/cargo")), + ), + ("CARGO_NET_OFFLINE".to_owned(), Json::str("true")), + ( + "DIOXUS_COMPOSE_APP_VERSION".to_owned(), + Json::str(&meta.version), + ), + ]; + sources.push(archive_source(source, None)); + sources.push(Json::str(cargo_sources)); + if let Some(renderer) = renderer { + sources.push(archive_source(renderer, Some("renderer"))); + env.push(( + "DIOXUS_COMPOSE_RENDERER_DIR".to_owned(), + Json::str(&format!("/run/build/{module}/renderer")), + )); + } + build_options.push(("env".into(), Json::Object(env))); + + let mut build = + format!("cargo --offline build --release --locked -p {package} --bin {bin}"); + for arg in cargo_args { + build.push(' '); + build.push_str(&shell_quote(arg)); + } + commands.push("cargo --offline fetch --manifest-path Cargo.toml --verbose".into()); + commands.push(build); + commands.push(format!("mkdir -p {lib} /app/bin")); + if renderer.is_some() { + commands.push(format!("cp -a renderer/. {lib}/")); + } + commands.push(format!( + "install -m755 target/release/{bin} {lib}/{}", + meta.exec + )); + if renderer.is_some() { + // The build records where the renderer was inside the build sandbox. Point + // the executable at the copy installed beside it instead. + commands.push(format!( + "recorded=\"$(readelf -d {lib}/{exec} | sed -n 's/.*(NEEDED).*\\[\\(.*libdioxus_compose_renderer.so\\)\\]/\\1/p')\"; \ + if [ -n \"$recorded\" ] && [ \"$recorded\" != libdioxus_compose_renderer.so ]; then \ + patchelf --replace-needed \"$recorded\" libdioxus_compose_renderer.so {lib}/{exec}; fi; \ + patchelf --set-rpath '$ORIGIN' {lib}/{exec}", + exec = meta.exec + )); + } + } + } + commands.push(format!("test -x {lib}/{}", meta.exec)); + commands.push(format!("ln -s ../lib/{0}/{0} /app/bin/{0}", meta.exec)); + + let desktop = desktop_file_name(meta); + let metainfo = metainfo_file_name(meta); + sources.push(file_source(&desktop, "packaging")); + sources.push(file_source(&metainfo, "packaging")); + commands.push(format!( + "install -Dm644 packaging/{desktop} /app/share/applications/{desktop}" + )); + commands.push(format!( + "install -Dm644 packaging/{metainfo} /app/share/metainfo/{metainfo}" + )); + for icon in options.icons { + let path = icon_source_path(meta, icon); + let dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); + sources.push(file_source(&path, &format!("packaging/{dir}"))); + commands.push(format!( + "install -Dm644 packaging/{path} /app/{}", + icon.install_path(&meta.id) + )); + } + + let mut module_fields = vec![ + ("name".to_owned(), Json::str(&module)), + ("buildsystem".to_owned(), Json::str("simple")), + ]; + if !build_options.is_empty() { + module_fields.push(("build-options".to_owned(), Json::Object(build_options))); + } + module_fields.push(( + "build-commands".to_owned(), + Json::Array(commands.iter().map(|c| Json::str(c)).collect()), + )); + module_fields.push(("sources".to_owned(), Json::Array(sources))); + + let runtime_version = meta + .runtime_version + .clone() + .unwrap_or_else(|| DEFAULT_RUNTIME_VERSION.to_owned()); + let mut top = vec![ + ("id".to_owned(), Json::str(&meta.id)), + ("runtime".to_owned(), Json::str("org.freedesktop.Platform")), + ("runtime-version".to_owned(), Json::str(&runtime_version)), + ("sdk".to_owned(), Json::str("org.freedesktop.Sdk")), + ]; + top.extend(top_extra); + top.push(("command".to_owned(), Json::str(&meta.exec))); + top.push(( + "finish-args".to_owned(), + Json::Array( + finish_args(meta, options.wayland) + .iter() + .map(|a| Json::str(a)) + .collect(), + ), + )); + top.push(( + "modules".to_owned(), + Json::Array(vec![Json::Object(module_fields)]), + )); + let mut out = Json::Object(top).render(0); + out.push('\n'); + out +} + +fn archive_source(source: &SourceRef, dest: Option<&str>) -> Json { + let mut fields = vec![("type".to_owned(), Json::str("archive"))]; + match source { + SourceRef::Url { url, sha256 } => { + fields.push(("url".to_owned(), Json::str(url))); + fields.push(("sha256".to_owned(), Json::str(sha256))); + } + SourceRef::Path(path) => fields.push(("path".to_owned(), Json::str(path))), + } + if let Some(dest) = dest { + fields.push(("dest".to_owned(), Json::str(dest))); + } + Json::Object(fields) +} + +fn file_source(path: &str, dest: &str) -> Json { + Json::Object(vec![ + ("type".to_owned(), Json::str("file")), + ("path".to_owned(), Json::str(path)), + ("dest".to_owned(), Json::str(dest)), + ]) +} + +/// Quotes one word for `sh` unless it is plainly safe. +pub fn shell_quote(word: &str) -> String { + if !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_=.,/:+@".contains(c)) + { + word.to_owned() + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } +} + +/// Just enough JSON to write a manifest whose keys stay in the order a reader expects. +#[derive(Debug, Clone, PartialEq)] +pub enum Json { + String(String), + Array(Vec), + Object(Vec<(String, Json)>), +} + +impl Json { + pub fn str(value: &str) -> Json { + Json::String(value.to_owned()) + } + + pub fn render(&self, depth: usize) -> String { + let pad = " ".repeat(depth + 1); + let close = " ".repeat(depth); + match self { + Json::String(value) => quote(value), + Json::Array(items) if items.is_empty() => "[]".to_owned(), + Json::Array(items) => { + let inner: Vec = items + .iter() + .map(|item| format!("{pad}{}", item.render(depth + 1))) + .collect(); + format!("[\n{}\n{close}]", inner.join(",\n")) + } + Json::Object(fields) if fields.is_empty() => "{}".to_owned(), + Json::Object(fields) => { + let inner: Vec = fields + .iter() + .map(|(key, value)| format!("{pad}{}: {}", quote(key), value.render(depth + 1))) + .collect(); + format!("{{\n{}\n{close}}}", inner.join(",\n")) + } + } + } +} + +fn quote(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/tools/packager-linux/src/icons.rs b/tools/packager-linux/src/icons.rs new file mode 100644 index 00000000..ee553274 --- /dev/null +++ b/tools/packager-linux/src/icons.rs @@ -0,0 +1,145 @@ +//! Application icons in the freedesktop icon theme layout. +//! +//! The size directory an icon goes in has to be its real size, so it is read from the +//! file rather than trusted from its name. Scaling is not done here: a store wants an icon +//! drawn for the size, and an upscaled one is the one thing a reviewer reliably notices. + +use std::fmt; +use std::path::{Path, PathBuf}; + +/// The sizes the hicolor theme defines directories for. +pub const HICOLOR_SIZES: &[u32] = &[16, 22, 24, 32, 48, 64, 96, 128, 192, 256, 512]; + +/// The smallest raster icon Flathub accepts. +pub const FLATHUB_MINIMUM: u32 = 128; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconKind { + /// A square PNG of this many pixels a side. + Png(u32), + Svg, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Icon { + pub source: PathBuf, + pub kind: IconKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IconError(pub String); + +impl fmt::Display for IconError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for IconError {} + +/// Width and height from a PNG's header. +pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { + const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if bytes.len() < 24 || &bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" { + return None; + } + let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?); + let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?); + Some((width, height)) +} + +impl Icon { + /// Classifies the icon file at `source`, whose contents are `bytes`. + pub fn classify(source: &Path, bytes: &[u8]) -> Result { + let shown = source.display(); + if let Some((width, height)) = png_size(bytes) { + if width != height { + return Err(IconError(format!( + "{shown} is {width}x{height}; an application icon must be square" + ))); + } + if !HICOLOR_SIZES.contains(&width) { + return Err(IconError(format!( + "{shown} is {width}x{width}; the icon theme has directories for {}", + HICOLOR_SIZES + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + ))); + } + return Ok(Icon { + source: source.to_owned(), + kind: IconKind::Png(width), + }); + } + let head = String::from_utf8_lossy(&bytes[..bytes.len().min(512)]).to_lowercase(); + if source + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("svg")) + || head.contains(" String { + match self.kind { + IconKind::Png(size) => { + format!("share/icons/hicolor/{size}x{size}/apps/{app_id}.png") + } + IconKind::Svg => format!("share/icons/hicolor/scalable/apps/{app_id}.svg"), + } + } + + /// File name beside the desktop entry at an AppDir's root. + pub fn root_name(&self, app_id: &str) -> String { + match self.kind { + IconKind::Png(_) => format!("{app_id}.png"), + IconKind::Svg => format!("{app_id}.svg"), + } + } +} + +/// The icon to show where only one is used: a vector one if there is one, else the largest. +pub fn best(icons: &[Icon]) -> Option<&Icon> { + icons.iter().max_by_key(|icon| match icon.kind { + IconKind::Svg => u32::MAX, + IconKind::Png(size) => size, + }) +} + +/// Checks a set of icons against what Flathub accepts: one vector icon, or a PNG of at +/// least 128 pixels, and never two for the same size. +pub fn check_for_flathub(icons: &[Icon]) -> Result<(), IconError> { + if icons.is_empty() { + return Err(IconError( + "no icon: pass --icon or set [bundle] icon".into(), + )); + } + let mut seen = Vec::new(); + for icon in icons { + if seen.contains(&icon.kind) { + return Err(IconError(format!( + "two icons for the same size ({})", + icon.source.display() + ))); + } + seen.push(icon.kind); + } + let large_enough = icons.iter().any(|icon| match icon.kind { + IconKind::Svg => true, + IconKind::Png(size) => size >= FLATHUB_MINIMUM, + }); + if !large_enough { + return Err(IconError(format!( + "the largest icon is smaller than {FLATHUB_MINIMUM}x{FLATHUB_MINIMUM}; Flathub needs an SVG or a PNG at least that size" + ))); + } + Ok(()) +} diff --git a/tools/packager-linux/src/lib.rs b/tools/packager-linux/src/lib.rs new file mode 100644 index 00000000..d2faa9c3 --- /dev/null +++ b/tools/packager-linux/src/lib.rs @@ -0,0 +1,16 @@ +//! Packages a built dioxus-compose application for Linux: an AppImage that updates itself +//! and a Flatpak manifest for Flathub, both described from the application's +//! `Dioxus.toml`. +//! +//! The pieces are plain functions from metadata to file contents, so they are tested +//! without building anything. `main.rs` reads files and writes the results; the shell +//! scripts beside this crate fetch the AppImage tools and run them. + +pub mod appimage; +pub mod appstream; +pub mod desktop; +pub mod flatpak; +pub mod icons; +pub mod metadata; + +pub use metadata::{AppMetadata, BuildFacts, MetadataError}; diff --git a/tools/packager-linux/src/main.rs b/tools/packager-linux/src/main.rs new file mode 100644 index 00000000..41c1f14b --- /dev/null +++ b/tools/packager-linux/src/main.rs @@ -0,0 +1,365 @@ +//! Command line for the Linux packager. Run with no arguments for usage. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; + +use packager_linux::appimage::{ + AppDirInputs, UpdateChannel, appimage_arch, appimage_file_name, assemble_appdir, +}; +use packager_linux::appstream::{metainfo, metainfo_file_name}; +use packager_linux::desktop::{desktop_entry, desktop_file_name}; +use packager_linux::flatpak::{FlatpakOptions, Payload, SourceRef, icon_source_path, manifest}; +use packager_linux::icons::{Icon, check_for_flathub}; +use packager_linux::metadata::{AppMetadata, BuildFacts, release_date}; + +const USAGE: &str = "\ +usage: packager-linux --dioxus-toml [common options] [command options] + +commands: + metadata write .desktop, .metainfo.xml and the icons into --out + appdir lay out an AppDir for appimagetool at --out + update-information print the AppImage update information line + file-name print the AppImage file name, --.AppImage + flatpak write a flatpak-builder manifest and its packaging files into --out + +common options: + --dioxus-toml the application's Dioxus.toml + --overlay a file of the same shape merged over it (repeatable) + --version overrides [bundle] version + --date release date; defaults to SOURCE_DATE_EPOCH, then today (UTC) + --exec installed executable name; overrides [linux.store] exec + --icon a square PNG or an SVG (repeatable); overrides [bundle] icon + --arch x86_64 or aarch64; defaults to this machine's + +appdir: + --payload the executable, or a directory holding it and its renderer + --updater an extracted appimageupdatetool to carry inside + --out + +update-information: + --github gh-releases-zsync, newest release + --zsync-url zsync, one fixed URL + +flatpak: + --out + --prebuilt-url --prebuilt-sha256 | --prebuilt-path + --source-url --source-sha256 | --source-path + with --cargo-sources --package --bin [--cargo-arg ]... + and optionally --renderer-url --renderer-sha256 | --renderer-path + --wayland Wayland socket with X11 as fallback, for a Wayland-capable renderer +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("error: {message}"); + ExitCode::FAILURE + } + } +} + +/// Options as given: each name maps to every value it was given, in order. +struct Options { + values: BTreeMap>, +} + +const FLAGS: &[&str] = &["wayland"]; + +impl Options { + fn parse(args: &[String]) -> Result { + let mut values: BTreeMap> = BTreeMap::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + let Some(name) = arg.strip_prefix("--") else { + return Err(format!("unexpected argument `{arg}`\n\n{USAGE}")); + }; + let (name, value) = match name.split_once('=') { + Some((name, value)) => (name.to_owned(), value.to_owned()), + None if FLAGS.contains(&name) => (name.to_owned(), String::new()), + None => { + let value = iter + .next() + .ok_or_else(|| format!("--{name} needs a value"))?; + (name.to_owned(), value.clone()) + } + }; + values.entry(name).or_default().push(value); + } + Ok(Options { values }) + } + + fn one(&self, name: &str) -> Result, String> { + match self.values.get(name).map(Vec::as_slice) { + None => Ok(None), + Some([value]) => Ok(Some(value.clone())), + Some(_) => Err(format!("--{name} was given more than once")), + } + } + + fn required(&self, name: &str) -> Result { + self.one(name)? + .ok_or_else(|| format!("--{name} is required")) + } + + fn all(&self, name: &str) -> Vec { + self.values.get(name).cloned().unwrap_or_default() + } + + fn flag(&self, name: &str) -> bool { + self.values.contains_key(name) + } + + fn reject_unknown(&self, known: &[&str]) -> Result<(), String> { + for name in self.values.keys() { + if !known.contains(&name.as_str()) { + return Err(format!( + "--{name} is not an option of this command\n\n{USAGE}" + )); + } + } + Ok(()) + } +} + +const COMMON: &[&str] = &[ + "dioxus-toml", + "overlay", + "version", + "date", + "exec", + "icon", + "arch", +]; + +fn run(args: &[String]) -> Result<(), String> { + let Some((command, rest)) = args.split_first() else { + return Err(USAGE.to_owned()); + }; + let options = Options::parse(rest)?; + let extra: &[&str] = match command.as_str() { + "metadata" => &["out"], + "appdir" => &["payload", "updater", "out"], + "update-information" => &["github", "zsync-url"], + "file-name" => &[], + "flatpak" => &[ + "out", + "prebuilt-url", + "prebuilt-sha256", + "prebuilt-path", + "source-url", + "source-sha256", + "source-path", + "cargo-sources", + "package", + "bin", + "cargo-arg", + "renderer-url", + "renderer-sha256", + "renderer-path", + "wayland", + ], + "help" | "--help" | "-h" => { + print!("{USAGE}"); + return Ok(()); + } + other => return Err(format!("unknown command `{other}`\n\n{USAGE}")), + }; + let known: Vec<&str> = COMMON.iter().chain(extra).copied().collect(); + options.reject_unknown(&known)?; + + let (meta, icons) = load(&options)?; + let arch = match options.one("arch")? { + Some(arch) => arch, + None => appimage_arch(std::env::consts::ARCH) + .ok_or_else(|| format!("no AppImage architecture for {}", std::env::consts::ARCH))? + .to_owned(), + }; + + match command.as_str() { + "metadata" => { + let out = PathBuf::from(options.required("out")?); + write( + &out.join(desktop_file_name(&meta)), + desktop_entry(&meta).as_bytes(), + )?; + write( + &out.join(metainfo_file_name(&meta)), + metainfo(&meta).as_bytes(), + )?; + for icon in &icons { + copy(&icon.source, &out.join(icon_source_path(&meta, icon)))?; + } + } + "appdir" => { + let out = PathBuf::from(options.required("out")?); + let payload = PathBuf::from(options.required("payload")?); + let updater = options.one("updater")?.map(PathBuf::from); + let written = assemble_appdir( + &AppDirInputs { + meta: &meta, + payload: &payload, + icons: &icons, + updater: updater.as_deref(), + }, + &out, + ) + .map_err(|e| e.to_string())?; + for path in written { + println!("{}", path.display()); + } + } + "update-information" => { + let channel = match (options.one("github")?, options.one("zsync-url")?) { + (Some(slug), None) => UpdateChannel::github(&slug), + (None, Some(url)) => UpdateChannel::zsync(&url), + _ => return Err("pass exactly one of --github and --zsync-url".into()), + } + .map_err(|e| e.to_string())?; + println!("{}", channel.update_information(&meta, &arch)); + } + "file-name" => println!("{}", appimage_file_name(&meta, &arch)), + "flatpak" => { + check_for_flathub(&icons).map_err(|e| e.to_string())?; + let out = PathBuf::from(options.required("out")?); + let payload = flatpak_payload(&options)?; + let text = manifest( + &meta, + &FlatpakOptions { + payload, + icons: &icons, + wayland: options.flag("wayland"), + }, + ); + write(&out.join(format!("{}.json", meta.id)), text.as_bytes())?; + write( + &out.join(desktop_file_name(&meta)), + desktop_entry(&meta).as_bytes(), + )?; + write( + &out.join(metainfo_file_name(&meta)), + metainfo(&meta).as_bytes(), + )?; + for icon in &icons { + copy(&icon.source, &out.join(icon_source_path(&meta, icon)))?; + } + println!("{}", out.join(format!("{}.json", meta.id)).display()); + } + _ => unreachable!("every command was matched above"), + } + Ok(()) +} + +fn load(options: &Options) -> Result<(AppMetadata, Vec), String> { + let toml_path = PathBuf::from(options.required("dioxus-toml")?); + let text = read_text(&toml_path)?; + let overlays: Vec = options + .all("overlay") + .iter() + .map(|path| read_text(Path::new(path))) + .collect::>()?; + let overlay_refs: Vec<&str> = overlays.iter().map(String::as_str).collect(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let date = match options.one("date")? { + Some(date) => date, + None => release_date(std::env::var("SOURCE_DATE_EPOCH").ok().as_deref(), now), + }; + let facts = BuildFacts { + version: options.one("version")?, + date, + exec: options.one("exec")?, + }; + let meta = AppMetadata::resolve(&text, &overlay_refs, &facts).map_err(|e| e.to_string())?; + + let given = options.all("icon"); + let paths: Vec = if given.is_empty() { + let base = toml_path.parent().unwrap_or(Path::new(".")); + meta.icons.iter().map(|icon| base.join(icon)).collect() + } else { + given.iter().map(PathBuf::from).collect() + }; + let icons = paths + .iter() + .map(|path| { + let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + Icon::classify(path, &bytes).map_err(|e| e.to_string()) + }) + .collect::, _>>()?; + Ok((meta, icons)) +} + +fn source_ref(options: &Options, prefix: &str) -> Result, String> { + let url = options.one(&format!("{prefix}-url"))?; + let sha = options.one(&format!("{prefix}-sha256"))?; + let path = options.one(&format!("{prefix}-path"))?; + match (url, sha, path) { + (None, None, None) => Ok(None), + (Some(url), Some(sha256), None) => { + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("--{prefix}-sha256 must be 64 hex digits")); + } + Ok(Some(SourceRef::Url { + url, + sha256: sha256.to_ascii_lowercase(), + })) + } + (None, None, Some(path)) => Ok(Some(SourceRef::Path(path))), + _ => Err(format!( + "pass --{prefix}-url with --{prefix}-sha256, or --{prefix}-path alone" + )), + } +} + +fn flatpak_payload(options: &Options) -> Result { + let prebuilt = source_ref(options, "prebuilt")?; + let source = source_ref(options, "source")?; + match (prebuilt, source) { + (Some(archive), None) => { + for name in [ + "cargo-sources", + "package", + "bin", + "cargo-arg", + "renderer-url", + "renderer-path", + ] { + if options.flag(name) { + return Err(format!("--{name} belongs to a build from source")); + } + } + Ok(Payload::Prebuilt { archive }) + } + (None, Some(source)) => Ok(Payload::FromSource { + source, + cargo_sources: options.required("cargo-sources")?, + package: options.required("package")?, + bin: options.required("bin")?, + cargo_args: options.all("cargo-arg"), + renderer: source_ref(options, "renderer")?, + }), + _ => Err("pass a prebuilt archive or a source archive, not both and not neither".into()), + } +} + +fn read_text(path: &Path) -> Result { + fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) +} + +fn write(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +fn copy(from: &Path, to: &Path) -> Result<(), String> { + let bytes = fs::read(from).map_err(|e| format!("{}: {e}", from.display()))?; + write(to, &bytes) +} diff --git a/tools/packager-linux/src/metadata.rs b/tools/packager-linux/src/metadata.rs new file mode 100644 index 00000000..b3186506 --- /dev/null +++ b/tools/packager-linux/src/metadata.rs @@ -0,0 +1,509 @@ +//! What a Linux package says about an application, read from its `Dioxus.toml`. +//! +//! The file is dx's, and so are most of the keys read here: `[application] name`, the +//! `[bundle]` table every platform shares, and dx's own `[linux]` table, whose keys +//! (`identifier`, `publisher`, `short_description`, `categories`, `keywords`, +//! `mime_types`, `flatpak_permissions` and the rest) override `[bundle]` for Linux. What +//! only a store listing needs and dx has no key for (a licence, a homepage, screenshots, +//! brand colours, a content rating) goes in a `[linux.store]` table, which dx ignores. +//! +//! A second file passed with `--overlay` has the same shape and is merged over the first, +//! table by table, so a release can add what does not belong in the application's own +//! file (release notes, for one) without editing it. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +/// The freedesktop main categories. A desktop entry needs at least one, or menus file it +/// under "Other" and Flathub rejects it. +pub const MAIN_CATEGORIES: &[&str] = &[ + "AudioVideo", + "Audio", + "Video", + "Development", + "Education", + "Game", + "Graphics", + "Network", + "Office", + "Science", + "Settings", + "System", + "Utility", +]; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct DioxusToml { + application: ApplicationTable, + bundle: BundleTable, + linux: DxLinuxTable, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct ApplicationTable { + name: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct BundleTable { + identifier: Option, + publisher: Option, + short_description: Option, + long_description: Option, + category: Option, + icon: Option>, + version: Option, +} + +/// dx's `[linux]` table: overrides of `[bundle]`, plus desktop entry and Flatpak keys. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct DxLinuxTable { + identifier: Option, + publisher: Option, + short_description: Option, + long_description: Option, + category: Option, + icon: Option>, + flatpak_permissions: Vec, + categories: Vec, + keywords: Vec, + mime_types: Vec, + store: StoreTable, +} + +/// The `[linux.store]` table: what a store listing needs that dx has no key for. Every +/// key is optional here; [`AppMetadata::resolve`] says which ones a package cannot do +/// without. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct StoreTable { + /// The executable's file name, as installed. + pub exec: Option, + /// SPDX expression for the application's own licence. + pub license: Option, + /// SPDX expression for the AppStream file itself. Defaults to `CC0-1.0`, which is what + /// Flathub asks for. + pub metadata_license: Option, + pub homepage: Option, + pub bugtracker: Option, + pub vcs_browser: Option, + pub donation: Option, + /// Reverse-DNS identifier of the developer. Defaults to the first two components of + /// the application identifier. + pub developer_id: Option, + /// Defaults to the publisher. + pub developer_name: Option, + pub screenshots: Vec, + /// Brand colours for store pages, `#rrggbb`. + pub brand_light: Option, + pub brand_dark: Option, + /// The `WM_CLASS` the window reports, so a desktop can match the window to its entry. + pub startup_wm_class: Option, + /// Release notes for this version, one paragraph per entry. + pub release_notes: Vec, + /// OARS 1.1 content rating attributes that are not `none`, such as + /// `social-chat = "intense"`. + pub content_rating: BTreeMap, + /// Branch of `org.freedesktop.Platform` the Flatpak runs on. + pub flatpak_runtime_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Screenshot { + pub url: String, + #[serde(default)] + pub caption: Option, +} + +/// What the command line adds: the facts that are about a build, not about the +/// application. +#[derive(Debug, Default, Clone)] +pub struct BuildFacts { + /// Overrides `[bundle] version`. + pub version: Option, + /// `YYYY-MM-DD`. + pub date: String, + /// Overrides `[linux] exec`. + pub exec: Option, +} + +/// Everything a desktop entry, an AppStream file, an AppDir and a Flatpak manifest need, +/// checked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppMetadata { + pub id: String, + pub name: String, + pub summary: String, + pub description: Vec, + pub exec: String, + pub version: String, + pub date: String, + pub license: String, + pub metadata_license: String, + pub homepage: String, + pub bugtracker: Option, + pub vcs_browser: Option, + pub donation: Option, + pub developer_id: String, + pub developer_name: String, + pub categories: Vec, + pub keywords: Vec, + pub mime_types: Vec, + /// Icon paths as the file lists them, relative to its directory. + pub icons: Vec, + pub screenshots: Vec, + pub brand_light: Option, + pub brand_dark: Option, + pub startup_wm_class: Option, + pub release_notes: Vec, + pub content_rating: BTreeMap, + pub runtime_version: Option, + pub finish_args: Vec, +} + +/// Why metadata could not be produced. Each one names the key to set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetadataError(pub String); + +impl fmt::Display for MetadataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for MetadataError {} + +fn error(message: impl Into) -> Result { + Err(MetadataError(message.into())) +} + +/// Merges `top` over `base`: tables recursively, every other value replaced. +pub fn merge(base: &mut toml::Table, top: toml::Table) { + for (key, value) in top { + match (base.get_mut(&key), value) { + (Some(toml::Value::Table(base)), toml::Value::Table(top)) => merge(base, top), + (_, value) => { + base.insert(key, value); + } + } + } +} + +/// Parses `Dioxus.toml` and any overlays, in order, into one table. +pub fn merged(dioxus_toml: &str, overlays: &[&str]) -> Result { + let mut table: toml::Table = toml::from_str(dioxus_toml) + .map_err(|e| MetadataError(format!("Dioxus.toml is not valid TOML: {e}")))?; + for overlay in overlays { + let top: toml::Table = toml::from_str(overlay) + .map_err(|e| MetadataError(format!("the overlay is not valid TOML: {e}")))?; + merge(&mut table, top); + } + Ok(table) +} + +impl AppMetadata { + /// Reads `Dioxus.toml` with its overlays merged in, adds the build facts, and checks + /// the result. + pub fn resolve( + dioxus_toml: &str, + overlays: &[&str], + facts: &BuildFacts, + ) -> Result { + let table = merged(dioxus_toml, overlays)?; + let config: DioxusToml = toml::Value::Table(table) + .try_into() + .map_err(|e| MetadataError(format!("Dioxus.toml is not usable: {e}")))?; + let DioxusToml { + application, + bundle, + linux: dx, + } = config; + let linux = dx.store; + + let id = required(dx.identifier.or(bundle.identifier), "[bundle] identifier")?; + check_app_id(&id)?; + let name = required(application.name, "[application] name")?; + let summary = required( + dx.short_description.or(bundle.short_description), + "[bundle] short_description", + )?; + // AppStream reads a summary as a fragment, not a sentence. + let summary = summary.trim().trim_end_matches('.').trim().to_owned(); + let description = paragraphs( + dx.long_description + .or(bundle.long_description) + .as_deref() + .unwrap_or(""), + ); + let description = if description.is_empty() { + vec![format!("{summary}.")] + } else { + description + }; + + let exec = facts.exec.clone().or(linux.exec).ok_or_else(|| { + MetadataError("no executable name: pass --exec or set [linux.store] exec".into()) + })?; + if exec.is_empty() + || !exec + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+')) + { + return error(format!( + "the executable name `{exec}` must be a bare file name of letters, digits, `.`, `_`, `-` and `+`" + )); + } + + let version = required( + facts.version.clone().or(bundle.version), + "a version (--version or [bundle] version)", + )?; + if version + .chars() + .any(|c| c.is_whitespace() || c == '/' || c == '|') + { + return error(format!( + "the version `{version}` must not contain spaces, slashes or `|`" + )); + } + check_date(&facts.date)?; + + let license = required(linux.license, "[linux.store] license (an SPDX expression)")?; + let homepage = required(linux.homepage, "[linux.store] homepage")?; + for (key, url) in [ + ("homepage", Some(&homepage)), + ("bugtracker", linux.bugtracker.as_ref()), + ("vcs_browser", linux.vcs_browser.as_ref()), + ("donation", linux.donation.as_ref()), + ] { + if let Some(url) = url { + check_url(key, url)?; + } + } + for shot in &linux.screenshots { + check_url("screenshots.url", &shot.url)?; + } + + let developer_name = linux + .developer_name + .or(dx.publisher) + .or(bundle.publisher) + .ok_or_else(|| { + MetadataError( + "no developer: set [linux.store] developer_name or [bundle] publisher".into(), + ) + })?; + let developer_id = linux + .developer_id + .unwrap_or_else(|| id.split('.').take(2).collect::>().join(".")); + + let mut categories = dx.categories; + if categories.is_empty() { + if let Some(category) = dx.category.or(bundle.category) { + categories.push(dx_category(&category).to_owned()); + } + } + if !categories + .iter() + .any(|c| MAIN_CATEGORIES.contains(&c.as_str())) + { + return error(format!( + "[linux] categories needs at least one freedesktop main category ({})", + MAIN_CATEGORIES.join(", ") + )); + } + for mime in &dx.mime_types { + if !mime.contains('/') || mime.contains(';') { + return error(format!("[linux] mime_types: `{mime}` is not a MIME type")); + } + } + for category in &categories { + if category.is_empty() || category.contains(';') { + return error(format!("the category `{category}` is not a single name")); + } + } + + for (key, colour) in [ + ("brand_light", linux.brand_light.as_ref()), + ("brand_dark", linux.brand_dark.as_ref()), + ] { + if let Some(colour) = colour { + let hex = colour.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return error(format!( + "[linux.store] {key} must be #rrggbb, not `{colour}`" + )); + } + } + } + for (attribute, value) in &linux.content_rating { + if !["none", "mild", "moderate", "intense"].contains(&value.as_str()) { + return error(format!( + "[linux.store.content_rating] {attribute} must be none, mild, moderate or intense" + )); + } + } + + Ok(AppMetadata { + id, + name, + summary, + description, + exec, + version, + date: facts.date.clone(), + license, + metadata_license: linux.metadata_license.unwrap_or_else(|| "CC0-1.0".into()), + homepage, + bugtracker: linux.bugtracker, + vcs_browser: linux.vcs_browser, + donation: linux.donation, + developer_id, + developer_name, + categories, + keywords: dx.keywords, + mime_types: dx.mime_types, + icons: dx.icon.or(bundle.icon).unwrap_or_default(), + screenshots: linux.screenshots, + brand_light: linux.brand_light, + brand_dark: linux.brand_dark, + startup_wm_class: linux.startup_wm_class, + release_notes: linux.release_notes, + content_rating: linux.content_rating, + runtime_version: linux.flatpak_runtime_version, + finish_args: dx.flatpak_permissions, + }) + } +} + +fn required(value: Option, key: &str) -> Result { + match value { + Some(value) if !value.trim().is_empty() => Ok(value.trim().to_owned()), + _ => error(format!("{key} is required for a Linux package")), + } +} + +/// dx's bundle category names, mapped to freedesktop categories. Anything else is passed +/// through, so a freedesktop name written there also works. +fn dx_category(category: &str) -> &str { + match category { + "Developer Tool" | "DeveloperTool" => "Development", + "Entertainment" | "Game" => "Game", + "Graphics and Design" | "GraphicsAndDesign" | "Photography" => "Graphics", + "Music" => "Audio", + "Productivity" | "Business" => "Office", + "Social Networking" | "SocialNetworking" | "News" => "Network", + "Utility" | "Utilities" => "Utility", + other => other, + } +} + +/// Blank lines separate paragraphs; single newlines inside one are folded to spaces. +pub fn paragraphs(text: &str) -> Vec { + text.split("\n\n") + .map(|p| p.split_whitespace().collect::>().join(" ")) + .filter(|p| !p.is_empty()) + .collect() +} + +/// A reverse-DNS application identifier that Flatpak, AppStream and desktop entries all +/// accept. +pub fn check_app_id(id: &str) -> Result<(), MetadataError> { + let components: Vec<&str> = id.split('.').collect(); + if id.len() > 255 { + return error(format!( + "the identifier `{id}` is longer than 255 characters" + )); + } + if components.len() < 3 { + return error(format!( + "the identifier `{id}` needs at least three dot-separated components, like dev.example.App" + )); + } + for component in &components { + let Some(first) = component.chars().next() else { + return error(format!("the identifier `{id}` has an empty component")); + }; + if first.is_ascii_digit() { + return error(format!( + "the identifier `{id}` has a component starting with a digit, which Flatpak refuses" + )); + } + if !component + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return error(format!( + "the identifier `{id}` may only use letters, digits, `_` and `-`" + )); + } + } + // Flatpak allows a hyphen anywhere, D-Bus (which a desktop entry's name is checked + // against) only in the last component. + if components[..components.len() - 1] + .iter() + .any(|c| c.contains('-')) + { + return error(format!( + "the identifier `{id}` has `-` before its last component; use `_`" + )); + } + Ok(()) +} + +fn check_date(date: &str) -> Result<(), MetadataError> { + let bytes = date.as_bytes(); + let shape = bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && date + .chars() + .enumerate() + .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()); + let month = date.get(5..7).and_then(|m| m.parse::().ok()); + let day = date.get(8..10).and_then(|d| d.parse::().ok()); + if !shape || !matches!(month, Some(1..=12)) || !matches!(day, Some(1..=31)) { + return error(format!("the release date `{date}` must be YYYY-MM-DD")); + } + Ok(()) +} + +fn check_url(key: &str, url: &str) -> Result<(), MetadataError> { + if !(url.starts_with("https://") || url.starts_with("http://")) || url.contains(' ') { + return error(format!( + "[linux.store] {key} must be an http(s) URL, not `{url}`" + )); + } + Ok(()) +} + +/// Today's date in UTC, or the date of `SOURCE_DATE_EPOCH` when that is set, so that two +/// builds of the same commit write the same files. +pub fn release_date(source_date_epoch: Option<&str>, now_unix: u64) -> String { + let seconds = source_date_epoch + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(now_unix); + let (y, m, d) = civil_from_days((seconds / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} diff --git a/tools/packager-linux/tests/metadata.rs b/tools/packager-linux/tests/metadata.rs new file mode 100644 index 00000000..5dec14a4 --- /dev/null +++ b/tools/packager-linux/tests/metadata.rs @@ -0,0 +1,696 @@ +//! Metadata generation for the Linux packages: from `Dioxus.toml` to a desktop entry, an +//! AppStream file, icons, an AppDir, AppImage update information and a Flatpak manifest. +//! +//! Whether the generated files are accepted by the tools that consume them +//! (`desktop-file-validate`, `appstreamcli validate`, `appimagetool`, `flatpak-builder` +//! and Flathub's linter) is checked by `.github/workflows/packager-linux.yml`, which runs +//! them. These tests pin what the files say. + +use std::path::{Path, PathBuf}; + +use packager_linux::appimage::{ + AppDirInputs, UPDATER_DIR, UpdateChannel, appimage_arch, appimage_file_name, apprun, + assemble_appdir, file_safe, +}; +use packager_linux::appstream::{appdata_file_name, metainfo, metainfo_file_name}; +use packager_linux::desktop::{desktop_entry, desktop_file_name}; +use packager_linux::flatpak::{ + DEFAULT_RUNTIME_VERSION, FlatpakOptions, Json, Payload, SourceRef, finish_args, manifest, + shell_quote, +}; +use packager_linux::icons::{Icon, IconKind, best, check_for_flathub, png_size}; +use packager_linux::metadata::{check_app_id, paragraphs, release_date}; +use packager_linux::{AppMetadata, BuildFacts}; + +const DIOXUS_TOML: &str = r##" +[application] +name = "Calculator" +asset_dir = "assets" + +[bundle] +identifier = "dev.example.Calculator" +publisher = "Example & Sons" +short_description = "A calculator built with dioxus-compose." +long_description = """ +Dense button grids, nested Row and Column, +keyboard input. + +Memory keys that remember.""" +icon = ["assets/icon-256.png"] + +[macos] +bundle_name = "Calculator" + +[linux] +categories = ["Utility", "Calculator"] +keywords = ["math", "arithmetic"] +flatpak_permissions = ["--share=network"] + +[linux.store] +exec = "sample-calculator" +license = "Apache-2.0" +homepage = "https://example.dev/calculator" +bugtracker = "https://example.dev/calculator/issues" +brand_light = "#e8f0ff" +brand_dark = "#1b2a4a" +screenshots = [{ url = "https://example.dev/shot.png", caption = "The keypad" }] +"##; + +fn facts() -> BuildFacts { + BuildFacts { + version: Some("1.2.3".into()), + date: "2026-10-03".into(), + exec: None, + } +} + +fn meta() -> AppMetadata { + AppMetadata::resolve(DIOXUS_TOML, &[], &facts()).unwrap() +} + +fn resolve_error(text: &str, overlays: &[&str]) -> String { + AppMetadata::resolve(text, overlays, &facts()) + .unwrap_err() + .to_string() +} + +#[test] +fn fr34_metadata_comes_from_dioxus_toml() { + let meta = meta(); + assert_eq!(meta.id, "dev.example.Calculator"); + assert_eq!(meta.name, "Calculator"); + // A summary is a fragment, not a sentence. + assert_eq!(meta.summary, "A calculator built with dioxus-compose"); + assert_eq!( + meta.description, + vec![ + "Dense button grids, nested Row and Column, keyboard input.".to_owned(), + "Memory keys that remember.".to_owned(), + ] + ); + assert_eq!(meta.exec, "sample-calculator"); + assert_eq!(meta.version, "1.2.3"); + assert_eq!(meta.developer_name, "Example & Sons"); + assert_eq!(meta.developer_id, "dev.example"); + assert_eq!(meta.metadata_license, "CC0-1.0"); + assert_eq!(meta.categories, ["Utility", "Calculator"]); + assert_eq!(meta.finish_args, ["--share=network"]); + assert_eq!(meta.icons, ["assets/icon-256.png"]); +} + +#[test] +fn fr34_dx_linux_keys_override_the_shared_bundle_table() { + let overlay = r#" +[linux] +identifier = "dev.example.CalculatorLinux" +short_description = "Sums, on Linux" +publisher = "Linux Desk" +"#; + let meta = AppMetadata::resolve(DIOXUS_TOML, &[overlay], &facts()).unwrap(); + assert_eq!(meta.id, "dev.example.CalculatorLinux"); + assert_eq!(meta.summary, "Sums, on Linux"); + assert_eq!(meta.developer_name, "Linux Desk"); +} + +#[test] +fn fr34_an_overlay_adds_what_the_application_file_lacks() { + // The shape of a sample's own file: nothing Linux-specific at all. + let bare = r#" +[application] +name = "Todo" +[bundle] +identifier = "dev.example.Todo" +publisher = "Example" +short_description = "Things to do." +"#; + let overlay = r#" +[linux] +categories = ["Office"] +[linux.store] +exec = "sample-todo" +license = "Apache-2.0" +homepage = "https://example.dev/" +release_notes = ["Now updates itself."] +"#; + let meta = AppMetadata::resolve(bare, &[overlay], &facts()).unwrap(); + assert_eq!(meta.exec, "sample-todo"); + assert_eq!(meta.release_notes, ["Now updates itself."]); + // Without a long description the summary stands in, as a sentence. + assert_eq!(meta.description, ["Things to do."]); +} + +#[test] +fn fr34_every_sample_resolves_with_an_overlay() { + let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); + let overlay = r#" +[linux] +categories = ["Utility"] +[linux.store] +exec = "sample" +license = "Apache-2.0" +homepage = "https://github.com/darkpyonix/compose-rust" +"#; + let mut seen = 0; + for entry in std::fs::read_dir(&samples).unwrap() { + let path = entry.unwrap().path().join("Dioxus.toml"); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let meta = AppMetadata::resolve(&text, &[overlay], &facts()) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert!(meta.id.starts_with("dev.darkpyonix."), "{}", meta.id); + seen += 1; + } + assert!(seen >= 4, "found only {seen} samples"); +} + +#[test] +fn fr34_missing_store_facts_name_the_key_to_set() { + let without = |key: &str| { + DIOXUS_TOML + .lines() + .filter(|line| !line.starts_with(key)) + .collect::>() + .join("\n") + }; + assert!(resolve_error(&without("license"), &[]).contains("[linux.store] license")); + assert!(resolve_error(&without("homepage"), &[]).contains("[linux.store] homepage")); + assert!(resolve_error(&without("identifier"), &[]).contains("[bundle] identifier")); + assert!(resolve_error(&without("exec"), &[]).contains("--exec")); + assert!(resolve_error(&without("categories"), &[]).contains("main category")); + + let no_version = AppMetadata::resolve( + DIOXUS_TOML, + &[], + &BuildFacts { + version: None, + ..facts() + }, + ) + .unwrap_err(); + assert!(no_version.to_string().contains("--version"), "{no_version}"); +} + +#[test] +fn fr34_bad_values_are_refused_before_any_file_is_written() { + for (overlay, expected) in [ + ("[linux.store]\nhomepage = \"example.dev\"", "http(s) URL"), + ("[linux.store]\nbrand_light = \"blue\"", "#rrggbb"), + ("[linux.store]\nexec = \"bin/app\"", "bare file name"), + ("[linux.store]\nexec = \"my app\"", "bare file name"), + ( + "[linux.store.content_rating]\nviolence-cartoon = \"lots\"", + "none, mild, moderate or intense", + ), + ("[linux]\nmime_types = [\"text\"]", "not a MIME type"), + ("[linux.store]\nunknown_key = 1", "unknown_key"), + ] { + let message = resolve_error(DIOXUS_TOML, &[overlay]); + assert!(message.contains(expected), "{overlay}: {message}"); + } + let bad_date = AppMetadata::resolve( + DIOXUS_TOML, + &[], + &BuildFacts { + date: "3 Oct 2026".into(), + ..facts() + }, + ) + .unwrap_err(); + assert!(bad_date.to_string().contains("YYYY-MM-DD")); +} + +#[test] +fn fr34_application_ids_follow_flatpak_and_dbus_rules() { + for good in [ + "dev.darkpyonix.dioxus.compose.samples.calculator", + "org.example.My_App", + "org.example.my-app", + ] { + check_app_id(good).unwrap_or_else(|e| panic!("{good}: {e}")); + } + for bad in [ + "calculator", + "dev.example", + "dev..example.App", + "dev.example.1App", + "dev.ex-ample.App", + "dev.example.App!", + ] { + assert!(check_app_id(bad).is_err(), "{bad}"); + } +} + +#[test] +fn fr34_desktop_entry_names_the_icon_and_executable() { + let meta = meta(); + assert_eq!(desktop_file_name(&meta), "dev.example.Calculator.desktop"); + assert_eq!( + desktop_entry(&meta), + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.5\n\ + Name=Calculator\n\ + Comment=A calculator built with dioxus-compose\n\ + Exec=sample-calculator\n\ + Icon=dev.example.Calculator\n\ + Terminal=false\n\ + Categories=Utility;Calculator;\n\ + Keywords=math;arithmetic;\n" + ); +} + +#[test] +fn fr34_desktop_entry_escapes_what_the_format_reserves() { + let mut meta = meta(); + meta.name = "Back\\slash\nNew".into(); + meta.keywords = vec!["semi;colon".into()]; + meta.startup_wm_class = Some("sample-calculator".into()); + let entry = desktop_entry(&meta); + assert!(entry.contains("Name=Back\\\\slash\\nNew\n"), "{entry}"); + assert!(entry.contains("Keywords=semi\\;colon;\n"), "{entry}"); + assert!( + entry.contains("StartupWMClass=sample-calculator\n"), + "{entry}" + ); +} + +#[test] +fn fr34_metainfo_carries_what_flathub_requires() { + let meta = meta(); + assert_eq!( + metainfo_file_name(&meta), + "dev.example.Calculator.metainfo.xml" + ); + assert_eq!( + appdata_file_name(&meta), + "dev.example.Calculator.appdata.xml" + ); + let xml = metainfo(&meta); + for expected in [ + "", + "dev.example.Calculator", + "CC0-1.0", + "Apache-2.0", + "A calculator built with dioxus-compose", + "", + "Example & Sons", + "

Memory keys that <really> remember.

", + "dev.example.Calculator.desktop", + "https://example.dev/calculator", + "https://example.dev/calculator/issues", + "", + "https://example.dev/shot.png", + "
", + "#e8f0ff", + "#1b2a4a", + "", + "sample-calculator", + "", + ] { + assert!(xml.contains(expected), "missing {expected} in\n{xml}"); + } + assert!(xml.starts_with("\n")); + assert!(xml.ends_with("\n")); +} + +#[test] +fn fr34_metainfo_writes_release_notes_and_content_rating() { + let overlay = r#" +[linux.store] +release_notes = ["Updates itself & more."] +[linux.store.content_rating] +social-chat = "intense" +"#; + let xml = metainfo(&AppMetadata::resolve(DIOXUS_TOML, &[overlay], &facts()).unwrap()); + assert!( + xml.contains( + "\n \n

Updates itself & more.

" + ), + "{xml}" + ); + assert!( + xml.contains("intense"), + "{xml}" + ); +} + +fn png(size: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + bytes.extend_from_slice(&size.to_be_bytes()); + bytes.extend_from_slice(&size.to_be_bytes()); + bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes +} + +#[test] +fn fr34_icons_go_where_their_real_size_says() { + assert_eq!(png_size(&png(256)), Some((256, 256))); + let icon = Icon::classify(Path::new("whatever.png"), &png(256)).unwrap(); + assert_eq!(icon.kind, IconKind::Png(256)); + assert_eq!( + icon.install_path("dev.example.App"), + "share/icons/hicolor/256x256/apps/dev.example.App.png" + ); + let svg = Icon::classify(Path::new("logo.svg"), b"").unwrap(); + assert_eq!( + svg.install_path("dev.example.App"), + "share/icons/hicolor/scalable/apps/dev.example.App.svg" + ); + assert_eq!(best(&[icon.clone(), svg.clone()]), Some(&svg)); + + let mut wide = png(64); + wide[16..20].copy_from_slice(&128u32.to_be_bytes()); + assert!( + Icon::classify(Path::new("w.png"), &wide) + .unwrap_err() + .0 + .contains("square") + ); + assert!(Icon::classify(Path::new("odd.png"), &png(100)).is_err()); + assert!(Icon::classify(Path::new("x.bmp"), b"BM....").is_err()); +} + +#[test] +fn fr34_flathub_needs_a_large_enough_icon() { + let small = Icon::classify(Path::new("64.png"), &png(64)).unwrap(); + let large = Icon::classify(Path::new("128.png"), &png(128)).unwrap(); + assert!( + check_for_flathub(&[small.clone()]) + .unwrap_err() + .0 + .contains("128") + ); + check_for_flathub(&[small.clone(), large.clone()]).unwrap(); + assert!(check_for_flathub(&[]).is_err()); + assert!(check_for_flathub(&[large.clone(), large]).is_err()); +} + +#[test] +fn fr34_appimage_update_information_points_at_zsync_files() { + let meta = meta(); + assert_eq!(appimage_arch("x86_64"), Some("x86_64")); + assert_eq!(appimage_arch("aarch64"), Some("aarch64")); + assert_eq!(appimage_arch("riscv64"), None); + assert_eq!( + appimage_file_name(&meta, "x86_64"), + "Calculator-1.2.3-x86_64.AppImage" + ); + assert_eq!(file_safe("My App: 2"), "My_App__2"); + + let github = UpdateChannel::github("darkpyonix/compose-rust").unwrap(); + assert_eq!( + github.update_information(&meta, "x86_64"), + "gh-releases-zsync|darkpyonix|compose-rust|latest|Calculator-*-x86_64.AppImage.zsync" + ); + // The pattern matches the file name of every later version. + let pattern = "Calculator-*-aarch64.AppImage.zsync"; + assert!( + github + .update_information(&meta, "aarch64") + .ends_with(pattern) + ); + + let local = + UpdateChannel::zsync("http://127.0.0.1:8080/Calculator-x86_64.AppImage.zsync").unwrap(); + assert_eq!( + local.update_information(&meta, "x86_64"), + "zsync|http://127.0.0.1:8080/Calculator-x86_64.AppImage.zsync" + ); + + for bad in ["darkpyonix", "a/b/c", "a|b/c", "/repo"] { + assert!(UpdateChannel::github(bad).is_err(), "{bad}"); + } + for bad in [ + "ftp://x/y.zsync", + "https://x/y.AppImage", + "https://x/a|b.zsync", + ] { + assert!(UpdateChannel::zsync(bad).is_err(), "{bad}"); + } +} + +#[test] +fn fr34_apprun_starts_the_payload_from_the_mount_point() { + let script = apprun(&meta()); + assert!(script.starts_with("#!/bin/sh\n")); + assert!(script.contains("APPDIR=\"${APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}\"\n")); + assert!( + script.ends_with("exec \"$APPDIR/usr/lib/sample-calculator/sample-calculator\" \"$@\"\n") + ); +} + +fn scratch(test: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("packager_linux") + .join(test); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn fr34_appdir_holds_entry_icon_metainfo_payload_and_updater() { + let dir = scratch("appdir"); + let meta = meta(); + let payload = dir.join("payload"); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("sample-calculator"), b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::write(payload.join("libdioxus_compose_renderer.so"), b"\x7fELF").unwrap(); + let updater = dir.join("squashfs-root"); + std::fs::create_dir_all(updater.join("usr/bin")).unwrap(); + std::fs::write(updater.join("usr/bin/appimageupdatetool"), b"tool").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink("usr/bin/appimageupdatetool", updater.join("AppRun")).unwrap(); + #[cfg(not(unix))] + std::fs::write(updater.join("AppRun"), b"tool").unwrap(); + let icon_path = dir.join("icon.png"); + std::fs::write(&icon_path, png(256)).unwrap(); + let icons = vec![Icon::classify(&icon_path, &png(256)).unwrap()]; + + let out = dir.join("Calculator.AppDir"); + let written = assemble_appdir( + &AppDirInputs { + meta: &meta, + payload: &payload, + icons: &icons, + updater: Some(&updater), + }, + &out, + ) + .unwrap(); + for path in [ + "AppRun", + "dev.example.Calculator.desktop", + "dev.example.Calculator.png", + "usr/share/applications/dev.example.Calculator.desktop", + "usr/share/metainfo/dev.example.Calculator.appdata.xml", + "usr/share/icons/hicolor/256x256/apps/dev.example.Calculator.png", + "usr/lib/sample-calculator/sample-calculator", + "usr/lib/sample-calculator/libdioxus_compose_renderer.so", + ] { + assert!(out.join(path).is_file(), "missing {path}"); + } + assert!(written.contains(&PathBuf::from(format!("{UPDATER_DIR}/AppRun")))); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let link = std::fs::read_link(out.join(UPDATER_DIR).join("AppRun")).unwrap(); + assert_eq!(link, Path::new("usr/bin/appimageupdatetool")); + for executable in ["AppRun", "usr/lib/sample-calculator/sample-calculator"] { + let mode = std::fs::metadata(out.join(executable)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o111, 0o111, "{executable} is not executable"); + } + } + assert_eq!( + std::fs::read_to_string(out.join("dev.example.Calculator.desktop")).unwrap(), + desktop_entry(&meta) + ); + + // Assembling over an existing directory would mix two builds. + let again = assemble_appdir( + &AppDirInputs { + meta: &meta, + payload: &payload, + icons: &icons, + updater: None, + }, + &out, + ); + assert!(again.unwrap_err().0.contains("already exists")); +} + +#[test] +fn fr34_appdir_refuses_a_payload_without_the_executable() { + let dir = scratch("appdir_missing"); + let payload = dir.join("payload"); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("other"), b"x").unwrap(); + let icon_path = dir.join("icon.png"); + std::fs::write(&icon_path, png(64)).unwrap(); + let icons = vec![Icon::classify(&icon_path, &png(64)).unwrap()]; + let error = assemble_appdir( + &AppDirInputs { + meta: &meta(), + payload: &payload, + icons: &icons, + updater: None, + }, + &dir.join("out"), + ) + .unwrap_err(); + assert!(error.0.contains("sample-calculator"), "{error}"); +} + +#[test] +fn fr34_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { + let meta = meta(); + assert_eq!( + finish_args(&meta, false), + [ + "--share=ipc", + "--socket=x11", + "--device=dri", + "--share=network" + ] + ); + assert_eq!( + finish_args(&meta, true), + [ + "--share=ipc", + "--socket=wayland", + "--socket=fallback-x11", + "--device=dri", + "--share=network" + ] + ); + let mut duplicate = meta.clone(); + duplicate.finish_args = vec!["--share=ipc".into()]; + assert_eq!(finish_args(&duplicate, false).len(), 3); +} + +fn icons_256() -> Vec { + vec![Icon { + source: "icon.png".into(), + kind: IconKind::Png(256), + }] +} + +#[test] +fn fr34_flatpak_manifest_for_a_prebuilt_archive() { + let meta = meta(); + let icons = icons_256(); + let text = manifest( + &meta, + &FlatpakOptions { + payload: Payload::Prebuilt { + archive: SourceRef::Url { + url: "https://example.dev/calculator-linux-x64.tar.gz".into(), + sha256: "ab".repeat(32), + }, + }, + icons: &icons, + wayland: false, + }, + ); + let expected_start = format!( + "{{\n \"id\": \"dev.example.Calculator\",\n \"runtime\": \"org.freedesktop.Platform\",\n \"runtime-version\": \"{DEFAULT_RUNTIME_VERSION}\",\n \"sdk\": \"org.freedesktop.Sdk\",\n \"command\": \"sample-calculator\",\n" + ); + assert!(text.starts_with(&expected_start), "{text}"); + for expected in [ + "\"--socket=x11\"", + "\"--share=ipc\"", + "\"--device=dri\"", + "\"type\": \"archive\"", + "\"url\": \"https://example.dev/calculator-linux-x64.tar.gz\"", + "\"dest\": \"payload\"", + "\"cp -a payload/. /app/lib/sample-calculator/\"", + "\"test -x /app/lib/sample-calculator/sample-calculator\"", + "\"ln -s ../lib/sample-calculator/sample-calculator /app/bin/sample-calculator\"", + "\"install -Dm644 packaging/dev.example.Calculator.desktop /app/share/applications/dev.example.Calculator.desktop\"", + "\"install -Dm644 packaging/dev.example.Calculator.metainfo.xml /app/share/metainfo/dev.example.Calculator.metainfo.xml\"", + "\"install -Dm644 packaging/icons/256x256/dev.example.Calculator.png /app/share/icons/hicolor/256x256/apps/dev.example.Calculator.png\"", + "\"dest\": \"packaging/icons/256x256\"", + ] { + assert!(text.contains(expected), "missing {expected} in\n{text}"); + } + assert!(!text.contains("rust-stable"), "{text}"); + assert!(!text.contains("cargo"), "{text}"); +} + +#[test] +fn fr34_flatpak_manifest_builds_from_source_offline() { + let mut meta = meta(); + meta.runtime_version = Some("24.08".into()); + let icons = icons_256(); + let text = manifest( + &meta, + &FlatpakOptions { + payload: Payload::FromSource { + source: SourceRef::Path("source.tar.gz".into()), + cargo_sources: "cargo-sources.json".into(), + package: "sample-calculator".into(), + bin: "sample-calculator".into(), + cargo_args: vec!["--no-default-features".into(), "it's".into()], + renderer: Some(SourceRef::Url { + url: "https://example.dev/renderer.tar.gz".into(), + sha256: "cd".repeat(32), + }), + }, + icons: &icons, + wayland: true, + }, + ); + for expected in [ + "\"runtime-version\": \"24.08\"", + "\"sdk-extensions\": [\n \"org.freedesktop.Sdk.Extension.rust-stable\"\n ]", + "\"append-path\": \"/usr/lib/sdk/rust-stable/bin\"", + "\"CARGO_HOME\": \"/run/build/sample-calculator/cargo\"", + "\"CARGO_NET_OFFLINE\": \"true\"", + "\"DIOXUS_COMPOSE_APP_VERSION\": \"1.2.3\"", + "\"DIOXUS_COMPOSE_RENDERER_DIR\": \"/run/build/sample-calculator/renderer\"", + "\"path\": \"source.tar.gz\"", + "\"cargo-sources.json\"", + "\"dest\": \"renderer\"", + "\"cargo --offline fetch --manifest-path Cargo.toml --verbose\"", + "\"cargo --offline build --release --locked -p sample-calculator --bin sample-calculator --no-default-features 'it'\\\\''s'\"", + "\"install -m755 target/release/sample-calculator /app/lib/sample-calculator/sample-calculator\"", + "\"cp -a renderer/. /app/lib/sample-calculator/\"", + "patchelf --set-rpath '$ORIGIN' /app/lib/sample-calculator/sample-calculator", + "\"--socket=wayland\"", + "\"--socket=fallback-x11\"", + ] { + assert!(text.contains(expected), "missing {expected} in\n{text}"); + } + // The vendored crates come before the build that needs them. + assert!( + text.find("cargo --offline fetch").unwrap() < text.find("cargo --offline build").unwrap() + ); +} + +#[test] +fn fr34_json_and_shell_quoting_survive_awkward_text() { + assert_eq!( + Json::Object(vec![( + "k\"ey".into(), + Json::Array(vec![Json::str("a\\b\n\u{1}"), Json::Array(vec![])]) + )]) + .render(0), + "{\n \"k\\\"ey\": [\n \"a\\\\b\\n\\u0001\",\n []\n ]\n}" + ); + assert_eq!(shell_quote("--features=a,b"), "--features=a,b"); + assert_eq!(shell_quote("two words"), "'two words'"); + assert_eq!(shell_quote(""), "''"); +} + +#[test] +fn fr34_release_dates_are_reproducible() { + assert_eq!(release_date(None, 0), "1970-01-01"); + assert_eq!(release_date(None, 1_790_985_600), "2026-10-03"); + assert_eq!(release_date(Some("951782400"), 1_790_985_600), "2000-02-29"); + assert_eq!(release_date(Some("not a number"), 86_400), "1970-01-02"); + assert_eq!(paragraphs("a\nb\n\n\n c "), ["a b", "c"]); +} From 624141362dff4bdb52a15edb3e6b6b84795a36a8 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:34:15 +0900 Subject: [PATCH 009/224] Chore: Build, update and lint Linux packages on a runner appimage.sh fetches pinned appimagetool, runtime and appimageupdatetool, assembles the AppDir and writes the AppImage with its .zsync. flatpak.sh vendors crates with a pinned flatpak-cargo-generator, writes the manifest, and builds and lints it with Flathub's org.flatpak.Builder. The workflow shows an AppImage 1.0.0 updating itself to 1.0.1 from .zsync files served by nginx on the runner, refuses a 1.0.2 signed by another key, updates an installed Flatpak with flatpak update, and starts the released calculator sample from both packages. --- .github/workflows/packager-linux.yml | 394 +++++++++++++++++++++++++++ tools/packager-linux/appimage.sh | 173 ++++++++++++ tools/packager-linux/flatpak.sh | 141 ++++++++++ 3 files changed, 708 insertions(+) create mode 100644 .github/workflows/packager-linux.yml create mode 100755 tools/packager-linux/appimage.sh create mode 100755 tools/packager-linux/flatpak.sh diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml new file mode 100644 index 00000000..58f90faa --- /dev/null +++ b/.github/workflows/packager-linux.yml @@ -0,0 +1,394 @@ +# Linux packaging: an AppImage that updates itself and a Flatpak built the way Flathub +# builds one. Each channel is shown updating an installed build to a newer one. +# +# - rust: the packager's tests, the update module's tests, lint, and the lock file. +# - appimage: builds a small program twice (1.0.0 and 1.0.1), wraps both as signed +# AppImages carrying zsync update information, serves the newer one's .zsync +# from nginx on the runner, and has the installed 1.0.0 update itself. Then a +# 1.0.2 signed by a different key is published and the update must be refused. +# Last, a real sample (the calculator release, renderer and all) is wrapped +# and started under a virtual display. +# - flatpak: builds the same program from source with flatpak-builder, offline, with +# every crate vendored, installs it, then builds 1.0.1 into the same repository +# and updates the installation with `flatpak update`. Then the calculator +# release is packaged as a prebuilt archive, started in the sandbox under a +# virtual display, and both manifests go through Flathub's linter. +# +# Dispatched by hand. It downloads two Flatpak SDKs and builds a Rust program inside one, +# which is not something to repeat on every push. + +name: Linux packaging + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: packager-linux-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: "0" + # The calculator from the published sample release: the renderer, its AWT and Skia + # companions and the executable, already pointed at each other by relative paths. + CALCULATOR_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-x64.tar.gz + CALCULATOR_SHA256: ebc52967d02bfbd2eb22317cb23d023417ec2029eac811f3ce6b018e1950fded + FLATPAK_RUNTIME: "25.08" + +jobs: + rust: + name: Packager tests + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: The lock file already records every dependency + # A Flathub build runs `cargo build --locked` with no network, so a lock file that + # needs updating is a build that cannot happen there. + run: | + cargo metadata --format-version 1 >/dev/null + if ! git diff --exit-code Cargo.lock; then + mkdir -p lock && cp Cargo.lock lock/ + echo "::error::Cargo.lock is out of date; the updated file is the 'cargo-lock' artifact" + echo "LOCK_STALE=1" >> "$GITHUB_ENV" + fi + + - uses: actions/upload-artifact@v4 + if: env.LOCK_STALE == '1' + with: + name: cargo-lock + path: lock/Cargo.lock + + - name: Formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: | + cargo clippy -p packager-linux -p packager-linux-demo --all-targets -- -D warnings + cargo clippy -p dioxus-compose --no-default-features --lib --test appimage_update -- -D warnings + + - name: Tests + run: | + cargo test -p packager-linux + cargo test -p dioxus-compose --no-default-features --test appimage_update + cargo test -p dioxus-compose --no-default-features --doc update + + - name: Repository rules + run: | + bash scripts/tests/no-em-dash.test.sh + bash scripts/tests/no-spec-citations.test.sh + + - name: Fail on a stale lock file + if: env.LOCK_STALE == '1' + run: exit 1 + + appimage: + name: AppImage updates itself + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + zsync desktop-file-utils appstream gnupg nginx-light fuse3 libfuse2t64 \ + xvfb xauth imagemagick libgl1 libegl1 libgl1-mesa-dri fontconfig \ + libxtst6 libxi6 libxrender1 libxext6 libxrandr2 fonts-dejavu-core + rustup toolchain install stable --profile minimal && rustup default stable + ls -l /dev/fuse || echo "no /dev/fuse" + + - name: Build the packager and two versions of the program + run: | + set -euo pipefail + cargo build --release -p packager-linux + mkdir -p work/1.0.0 work/1.0.1 work/1.0.2 + for v in 1.0.0 1.0.1 1.0.2; do + DIOXUS_COMPOSE_APP_VERSION=$v cargo build --release -p packager-linux-demo + cp target/release/update-demo "work/$v/update-demo" + "work/$v/update-demo" --version + done + echo "PACKAGER_LINUX=$PWD/target/release/packager-linux" >> "$GITHUB_ENV" + + - name: Make two signing keys + # The release key, and an impostor's, to show an update signed by anyone else is + # refused. + run: | + set -euo pipefail + export GNUPGHOME="$PWD/work/gnupg" && mkdir -p -m 700 "$GNUPGHOME" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + gpg --batch --passphrase '' --quick-gen-key "Release " ed25519 sign never + gpg --batch --passphrase '' --quick-gen-key "Impostor " ed25519 sign never + echo "RELEASE_KEY=$(gpg --list-keys --with-colons release@example.invalid | awk -F: '/^fpr/ {print $10; exit}')" >> "$GITHUB_ENV" + echo "IMPOSTOR_KEY=$(gpg --list-keys --with-colons impostor@example.invalid | awk -F: '/^fpr/ {print $10; exit}')" >> "$GITHUB_ENV" + + - name: Package all three versions + run: | + set -euo pipefail + url=http://127.0.0.1:8080/latest/UpdateDemo-x86_64.AppImage.zsync + for v in 1.0.0 1.0.1 1.0.2; do + key="$RELEASE_KEY"; [[ $v == 1.0.2 ]] && key="$IMPOSTOR_KEY" + tools/packager-linux/appimage.sh \ + --dioxus-toml tools/packager-linux/demo/Dioxus.toml \ + --payload "work/$v/update-demo" --version "$v" \ + --zsync-url "$url" --sign-key "$key" --out-dir "work/dist" + done + ls -l work/dist + head -c 400 work/dist/UpdateDemo-1.0.1-x86_64.AppImage.zsync | sed -n '1,/^$/p' + + - name: The AppImage carries its update information and its updater + run: | + set -euo pipefail + app=work/dist/UpdateDemo-1.0.0-x86_64.AppImage + info="$("$app" --appimage-updateinformation)" + echo "$info" + [[ "$info" == "zsync|http://127.0.0.1:8080/latest/UpdateDemo-x86_64.AppImage.zsync" ]] + "$app" --appimage-signature | head -3 + "$app" --appimage-extract >/dev/null + test -e squashfs-root/usr/lib/appimageupdatetool/AppRun + test -x squashfs-root/usr/lib/update-demo/update-demo + desktop-file-validate squashfs-root/dev.darkpyonix.compose.UpdateDemo.desktop + appstreamcli validate --no-net --explain \ + squashfs-root/usr/share/metainfo/dev.darkpyonix.compose.UpdateDemo.appdata.xml + rm -rf squashfs-root + + - name: Serve releases from nginx on the runner + run: | + set -euo pipefail + srv="$PWD/work/server" && mkdir -p "$srv/latest" "$srv/tmp" + cat > "$srv/nginx.conf" <> "$GITHUB_ENV" + + - name: Install 1.0.0, publish 1.0.0, and it is up to date + run: | + set -euo pipefail + publish() { + rm -f "$SERVER/latest/"* + cp "work/dist/UpdateDemo-$1-x86_64.AppImage" "$SERVER/latest/" + # One fixed name for the newest .zsync; it names the AppImage beside it. + cp "work/dist/UpdateDemo-$1-x86_64.AppImage.zsync" "$SERVER/latest/UpdateDemo-x86_64.AppImage.zsync" + } + declare -f publish > work/publish.sh + publish 1.0.0 + mkdir -p work/installed + cp work/dist/UpdateDemo-1.0.0-x86_64.AppImage work/installed/UpdateDemo.AppImage + app=work/installed/UpdateDemo.AppImage + "$app" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" + "$app" --delivery + "$app" --check + + - name: Publish 1.0.1, and 1.0.0 updates itself to it + run: | + set -euo pipefail + source work/publish.sh + publish 1.0.1 + : > "$SERVER/access.log" + app="$PWD/work/installed/UpdateDemo.AppImage" + before="$(sha256sum "$app" | cut -d' ' -f1)" + set +e; "$app" --check; code=$?; set -e + [[ $code -eq 10 ]] || { echo "expected 10 (update available), got $code"; exit 1; } + # Check, ask (answered yes here), apply, and restart into the new version. + "$app" --update --yes 2>&1 | tee work/update.log + tail -1 work/update.log | grep -qx "update-demo 1.0.1" + "$app" --version | grep -qx "update-demo 1.0.1" + "$app" --check + after="$(sha256sum "$app" | cut -d' ' -f1)" + published="$(sha256sum "$SERVER/latest/UpdateDemo-1.0.1-x86_64.AppImage" | cut -d' ' -f1)" + echo "before $before"; echo "after $after"; echo "served $published" + [[ "$after" == "$published" && "$before" != "$after" ]] + echo "--- requests" + cat "$SERVER/access.log" + size="$(stat -c %s "$app")" + fetched="$(awk '/AppImage HTTP/ {sum += $2} END {print sum + 0}' "$SERVER/access.log")" + echo "downloaded $fetched of $size bytes of the AppImage" + grep -q '^206 ' "$SERVER/access.log" || { echo "no ranged request: the update was not a delta"; exit 1; } + { + echo "### AppImage 1.0.0 updated itself to 1.0.1" + echo + echo "Downloaded $fetched of $size bytes (zsync ranged requests)." + } >> "$GITHUB_STEP_SUMMARY" + + - name: An update signed by another key is refused + run: | + set -euo pipefail + source work/publish.sh + publish 1.0.2 + app="$PWD/work/installed/UpdateDemo.AppImage" + before="$(sha256sum "$app" | cut -d' ' -f1)" + set +e; "$app" --update --yes > work/impostor.log 2>&1; code=$?; set -e + cat work/impostor.log + echo "exit status $code" + [[ $code -ne 0 ]] || { echo "the impostor's update was accepted"; exit 1; } + "$app" --version | grep -qx "update-demo 1.0.1" + [[ "$(sha256sum "$app" | cut -d' ' -f1)" == "$before" ]] + ls -la work/installed + echo "The impostor's 1.0.2 was refused and 1.0.1 still runs." >> "$GITHUB_STEP_SUMMARY" + + - name: Wrap the calculator release as an AppImage and start it + run: | + set -euo pipefail + mkdir -p work/calculator + curl -fsSL -o work/calculator.tar.gz "$CALCULATOR_URL" + echo "$CALCULATOR_SHA256 work/calculator.tar.gz" | sha256sum -c + tar -xzf work/calculator.tar.gz -C work/calculator + tools/packager-linux/appimage.sh \ + --dioxus-toml samples/calculator/Dioxus.toml \ + --overlay tools/packager-linux/samples.toml \ + --exec sample-calculator --icon samples/calculator/assets/icon.png \ + --payload work/calculator/calculator --version 0.1.1 \ + --github darkpyonix/compose-rust --sign-key "$RELEASE_KEY" \ + --out-dir work/dist + app=work/dist/Calculator-0.1.1-x86_64.AppImage + "$app" --appimage-updateinformation + xvfb-run -a -s "-screen 0 1280x800x24" bash -c " + '$app' > work/calculator.log 2>&1 & + pid=\$! + sleep 25 + import -window root work/calculator-appimage.png + if kill -0 \$pid 2>/dev/null; then echo 'still running after 25 s'; kill \$pid; else echo 'exited early'; wait \$pid; echo \"status \$?\"; fi + " | tee work/calculator-run.txt + cat work/calculator.log + grep -q 'still running' work/calculator-run.txt + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: appimage + path: | + work/dist/*.AppImage + work/dist/*.zsync + work/*.log + work/*.png + work/server/access.log + + flatpak: + name: Flatpak builds and updates + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - name: Install flatpak and Flathub's builder + run: | + set -euo pipefail + # Ubuntu 24.04 stops unprivileged user namespaces through AppArmor, and + # bubblewrap, which builds and runs every sandbox, needs them. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + sudo apt-get update + sudo apt-get install -y --no-install-recommends flatpak xvfb xauth imagemagick python3-pip + pip install --user 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1' + flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true + flatpak --user install --noninteractive flathub org.flatpak.Builder \ + "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ + "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" + echo "== what the SDK has for rewriting an executable's library names" + flatpak run --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' + rustup toolchain install stable --profile minimal && rustup default stable + cargo build --release -p packager-linux + echo "PACKAGER_LINUX=$PWD/target/release/packager-linux" >> "$GITHUB_ENV" + + - name: Build 1.0.0 from source, offline, and install it + run: | + set -euo pipefail + mkdir -p work/flatpak-src + # What a release publishes: the tagged source as an archive. + git archive --format=tar.gz --prefix=compose-rust/ -o work/flatpak-src/source.tar.gz HEAD + tools/packager-linux/flatpak.sh \ + --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.0 \ + --source-path source.tar.gz --cargo-lock Cargo.lock \ + --package packager-linux-demo --bin update-demo \ + --out work/flatpak-src --build work/repo --lint + id=dev.darkpyonix.compose.UpdateDemo + flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" + flatpak run "$id" --delivery + flatpak run "$id" --update --yes | tee /dev/stderr | grep -q "store" + cat work/flatpak-src/$id.json + + - name: Build 1.0.1 into the same repository, and flatpak update installs it + run: | + set -euo pipefail + id=dev.darkpyonix.compose.UpdateDemo + # The installed copy came from the builder; install it again from the repository + # as a remote, the way a store delivers it, so that `flatpak update` has + # somewhere to look. + flatpak --user remote-add --no-gpg-verify --if-not-exists local-repo "$PWD/work/repo" + flatpak --user uninstall --noninteractive "$id" + flatpak --user install --noninteractive local-repo "$id" + flatpak run "$id" --version | grep -qx "update-demo 1.0.0" + tools/packager-linux/flatpak.sh \ + --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.1 \ + --source-path source.tar.gz --cargo-lock Cargo.lock \ + --package packager-linux-demo --bin update-demo \ + --out work/flatpak-src + (cd work/flatpak-src && flatpak run --command=flatpak-builder org.flatpak.Builder \ + --user --force-clean --repo="$PWD/../repo" build "$id.json") + flatpak --user update --noninteractive "$id" + flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" + flatpak --user info "$id" + echo "### Flatpak 1.0.0 updated to 1.0.1 with flatpak update" >> "$GITHUB_STEP_SUMMARY" + + - name: Package the calculator release as a prebuilt archive and start it in the sandbox + run: | + set -euo pipefail + mkdir -p work/flatpak-calculator + # Flathub needs an icon of at least 128 pixels; the sample's is 64. + convert samples/calculator/assets/icon.png -filter point -resize 256x256 work/calculator-256.png + tools/packager-linux/flatpak.sh \ + --dioxus-toml samples/calculator/Dioxus.toml \ + --overlay tools/packager-linux/samples.toml \ + --exec sample-calculator --icon work/calculator-256.png --version 0.1.1 \ + --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" \ + --out work/flatpak-calculator --build work/repo-calculator --lint + id=dev.darkpyonix.dioxus.compose.samples.calculator + cat "work/flatpak-calculator/$id.json" + xvfb-run -a -s "-screen 0 1280x800x24" bash -c " + flatpak run $id > work/calculator-flatpak.log 2>&1 & + pid=\$! + sleep 30 + import -window root work/calculator-flatpak.png + if kill -0 \$pid 2>/dev/null; then echo 'still running after 30 s'; kill \$pid; else echo 'exited early'; wait \$pid; echo \"status \$?\"; fi + " | tee work/calculator-flatpak-run.txt + cat work/calculator-flatpak.log + grep -q 'still running' work/calculator-flatpak-run.txt + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: flatpak + path: | + work/flatpak-src/*.json + work/flatpak-src/*.xml + work/flatpak-src/*.desktop + work/flatpak-calculator/*.json + work/flatpak-calculator/*.xml + work/flatpak-calculator/*.desktop + work/*.log + work/*.png diff --git a/tools/packager-linux/appimage.sh b/tools/packager-linux/appimage.sh new file mode 100755 index 00000000..d2d62a07 --- /dev/null +++ b/tools/packager-linux/appimage.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Wraps a built application as an AppImage that updates itself, with the .zsync file to +# publish beside it. +# +# tools/packager-linux/appimage.sh \ +# --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ +# --payload staging/calculator --version 1.2.3 \ +# --github darkpyonix/compose-rust --out-dir dist +# +# writes dist/Calculator-1.2.3-x86_64.AppImage and dist/Calculator-1.2.3-x86_64.AppImage.zsync. +# Publish both as assets of the same release; every later release's AppImage finds the +# newest .zsync through the update information embedded here. +# +# Options: +# --dioxus-toml, --overlay, --version, --date, --exec, --icon as packager-linux takes them +# --payload the executable, or the directory holding it and its renderer +# --github update from that repository's newest GitHub release +# --zsync-url update from one fixed .zsync URL instead +# --sign-key sign with this gpg key; an installed AppImage that was signed +# refuses an update signed by any other key +# --no-updater do not carry appimageupdatetool inside +# --out-dir +# +# Tools: appimagetool, the AppImage runtime and appimageupdatetool are downloaded at +# pinned versions and checked against pinned digests, into PACKAGER_LINUX_CACHE (default +# .scratch/packager-linux/tools in this repository). zsyncmake must be on PATH (Debian and +# Ubuntu: apt-get install zsync); appimagetool needs it to write the .zsync file and +# skips it silently otherwise, which would ship an AppImage that can never update. +# desktop-file-validate and appstreamcli are used when present. +# +# packager-linux itself is run through `cargo run` unless PACKAGER_LINUX names a built one. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" + +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do echo " $line" >&2; done + exit 1 +} + +# Pinned tools. A digest that does not match stops the build rather than packaging with +# something nobody reviewed. +APPIMAGETOOL_VERSION=1.9.1 +RUNTIME_VERSION=20251108 +UPDATER_VERSION=2.0.0-alpha-1-20251018 +declare -A DIGESTS=( + [appimagetool-x86_64.AppImage]=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + [appimagetool-aarch64.AppImage]=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + [runtime-x86_64]=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + [runtime-aarch64]=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + [appimageupdatetool-x86_64.AppImage]=d976cdac667b03dee8cb23fb95ef74b042c406c5cbab3ff294d2b16efeaff84f + [appimageupdatetool-aarch64.AppImage]=7aaf89dd4cf66ebd940d416c67e1c240c57a139cee38d9c0ed3bb9387bc435b0 +) + +common=() +payload="" +channel=() +sign_key="" +bundle_updater=1 +out_dir="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dioxus-toml | --overlay | --version | --date | --exec | --icon) + [[ $# -ge 2 ]] || fail "$1 needs a value" + common+=("$1" "$2") + shift 2 + ;; + --payload) payload="$2"; shift 2 ;; + --github | --zsync-url) channel=("$1" "$2"); shift 2 ;; + --sign-key) sign_key="$2"; shift 2 ;; + --no-updater) bundle_updater=0; shift ;; + --out-dir) out_dir="$2"; shift 2 ;; + *) fail "unknown option $1" "See the comment at the top of $0." ;; + esac +done +[[ -n "$payload" ]] || fail "--payload is required" +[[ -n "$out_dir" ]] || fail "--out-dir is required" +[[ ${#channel[@]} -eq 2 ]] || fail "pass --github or --zsync-url " \ + "An AppImage without update information cannot update itself." + +case "$(uname -m)" in + x86_64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) fail "no pinned AppImage tools for $(uname -m)" ;; +esac +[[ "$(uname -s)" == Linux ]] || fail "an AppImage is assembled on Linux" +command -v zsyncmake >/dev/null || fail "zsyncmake is not on PATH" \ + "appimagetool writes the .zsync file with it and skips that step silently without it." \ + "Debian and Ubuntu: apt-get install zsync." + +packager() { + if [[ -n "${PACKAGER_LINUX:-}" ]]; then + "$PACKAGER_LINUX" "$@" + else + cargo run --quiet --release --manifest-path "$repo_root/Cargo.toml" \ + -p packager-linux -- "$@" + fi +} + +cache="${PACKAGER_LINUX_CACHE:-$repo_root/.scratch/packager-linux/tools}" +mkdir -p "$cache" + +fetch() { + local name="$1" url="$2" path="$cache/$1" + local want="${DIGESTS[$name]}" + if [[ ! -f "$path" ]] || [[ "$(sha256sum "$path" | cut -d' ' -f1)" != "$want" ]]; then + echo "== fetching $name" + curl --fail --location --silent --show-error --retry 3 -o "$path.part" "$url" + local got + got="$(sha256sum "$path.part" | cut -d' ' -f1)" + [[ "$got" == "$want" ]] || fail "$name has digest $got, pinned is $want" \ + "Either the release was replaced or the download is damaged." + mv "$path.part" "$path" + chmod +x "$path" + fi +} + +fetch "appimagetool-$arch.AppImage" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$arch.AppImage" +fetch "runtime-$arch" \ + "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$arch" + +updater_args=() +if [[ $bundle_updater -eq 1 ]]; then + fetch "appimageupdatetool-$arch.AppImage" \ + "https://github.com/AppImageCommunity/AppImageUpdate/releases/download/$UPDATER_VERSION/appimageupdatetool-$arch.AppImage" + extracted="$cache/appimageupdatetool-$arch-$UPDATER_VERSION" + if [[ ! -e "$extracted/AppRun" ]]; then + rm -rf "$extracted" "$cache/squashfs-root" + # Extracting reads the image without mounting it, so no FUSE is needed here. + (cd "$cache" && "./appimageupdatetool-$arch.AppImage" --appimage-extract >/dev/null) + mv "$cache/squashfs-root" "$extracted" + fi + updater_args=(--updater "$extracted") +fi + +update_information="$(packager update-information "${common[@]}" --arch "$arch" "${channel[@]}")" +file_name="$(packager file-name "${common[@]}" --arch "$arch")" + +mkdir -p "$out_dir" +out_dir="$(cd "$out_dir" && pwd)" +appdir="$out_dir/${file_name%.AppImage}.AppDir" +rm -rf "$appdir" "$out_dir/$file_name" "$out_dir/$file_name.zsync" +packager appdir "${common[@]}" --arch "$arch" --payload "$payload" \ + "${updater_args[@]}" --out "$appdir" >/dev/null + +sign_args=() +if [[ -n "$sign_key" ]]; then + sign_args=(--sign --sign-key "$sign_key") +fi + +echo "== $file_name" +echo " update information: $update_information" +# appimagetool is itself an AppImage. Extract-and-run spares the build machine FUSE. +# It runs from the output directory because zsyncmake writes the .zsync file into the +# directory it is started in. +( + cd "$out_dir" + APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$arch" "$cache/appimagetool-$arch.AppImage" \ + --runtime-file "$cache/runtime-$arch" \ + --updateinformation "$update_information" \ + "${sign_args[@]}" \ + "$appdir" "$out_dir/$file_name" +) + +[[ -f "$out_dir/$file_name.zsync" ]] || fail "appimagetool wrote no $file_name.zsync" \ + "Without it the update information points at nothing." +rm -rf "$appdir" +(cd "$out_dir" && sha256sum "$file_name" "$file_name.zsync") diff --git a/tools/packager-linux/flatpak.sh b/tools/packager-linux/flatpak.sh new file mode 100755 index 00000000..0c53f028 --- /dev/null +++ b/tools/packager-linux/flatpak.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Writes a Flatpak manifest for an application and, optionally, builds, installs and lints +# it the way Flathub does. +# +# tools/packager-linux/flatpak.sh \ +# --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ +# --prebuilt-url https://.../calculator-linux-x64.tar.gz --prebuilt-sha256 \ +# --icon icon-256.png --version 1.2.3 --out flatpak/ --build repo/ --lint +# +# Every option packager-linux's `flatpak` command takes is passed through to it. A build +# from source also needs the crates vendored, because a Flathub build has no network: +# +# --cargo-lock write /cargo-sources.json from this lock file with the +# pinned flatpak-cargo-generator (and pass it as --cargo-sources) +# --build build with flatpak-builder into this OSTree repository and +# install the result for the current user +# --lint run Flathub's linter on the manifest, and on the repository +# when one was built. Findings are printed and written to +# /lint-*.json; they do not stop the script, because some of +# them (an app ID whose domain is not verified yet) are only +# settled by Flathub +# +# The builder is Flathub's own, org.flatpak.Builder from Flathub, which also carries the +# linter. It is installed for the user when missing. flatpak itself must be installed. +# +# Downloads (the generator) go to PACKAGER_LINUX_CACHE, default .scratch/packager-linux/tools +# in this repository. packager-linux is run through `cargo run` unless PACKAGER_LINUX +# names a built one. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" + +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do echo " $line" >&2; done + exit 1 +} + +GENERATOR_COMMIT=41c20aa10819cdb2a4f3ca171758a96d1955c018 +GENERATOR_SHA256=0a2db6be87d75910facef28ab46d4d6460802e8419ab850d0caa6a364d26b380 + +passthrough=() +out="" +cargo_lock="" +build_repo="" +lint=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --cargo-lock) cargo_lock="$2"; shift 2 ;; + --build) build_repo="$2"; shift 2 ;; + --lint) lint=1; shift ;; + --out) out="$2"; passthrough+=("$1" "$2"); shift 2 ;; + --wayland) passthrough+=("$1"); shift ;; + --*) + [[ $# -ge 2 ]] || fail "$1 needs a value" + passthrough+=("$1" "$2") + shift 2 + ;; + *) fail "unexpected argument $1" "See the comment at the top of $0." ;; + esac +done +[[ -n "$out" ]] || fail "--out is required" +mkdir -p "$out" +out="$(cd "$out" && pwd)" + +packager() { + if [[ -n "${PACKAGER_LINUX:-}" ]]; then + "$PACKAGER_LINUX" "$@" + else + cargo run --quiet --release --manifest-path "$repo_root/Cargo.toml" \ + -p packager-linux -- "$@" + fi +} + +cache="${PACKAGER_LINUX_CACHE:-$repo_root/.scratch/packager-linux/tools}" +mkdir -p "$cache" + +if [[ -n "$cargo_lock" ]]; then + generator="$cache/flatpak-cargo-generator-$GENERATOR_COMMIT.py" + if [[ ! -f "$generator" ]]; then + curl --fail --location --silent --show-error --retry 3 -o "$generator.part" \ + "https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GENERATOR_COMMIT/cargo/flatpak-cargo-generator.py" + got="$(sha256sum "$generator.part" | cut -d' ' -f1)" + [[ "$got" == "$GENERATOR_SHA256" ]] || fail \ + "flatpak-cargo-generator has digest $got, pinned is $GENERATOR_SHA256" + mv "$generator.part" "$generator" + fi + python3 -c 'import aiohttp, tomlkit' 2>/dev/null || fail \ + "flatpak-cargo-generator needs the Python modules aiohttp and tomlkit" \ + "pip install 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1'" + echo "== vendoring the crates in $cargo_lock" + python3 "$generator" "$cargo_lock" -o "$out/cargo-sources.json" + passthrough+=(--cargo-sources cargo-sources.json) +fi + +manifest="$(packager flatpak "${passthrough[@]}")" +echo "== $manifest" + +builder() { + flatpak run --command=flatpak-builder org.flatpak.Builder "$@" +} + +if [[ -n "$build_repo" || $lint -eq 1 ]]; then + flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak --user info org.flatpak.Builder >/dev/null 2>&1 || + flatpak --user install --noninteractive flathub org.flatpak.Builder +fi + +if [[ -n "$build_repo" ]]; then + mkdir -p "$build_repo" + build_repo="$(cd "$build_repo" && pwd)" + echo "== building into $build_repo" + ( + cd "$out" + builder --user --force-clean --install-deps-from=flathub --ccache \ + --repo="$build_repo" --install --state-dir="$out/.flatpak-builder" \ + "$out/build" "$manifest" + ) +fi + +if [[ $lint -eq 1 ]]; then + lint_run() { + local name="$1" + shift + echo "== flatpak-builder-lint $*" + if flatpak run --command=flatpak-builder-lint org.flatpak.Builder "$@" \ + >"$out/lint-$name.json"; then + echo " clean" + else + echo " findings:" + fi + cat "$out/lint-$name.json" + } + lint_run manifest manifest "$manifest" + if [[ -n "$build_repo" ]]; then + lint_run repo repo "$build_repo" + fi +fi From 7bc5f43aabbfbcef9901852b24513c3c5d6df1da Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:34:40 +0900 Subject: [PATCH 010/224] Chore: Run the Linux packaging workflow when packaging code changes A workflow that is only dispatchable cannot be dispatched from a branch until it reaches the default branch. --- .github/workflows/packager-linux.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 58f90faa..c17623cc 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -14,12 +14,18 @@ # release is packaged as a prebuilt archive, started in the sandbox under a # virtual display, and both manifests go through Flathub's linter. # -# Dispatched by hand. It downloads two Flatpak SDKs and builds a Rust program inside one, -# which is not something to repeat on every push. +# Runs when the packaging code changes, or by hand. It downloads Flatpak SDKs and builds a +# Rust program inside one, which is not something to repeat on every push. name: Linux packaging on: + push: + paths: + - tools/packager-linux/** + - dioxus-compose/src/update.rs + - dioxus-compose/tests/appimage_update.rs + - .github/workflows/packager-linux.yml workflow_dispatch: permissions: From 25b73fef4e3090245860b2d03e2f9c5a79fd4825 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:34:48 +0900 Subject: [PATCH 011/224] Fix: Serve the test feed with byte ranges Windows fetches the bundle a feed names in byte ranges and refused Python's plain server (0x80D05011). --- tools/msix/scripts/serve_feed.py | 85 ++++++++++++++++++++++++++++++ tools/msix/scripts/test-update.ps1 | 7 +-- 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tools/msix/scripts/serve_feed.py diff --git a/tools/msix/scripts/serve_feed.py b/tools/msix/scripts/serve_feed.py new file mode 100644 index 00000000..4508ccfe --- /dev/null +++ b/tools/msix/scripts/serve_feed.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Serves a directory over HTTP with byte ranges, for testing an App Installer feed. + +Windows downloads a bundle named by an App Installer feed in pieces and refuses a server +that cannot answer a Range request (0x80D05011), which Python's own http.server cannot. +This adds single-range support and the media types App Installer expects, and nothing +else. It is for a test on one machine, not for publishing. + +Usage: serve_feed.py +""" + +import http.server +import os +import re +import sys + + +class RangeHandler(http.server.SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + extensions_map = { + **http.server.SimpleHTTPRequestHandler.extensions_map, + ".appinstaller": "application/appinstaller", + ".msix": "application/msix", + ".msixbundle": "application/msixbundle", + } + + def send_head(self): + header = self.headers.get("Range") + path = self.translate_path(self.path) + if not header or not os.path.isfile(path): + return super().send_head() + match = re.fullmatch(r"bytes=(\d*)-(\d*)", header.strip()) + size = os.path.getsize(path) + if not match or (match.group(1) == "" and match.group(2) == ""): + self.send_error(416) + return None + if match.group(1) == "": + start = max(0, size - int(match.group(2))) + end = size - 1 + else: + start = int(match.group(1)) + end = int(match.group(2)) if match.group(2) else size - 1 + end = min(end, size - 1) + if start > end: + self.send_response(416) + self.send_header("Content-Range", f"bytes */{size}") + self.end_headers() + return None + f = open(path, "rb") + f.seek(start) + self._remaining = end - start + 1 + self.send_response(206) + self.send_header("Content-Type", self.guess_type(path)) + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self.send_header("Content-Length", str(self._remaining)) + self.send_header("Accept-Ranges", "bytes") + self.end_headers() + return f + + def copyfile(self, source, outputfile): + remaining = getattr(self, "_remaining", None) + if remaining is None: + return super().copyfile(source, outputfile) + self._remaining = None + while remaining > 0: + chunk = source.read(min(65536, remaining)) + if not chunk: + break + outputfile.write(chunk) + remaining -= len(chunk) + + def end_headers(self): + if not self.headers.get("Range"): + self.send_header("Accept-Ranges", "bytes") + super().end_headers() + + +def main(): + directory, port = sys.argv[1], int(sys.argv[2]) + handler = lambda *a, **kw: RangeHandler(*a, directory=directory, **kw) # noqa: E731 + http.server.ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tools/msix/scripts/test-update.ps1 b/tools/msix/scripts/test-update.ps1 index d0e1653d..e7ee4bd4 100644 --- a/tools/msix/scripts/test-update.ps1 +++ b/tools/msix/scripts/test-update.ps1 @@ -5,8 +5,9 @@ and checks that Windows sees the update and replaces the installed package with .DESCRIPTION This is the update path a downloaded application takes, exercised end to end on one -machine. The feed is served over HTTP from a directory this script fills, standing in -for the web server a release would publish to: +machine. The feed is served over HTTP (by serve_feed.py, because Windows fetches the +bundle in byte ranges) from a directory this script fills, standing in for the web +server a release would publish to: 1. the older bundle and its feed go on the share, and the feed is installed; 2. the newer bundle goes on the share and its feed replaces the older one; @@ -72,7 +73,7 @@ function Publish-Feed([string]$from) { New-Item -ItemType Directory -Force -Path $Serve | Out-Null $python = (Get-Command python -ErrorAction Stop).Source $server = Start-Process -FilePath $python -PassThru -WindowStyle Hidden ` - -ArgumentList '-m', 'http.server', "$Port", '--bind', '127.0.0.1', '--directory', $Serve + -ArgumentList (Join-Path $PSScriptRoot 'serve_feed.py'), $Serve, "$Port" try { Start-Sleep -Seconds 2 Get-Installed | Remove-AppxPackage From 809ee9bd4e4850d44e8d79dd32ea45ffd7ffd8d9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:39:05 +0900 Subject: [PATCH 012/224] Feat: Package macOS applications for Sparkle and the Mac App Store tools/packager/package-macos makes Name.app from the executable cargo built, with Info.plist taken from the project's Cargo.toml and Dioxus.toml, and signs, wraps and publishes it for one of two channels: - sparkle: hardened runtime, no sandbox, Sparkle.framework inside, SUFeedURL and SUPublicEDKey in Info.plist; zip archive, notarytool submission and stapling, and an appcast whose EdDSA signature is checked against the key the application carries before it is written. - app-store: sandboxed with only the declared capabilities, export compliance answered, no Sparkle anywhere (framework, SU keys, or a host binary that names it), and the productbuild package App Store Connect takes. Signing goes inside out without --deep. Ed25519 is implemented after RFC 8032 so keys need no keychain and Linux can sign; signatures are identical to Sparkle's sign_update, which is preferred when given. Covers the draft acceptance criteria 1 to 9 of the proposed FR-34 (update and store packaging, macOS part), which is not yet in the SPEC. --- .gitignore | 3 + tools/packager/dxc_packager/__init__.py | 1 + tools/packager/dxc_packager/appcast.py | 141 ++++++++ tools/packager/dxc_packager/bundle.py | 416 ++++++++++++++++++++++++ tools/packager/dxc_packager/cli.py | 300 +++++++++++++++++ tools/packager/dxc_packager/ed25519.py | 185 +++++++++++ tools/packager/dxc_packager/metadata.py | 201 ++++++++++++ tools/packager/dxc_packager/plist.py | 218 +++++++++++++ tools/packager/dxc_packager/signing.py | 78 +++++ tools/packager/package-macos | 11 + tools/packager/tests/support.py | 49 +++ tools/packager/tests/test_appcast.py | 112 +++++++ tools/packager/tests/test_bundle.py | 215 ++++++++++++ tools/packager/tests/test_ed25519.py | 86 +++++ tools/packager/tests/test_metadata.py | 76 +++++ tools/packager/tests/test_plist.py | 175 ++++++++++ tools/packager/tests/test_signing.py | 61 ++++ 17 files changed, 2328 insertions(+) create mode 100644 tools/packager/dxc_packager/__init__.py create mode 100644 tools/packager/dxc_packager/appcast.py create mode 100644 tools/packager/dxc_packager/bundle.py create mode 100644 tools/packager/dxc_packager/cli.py create mode 100644 tools/packager/dxc_packager/ed25519.py create mode 100644 tools/packager/dxc_packager/metadata.py create mode 100644 tools/packager/dxc_packager/plist.py create mode 100644 tools/packager/dxc_packager/signing.py create mode 100755 tools/packager/package-macos create mode 100644 tools/packager/tests/support.py create mode 100644 tools/packager/tests/test_appcast.py create mode 100644 tools/packager/tests/test_bundle.py create mode 100644 tools/packager/tests/test_ed25519.py create mode 100644 tools/packager/tests/test_metadata.py create mode 100644 tools/packager/tests/test_plist.py create mode 100644 tools/packager/tests/test_signing.py diff --git a/.gitignore b/.gitignore index c825f49f..c8dadae1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ target # macOS .DS_Store +# Python bytecode from running tools/packager and its tests +__pycache__/ + # Agent worktrees .claude/ .agent-salvage/ diff --git a/tools/packager/dxc_packager/__init__.py b/tools/packager/dxc_packager/__init__.py new file mode 100644 index 00000000..1febdc16 --- /dev/null +++ b/tools/packager/dxc_packager/__init__.py @@ -0,0 +1 @@ +"""Packaging dioxus-compose applications for macOS: bundles, signing, store packages, Sparkle feeds.""" diff --git a/tools/packager/dxc_packager/appcast.py b/tools/packager/dxc_packager/appcast.py new file mode 100644 index 00000000..8f79965a --- /dev/null +++ b/tools/packager/dxc_packager/appcast.py @@ -0,0 +1,141 @@ +"""The Sparkle appcast: an RSS feed whose items are the versions an application can update to. + +Sparkle 2 reads, per item: `sparkle:version` (compared with the installed CFBundleVersion), +`sparkle:shortVersionString` (what the person is shown), `sparkle:minimumSystemVersion`, +an optional release notes link, and one `enclosure` whose `url`, `length` and +`sparkle:edSignature` say where the archive is and how to know it is the one that was +published. + +Adding a version keeps every item already in the feed, including anything this module does +not itself write, so a feed that grew by hand or by Sparkle's generate_appcast can be +extended without losing what it says. +""" + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import format_datetime + +from .metadata import PackagingError + +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DC_NS = "http://purl.org/dc/elements/1.1/" +ET.register_namespace("sparkle", SPARKLE_NS) +ET.register_namespace("dc", DC_NS) + + +def _s(name): + return f"{{{SPARKLE_NS}}}{name}" + + +@dataclass(frozen=True) +class Item: + version: str + short_version: str + url: str + length: int + ed_signature: str + minimum_system_version: str + pub_date: datetime + title: str | None = None + release_notes_url: str | None = None + critical: bool = False + # "arm64" for an application that has no Intel code, so an Intel Mac is not offered an + # update it cannot run. Sparkle's generate_appcast writes the same from the binary. + hardware_requirements: str | None = None + + +def version_key(version): + """Order versions the way Sparkle's default comparator does for numeric versions.""" + return tuple(int(part) for part in re.findall(r"\d+", version)) + + +def _item_element(item): + element = ET.Element("item") + ET.SubElement(element, "title").text = item.title or f"Version {item.short_version}" + ET.SubElement(element, "pubDate").text = format_datetime( + item.pub_date.astimezone(timezone.utc) + ) + ET.SubElement(element, _s("version")).text = item.version + ET.SubElement(element, _s("shortVersionString")).text = item.short_version + ET.SubElement(element, _s("minimumSystemVersion")).text = item.minimum_system_version + if item.hardware_requirements: + ET.SubElement(element, _s("hardwareRequirements")).text = item.hardware_requirements + if item.release_notes_url: + ET.SubElement(element, _s("releaseNotesLink")).text = item.release_notes_url + if item.critical: + ET.SubElement(element, _s("criticalUpdate")) + ET.SubElement( + element, + "enclosure", + { + "url": item.url, + "length": str(item.length), + "type": "application/octet-stream", + _s("edSignature"): item.ed_signature, + }, + ) + return element + + +def element_version(element): + """The version an existing item offers, wherever its writer put it.""" + version = element.findtext(_s("version")) + if version: + return version.strip() + # Sparkle 1 feeds put it on the enclosure. + enclosure = element.find("enclosure") + if enclosure is not None and enclosure.get(_s("version")): + return enclosure.get(_s("version")).strip() + raise PackagingError("an existing appcast item has no sparkle:version") + + +def new_feed(title, link=None, description=None): + rss = ET.Element("rss", {"version": "2.0"}) + channel = ET.SubElement(rss, "channel") + ET.SubElement(channel, "title").text = title + if link: + ET.SubElement(channel, "link").text = link + if description: + ET.SubElement(channel, "description").text = description + ET.SubElement(channel, "language").text = "en" + return rss + + +def parse(text): + try: + rss = ET.fromstring(text) + except ET.ParseError as error: + raise PackagingError(f"the existing appcast is not XML: {error}") from error + if rss.tag != "rss" or rss.find("channel") is None: + raise PackagingError("the existing appcast is not an RSS feed with a channel") + return rss + + +def add(rss, item): + """Add `item` to the feed, replacing an item for the same version, newest first.""" + channel = rss.find("channel") + items = [element for element in channel.findall("item")] + for element in items: + channel.remove(element) + kept = [element for element in items if element_version(element) != item.version] + kept.append(_item_element(item)) + kept.sort(key=lambda element: version_key(element_version(element)), reverse=True) + for element in kept: + channel.append(element) + return rss + + +def versions(rss): + return [element_version(element) for element in rss.find("channel").findall("item")] + + +def dumps(rss): + ET.indent(rss, space=" ") + body = ET.tostring(rss, encoding="unicode") + return '\n' + body + "\n" + + +def now(): + return datetime.now(timezone.utc).replace(microsecond=0) diff --git a/tools/packager/dxc_packager/bundle.py b/tools/packager/dxc_packager/bundle.py new file mode 100644 index 00000000..8420e118 --- /dev/null +++ b/tools/packager/dxc_packager/bundle.py @@ -0,0 +1,416 @@ +"""Making, signing and wrapping the .app. + +Layout of what this makes: + + Name.app/Contents/ + Info.plist, PkgInfo + MacOS/ + Frameworks/lib/ the renderer's own lib directory, whole, when the + executable loads the renderer as a library + Frameworks/Sparkle.framework sparkle channel only + Resources/AppIcon.icns + embedded.provisionprofile app-store channel, when given + +The renderer directory goes to Frameworks/lib and not straight into Frameworks because AWT +reads its companions from the parent of the renderer's directory plus "lib"; that is +scripts/bundle-renderer.sh's finding, and that script is what points the executable at the +copy. An executable that is the whole application (one native image with the renderer +linked in) has no renderer library, and then there is nothing to copy. +""" + +import os +import plistlib +import shutil +import subprocess +import tempfile +from pathlib import Path + +from . import plist as plists +from .metadata import PackagingError + +RENDERER_LIBRARY = "libdioxus_compose_renderer.dylib" +REPOSITORY = Path(__file__).resolve().parents[3] +BUNDLE_RENDERER = REPOSITORY / "scripts" / "bundle-renderer.sh" + +# What the Sparkle framework contains that is code, innermost first. Signing has to go +# from the inside out, each piece before whatever contains it, and `codesign --deep` is +# not a substitute: it signs everything with the outer entitlements, which gives Sparkle's +# Downloader service the wrong ones. +SPARKLE_NESTED = ( + ("Versions/B/XPCServices/Installer.xpc", False), + ("Versions/B/XPCServices/Downloader.xpc", True), + ("Versions/B/Autoupdate", False), + ("Versions/B/Updater.app", False), +) + +# Strings that are in any binary which can drive Sparkle. The store build is checked for +# them as well as for the framework, because a host compiled with the updater still names +# them even when the framework was left out, and App Review reads binaries. +SPARKLE_MARKERS = (b"SPUStandardUpdaterController", b"Sparkle.framework") + + +def run(command, **kwargs): + try: + return subprocess.run(command, check=True, text=True, capture_output=True, **kwargs) + except FileNotFoundError as error: + raise PackagingError(f"{command[0]} is not installed: {error}") from error + except subprocess.CalledProcessError as error: + raise PackagingError( + f"{' '.join(map(str, command))} failed with status {error.returncode}:\n" + f"{error.stdout}{error.stderr}" + ) from error + + +def load_commands(path): + """The libraries a Mach-O file names, from every architecture it has.""" + output = run(["otool", "-L", str(path)]).stdout + # Libraries are indented; the unindented lines are "file:" or, for a universal file, + # "file (architecture arm64):" headers, one per architecture. A library lists its own + # install name first, which is what it is called and not something it loads. + own = set( + line.strip() + for line in run(["otool", "-D", str(path)]).stdout.splitlines() + if line.strip() and not line.rstrip().endswith(":") + ) + return [ + name + for name in ( + line.strip().split(" (", 1)[0] + for line in output.splitlines() + if line[:1].isspace() and line.strip() + ) + if name not in own + ] + + +def renderer_dir_of(executable): + """The directory the executable loads the renderer from, or None if it does not.""" + for path in load_commands(executable): + if path.endswith("/" + RENDERER_LIBRARY): + if not path.startswith("/"): + raise PackagingError( + f"{executable} already loads the renderer by a relative path ({path}); " + f"package the executable cargo built, not one taken from a bundle" + ) + return Path(path).parent + return None + + +def is_mach_o(path): + try: + with open(path, "rb") as handle: + magic = handle.read(4) + except OSError: + return False + return magic in ( + b"\xcf\xfa\xed\xfe", # 64 bit, little endian + b"\xce\xfa\xed\xfe", + b"\xca\xfe\xba\xbe", # universal + b"\xbe\xba\xfe\xca", + ) + + +def hardware_requirements(app): + """What Sparkle should require of a Mac before offering this app: "arm64" or nothing.""" + info = plistlib.loads((Path(app) / "Contents" / "Info.plist").read_bytes()) + executable = Path(app) / "Contents" / "MacOS" / info["CFBundleExecutable"] + architectures = set(run(["lipo", "-archs", str(executable)]).stdout.split()) + if architectures and architectures <= {"arm64", "arm64e"}: + return "arm64" + return None + + +def make_icns(source, destination): + source = Path(source) + if source.suffix.lower() == ".icns": + shutil.copyfile(source, destination) + return + width = int(run(["sips", "-g", "pixelWidth", str(source)]).stdout.split()[-1]) + if width < 1024: + print( + f"warning: {source} is {width} pixels wide. The Mac App Store asks for a 1024 " + f"pixel icon (512 points at 2x), so this one is scaled up." + ) + with tempfile.TemporaryDirectory() as scratch: + iconset = Path(scratch) / "AppIcon.iconset" + iconset.mkdir() + for points in (16, 32, 128, 256, 512): + for scale in (1, 2): + pixels = points * scale + suffix = "" if scale == 1 else "@2x" + run( + [ + "sips", "-z", str(pixels), str(pixels), str(source), + "--out", str(iconset / f"icon_{points}x{points}{suffix}.png"), + ] + ) + run(["iconutil", "-c", "icns", str(iconset), "-o", str(destination)]) + + +def assemble( + metadata, + executable, + out_dir, + channel, + *, + sparkle=None, + sparkle_framework=None, + uses_non_exempt_encryption=None, + extra_plist=None, + provisioning_profile=None, +): + """Build Name.app in `out_dir` and return its path. Replaces one already there.""" + executable = Path(executable) + if not executable.is_file(): + raise PackagingError(f"no executable at {executable}") + info = plists.info_plist( + metadata, + channel, + sparkle=sparkle, + uses_non_exempt_encryption=uses_non_exempt_encryption, + extra=extra_plist, + ) + if channel == plists.SPARKLE: + if sparkle_framework is None: + raise PackagingError( + "the sparkle channel needs --sparkle-framework, the Sparkle.framework " + "from a Sparkle 2 release archive" + ) + sparkle_framework = Path(sparkle_framework) + if not (sparkle_framework / "Versions" / "B" / "Sparkle").exists(): + raise PackagingError(f"{sparkle_framework} is not a Sparkle 2 framework") + elif sparkle_framework is not None: + raise PackagingError("the app-store build must not contain Sparkle.framework") + if channel == plists.APP_STORE and provisioning_profile is None: + print( + "warning: no provisioning profile. The bundle runs and can be checked, but App " + "Store Connect refuses an upload without the profile for this identifier." + ) + + app = Path(out_dir) / f"{metadata.name}.app" + if app.exists(): + shutil.rmtree(app) + contents = app / "Contents" + (contents / "MacOS").mkdir(parents=True) + (contents / "Resources").mkdir() + (contents / "Frameworks").mkdir() + + staged = contents / "MacOS" / metadata.executable + shutil.copy2(executable, staged) + os.chmod(staged, 0o755) + + renderer = renderer_dir_of(executable) + if renderer is not None: + if not (renderer / RENDERER_LIBRARY).exists(): + raise PackagingError( + f"{executable} was linked against {renderer / RENDERER_LIBRARY}, which is " + f"no longer there" + ) + # Contents/Frameworks may hold only code. codesign seals anything else there as an + # unsigned subcomponent and refuses the bundle, so say which file it is now rather + # than leave the person to read that out of a codesign failure. The macOS renderer + # directory is all dylibs today. + strays = [ + str(path.relative_to(renderer)) + for path in sorted(renderer.rglob("*")) + if path.is_file() and not is_mach_o(path) + ] + if strays: + raise PackagingError( + f"the renderer directory {renderer} holds files that are not code " + f"({', '.join(strays)}). Contents/Frameworks/lib may hold only code, so " + f"these would make the bundle unsignable; data the renderer reads belongs " + f"in Contents/Resources." + ) + # symlinks=True keeps the directory as it was laid out; nothing in it is a link + # today, and a link pointing outside the bundle would be refused by codesign. + shutil.copytree(renderer, contents / "Frameworks" / "lib", symlinks=True) + run(["bash", str(BUNDLE_RENDERER), str(staged), "../Frameworks/lib"]) + + if sparkle_framework is not None: + # symlinks=True: a framework is Versions/Current plus links into it, and copying + # the links as files makes a framework codesign rejects as ambiguous. + shutil.copytree( + sparkle_framework, contents / "Frameworks" / "Sparkle.framework", symlinks=True + ) + + if metadata.icon is not None: + make_icns(metadata.icon, contents / "Resources" / "AppIcon.icns") + if provisioning_profile is not None: + shutil.copyfile(provisioning_profile, contents / "embedded.provisionprofile") + + (contents / "Info.plist").write_bytes(plists.dumps(info)) + (contents / "PkgInfo").write_text("APPL????") + return app + + +def _codesign( + path, identity, *, entitlements=None, runtime=True, preserve_entitlements=False, keychain=None +): + command = ["codesign", "--force", "--sign", identity] + if keychain is not None: + command += ["--keychain", str(keychain)] + if identity != "-": + # A secure timestamp is required for notarization. Ad-hoc signatures have no + # certificate to timestamp. + command.append("--timestamp") + if runtime: + command += ["--options", "runtime"] + if entitlements is not None: + command += ["--entitlements", str(entitlements)] + elif preserve_entitlements: + command.append("--preserve-metadata=entitlements") + command.append(str(path)) + run(command) + + +def sign(app, identity, channel, entitlements, *, keychain=None): + """Sign every piece of code in `app`, innermost first, then the app itself. + + `identity` is "-" for an ad-hoc signature, otherwise the name or hash of a certificate: + "Developer ID Application: ..." for the sparkle channel, "3rd Party Mac Developer + Application: ..." or "Apple Distribution: ..." for the app-store channel. + """ + app = Path(app) + contents = app / "Contents" + # The store build runs sandboxed and the store re-checks it; the hardened runtime is + # what notarization requires of the other one. Both get it: the sandboxed build loses + # nothing by it and one signing path is one fewer thing to get wrong. + runtime = True + + libraries = contents / "Frameworks" / "lib" + if libraries.is_dir(): + for path in sorted(libraries.rglob("*")): + if path.is_file() and not path.is_symlink() and is_mach_o(path): + _codesign(path, identity, runtime=runtime, keychain=keychain) + + framework = contents / "Frameworks" / "Sparkle.framework" + if framework.exists(): + if channel == plists.APP_STORE: + raise PackagingError(f"{framework} is in an app-store build") + for relative, keep_entitlements in SPARKLE_NESTED: + nested = framework / relative + if nested.exists(): + _codesign( + nested, + identity, + runtime=runtime, + preserve_entitlements=keep_entitlements, + keychain=keychain, + ) + _codesign(framework, identity, runtime=runtime, keychain=keychain) + + handle, name = tempfile.mkstemp(suffix=".entitlements") + os.close(handle) + entitlements_file = Path(name) + try: + entitlements_file.write_bytes(plists.dumps(entitlements)) + _codesign( + app, identity, entitlements=entitlements_file, runtime=runtime, keychain=keychain + ) + finally: + entitlements_file.unlink() + run(["codesign", "--verify", "--strict", "--deep", "--verbose=2", str(app)]) + return app + + +def zip_for_sparkle(app, out): + """The archive Sparkle downloads: a zip made by ditto, which keeps links and metadata.""" + out = Path(out) + if out.exists(): + out.unlink() + run(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", str(app), str(out)]) + return out + + +def dmg(app, out, volume_name): + """A disk image holding the app and a link to /Applications, for handing out directly.""" + out = Path(out) + if out.exists(): + out.unlink() + with tempfile.TemporaryDirectory() as scratch: + staging = Path(scratch) / volume_name + staging.mkdir() + run(["ditto", str(app), str(staging / Path(app).name)]) + (staging / "Applications").symlink_to("/Applications") + run( + [ + "hdiutil", "create", "-volname", volume_name, "-srcfolder", str(staging), + "-ov", "-format", "UDZO", str(out), + ] + ) + return out + + +def pkg(app, out, installer_identity=None): + """The installer package App Store Connect takes, installing the app into /Applications.""" + out = Path(out) + if out.exists(): + out.unlink() + command = ["productbuild", "--component", str(app), "/Applications"] + if installer_identity: + command += ["--sign", installer_identity] + command.append(str(out)) + run(command) + return out + + +def notarize(path, keychain_profile): + """Submit to Apple's notary service, wait, and staple the ticket to the app or disk image. + + `keychain_profile` is the name given to `xcrun notarytool store-credentials`, which + keeps the Apple ID app password or App Store Connect API key out of command lines. + """ + run( + [ + "xcrun", "notarytool", "submit", str(path), + "--keychain-profile", keychain_profile, "--wait", + ] + ) + + +def staple(path): + run(["xcrun", "stapler", "staple", str(path)]) + + +def sparkle_residue(app): + """Everything in `app` that would make it an application that updates itself.""" + app = Path(app) + problems = [] + info_path = app / "Contents" / "Info.plist" + if info_path.exists(): + info = plistlib.loads(info_path.read_bytes()) + problems += [f"Info.plist sets {key}" for key in sorted(info) if key.startswith("SU")] + for path in sorted(app.rglob("*")): + if path.name == "Sparkle.framework": + problems.append(f"{path.relative_to(app)} is in the bundle") + if path.is_file() and not path.is_symlink() and is_mach_o(path): + data = path.read_bytes() + for marker in SPARKLE_MARKERS: + if marker in data: + problems.append( + f"{path.relative_to(app)} contains {marker.decode()}; build the " + f"store executable without the updater" + ) + return problems + + +def absolute_references(app): + """Libraries the bundle's code loads from outside the bundle and outside the system.""" + app = Path(app) + problems = [] + for path in sorted(app.rglob("*")): + if not path.is_file() or path.is_symlink() or not is_mach_o(path): + continue + for target in load_commands(path): + if target.startswith("/") and not target.startswith(("/usr/lib/", "/System/")): + problems.append(f"{path.relative_to(app)} loads {target}") + return problems + + +def entitlements_of(app): + output = run(["codesign", "-d", "--entitlements", "-", "--xml", str(app)]).stdout + if not output.strip(): + return {} + start = output.find("= 0 else {} diff --git a/tools/packager/dxc_packager/cli.py b/tools/packager/dxc_packager/cli.py new file mode 100644 index 00000000..238a34ca --- /dev/null +++ b/tools/packager/dxc_packager/cli.py @@ -0,0 +1,300 @@ +"""package-macos: bundle, sign and publish a dioxus-compose application for macOS. + +The two channels, end to end: + + Sparkle (Developer ID, updates itself): + package-macos keygen --out keys/sparkle.key # once, ever; keep the key + package-macos app --channel sparkle --manifest-dir . --executable target/release/app \\ + --feed-url https://example.com/appcast.xml --ed-key-file keys/sparkle.key \\ + --sparkle-framework Sparkle.framework --sign "Developer ID Application: ..." \\ + --out dist + package-macos archive --app dist/App.app --out dist/App-1.2.0.zip + package-macos notarize --path dist/App-1.2.0.zip --keychain-profile notary --staple dist/App.app + package-macos archive --app dist/App.app --out dist/App-1.2.0.zip # again, stapled + package-macos appcast --appcast site/appcast.xml --app dist/App.app \\ + --archive dist/App-1.2.0.zip --url https://example.com/App-1.2.0.zip \\ + --ed-key-file keys/sparkle.key --sign-update Sparkle/bin/sign_update + + Mac App Store (sandboxed, updated by the store): + package-macos app --channel app-store --manifest-dir . --executable target/release/app \\ + --uses-non-exempt-encryption no --network-client --user-selected-files read-write \\ + --provisioning-profile App.provisionprofile --team-id ABCDE12345 \\ + --sign "3rd Party Mac Developer Application: ..." --out dist-store + package-macos pkg --app dist-store/App.app --out dist-store/App.pkg \\ + --sign "3rd Party Mac Developer Installer: ..." +""" + +import argparse +import plistlib +import sys +from pathlib import Path + +from . import appcast, bundle, metadata, plist, signing +from .metadata import PackagingError + + +def _yes_no(value): + if value in ("yes", "true"): + return True + if value in ("no", "false"): + return False + raise argparse.ArgumentTypeError("answer yes or no") + + +def _add_app_arguments(parser): + parser.add_argument("--manifest-dir", required=True, type=Path, + help="directory holding the application's Cargo.toml and Dioxus.toml") + parser.add_argument("--executable", required=True, type=Path, + help="the executable cargo built (not one already taken from a bundle)") + parser.add_argument("--channel", required=True, choices=plist.CHANNELS) + parser.add_argument("--out", required=True, type=Path, help="directory to put Name.app in") + parser.add_argument("--version", help="CFBundleShortVersionString; default from Cargo.toml") + parser.add_argument("--build", help="CFBundleVersion; default the version") + parser.add_argument("--category", help="LSApplicationCategoryType or a dx category name") + parser.add_argument("--minimum-system-version") + parser.add_argument("--plist", action="append", metavar="KEY=VALUE", + help="an extra Info.plist entry; repeatable") + parser.add_argument("--plist-file", type=Path, + help="a plist whose entries are added to Info.plist") + parser.add_argument("--sign", default="-", + help='codesign identity; "-" (the default) signs ad hoc') + parser.add_argument("--keychain", type=Path, help="keychain holding the identity") + + sparkle = parser.add_argument_group("sparkle channel") + sparkle.add_argument("--feed-url") + key = sparkle.add_mutually_exclusive_group() + key.add_argument("--public-ed-key", help="SUPublicEDKey, base64") + key.add_argument("--ed-key-file", type=Path, + help="the private key file; SUPublicEDKey is derived from it") + sparkle.add_argument("--sparkle-framework", type=Path) + sparkle.add_argument("--check-interval", type=int, default=plist.DEFAULT_CHECK_INTERVAL) + sparkle.add_argument("--no-automatic-checks", action="store_true") + sparkle.add_argument("--automatically-update", action="store_true", + help="download and install without asking (SUAutomaticallyUpdate)") + + store = parser.add_argument_group("app-store channel") + store.add_argument("--uses-non-exempt-encryption", type=_yes_no) + store.add_argument("--provisioning-profile", type=Path) + store.add_argument("--team-id") + store.add_argument("--network-client", action="store_true") + store.add_argument("--network-server", action="store_true") + store.add_argument("--user-selected-files", choices=("read-only", "read-write")) + store.add_argument("--downloads-folder", choices=("read-only", "read-write")) + + +def command_app(args): + meta = metadata.load(args.manifest_dir, version=args.version, build=args.build) + meta = metadata.with_overrides( + meta, category=args.category, minimum_system_version=args.minimum_system_version + ) + extra = plist.parse_extra(args.plist) + if args.plist_file: + extra.update(plistlib.loads(args.plist_file.read_bytes())) + + sparkle = None + if args.channel == plist.SPARKLE: + public_key = args.public_ed_key + if args.ed_key_file: + public_key = signing.public_key_of(args.ed_key_file) + if not args.feed_url or not public_key: + raise PackagingError("the sparkle channel needs --feed-url and a key") + sparkle = plist.SparkleSettings( + feed_url=args.feed_url, + public_ed_key=public_key, + automatic_checks=not args.no_automatic_checks, + check_interval=args.check_interval, + automatically_update=args.automatically_update, + ) + + args.out.mkdir(parents=True, exist_ok=True) + app = bundle.assemble( + meta, + args.executable, + args.out, + args.channel, + sparkle=sparkle, + sparkle_framework=args.sparkle_framework, + uses_non_exempt_encryption=args.uses_non_exempt_encryption, + extra_plist=extra, + provisioning_profile=args.provisioning_profile, + ) + capabilities = plist.Capabilities( + network_client=args.network_client, + network_server=args.network_server, + user_selected_files=args.user_selected_files, + downloads_folder=args.downloads_folder, + ) + entitlements = plist.entitlements( + args.channel, capabilities=capabilities, team_id=args.team_id, identifier=meta.identifier + ) + if args.sign == "-": + print("note: signing ad hoc. This bundle runs here and cannot be distributed.") + bundle.sign(app, args.sign, args.channel, entitlements, keychain=args.keychain) + problems = _verify(app, args.channel) + if problems: + raise PackagingError("the bundle is not shippable:\n " + "\n ".join(problems)) + print(app) + + +def _verify(app, channel): + problems = bundle.absolute_references(app) + granted = bundle.entitlements_of(app) + if channel == plist.APP_STORE: + problems += bundle.sparkle_residue(app) + if not granted.get("com.apple.security.app-sandbox"): + problems.append("the app-store build is not sandboxed") + else: + if not (app / "Contents" / "Frameworks" / "Sparkle.framework").exists(): + problems.append("the sparkle build has no Sparkle.framework") + if granted.get("com.apple.security.app-sandbox"): + problems.append("the sparkle build is sandboxed, and Sparkle cannot replace it") + return problems + + +def command_verify(args): + problems = _verify(args.app, args.channel) + for problem in problems: + print(f"error: {problem}", file=sys.stderr) + if problems: + return 1 + print(f"ok {args.app} is a {args.channel} bundle") + return 0 + + +def command_pkg(args): + problems = _verify(args.app, plist.APP_STORE) + if problems: + raise PackagingError( + "only an app-store bundle goes into the store package:\n " + "\n ".join(problems) + ) + if not args.sign: + print("note: the package is unsigned. App Store Connect needs it signed with a " + "3rd Party Mac Developer Installer (or Mac Installer Distribution) identity.") + print(bundle.pkg(args.app, args.out, args.sign)) + + +def command_archive(args): + print(bundle.zip_for_sparkle(args.app, args.out)) + + +def command_dmg(args): + print(bundle.dmg(args.app, args.out, args.volume_name or args.app.stem)) + + +def command_notarize(args): + bundle.notarize(args.path, args.keychain_profile) + if args.staple: + bundle.staple(args.staple) + + +def command_keygen(args): + public = signing.keygen(args.out) + print(f"private key written to {args.out}; keep it out of the repository") + print(f"SUPublicEDKey: {public}") + + +def command_public_key(args): + print(signing.public_key_of(args.key)) + + +def command_appcast(args): + info = plistlib.loads((args.app / "Contents" / "Info.plist").read_bytes()) + public_key = info.get("SUPublicEDKey") + if not public_key: + raise PackagingError(f"{args.app} has no SUPublicEDKey; it is not a sparkle build") + signature = signing.sign_archive( + args.archive, args.ed_key_file, sign_update=args.sign_update, public_key=public_key + ) + item = appcast.Item( + version=info["CFBundleVersion"], + short_version=info["CFBundleShortVersionString"], + url=args.url, + length=args.archive.stat().st_size, + ed_signature=signature, + minimum_system_version=info.get("LSMinimumSystemVersion", "13.0"), + pub_date=appcast.now(), + title=args.title, + release_notes_url=args.release_notes_url, + critical=args.critical, + hardware_requirements=bundle.hardware_requirements(args.app), + ) + if args.appcast.exists(): + feed = appcast.parse(args.appcast.read_text()) + else: + feed = appcast.new_feed(args.feed_title or info.get("CFBundleName", "Updates")) + appcast.add(feed, item) + args.appcast.parent.mkdir(parents=True, exist_ok=True) + args.appcast.write_text(appcast.dumps(feed)) + print(f"{args.appcast}: {', '.join(appcast.versions(feed))}") + + +def parser(): + root = argparse.ArgumentParser( + prog="package-macos", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + commands = root.add_subparsers(dest="command", required=True) + + app = commands.add_parser("app", help="make and sign Name.app for one channel") + _add_app_arguments(app) + app.set_defaults(run=command_app) + + verify = commands.add_parser("verify", help="check a bundle is what its channel needs") + verify.add_argument("--app", required=True, type=Path) + verify.add_argument("--channel", required=True, choices=plist.CHANNELS) + verify.set_defaults(run=command_verify) + + pkg = commands.add_parser("pkg", help="the installer package for App Store Connect") + pkg.add_argument("--app", required=True, type=Path) + pkg.add_argument("--out", required=True, type=Path) + pkg.add_argument("--sign", help="installer identity") + pkg.set_defaults(run=command_pkg) + + archive = commands.add_parser("archive", help="the zip Sparkle downloads") + archive.add_argument("--app", required=True, type=Path) + archive.add_argument("--out", required=True, type=Path) + archive.set_defaults(run=command_archive) + + dmg = commands.add_parser("dmg", help="a disk image for handing the app out directly") + dmg.add_argument("--app", required=True, type=Path) + dmg.add_argument("--out", required=True, type=Path) + dmg.add_argument("--volume-name") + dmg.set_defaults(run=command_dmg) + + notarize = commands.add_parser("notarize", help="notarize a zip, dmg or pkg and staple") + notarize.add_argument("--path", required=True, type=Path) + notarize.add_argument("--keychain-profile", required=True) + notarize.add_argument("--staple", type=Path, help="app or dmg to staple the ticket to") + notarize.set_defaults(run=command_notarize) + + keygen = commands.add_parser("keygen", help="a new EdDSA key pair for Sparkle") + keygen.add_argument("--out", required=True, type=Path) + keygen.set_defaults(run=command_keygen) + + public_key = commands.add_parser("public-key", help="SUPublicEDKey for a private key file") + public_key.add_argument("--key", required=True, type=Path) + public_key.set_defaults(run=command_public_key) + + feed = commands.add_parser("appcast", help="add a signed version to an appcast") + feed.add_argument("--appcast", required=True, type=Path) + feed.add_argument("--app", required=True, type=Path, + help="the bundle inside the archive; versions and key are read from it") + feed.add_argument("--archive", required=True, type=Path) + feed.add_argument("--url", required=True, help="where the archive will be downloaded from") + feed.add_argument("--ed-key-file", required=True, type=Path) + feed.add_argument("--sign-update", type=Path, help="Sparkle's sign_update, preferred when given") + feed.add_argument("--title") + feed.add_argument("--feed-title") + feed.add_argument("--release-notes-url") + feed.add_argument("--critical", action="store_true") + feed.set_defaults(run=command_appcast) + return root + + +def main(argv=None): + args = parser().parse_args(argv) + try: + status = args.run(args) + except PackagingError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return status or 0 diff --git a/tools/packager/dxc_packager/ed25519.py b/tools/packager/dxc_packager/ed25519.py new file mode 100644 index 00000000..56ecee3a --- /dev/null +++ b/tools/packager/dxc_packager/ed25519.py @@ -0,0 +1,185 @@ +"""Ed25519 as RFC 8032 writes it, for the keys Sparkle uses to sign updates. + +Sparkle keeps its private key as the base64 of a 32 byte seed, and puts the base64 of the +32 byte public key in the application's Info.plist as SUPublicEDKey. Those two numbers are +the whole of the trust an installed application places in an update, so the packager has +to be able to answer three questions without a keychain: which public key belongs to this +private key, does this signature verify under the key the application carries, and, on a +machine without Sparkle's tools (Linux CI), what is the signature. + +This is the reference algorithm and not a hardened one: its arithmetic takes time that +depends on the secret. Signing on a shared machine should go through Sparkle's +sign_update, which the packager prefers whenever it is given one; this module then only +checks that what sign_update produced verifies under the application's public key. +""" + +import base64 +import hashlib + +_P = 2**255 - 19 +_L = 2**252 + 27742317777372353535851937790883648493 +_D = -121665 * pow(121666, _P - 2, _P) % _P +_SQRT_M1 = pow(2, (_P - 1) // 4, _P) + + +def _sha512(data): + return hashlib.sha512(data).digest() + + +def _sha512_mod_l(data): + return int.from_bytes(_sha512(data), "little") % _L + + +def _add(a, b): + x1, y1, z1, t1 = a + x2, y2, z2, t2 = b + pa = (y1 - x1) * (y2 - x2) % _P + pb = (y1 + x1) * (y2 + x2) % _P + pc = 2 * t1 * t2 * _D % _P + pd = 2 * z1 * z2 % _P + e, f, g, h = pb - pa, pd - pc, pd + pc, pb + pa + return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) + + +def _mul(scalar, point): + result = (0, 1, 1, 0) + while scalar > 0: + if scalar & 1: + result = _add(result, point) + point = _add(point, point) + scalar >>= 1 + return result + + +def _equal(a, b): + return (a[0] * b[2] - b[0] * a[2]) % _P == 0 and (a[1] * b[2] - b[1] * a[2]) % _P == 0 + + +def _recover_x(y, sign): + if y >= _P: + return None + x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) + if x2 == 0: + return None if sign else 0 + x = pow(x2, (_P + 3) // 8, _P) + if (x * x - x2) % _P != 0: + x = x * _SQRT_M1 % _P + if (x * x - x2) % _P != 0: + return None + if (x & 1) != sign: + x = _P - x + return x + + +_GY = 4 * pow(5, _P - 2, _P) % _P +_GX = _recover_x(_GY, 0) +_G = (_GX, _GY, 1, _GX * _GY % _P) + + +def _compress(point): + zinv = pow(point[2], _P - 2, _P) + x = point[0] * zinv % _P + y = point[1] * zinv % _P + return int.to_bytes(y | ((x & 1) << 255), 32, "little") + + +def _decompress(data): + if len(data) != 32: + return None + y = int.from_bytes(data, "little") + sign = y >> 255 + y &= (1 << 255) - 1 + x = _recover_x(y, sign) + if x is None: + return None + return (x, y, 1, x * y % _P) + + +def _expand_seed(seed): + digest = _sha512(seed) + return _clamp(digest[:32]), digest[32:] + + +def _clamp(scalar_bytes): + a = int.from_bytes(scalar_bytes, "little") + a &= (1 << 254) - 8 + a |= 1 << 254 + return a + + +class InvalidKey(ValueError): + """A private key that is not one Sparkle could have written.""" + + +def _expanded_from_private(private): + """The secret scalar and nonce prefix for a Sparkle 2 private key. + + Sparkle 2's generate_keys exports a 32 byte seed, and that is the form sign_update was + checked against. Keys from Sparkle 1 are longer and carry the public key beside the + secret; generate_keys can import one (`-f`) and export it again as a seed (`-x`). + """ + if len(private) == 32: + return _expand_seed(private) + raise InvalidKey( + f"a Sparkle 2 private key is a 32 byte seed, and this one is {len(private)} bytes. " + f"A key from Sparkle 1 can be converted by importing it with `generate_keys -f` " + f"and exporting it with `generate_keys -x`." + ) + + +def public_key(private): + scalar, _ = _expanded_from_private(private) + return _compress(_mul(scalar, _G)) + + +def sign(private, message): + scalar, prefix = _expanded_from_private(private) + public = _compress(_mul(scalar, _G)) + r = _sha512_mod_l(prefix + message) + encoded_r = _compress(_mul(r, _G)) + h = _sha512_mod_l(encoded_r + public + message) + s = (r + h * scalar) % _L + return encoded_r + int.to_bytes(s, 32, "little") + + +def verify(public, message, signature): + if len(public) != 32 or len(signature) != 64: + return False + point_a = _decompress(public) + if point_a is None: + return False + encoded_r = signature[:32] + point_r = _decompress(encoded_r) + if point_r is None: + return False + s = int.from_bytes(signature[32:], "little") + if s >= _L: + return False + h = _sha512_mod_l(encoded_r + public + message) + return _equal(_mul(s, _G), _add(point_r, _mul(h, point_a))) + + +def read_private_key_file(path): + """The key bytes from a file holding the base64 Sparkle's tools write.""" + with open(path, "rb") as handle: + text = handle.read().strip() + try: + private = base64.b64decode(text, validate=True) + except ValueError as error: + raise InvalidKey(f"{path} is not base64: {error}") from error + _expanded_from_private(private) + return private + + +def decode_public_key(text): + """The 32 bytes behind an SUPublicEDKey value, or a ValueError saying what is wrong.""" + try: + raw = base64.b64decode(text, validate=True) + except ValueError as error: + raise ValueError(f"SUPublicEDKey {text!r} is not base64: {error}") from error + if len(raw) != 32: + raise ValueError( + f"SUPublicEDKey decodes to {len(raw)} bytes; an Ed25519 public key is 32. " + f"It is the line generate_keys prints, or `package-macos keygen` writes." + ) + return raw diff --git a/tools/packager/dxc_packager/metadata.py b/tools/packager/dxc_packager/metadata.py new file mode 100644 index 00000000..5fff5b84 --- /dev/null +++ b/tools/packager/dxc_packager/metadata.py @@ -0,0 +1,201 @@ +"""What an application says about itself, read from the files its project already has. + +An application built with dioxus-compose is an ordinary dx project, so its name, bundle +identifier and description are in `Dioxus.toml` and its version is in `Cargo.toml`. The +packager reads them there rather than asking for them again, so the bundle cannot drift +from the project it was built from. + +`Dioxus.toml` keys read (all optional except the identifier): + + [application] name + [bundle] identifier, publisher, copyright, category, short_description, icon + [bundle.macos] or [macos] + bundle_name, minimum_system_version +""" + +import re +import sys +from dataclasses import dataclass, replace +from pathlib import Path + +if sys.version_info < (3, 11): + raise SystemExit("the packager reads TOML with tomllib, which needs Python 3.11 or newer") + +import tomllib + + +class PackagingError(Exception): + """Something the person packaging has to fix, worded so they can.""" + + +# dx (through tauri-bundler) names categories in CamelCase; macOS wants the uniform type +# identifier. Either spelling is accepted in Dioxus.toml. +CATEGORIES = { + "Business": "public.app-category.business", + "DeveloperTool": "public.app-category.developer-tools", + "Education": "public.app-category.education", + "Entertainment": "public.app-category.entertainment", + "Finance": "public.app-category.finance", + "Game": "public.app-category.games", + "GraphicsAndDesign": "public.app-category.graphics-design", + "HealthcareAndFitness": "public.app-category.healthcare-fitness", + "Lifestyle": "public.app-category.lifestyle", + "Medical": "public.app-category.medical", + "Music": "public.app-category.music", + "News": "public.app-category.news", + "Photography": "public.app-category.photography", + "Productivity": "public.app-category.productivity", + "Reference": "public.app-category.reference", + "SocialNetworking": "public.app-category.social-networking", + "Sports": "public.app-category.sports", + "Travel": "public.app-category.travel", + "Utility": "public.app-category.utilities", + "Video": "public.app-category.video", + "Weather": "public.app-category.weather", +} + +# The App Store and Sparkle both compare CFBundleVersion as dot separated integers, and App +# Store Connect refuses anything else, so a version is checked here rather than at upload. +_BUNDLE_VERSION = re.compile(r"^\d+(\.\d+){0,2}$") +_IDENTIFIER = re.compile(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$") + +# Compose's AWT and Metal paths and the renderer are built and tested on macOS 13 and +# newer, so nothing older is promised. +DEFAULT_MINIMUM_SYSTEM_VERSION = "13.0" + + +@dataclass(frozen=True) +class AppMetadata: + name: str + display_name: str + identifier: str + executable: str + version: str + build: str + minimum_system_version: str = DEFAULT_MINIMUM_SYSTEM_VERSION + copyright: str | None = None + category: str | None = None + description: str | None = None + icon: Path | None = None + + +def category_type(value): + if value is None: + return None + if value.startswith("public.app-category."): + return value + if value in CATEGORIES: + return CATEGORIES[value] + known = ", ".join(sorted(CATEGORIES)) + raise PackagingError( + f"category {value!r} is neither a public.app-category.* identifier nor one of the " + f"names dx uses ({known})" + ) + + +def check_bundle_version(field, value): + if not _BUNDLE_VERSION.match(value): + raise PackagingError( + f"{field} is {value!r}, and macOS wants one to three dot separated integers " + f"(1, 1.2 or 1.2.3). A prerelease version from Cargo.toml needs --version and " + f"--build given explicitly." + ) + return value + + +def _read_toml(path): + try: + with open(path, "rb") as handle: + return tomllib.load(handle) + except FileNotFoundError: + return None + except tomllib.TOMLDecodeError as error: + raise PackagingError(f"{path} is not valid TOML: {error}") from error + + +def load(manifest_dir, *, executable=None, version=None, build=None): + """Metadata for the project at `manifest_dir`, with explicit overrides applied last.""" + manifest_dir = Path(manifest_dir) + cargo = _read_toml(manifest_dir / "Cargo.toml") + if cargo is None: + raise PackagingError(f"no Cargo.toml in {manifest_dir}") + dioxus = _read_toml(manifest_dir / "Dioxus.toml") or {} + package = cargo.get("package", {}) + + cargo_version = package.get("version") + if isinstance(cargo_version, dict): + # `version.workspace = true`: the number is in a workspace this tool does not walk. + cargo_version = None + version = version or cargo_version + if not version: + raise PackagingError( + f"{manifest_dir}/Cargo.toml does not state a version here (it may inherit one " + f"from the workspace); pass --version" + ) + + application = dioxus.get("application", {}) + bundle = dioxus.get("bundle", {}) + macos = {**dioxus.get("macos", {}), **bundle.get("macos", {})} + + identifier = bundle.get("identifier") + if not identifier: + raise PackagingError( + f"{manifest_dir}/Dioxus.toml has no [bundle] identifier. The bundle identifier " + f"is what macOS, Sparkle and App Store Connect know the application by, so it " + f"has to be chosen rather than made up here." + ) + if not _IDENTIFIER.match(identifier): + raise PackagingError( + f"bundle identifier {identifier!r} must be reverse DNS made of letters, digits, " + f"hyphens and dots" + ) + + name = macos.get("bundle_name") or application.get("name") or package.get("name") + executable = executable or package.get("name") + + icon = None + icons = bundle.get("icon") + if isinstance(icons, str): + icons = [icons] + for candidate in icons or []: + path = manifest_dir / candidate + if path.suffix.lower() in (".png", ".icns") and path.exists(): + icon = path + break + if icon is None: + fallback = manifest_dir / application.get("asset_dir", "assets") / "icon.png" + if fallback.exists(): + icon = fallback + + copyright_text = bundle.get("copyright") + if copyright_text is None and bundle.get("publisher"): + copyright_text = f"Copyright {bundle['publisher']}" + + metadata = AppMetadata( + name=name, + display_name=name, + identifier=identifier, + executable=executable, + version=version, + build=build or version, + minimum_system_version=macos.get( + "minimum_system_version", DEFAULT_MINIMUM_SYSTEM_VERSION + ), + copyright=copyright_text, + category=category_type(bundle.get("category")), + description=bundle.get("short_description"), + icon=icon, + ) + check_bundle_version("CFBundleShortVersionString", metadata.version) + check_bundle_version("CFBundleVersion", metadata.build) + return metadata + + +def with_overrides(metadata, **changes): + changes = {key: value for key, value in changes.items() if value is not None} + if "category" in changes: + changes["category"] = category_type(changes["category"]) + updated = replace(metadata, **changes) + check_bundle_version("CFBundleShortVersionString", updated.version) + check_bundle_version("CFBundleVersion", updated.build) + return updated diff --git a/tools/packager/dxc_packager/plist.py b/tools/packager/dxc_packager/plist.py new file mode 100644 index 00000000..08e46cd1 --- /dev/null +++ b/tools/packager/dxc_packager/plist.py @@ -0,0 +1,218 @@ +"""Info.plist and entitlements for the two macOS channels. + +There are two ways an application reaches someone, and they want different bundles: + +- `sparkle`: signed with a Developer ID, notarized, handed out as a download, and updated + in place by Sparkle from an appcast. Hardened runtime, no sandbox, Sparkle inside. +- `app-store`: signed for the Mac App Store, sandboxed, installed and updated by the store. + It must not contain Sparkle or anything else that updates the application by itself. + +Everything that differs between them is decided here, from the channel, so a bundle +cannot be half one and half the other. +""" + +import plistlib +from dataclasses import dataclass +from urllib.parse import urlparse + +from . import ed25519 +from .metadata import PackagingError + +SPARKLE = "sparkle" +APP_STORE = "app-store" +CHANNELS = (SPARKLE, APP_STORE) + +# Sparkle checks once a day unless told otherwise, and will not check more often than +# once an hour however it is told. +DEFAULT_CHECK_INTERVAL = 86400 +MINIMUM_CHECK_INTERVAL = 3600 + +_LOOPBACK = {"localhost", "127.0.0.1", "::1"} + + +@dataclass(frozen=True) +class SparkleSettings: + feed_url: str + public_ed_key: str + automatic_checks: bool = True + check_interval: int = DEFAULT_CHECK_INTERVAL + automatically_update: bool = False + + +def check_feed_url(url): + parsed = urlparse(url) + if parsed.scheme == "https" and parsed.netloc: + return url + if parsed.scheme == "http" and parsed.hostname in _LOOPBACK: + # Loopback is how an update is rehearsed on one machine. Nothing else gets http: + # the EdDSA signature protects the archive, but not the appcast that says which + # archive is newest or what its release notes say. + return url + raise PackagingError( + f"SUFeedURL {url!r} must be https. Plain http is accepted only for localhost, " + f"which is for rehearsing an update on one machine." + ) + + +def info_plist(metadata, channel, *, sparkle=None, uses_non_exempt_encryption=None, extra=None): + """The Info.plist dictionary for `metadata` packaged for `channel`.""" + if channel not in CHANNELS: + raise PackagingError(f"channel {channel!r} is not one of {', '.join(CHANNELS)}") + + plist = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": metadata.display_name, + "CFBundleExecutable": metadata.executable, + "CFBundleIdentifier": metadata.identifier, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": metadata.name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": metadata.version, + "CFBundleSupportedPlatforms": ["MacOSX"], + "CFBundleVersion": metadata.build, + "LSMinimumSystemVersion": metadata.minimum_system_version, + # Without this a bundled application is drawn at 1x and scaled up, which shows as + # stepped edges on every rounded corner. A bare executable gets 2x by default, so + # the regression appears the moment an application is first put in a bundle. + "NSHighResolutionCapable": True, + "NSPrincipalClass": "NSApplication", + "NSSupportsAutomaticGraphicsSwitching": True, + } + if metadata.icon is not None: + plist["CFBundleIconFile"] = "AppIcon" + if metadata.copyright: + plist["NSHumanReadableCopyright"] = metadata.copyright + if metadata.category: + plist["LSApplicationCategoryType"] = metadata.category + + if channel == SPARKLE: + if sparkle is None: + raise PackagingError( + "the sparkle channel needs a feed URL and the public EdDSA key " + "(--feed-url and --public-ed-key): an application without them has " + "nowhere to look for updates and nothing to check them against" + ) + ed25519.decode_public_key(sparkle.public_ed_key) + if sparkle.check_interval < MINIMUM_CHECK_INTERVAL: + raise PackagingError( + f"Sparkle does not check more often than every {MINIMUM_CHECK_INTERVAL} " + f"seconds; {sparkle.check_interval} was asked for" + ) + plist.update( + { + "SUFeedURL": check_feed_url(sparkle.feed_url), + "SUPublicEDKey": sparkle.public_ed_key, + "SUEnableAutomaticChecks": sparkle.automatic_checks, + "SUScheduledCheckInterval": sparkle.check_interval, + "SUAutomaticallyUpdate": sparkle.automatically_update, + } + ) + else: + if sparkle is not None: + raise PackagingError( + "the app-store channel cannot carry Sparkle settings: the store updates " + "the application, and App Review rejects one that updates itself" + ) + if not metadata.category: + raise PackagingError( + "the Mac App Store needs LSApplicationCategoryType; set [bundle] category " + "in Dioxus.toml or pass --category" + ) + if uses_non_exempt_encryption is None: + raise PackagingError( + "the Mac App Store asks every build whether it uses non-exempt encryption. " + "Answer once with --uses-non-exempt-encryption yes|no so App Store Connect " + "does not hold each upload for the question. HTTPS through the system " + "alone is exempt (no)." + ) + plist["ITSAppUsesNonExemptEncryption"] = bool(uses_non_exempt_encryption) + + for key, value in (extra or {}).items(): + if key in plist and plist[key] != value: + raise PackagingError( + f"the extra Info.plist entries set {key}, which the packager already " + f"derives ({plist[key]!r}); change it where it comes from instead" + ) + if channel == APP_STORE and key.startswith("SU"): + raise PackagingError(f"{key} is a Sparkle setting and the app-store build has no Sparkle") + plist[key] = value + return plist + + +@dataclass(frozen=True) +class Capabilities: + """What a sandboxed application asks the system to let it do. + + `user_selected_files` is None, "read-only" or "read-write": access to files the person + chose in an open or save panel or dropped on the window, and nothing else. + """ + + network_client: bool = False + network_server: bool = False + user_selected_files: str | None = None + downloads_folder: str | None = None + + +def entitlements(channel, *, capabilities=None, team_id=None, identifier=None): + """The entitlements dictionary for `channel`. + + Neither channel needs any hardened runtime exception. The renderer is compiled ahead of + time by GraalVM native-image, so nothing generates code at run time (no allow-jit, no + allow-unsigned-executable-memory), Skia draws through Metal, which needs no + entitlement, and every library the application loads is inside the bundle and signed + by the same team, so library validation stays on. + """ + capabilities = capabilities or Capabilities() + if channel == SPARKLE: + # No sandbox: Sparkle replaces the application in place, and the sandboxed form of + # that needs Sparkle's XPC installer services and a different signing arrangement. + # The store build is the sandboxed one. Network and file access need no + # entitlement outside the sandbox, so capabilities have nothing to add here. + return {} + if channel != APP_STORE: + raise PackagingError(f"channel {channel!r} is not one of {', '.join(CHANNELS)}") + + result = {"com.apple.security.app-sandbox": True} + if capabilities.network_client: + result["com.apple.security.network.client"] = True + if capabilities.network_server: + result["com.apple.security.network.server"] = True + for name, value in ( + ("files.user-selected", capabilities.user_selected_files), + ("files.downloads", capabilities.downloads_folder), + ): + if value is None: + continue + if value not in ("read-only", "read-write"): + raise PackagingError(f"{name} is read-only or read-write, not {value!r}") + result[f"com.apple.security.{name}.{value}"] = True + + if team_id is not None: + # The store matches these against the provisioning profile. Xcode writes them for + # an Xcode project; a bundle made outside Xcode has to say them itself, or App + # Store Connect rejects the upload for a missing application identifier. + if identifier is None: + raise PackagingError("the application identifier needs the bundle identifier") + result["com.apple.application-identifier"] = f"{team_id}.{identifier}" + result["com.apple.developer.team-identifier"] = team_id + return result + + +def dumps(dictionary): + return plistlib.dumps(dictionary, fmt=plistlib.FMT_XML, sort_keys=True) + + +def parse_extra(pairs): + """`KEY=VALUE` pairs from the command line. true/false become booleans, digits integers.""" + extra = {} + for pair in pairs or []: + key, separator, value = pair.partition("=") + if not separator or not key: + raise PackagingError(f"--plist expects KEY=VALUE, not {pair!r}") + if value in ("true", "false"): + extra[key] = value == "true" + elif value.isdigit(): + extra[key] = int(value) + else: + extra[key] = value + return extra diff --git a/tools/packager/dxc_packager/signing.py b/tools/packager/dxc_packager/signing.py new file mode 100644 index 00000000..f24ecc2f --- /dev/null +++ b/tools/packager/dxc_packager/signing.py @@ -0,0 +1,78 @@ +"""EdDSA signatures over update archives, and the key pair they are made with. + +The private key is a file holding the base64 of a 32 byte seed, which is the form Sparkle's +own `generate_keys -x` exports and `sign_update --ed-key-file` reads. It is kept out of +any keychain on purpose: a CI job gets it from a secret, writes it to a file only it can +read, and nothing about the key outlives the job. +""" + +import base64 +import os +import subprocess +from pathlib import Path + +from . import ed25519 +from .metadata import PackagingError + + +def keygen(private_key_path): + """Write a new private key to `private_key_path` and return the public key (base64).""" + path = Path(private_key_path) + if path.exists(): + raise PackagingError( + f"{path} already exists. A new key would orphan every installed copy that " + f"trusts the old one, so this never overwrites a key." + ) + seed = os.urandom(32) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as handle: + handle.write(base64.b64encode(seed).decode() + "\n") + return base64.b64encode(ed25519.public_key(seed)).decode() + + +def public_key_of(private_key_path): + private = ed25519.read_private_key_file(private_key_path) + return base64.b64encode(ed25519.public_key(private)).decode() + + +def _sign_with_sparkle(archive, private_key_path, sign_update): + try: + result = subprocess.run( + [str(sign_update), "--ed-key-file", str(private_key_path), "-p", str(archive)], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as error: + raise PackagingError(f"sign_update failed: {error.stdout}{error.stderr}") from error + return result.stdout.strip() + + +def sign_archive(archive, private_key_path, *, sign_update=None, public_key=None): + """The base64 EdDSA signature of `archive`, checked against `public_key` if given. + + With `sign_update` (the path of Sparkle's tool) Sparkle signs, and this module only + checks the result. Without it the built-in implementation signs, for machines that + have no Sparkle, such as a Linux runner publishing the feed. + + Checking against the public key the application carries is the point of the + argument: a feed signed with a different key from the one in the installed + application's Info.plist is a feed every installed copy refuses, and it is better + found here than by every user at once. + """ + data = Path(archive).read_bytes() + if sign_update is not None: + signature = _sign_with_sparkle(archive, private_key_path, sign_update) + else: + private = ed25519.read_private_key_file(private_key_path) + signature = base64.b64encode(ed25519.sign(private, data)).decode() + if public_key is not None: + raw_public = ed25519.decode_public_key(public_key) + if not ed25519.verify(raw_public, data, base64.b64decode(signature)): + raise PackagingError( + f"the signature of {archive} does not verify under SUPublicEDKey " + f"{public_key}. The private key is not the one the application was built " + f"to trust, and every installed copy would refuse this update." + ) + return signature diff --git a/tools/packager/package-macos b/tools/packager/package-macos new file mode 100755 index 00000000..b4a56b41 --- /dev/null +++ b/tools/packager/package-macos @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Entry point; see dxc_packager/cli.py for what it does.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from dxc_packager.cli import main # noqa: E402 + +sys.exit(main()) diff --git a/tools/packager/tests/support.py b/tools/packager/tests/support.py new file mode 100644 index 00000000..18f5506b --- /dev/null +++ b/tools/packager/tests/support.py @@ -0,0 +1,49 @@ +"""Shared helpers for the packager tests.""" + +import os +import sys +import tempfile +from pathlib import Path + +PACKAGER = Path(__file__).resolve().parents[1] +REPOSITORY = PACKAGER.parents[1] +sys.path.insert(0, str(PACKAGER)) + +# Where Sparkle's release archive was unpacked, when the run has one. Tests that compare +# against Sparkle's own tools are skipped without it rather than downloading it. +SPARKLE_DIR = Path(os.environ["SPARKLE_DIR"]) if os.environ.get("SPARKLE_DIR") else None + + +def project(cargo, dioxus=None, files=()): + """A throwaway project directory with the given Cargo.toml and Dioxus.toml text.""" + directory = Path(tempfile.mkdtemp(prefix="packager-test-")) + (directory / "Cargo.toml").write_text(cargo) + if dioxus is not None: + (directory / "Dioxus.toml").write_text(dioxus) + for relative, data in files: + path = directory / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return directory + + +CARGO = """ +[package] +name = "sample-demo" +version = "1.4.2" +""" + +DIOXUS = """ +[application] +name = "Demo" +asset_dir = "assets" + +[bundle] +identifier = "dev.example.demo" +publisher = "Example" +category = "DeveloperTool" +short_description = "A demonstration." + +[macos] +bundle_name = "Demo App" +""" diff --git a/tools/packager/tests/test_appcast.py b/tools/packager/tests/test_appcast.py new file mode 100644 index 00000000..561e6d28 --- /dev/null +++ b/tools/packager/tests/test_appcast.py @@ -0,0 +1,112 @@ +"""Writing and extending the Sparkle appcast.""" + +import unittest +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +from support import SPARKLE_DIR # noqa: F401 (puts the packager on the path) + +from dxc_packager import appcast +from dxc_packager.metadata import PackagingError + +S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" + + +def item(version, **changes): + fields = dict( + version=version, + short_version=f"{version}.0", + url=f"https://example.com/Demo-{version}.zip", + length=1234, + ed_signature="c2lnbmF0dXJl", + minimum_system_version="13.0", + pub_date=datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc), + ) + fields.update(changes) + return appcast.Item(**fields) + + +def feed_with(*items): + rss = appcast.new_feed("Demo") + for entry in items: + appcast.add(rss, entry) + return ET.fromstring(appcast.dumps(rss)) + + +class Appcast(unittest.TestCase): + def test_fr34_9_item_carries_what_sparkle_2_reads(self): + rss = feed_with(item("2", release_notes_url="https://example.com/2.html")) + element = rss.find("channel/item") + self.assertEqual(element.findtext(f"{S}version"), "2") + self.assertEqual(element.findtext(f"{S}shortVersionString"), "2.0") + self.assertEqual(element.findtext(f"{S}minimumSystemVersion"), "13.0") + self.assertEqual(element.findtext(f"{S}releaseNotesLink"), "https://example.com/2.html") + self.assertEqual(element.findtext("pubDate"), "Sat, 03 Oct 2026 12:00:00 +0000") + enclosure = element.find("enclosure") + self.assertEqual(enclosure.get("url"), "https://example.com/Demo-2.zip") + self.assertEqual(enclosure.get("length"), "1234") + self.assertEqual(enclosure.get(f"{S}edSignature"), "c2lnbmF0dXJl") + self.assertIsNone(element.find(f"{S}criticalUpdate")) + + def test_fr34_9_output_is_a_feed_declaring_the_sparkle_namespace(self): + text = appcast.dumps(appcast.add(appcast.new_feed("Demo"), item("1"))) + self.assertTrue(text.startswith('')) + self.assertIn('xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"', text) + self.assertEqual(ET.fromstring(text).get("version"), "2.0") + + def test_fr34_9_newest_first_comparing_numbers_not_text(self): + rss = feed_with(item("9"), item("10"), item("1.2")) + self.assertEqual(appcast.versions(rss), ["10", "9", "1.2"]) + + def test_fr34_9_publishing_a_version_again_replaces_it(self): + rss = feed_with(item("1"), item("2"), item("2", length=99)) + self.assertEqual(appcast.versions(rss), ["2", "1"]) + self.assertEqual(rss.find("channel/item/enclosure").get("length"), "99") + + def test_fr34_9_existing_items_and_their_unknown_elements_survive(self): + existing = """ + + + Demo + + Version 1.0 + 1 + beta + + + + Sparkle 1 style + + + +""" + rss = appcast.parse(existing) + appcast.add(rss, item("2")) + rss = ET.fromstring(appcast.dumps(rss)) + self.assertEqual(appcast.versions(rss), ["2", "1", "0.9"]) + old = rss.findall("channel/item")[1] + self.assertEqual(old.findtext(f"{S}channel"), "beta") + self.assertEqual(rss.findtext("channel/title"), "Demo") + + def test_fr34_9_an_apple_silicon_only_update_says_so(self): + rss = feed_with(item("4", hardware_requirements="arm64")) + self.assertEqual(rss.findtext(f"channel/item/{S}hardwareRequirements"), "arm64") + self.assertIsNone(feed_with(item("4")).find(f"channel/item/{S}hardwareRequirements")) + + def test_fr34_9_critical_updates_are_marked(self): + rss = feed_with(item("3", critical=True)) + self.assertIsNotNone(rss.find(f"channel/item/{S}criticalUpdate")) + + def test_fr34_9_something_that_is_not_a_feed_is_refused(self): + with self.assertRaisesRegex(PackagingError, "not XML"): + appcast.parse("") + with self.assertRaisesRegex(PackagingError, "RSS"): + appcast.parse("") + with self.assertRaisesRegex(PackagingError, "sparkle:version"): + appcast.add(appcast.parse(""), item("1")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/packager/tests/test_bundle.py b/tools/packager/tests/test_bundle.py new file mode 100644 index 00000000..623da9d8 --- /dev/null +++ b/tools/packager/tests/test_bundle.py @@ -0,0 +1,215 @@ +"""Assembling, signing and wrapping real bundles. macOS only: this is codesign and productbuild. + +The executable is a copy of /usr/bin/true, which is a real Mach-O program that needs +nothing beside it, so these run in seconds and need no Rust build. What a dioxus-compose +executable adds, a renderer loaded from an absolute path, is made by pointing the copy's +load command at a stand-in library. +""" + +import plistlib +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from support import CARGO, DIOXUS, REPOSITORY, SPARKLE_DIR, project + +from dxc_packager import bundle, cli, metadata, plist + +PUBLIC_KEY = "2kja3FFzveTqSefH+8D0npKfvngmu2zAMqubhIgBuh8=" +ICON = REPOSITORY / "samples" / "calculator" / "assets" / "icon.png" + + +def executable_in(directory): + path = Path(directory) / "sample-demo" + shutil.copyfile("/usr/bin/true", path) + path.chmod(0o755) + return path + + +def compile_c(source, out, *flags): + subprocess.run( + # The flags come before `-x c`, which would otherwise read a library as C source. + ["cc", "-o", str(out), "-Wl,-headerpad_max_install_names", *flags, "-x", "c", "-"], + input=source + "\n", text=True, check=True, + ) + + +def store_app(out, executable): + meta = metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), icon=ICON) + app = bundle.assemble(meta, executable, out, plist.APP_STORE, uses_non_exempt_encryption=False) + granted = plist.entitlements( + plist.APP_STORE, capabilities=plist.Capabilities(network_client=True) + ) + return bundle.sign(app, "-", plist.APP_STORE, granted) + + +@unittest.skipUnless(sys.platform == "darwin", "codesign and productbuild are macOS tools") +class StoreBundle(unittest.TestCase): + def setUp(self): + self.scratch = Path(tempfile.mkdtemp(prefix="packager-bundle-")) + self.app = store_app(self.scratch / "out", executable_in(self.scratch)) + + def test_fr34_1_layout_and_info_plist(self): + contents = self.app / "Contents" + self.assertEqual(self.app.name, "Demo App.app") + self.assertTrue((contents / "MacOS" / "sample-demo").is_file()) + self.assertTrue((contents / "Resources" / "AppIcon.icns").is_file()) + self.assertEqual((contents / "PkgInfo").read_text(), "APPL????") + info = plistlib.loads((contents / "Info.plist").read_bytes()) + self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") + self.assertEqual(info["CFBundleIconFile"], "AppIcon") + self.assertIs(info["NSHighResolutionCapable"], True) + + def test_fr34_5_signature_verifies_with_the_hardened_runtime(self): + subprocess.run( + ["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True + ) + details = subprocess.run( + ["codesign", "-dv", str(self.app)], capture_output=True, text=True + ).stderr + self.assertIn("runtime", details) + self.assertIn("Identifier=dev.example.demo", details) + + def test_fr34_4_store_bundle_is_sandboxed_with_declared_capabilities(self): + self.assertEqual( + bundle.entitlements_of(self.app), + { + "com.apple.security.app-sandbox": True, + "com.apple.security.network.client": True, + }, + ) + self.assertEqual(cli._verify(self.app, plist.APP_STORE), []) + + def test_fr34_6_store_bundle_has_no_sparkle_and_a_host_naming_it_is_caught(self): + self.assertEqual(bundle.sparkle_residue(self.app), []) + executable = self.app / "Contents" / "MacOS" / "sample-demo" + with open(executable, "ab") as handle: + handle.write(b"\0SPUStandardUpdaterController\0") + problems = bundle.sparkle_residue(self.app) + self.assertEqual(len(problems), 1) + self.assertIn("without the updater", problems[0]) + + def test_fr34_7_pkg_installs_the_app_into_applications(self): + package = bundle.pkg(self.app, self.scratch / "Demo.pkg") + expanded = self.scratch / "expanded" + subprocess.run(["pkgutil", "--expand-full", str(package), str(expanded)], check=True) + distribution = (expanded / "Distribution").read_text() + self.assertIn("dev.example.demo", distribution) + payloads = list(expanded.glob("*.pkg/Payload/Demo App.app/Contents/Info.plist")) + self.assertEqual(len(payloads), 1) + package_info = next(expanded.glob("*.pkg/PackageInfo")).read_text() + self.assertIn('install-location="/Applications"', package_info) + + def test_fr34_7_pkg_refuses_a_bundle_that_is_not_for_the_store(self): + info_path = self.app / "Contents" / "Info.plist" + info = plistlib.loads(info_path.read_bytes()) + info["SUFeedURL"] = "https://example.com/appcast.xml" + info_path.write_bytes(plistlib.dumps(info)) + status = cli.main( + ["pkg", "--app", str(self.app), "--out", str(self.scratch / "Demo.pkg")] + ) + self.assertEqual(status, 1) + self.assertFalse((self.scratch / "Demo.pkg").exists()) + + +@unittest.skipUnless(sys.platform == "darwin", "codesign is a macOS tool") +@unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") +class SparkleBundle(unittest.TestCase): + def setUp(self): + self.scratch = Path(tempfile.mkdtemp(prefix="packager-sparkle-")) + # A stand-in renderer and an executable linked against it by its absolute path, + # the way the crate's build links a real one: the library is named by where it + # is, and both have header room for the longer relative name (rustc asks for + # that room too). + renderer = self.scratch / "renderer" / "lib" + renderer.mkdir(parents=True) + library = renderer / bundle.RENDERER_LIBRARY + compile_c( + "int dioxus_compose_renderer_stand_in(void) { return 0; }", + library, "-dynamiclib", "-install_name", str(library), + ) + compile_c("int skiko_stand_in(void) { return 0; }", renderer / "libskiko-stand-in.dylib", + "-dynamiclib") + self.executable = self.scratch / "sample-demo" + compile_c( + "int dioxus_compose_renderer_stand_in(void);\n" + "int main(void) { return dioxus_compose_renderer_stand_in(); }", + self.executable, str(library), + ) + meta = metadata.load(project(CARGO, DIOXUS)) + settings = plist.SparkleSettings( + feed_url="https://example.com/appcast.xml", public_ed_key=PUBLIC_KEY + ) + self.app = bundle.assemble( + meta, self.executable, self.scratch / "out", plist.SPARKLE, sparkle=settings, + sparkle_framework=SPARKLE_DIR / "Sparkle.framework", + ) + bundle.sign(self.app, "-", plist.SPARKLE, plist.entitlements(plist.SPARKLE)) + + def test_fr34_1_renderer_directory_travels_whole_and_is_loaded_relatively(self): + lib = self.app / "Contents" / "Frameworks" / "lib" + self.assertTrue((lib / bundle.RENDERER_LIBRARY).is_file()) + self.assertTrue((lib / "libskiko-stand-in.dylib").is_file()) + subprocess.run(["codesign", "--verify", "--strict", str(lib / "libskiko-stand-in.dylib")], + check=True) + loads = subprocess.run( + ["otool", "-L", str(self.app / "Contents" / "MacOS" / "sample-demo")], + capture_output=True, text=True, check=True, + ).stdout + self.assertIn("@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, loads) + self.assertEqual(bundle.absolute_references(self.app), []) + + def test_fr34_1_a_renderer_directory_holding_data_is_refused_by_name(self): + renderer = self.scratch / "renderer" / "lib" + (renderer / "fonts.conf").write_text("data") + meta = metadata.load(project(CARGO, DIOXUS)) + settings = plist.SparkleSettings( + feed_url="https://example.com/appcast.xml", public_ed_key=PUBLIC_KEY + ) + with self.assertRaisesRegex(metadata.PackagingError, "fonts.conf"): + bundle.assemble( + meta, self.executable, self.scratch / "again", plist.SPARKLE, sparkle=settings, + sparkle_framework=SPARKLE_DIR / "Sparkle.framework", + ) + + def test_fr34_5_framework_and_everything_in_it_is_signed(self): + framework = self.app / "Contents" / "Frameworks" / "Sparkle.framework" + self.assertTrue((framework / "Versions" / "Current").is_symlink()) + for nested in ("Versions/B/Autoupdate", "Versions/B/Updater.app", + "Versions/B/XPCServices/Downloader.xpc"): + subprocess.run( + ["codesign", "--verify", "--strict", str(framework / nested)], check=True + ) + subprocess.run( + ["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True + ) + self.assertEqual(cli._verify(self.app, plist.SPARKLE), []) + + def test_fr34_6_a_sparkle_bundle_is_not_a_store_bundle(self): + problems = bundle.sparkle_residue(self.app) + self.assertTrue(any("Sparkle.framework is in the bundle" in p for p in problems)) + self.assertTrue(any("SUFeedURL" in p for p in problems)) + self.assertTrue(cli._verify(self.app, plist.APP_STORE)) + + def test_fr34_9_hardware_requirement_follows_the_executable(self): + expected = "arm64" if subprocess.run( + ["uname", "-m"], capture_output=True, text=True + ).stdout.strip() == "arm64" else None + self.assertEqual(bundle.hardware_requirements(self.app), expected) + + def test_fr34_8_archive_round_trips_with_links_intact(self): + archive = bundle.zip_for_sparkle(self.app, self.scratch / "Demo-1.4.2.zip") + unpacked = self.scratch / "unpacked" + subprocess.run(["ditto", "-x", "-k", str(archive), str(unpacked)], check=True) + copy = unpacked / self.app.name + self.assertTrue( + (copy / "Contents/Frameworks/Sparkle.framework/Versions/Current").is_symlink() + ) + subprocess.run(["codesign", "--verify", "--strict", "--deep", str(copy)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/packager/tests/test_ed25519.py b/tools/packager/tests/test_ed25519.py new file mode 100644 index 00000000..3c36b539 --- /dev/null +++ b/tools/packager/tests/test_ed25519.py @@ -0,0 +1,86 @@ +"""The signature arithmetic, against RFC 8032 and against Sparkle's own sign_update.""" + +import base64 +import subprocess +import tempfile +import unittest +from pathlib import Path + +from support import SPARKLE_DIR # noqa: F401 (puts the packager on the path) + +from dxc_packager import ed25519, signing + +# RFC 8032, section 7.1, tests 1 and 2. +VECTORS = [ + ( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bac" + "c61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ), + ( + "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e" + "458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", + ), +] + + +class Rfc8032(unittest.TestCase): + def test_fr34_9_public_key_and_signature_match_the_rfc_vectors(self): + for secret, public, message, signature in VECTORS: + secret = bytes.fromhex(secret) + message = bytes.fromhex(message) + self.assertEqual(ed25519.public_key(secret).hex(), public) + self.assertEqual(ed25519.sign(secret, message).hex(), signature) + self.assertTrue( + ed25519.verify(bytes.fromhex(public), message, bytes.fromhex(signature)) + ) + + def test_fr34_9_a_changed_byte_does_not_verify(self): + secret, public, _, _ = VECTORS[1] + signature = ed25519.sign(bytes.fromhex(secret), b"archive") + self.assertFalse(ed25519.verify(bytes.fromhex(public), b"archivf", signature)) + tampered = bytes([signature[0] ^ 1]) + signature[1:] + self.assertFalse(ed25519.verify(bytes.fromhex(public), b"archive", tampered)) + + def test_fr34_9_a_key_that_is_not_a_seed_is_refused_with_the_way_out(self): + with tempfile.TemporaryDirectory() as scratch: + path = Path(scratch) / "key" + path.write_text(base64.b64encode(bytes(64)).decode()) + with self.assertRaisesRegex(ed25519.InvalidKey, "generate_keys -x"): + ed25519.read_private_key_file(path) + + def test_fr34_9_public_key_must_be_32_bytes(self): + with self.assertRaisesRegex(ValueError, "32"): + ed25519.decode_public_key(base64.b64encode(bytes(31)).decode()) + + +@unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") +class AgreesWithSparkle(unittest.TestCase): + def test_fr34_9_signature_is_the_one_sign_update_makes(self): + with tempfile.TemporaryDirectory() as scratch: + key = Path(scratch) / "sparkle.key" + signing.keygen(key) + archive = Path(scratch) / "update.zip" + archive.write_bytes(bytes(range(256)) * 1000) + ours = signing.sign_archive(archive, key) + theirs = subprocess.run( + [str(SPARKLE_DIR / "bin" / "sign_update"), "--ed-key-file", str(key), "-p", + str(archive)], + check=True, text=True, capture_output=True, + ).stdout.strip() + self.assertEqual(ours, theirs) + checked = subprocess.run( + [str(SPARKLE_DIR / "bin" / "sign_update"), "--ed-key-file", str(key), + "--verify", str(archive), ours], + capture_output=True, + ) + self.assertEqual(checked.returncode, 0, checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/packager/tests/test_metadata.py b/tools/packager/tests/test_metadata.py new file mode 100644 index 00000000..7322f3d4 --- /dev/null +++ b/tools/packager/tests/test_metadata.py @@ -0,0 +1,76 @@ +"""Reading what an application says about itself from Cargo.toml and Dioxus.toml.""" + +import unittest + +from support import CARGO, DIOXUS, REPOSITORY, project + +from dxc_packager import metadata +from dxc_packager.metadata import PackagingError + + +class Load(unittest.TestCase): + def test_fr34_1_metadata_comes_from_the_project_files(self): + meta = metadata.load(project(CARGO, DIOXUS)) + self.assertEqual(meta.identifier, "dev.example.demo") + self.assertEqual(meta.name, "Demo App") + self.assertEqual(meta.executable, "sample-demo") + self.assertEqual(meta.version, "1.4.2") + self.assertEqual(meta.build, "1.4.2") + self.assertEqual(meta.category, "public.app-category.developer-tools") + self.assertEqual(meta.copyright, "Copyright Example") + self.assertEqual(meta.minimum_system_version, "13.0") + + def test_fr34_1_every_sample_in_the_repository_loads(self): + samples = sorted(path for path in (REPOSITORY / "samples").iterdir() + if (path / "Dioxus.toml").exists()) + self.assertTrue(samples) + for sample in samples: + meta = metadata.load(sample) + self.assertTrue(meta.identifier.startswith("dev.darkpyonix."), sample) + self.assertTrue(meta.executable.startswith("sample-"), sample) + + def test_fr34_1_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(self): + dioxus = DIOXUS.replace("[macos]\nbundle_name = \"Demo App\"", "") + ( + "\n[bundle.macos]\nbundle_name = \"Other\"\nminimum_system_version = \"14.0\"\n" + ) + meta = metadata.load(project(CARGO, dioxus)) + self.assertEqual(meta.name, "Other") + self.assertEqual(meta.minimum_system_version, "14.0") + + def test_fr34_1_overrides_win_and_build_follows_version(self): + meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0") + self.assertEqual((meta.version, meta.build), ("2.0.0", "2.0.0")) + meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0", build="57") + self.assertEqual(meta.build, "57") + + def test_fr34_1_icon_falls_back_to_the_asset_directory(self): + directory = project(CARGO, DIOXUS, files=[("assets/icon.png", b"png")]) + self.assertEqual(metadata.load(directory).icon, directory / "assets" / "icon.png") + + def test_fr34_1_missing_identifier_is_an_error_not_a_guess(self): + with self.assertRaisesRegex(PackagingError, "identifier"): + metadata.load(project(CARGO, "[application]\nname = \"X\"\n")) + + def test_fr34_1_prerelease_version_needs_explicit_numbers(self): + cargo = CARGO.replace("1.4.2", "1.4.2-beta.1") + with self.assertRaisesRegex(PackagingError, "one to three dot separated integers"): + metadata.load(project(cargo, DIOXUS)) + meta = metadata.load(project(cargo, DIOXUS), version="1.4.2", build="142") + self.assertEqual(meta.version, "1.4.2") + + def test_fr34_1_workspace_version_has_to_be_given(self): + cargo = CARGO.replace('version = "1.4.2"', "version.workspace = true") + with self.assertRaisesRegex(PackagingError, "--version"): + metadata.load(project(cargo, DIOXUS)) + + def test_fr34_1_categories_accept_both_spellings_and_refuse_others(self): + self.assertEqual(metadata.category_type("Utility"), "public.app-category.utilities") + self.assertEqual( + metadata.category_type("public.app-category.music"), "public.app-category.music" + ) + with self.assertRaisesRegex(PackagingError, "DeveloperTool"): + metadata.category_type("Tools") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/packager/tests/test_plist.py b/tools/packager/tests/test_plist.py new file mode 100644 index 00000000..b888e375 --- /dev/null +++ b/tools/packager/tests/test_plist.py @@ -0,0 +1,175 @@ +"""Info.plist and entitlements for the sparkle and app-store channels.""" + +import unittest + +from support import CARGO, DIOXUS, project + +from dxc_packager import metadata, plist +from dxc_packager.metadata import PackagingError + +PUBLIC_KEY = "2kja3FFzveTqSefH+8D0npKfvngmu2zAMqubhIgBuh8=" + + +def meta(**changes): + return metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), **changes) + + +def sparkle(**changes): + settings = {"feed_url": "https://example.com/appcast.xml", "public_ed_key": PUBLIC_KEY} + settings.update(changes) + return plist.SparkleSettings(**settings) + + +class InfoPlist(unittest.TestCase): + def test_fr34_1_common_keys_come_from_the_metadata(self): + info = plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle()) + self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") + self.assertEqual(info["CFBundleExecutable"], "sample-demo") + self.assertEqual(info["CFBundleName"], "Demo App") + self.assertEqual(info["CFBundleShortVersionString"], "1.4.2") + self.assertEqual(info["CFBundleVersion"], "1.4.2") + self.assertEqual(info["CFBundlePackageType"], "APPL") + self.assertEqual(info["LSMinimumSystemVersion"], "13.0") + self.assertEqual(info["LSApplicationCategoryType"], "public.app-category.developer-tools") + self.assertIs(info["NSHighResolutionCapable"], True) + + def test_fr34_2_sparkle_build_carries_feed_key_and_schedule(self): + info = plist.info_plist( + meta(), plist.SPARKLE, sparkle=sparkle(check_interval=7200, automatically_update=True) + ) + self.assertEqual(info["SUFeedURL"], "https://example.com/appcast.xml") + self.assertEqual(info["SUPublicEDKey"], PUBLIC_KEY) + self.assertIs(info["SUEnableAutomaticChecks"], True) + self.assertEqual(info["SUScheduledCheckInterval"], 7200) + self.assertIs(info["SUAutomaticallyUpdate"], True) + self.assertNotIn("ITSAppUsesNonExemptEncryption", info) + + def test_fr34_2_sparkle_build_needs_its_settings(self): + with self.assertRaisesRegex(PackagingError, "--feed-url"): + plist.info_plist(meta(), plist.SPARKLE) + + def test_fr34_2_feed_must_be_https_except_on_loopback(self): + for url in ("http://example.com/appcast.xml", "ftp://example.com/a", "appcast.xml"): + with self.assertRaisesRegex(PackagingError, "https"): + plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(feed_url=url)) + for url in ("http://127.0.0.1:8000/appcast.xml", "http://localhost/appcast.xml"): + info = plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(feed_url=url)) + self.assertEqual(info["SUFeedURL"], url) + + def test_fr34_2_public_key_that_is_not_an_ed25519_key_is_refused(self): + with self.assertRaisesRegex(ValueError, "32"): + plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(public_ed_key="AAAA")) + + def test_fr34_2_check_interval_below_sparkles_floor_is_refused(self): + with self.assertRaisesRegex(PackagingError, "3600"): + plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(check_interval=60)) + + def test_fr34_3_store_build_has_no_sparkle_keys_and_answers_export_compliance(self): + info = plist.info_plist(meta(), plist.APP_STORE, uses_non_exempt_encryption=False) + self.assertFalse([key for key in info if key.startswith("SU")]) + self.assertIs(info["ITSAppUsesNonExemptEncryption"], False) + + def test_fr34_3_store_build_refuses_sparkle_settings(self): + with self.assertRaisesRegex(PackagingError, "updates itself"): + plist.info_plist( + meta(), plist.APP_STORE, sparkle=sparkle(), uses_non_exempt_encryption=False + ) + with self.assertRaisesRegex(PackagingError, "Sparkle setting"): + plist.info_plist( + meta(), plist.APP_STORE, uses_non_exempt_encryption=False, + extra={"SUFeedURL": "https://example.com"}, + ) + + def test_fr34_3_store_build_needs_category_and_encryption_answer(self): + directory = project(CARGO, DIOXUS.replace('category = "DeveloperTool"\n', "")) + with self.assertRaisesRegex(PackagingError, "LSApplicationCategoryType"): + plist.info_plist( + metadata.load(directory), plist.APP_STORE, uses_non_exempt_encryption=False + ) + with self.assertRaisesRegex(PackagingError, "non-exempt encryption"): + plist.info_plist(meta(), plist.APP_STORE) + + def test_fr34_1_extra_entries_are_added_but_cannot_contradict_derived_ones(self): + info = plist.info_plist( + meta(), plist.SPARKLE, sparkle=sparkle(), + extra={"NSAppTransportSecurity": {"NSAllowsLocalNetworking": True}}, + ) + self.assertEqual(info["NSAppTransportSecurity"], {"NSAllowsLocalNetworking": True}) + with self.assertRaisesRegex(PackagingError, "CFBundleIdentifier"): + plist.info_plist( + meta(), plist.SPARKLE, sparkle=sparkle(), + extra={"CFBundleIdentifier": "other.id"}, + ) + + def test_fr34_1_command_line_entries_are_typed(self): + self.assertEqual( + plist.parse_extra(["A=true", "B=12", "C=text"]), {"A": True, "B": 12, "C": "text"} + ) + with self.assertRaisesRegex(PackagingError, "KEY=VALUE"): + plist.parse_extra(["nothing"]) + + def test_fr34_1_plist_serialises_as_xml(self): + data = plist.dumps(plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle())) + self.assertTrue(data.startswith(b"SUPublicEDKey", data) + + +class Entitlements(unittest.TestCase): + def test_fr34_4_sparkle_build_asks_for_nothing(self): + self.assertEqual(plist.entitlements(plist.SPARKLE), {}) + self.assertEqual( + plist.entitlements( + plist.SPARKLE, capabilities=plist.Capabilities(network_client=True) + ), + {}, + ) + + def test_fr34_4_store_build_is_sandboxed_with_only_what_was_declared(self): + self.assertEqual( + plist.entitlements(plist.APP_STORE), {"com.apple.security.app-sandbox": True} + ) + granted = plist.entitlements( + plist.APP_STORE, + capabilities=plist.Capabilities( + network_client=True, user_selected_files="read-write" + ), + ) + self.assertEqual( + granted, + { + "com.apple.security.app-sandbox": True, + "com.apple.security.network.client": True, + "com.apple.security.files.user-selected.read-write": True, + }, + ) + + def test_fr34_4_no_channel_asks_for_a_hardened_runtime_exception(self): + everything = plist.Capabilities( + network_client=True, network_server=True, user_selected_files="read-only", + downloads_folder="read-write", + ) + for channel in plist.CHANNELS: + granted = plist.entitlements( + channel, capabilities=everything, team_id="ABCDE12345", + identifier="dev.example.demo", + ) + self.assertFalse([key for key in granted if key.startswith("com.apple.security.cs.")]) + + def test_fr34_4_team_id_names_the_application_for_the_store(self): + granted = plist.entitlements( + plist.APP_STORE, team_id="ABCDE12345", identifier="dev.example.demo" + ) + self.assertEqual( + granted["com.apple.application-identifier"], "ABCDE12345.dev.example.demo" + ) + self.assertEqual(granted["com.apple.developer.team-identifier"], "ABCDE12345") + + def test_fr34_4_file_access_is_read_only_or_read_write(self): + with self.assertRaisesRegex(PackagingError, "read-only or read-write"): + plist.entitlements( + plist.APP_STORE, capabilities=plist.Capabilities(user_selected_files="all") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/packager/tests/test_signing.py b/tools/packager/tests/test_signing.py new file mode 100644 index 00000000..18e615c6 --- /dev/null +++ b/tools/packager/tests/test_signing.py @@ -0,0 +1,61 @@ +"""Keys and archive signatures for the Sparkle channel.""" + +import base64 +import os +import stat +import tempfile +import unittest +from pathlib import Path + +from support import SPARKLE_DIR + +from dxc_packager import ed25519, signing +from dxc_packager.metadata import PackagingError + + +class Signing(unittest.TestCase): + def setUp(self): + self.scratch = Path(tempfile.mkdtemp(prefix="packager-signing-")) + self.key = self.scratch / "keys" / "sparkle.key" + self.public = signing.keygen(self.key) + self.archive = self.scratch / "Demo-2.zip" + self.archive.write_bytes(os.urandom(4096)) + + def test_fr34_9_keygen_writes_a_private_file_and_prints_the_matching_public_key(self): + self.assertEqual(stat.S_IMODE(self.key.stat().st_mode), 0o600) + self.assertEqual(len(base64.b64decode(self.key.read_text())), 32) + self.assertEqual(signing.public_key_of(self.key), self.public) + self.assertEqual(len(ed25519.decode_public_key(self.public)), 32) + + def test_fr34_9_keygen_never_overwrites_a_key(self): + before = self.key.read_bytes() + with self.assertRaisesRegex(PackagingError, "never overwrites"): + signing.keygen(self.key) + self.assertEqual(self.key.read_bytes(), before) + + def test_fr34_9_signature_verifies_under_the_public_key(self): + signature = signing.sign_archive(self.archive, self.key, public_key=self.public) + self.assertTrue( + ed25519.verify( + base64.b64decode(self.public), + self.archive.read_bytes(), + base64.b64decode(signature), + ) + ) + + def test_fr34_9_signing_with_a_key_the_app_does_not_trust_is_refused(self): + other = signing.keygen(self.scratch / "other.key") + with self.assertRaisesRegex(PackagingError, "every installed copy would refuse"): + signing.sign_archive(self.archive, self.key, public_key=other) + + @unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") + def test_fr34_9_sign_update_is_used_when_given_and_checked(self): + signature = signing.sign_archive( + self.archive, self.key, sign_update=SPARKLE_DIR / "bin" / "sign_update", + public_key=self.public, + ) + self.assertEqual(signature, signing.sign_archive(self.archive, self.key)) + + +if __name__ == "__main__": + unittest.main() From 355900e33821631671c25a70c10d9d3c5c605e3c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:39:05 +0900 Subject: [PATCH 013/224] Feat: Start Sparkle 2 from the Host so an application can update itself dioxus-compose-update loads Sparkle.framework from the bundle's Contents/Frameworks at run time and creates SPUStandardUpdaterController on the main thread through objc2, where the renderer's C entry runs [NSApp run]. The application calls start() before launch and check_for_updates() from a menu item or button. Install::Immediately supplies an updater delegate that installs a silently downloaded update at once and relaunches. The Host rather than the Renderer, because Sparkle is Objective-C and the Rust side reaches it directly, while the native-image Renderer would need hand-written glue; nothing crosses the boundary. Sparkle is not linked, so the crate builds anywhere and is a no-op off macOS. A Mac App Store build leaves the dependency out, and the packager refuses a store bundle that names Sparkle. Covers draft criterion 10 of the proposed FR-34. --- Cargo.lock | 43 +++++ Cargo.toml | 1 + dioxus-compose-update/Cargo.toml | 25 +++ dioxus-compose-update/src/lib.rs | 228 +++++++++++++++++++++++++++ dioxus-compose-update/src/sparkle.rs | 209 ++++++++++++++++++++++++ 5 files changed, 506 insertions(+) create mode 100644 dioxus-compose-update/Cargo.toml create mode 100644 dioxus-compose-update/src/lib.rs create mode 100644 dioxus-compose-update/src/sparkle.rs diff --git a/Cargo.lock b/Cargo.lock index 1b9b3b84..e0af2556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -239,6 +248,15 @@ dependencies = [ "jni", ] +[[package]] +name = "dioxus-compose-update" +version = "0.0.0" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "dioxus-core" version = "0.7.10" @@ -555,6 +573,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index 516a9ae2..66ae3931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "dioxus-compose", + "dioxus-compose-update", "samples/academic", "samples/calculator", "samples/chat", diff --git a/dioxus-compose-update/Cargo.toml b/dioxus-compose-update/Cargo.toml new file mode 100644 index 00000000..a19f00a3 --- /dev/null +++ b/dioxus-compose-update/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "dioxus-compose-update" +version = "0.0.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Lets a dioxus-compose application handed out outside a store update itself (Sparkle 2 on macOS)" +repository = "https://github.com/DarkPyonix/dioxus-compose" +keywords = ["update", "sparkle", "dioxus", "compose", "macos"] +categories = ["gui"] +# Not published yet. The crate is new, and an application that ships through the Mac App +# Store must not depend on it at all, which is easier to keep straight while it lives in +# this repository beside the packager that checks for it. +publish = false + +[lib] +bench = false + +# Sparkle is not linked. The framework is loaded at run time from the application's own +# Contents/Frameworks, so this crate builds on a machine that has never seen Sparkle and a +# binary built with it starts, and says why, when the framework is missing. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] } +block2 = "0.6" diff --git a/dioxus-compose-update/src/lib.rs b/dioxus-compose-update/src/lib.rs new file mode 100644 index 00000000..64eccc26 --- /dev/null +++ b/dioxus-compose-update/src/lib.rs @@ -0,0 +1,228 @@ +//! Updates for a dioxus-compose application that is handed out directly rather than +//! through a store. +//! +//! On macOS this drives [Sparkle 2](https://sparkle-project.org): the application checks +//! an appcast, shows Sparkle's own update window (or installs silently when the person +//! allowed that), verifies the archive's EdDSA signature against the key in its +//! Info.plist, and replaces itself. Everything about where to look and which key to trust +//! is in the bundle, written by `tools/packager/package-macos app --channel sparkle`; +//! this crate only starts Sparkle and passes on the person's requests. +//! +//! ```no_run +//! fn main() { +//! // Before launch: the request waits on the main thread's queue and runs once +//! // the renderer has AppKit running there. +//! if let Err(error) = dioxus_compose_update::start(Default::default()) { +//! eprintln!("updates are off: {error}"); +//! } +//! // dioxus_compose::launch(app); +//! } +//! +//! // From a menu item or a button: +//! // on_click: move |_| dioxus_compose_update::check_for_updates(), +//! ``` +//! +//! A build for the Mac App Store must not contain this crate: the store updates the +//! application, App Review rejects one that updates itself, and the packager refuses a +//! store bundle whose executable names Sparkle. Put the dependency behind a Cargo feature +//! that the store build leaves off. +//! +//! Elsewhere (Windows, Linux, iOS, Android, the browser) [`start`] answers +//! [`UpdaterError::Unsupported`] and the other functions do nothing, so calling code +//! needs no `cfg` of its own. + +use std::fmt; +use std::path::{Path, PathBuf}; + +#[cfg(target_os = "macos")] +mod sparkle; + +/// When an update that was downloaded without asking gets installed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Install { + /// When the application quits, which is Sparkle's default: nothing interrupts the + /// person, and the next launch is the new version. + #[default] + OnQuit, + /// As soon as it is ready, relaunching the application. For applications that run for + /// weeks without quitting, and for rehearsing an update unattended. + Immediately, +} + +/// How the updater starts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct UpdaterOptions { + /// Check once at launch, in the background, besides Sparkle's own schedule. + pub check_on_start: bool, + /// What happens to an update Sparkle downloaded on its own. + pub install: Install, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum UpdaterError { + /// This platform has no updater here; its store or package manager updates it. + Unsupported, + /// The executable is not inside an application bundle, so there is no Info.plist to + /// read the feed and key from and nowhere Sparkle could install to. + NotInBundle(PathBuf), + /// The bundle has no Sparkle.framework where the packager puts it. + FrameworkMissing(PathBuf), + /// [`start`] was already called in this process. + AlreadyStarted, + /// The executable's own path could not be read. + ExecutablePath(String), +} + +impl fmt::Display for UpdaterError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unsupported => write!(f, "this platform has no self-updater"), + Self::NotInBundle(path) => write!( + f, + "{} is not inside an application bundle (Name.app/Contents/MacOS), so there \ + is no Info.plist to read the update feed from; run the packaged application", + path.display() + ), + Self::FrameworkMissing(path) => write!( + f, + "there is no Sparkle framework at {}; package the application with \ + `package-macos app --channel sparkle --sparkle-framework ...`", + path.display() + ), + Self::AlreadyStarted => write!(f, "the updater was already started"), + Self::ExecutablePath(error) => { + write!(f, "could not read this executable's path: {error}") + } + } + } +} + +impl std::error::Error for UpdaterError {} + +/// Where the packager puts Sparkle for an executable at `executable`. +/// +/// The executable of a bundle is `Name.app/Contents/MacOS/`, and frameworks are in +/// `Name.app/Contents/Frameworks`. Anything else is not a bundle. +pub fn framework_path(executable: &Path) -> Result { + let not_in_bundle = || UpdaterError::NotInBundle(executable.to_path_buf()); + let macos = executable.parent().ok_or_else(not_in_bundle)?; + if macos.file_name().and_then(|name| name.to_str()) != Some("MacOS") { + return Err(not_in_bundle()); + } + let contents = macos.parent().ok_or_else(not_in_bundle)?; + if contents.file_name().and_then(|name| name.to_str()) != Some("Contents") { + return Err(not_in_bundle()); + } + let app = contents.parent().ok_or_else(not_in_bundle)?; + if app.extension().and_then(|extension| extension.to_str()) != Some("app") { + return Err(not_in_bundle()); + } + Ok(contents.join("Frameworks").join("Sparkle.framework")) +} + +/// Whether `framework` looks like a Sparkle 2 framework, judged by its binary. +pub fn is_sparkle_framework(framework: &Path) -> bool { + framework.join("Versions").join("B").join("Sparkle").is_file() +} + +/// Start the updater. Call it once, before `dioxus_compose::launch`. +/// +/// The framework is checked for here, on the calling thread, so a missing one is an error +/// the caller sees. Loading it and creating Sparkle's controller happen on the main thread +/// once AppKit runs there, which is after `launch` has started the renderer; a failure at +/// that point is written to standard error. +pub fn start(options: UpdaterOptions) -> Result<(), UpdaterError> { + #[cfg(target_os = "macos")] + { + let executable = std::env::current_exe() + .map_err(|error| UpdaterError::ExecutablePath(error.to_string()))?; + let framework = framework_path(&executable)?; + if !is_sparkle_framework(&framework) { + return Err(UpdaterError::FrameworkMissing(framework)); + } + sparkle::start(framework, options) + } + #[cfg(not(target_os = "macos"))] + { + let _ = options; + Err(UpdaterError::Unsupported) + } +} + +/// Check now, showing Sparkle's window: the "Check for Updates..." menu item. +/// +/// Does nothing if [`start`] was not called or failed. +pub fn check_for_updates() { + #[cfg(target_os = "macos")] + sparkle::check_for_updates(); +} + +/// Check now without a window unless an update is found. +pub fn check_in_background() { + #[cfg(target_os = "macos")] + sparkle::check_in_background(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr34_10_framework_is_found_beside_the_executable_in_its_bundle() { + assert_eq!( + framework_path(Path::new("/Applications/Demo.app/Contents/MacOS/demo")), + Ok(PathBuf::from( + "/Applications/Demo.app/Contents/Frameworks/Sparkle.framework" + )) + ); + } + + #[test] + fn fr34_10_an_executable_outside_a_bundle_is_named_in_the_error() { + for path in [ + "/usr/local/bin/demo", + "/Applications/Demo.app/Contents/demo", + "/Applications/Demo/Contents/MacOS/demo", + "demo", + ] { + let error = framework_path(Path::new(path)).unwrap_err(); + assert_eq!(error, UpdaterError::NotInBundle(PathBuf::from(path))); + assert!(error.to_string().contains(path), "{error}"); + } + } + + #[test] + fn fr34_10_a_directory_without_the_binary_is_not_a_framework() { + let scratch = std::env::temp_dir().join(format!( + "dioxus-compose-update-test-{}", + std::process::id() + )); + let framework = scratch.join("Sparkle.framework"); + std::fs::create_dir_all(framework.join("Versions").join("B")).unwrap(); + assert!(!is_sparkle_framework(&framework)); + std::fs::write(framework.join("Versions").join("B").join("Sparkle"), b"").unwrap(); + assert!(is_sparkle_framework(&framework)); + std::fs::remove_dir_all(&scratch).unwrap(); + } + + #[test] + fn fr34_10_an_executable_outside_a_bundle_cannot_start_the_updater() { + // The test binary lives in target/, not in a bundle, which is exactly the case of + // running an application with `cargo run`. That must be an error the caller can + // print, not a crash and not a silent success. + let error = start(UpdaterOptions::default()).unwrap_err(); + if cfg!(target_os = "macos") { + assert!(matches!(error, UpdaterError::NotInBundle(_)), "{error:?}"); + } else { + assert_eq!(error, UpdaterError::Unsupported); + } + } + + #[test] + fn fr34_10_checks_before_start_do_nothing() { + // Neither may panic or block: on macOS they queue work for the main thread, which + // in a test binary never runs, and the queued work finds no controller. + check_for_updates(); + check_in_background(); + } +} diff --git a/dioxus-compose-update/src/sparkle.rs b/dioxus-compose-update/src/sparkle.rs new file mode 100644 index 00000000..295df3b5 --- /dev/null +++ b/dioxus-compose-update/src/sparkle.rs @@ -0,0 +1,209 @@ +//! Sparkle 2, loaded from the bundle at run time and driven through the Objective-C +//! runtime. +//! +//! Why the Host and not the Renderer: Sparkle is an Objective-C framework, and the Rust +//! side can call it directly through objc2. The Renderer is a GraalVM native image, from +//! which the same calls would need hand-written JNI or C glue, which this project does not +//! write. Nothing crosses the Host/Renderer boundary for this either: Sparkle draws its +//! own AppKit windows, and the only thing the application's UI does is ask for a check, +//! which is a plain Rust function call from an event handler. +//! +//! Threads: everything Sparkle is told happens on the main thread, where the renderer's +//! C entry runs `[NSApp run]`. Work is put on the main dispatch queue, which that run loop +//! drains, so it does not matter which thread asked or whether AppKit is running yet. + +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; + +use block2::{Block, RcBlock}; +use objc2::rc::{Allocated, Retained}; +use objc2::runtime::{AnyClass, AnyObject, Bool, NSObject}; +use objc2::{AnyThread, define_class, msg_send}; +use objc2_foundation::NSString; + +use crate::{Install, UpdaterError, UpdaterOptions}; + +/// The SPUStandardUpdaterController, retained for the life of the process. +static CONTROLLER: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); +static STARTED: AtomicBool = AtomicBool::new(false); + +#[repr(C)] +struct DispatchQueue { + _opaque: [u8; 0], +} + +// libdispatch, which every macOS process links through libSystem. +// `dispatch_get_main_queue()` is a macro for the address of `_dispatch_main_q`. +unsafe extern "C" { + static _dispatch_main_q: DispatchQueue; + fn dispatch_async_f( + queue: *const DispatchQueue, + context: *mut c_void, + work: extern "C" fn(*mut c_void), + ); +} + +/// Run `work` on the main thread, after whatever is already queued there. +fn on_main_thread(work: F) { + extern "C" fn trampoline(context: *mut c_void) { + // SAFETY: `context` is the box leaked below, and the queue calls this once. + let work = unsafe { Box::from_raw(context.cast::()) }; + // An unwind may not cross into libdispatch. + if catch_unwind(AssertUnwindSafe(work)).is_err() { + eprintln!("dioxus-compose-update: an update request panicked on the main thread"); + } + } + let context = Box::into_raw(Box::new(work)).cast::(); + // SAFETY: the main queue lives for the whole process and the trampoline matches the + // context's type. + unsafe { dispatch_async_f(&raw const _dispatch_main_q, context, trampoline::) }; +} + +/// A value that is only touched on the main thread, carried there from the main thread. +struct OnMain(T); + +// SAFETY: only constructed on the main thread and only consumed by work queued to the +// main thread, so the value never runs anywhere else. +unsafe impl Send for OnMain {} + +impl OnMain> { + fn call(self) { + self.0.call(()); + } +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements, and this class has no Drop. + #[unsafe(super(NSObject))] + struct InstallNowDelegate; + + impl InstallNowDelegate { + // SPUUpdaterDelegate. Sparkle asks this when an update it downloaded on its own is + // ready and would otherwise wait for the application to quit. Answering YES and + // calling the block installs it now and relaunches. + #[unsafe(method(updater:willInstallUpdateOnQuit:immediateInstallationBlock:))] + fn will_install_update_on_quit( + &self, + _updater: *mut AnyObject, + _item: *mut AnyObject, + install: &Block, + ) -> Bool { + eprintln!("dioxus-compose-update: update ready, installing and relaunching"); + // Not from inside the delegate call: Sparkle is still in the middle of the + // step that asked. + let install = OnMain(install.copy()); + on_main_thread(move || install.call()); + Bool::YES + } + } +); + +impl InstallNowDelegate { + fn new() -> Retained { + let this = Self::alloc().set_ivars(()); + // SAFETY: NSObject's init on a freshly allocated object. + unsafe { msg_send![super(this), init] } + } +} + +pub(crate) fn start(framework: PathBuf, options: UpdaterOptions) -> Result<(), UpdaterError> { + if STARTED.swap(true, Ordering::AcqRel) { + return Err(UpdaterError::AlreadyStarted); + } + on_main_thread(move || { + // SAFETY: on the main thread, which is where AppKit and Sparkle must be used. + match unsafe { start_on_main(&framework, options.install) } { + Ok(()) => eprintln!("dioxus-compose-update: Sparkle started"), + Err(message) => eprintln!("dioxus-compose-update: {message}"), + } + }); + if options.check_on_start { + check_in_background(); + } + Ok(()) +} + +unsafe fn start_on_main(framework: &Path, install: Install) -> Result<(), String> { + let path = NSString::from_str(&framework.to_string_lossy()); + let bundle_class = + AnyClass::get(c"NSBundle").ok_or("the Objective-C runtime has no NSBundle")?; + // SAFETY: +[NSBundle bundleWithPath:] takes a string and returns a bundle or nil. + let bundle: Option> = unsafe { msg_send![bundle_class, bundleWithPath: &*path] }; + let bundle = bundle.ok_or_else(|| format!("{} is not a bundle", framework.display()))?; + // SAFETY: -[NSBundle load] takes nothing and returns whether the code is loaded. + let loaded: Bool = unsafe { msg_send![&*bundle, load] }; + if !loaded.as_bool() { + return Err(format!( + "{} did not load. A framework signed by a different team than the application \ + is refused by library validation; sign the whole bundle with one identity.", + framework.display() + )); + } + let controller_class = AnyClass::get(c"SPUStandardUpdaterController").ok_or_else(|| { + format!( + "{} loaded but has no SPUStandardUpdaterController; it is not Sparkle 2", + framework.display() + ) + })?; + + let delegate = match install { + Install::Immediately => Some(InstallNowDelegate::new()), + Install::OnQuit => None, + }; + // SAFETY: +alloc on a class that exists. + let allocated: Allocated = unsafe { msg_send![controller_class, alloc] }; + // SAFETY: SPUStandardUpdaterController's designated initializer: whether to start the + // updater now, an updater delegate or nil, and a user driver delegate or nil. + let controller: Option> = unsafe { + msg_send![ + allocated, + initWithStartingUpdater: Bool::YES, + updaterDelegate: delegate.as_deref(), + userDriverDelegate: None::<&AnyObject> + ] + }; + let controller = controller + .ok_or_else(|| "SPUStandardUpdaterController refused to initialise".to_owned())?; + + // Sparkle keeps its delegate weakly, so the delegate is kept here, for the life of the + // process, as is the controller. + if let Some(delegate) = delegate { + let _ = Retained::into_raw(delegate); + } + CONTROLLER.store(Retained::into_raw(controller), Ordering::Release); + Ok(()) +} + +pub(crate) fn check_for_updates() { + on_main_thread(|| { + let controller = CONTROLLER.load(Ordering::Acquire); + if controller.is_null() { + eprintln!("dioxus-compose-update: no updater is running, so there is nothing to check with"); + return; + } + // SAFETY: the controller is retained for the life of the process; checkForUpdates: + // is its menu action and takes the sender, which may be nil. + unsafe { + let () = msg_send![controller, checkForUpdates: None::<&AnyObject>]; + } + }); +} + +pub(crate) fn check_in_background() { + on_main_thread(|| { + let controller = CONTROLLER.load(Ordering::Acquire); + if controller.is_null() { + eprintln!("dioxus-compose-update: no updater is running, so there is nothing to check with"); + return; + } + // SAFETY: as above; -updater returns the controller's SPUUpdater. + unsafe { + let updater: Option> = msg_send![controller, updater]; + if let Some(updater) = updater { + let () = msg_send![&*updater, checkForUpdatesInBackground]; + } + } + }); +} From 0429e22d42c5c515e27b72d8922d509b6eb8bf95 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:39:05 +0900 Subject: [PATCH 014/224] Test: Rehearse a Sparkle self-update and a sandboxed store build on CI experiments/self-update builds a small dioxus-compose application at 1.0.0 and 2.0.0 against the published crate (so the released renderer is fetched, not built), packages both for the sparkle channel signed ad hoc, serves an appcast from 127.0.0.1, and waits for the installed 1.0.0 to replace itself with 2.0.0: once installing immediately and relaunching, once installing on quit. It then builds the same application without the updater for the app-store channel, makes the .pkg, checks nothing of Sparkle is in it, and runs it sandboxed. The macOS packaging workflow runs the packager tests on macOS and Linux, the updater crate's tests and lints, and the rehearsal, uploading its evidence. Covers draft criterion 11 of the proposed FR-34. --- .github/workflows/packager-macos.yml | 133 ++++++++++ experiments/self-update/app/Cargo.toml | 31 +++ experiments/self-update/app/Dioxus.toml | 15 ++ experiments/self-update/app/assets/icon.png | Bin 0 -> 482 bytes experiments/self-update/app/src/main.rs | 149 +++++++++++ experiments/self-update/rehearse.sh | 277 ++++++++++++++++++++ 6 files changed, 605 insertions(+) create mode 100644 .github/workflows/packager-macos.yml create mode 100644 experiments/self-update/app/Cargo.toml create mode 100644 experiments/self-update/app/Dioxus.toml create mode 100644 experiments/self-update/app/assets/icon.png create mode 100644 experiments/self-update/app/src/main.rs create mode 100755 experiments/self-update/rehearse.sh diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml new file mode 100644 index 00000000..ceda129e --- /dev/null +++ b/.github/workflows/packager-macos.yml @@ -0,0 +1,133 @@ +# macOS packaging: the packager's tests, the updater crate's tests, and the rehearsal that +# installs one version of an application and watches it update itself to the next. +# +# The rehearsal is the only place the two halves meet a real renderer: it builds the demo +# against the published dioxus-compose, whose build script fetches the renderer released for +# its version, so nothing here builds the renderer. It signs ad hoc, because no Apple +# identity exists on a runner; what that leaves unproven (Developer ID signing, notarization, +# App Store Connect accepting the package) is the owner's to run with real certificates. +# +# Its evidence (logs, Info.plists, signatures, the appcast, screenshots) is uploaded as the +# self-update-evidence artifact whether or not it passed. + +name: macOS packaging + +on: + push: + branches: [develop, main, 'feature/packager-*'] + paths: + - 'tools/packager/**' + - 'dioxus-compose-update/**' + - 'experiments/self-update/**' + - 'scripts/bundle-renderer.sh' + - '.github/workflows/packager-macos.yml' + pull_request: + paths: + - 'tools/packager/**' + - 'dioxus-compose-update/**' + - 'experiments/self-update/**' + - 'scripts/bundle-renderer.sh' + - '.github/workflows/packager-macos.yml' + workflow_dispatch: + +concurrency: + group: packager-macos-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + SPARKLE_VERSION: '2.10.0' + SPARKLE_SHA256: c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c + +jobs: + packager: + name: Packager tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Unpack Sparkle + # On macOS the tests also compare against Sparkle's own tools and sign a real + # Sparkle.framework. Linux has neither codesign nor Sparkle, and runs the rest. + if: runner.os == 'macOS' + run: | + set -euo pipefail + dir="$RUNNER_TEMP/sparkle" + mkdir -p "$dir" + curl -fsSL -o "$dir/Sparkle.tar.xz" \ + "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" + echo "$SPARKLE_SHA256 $dir/Sparkle.tar.xz" | shasum -a 256 -c - + tar -xf "$dir/Sparkle.tar.xz" -C "$dir" + echo "SPARKLE_DIR=$dir" >> "$GITHUB_ENV" + + - name: Run the packager's tests + run: python -m unittest discover -s tools/packager/tests -v + + updater: + name: Updater crate (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + + - name: Test + run: cargo test -p dioxus-compose-update + + - name: Lint + run: cargo clippy -p dioxus-compose-update --all-targets -- -D warnings + + - name: Lint the rehearsal application + # It depends on the published dioxus-compose, so this fetches the released renderer. + if: runner.os == 'macOS' + run: | + cargo clippy --manifest-path experiments/self-update/app/Cargo.toml -- -D warnings + cargo clippy --manifest-path experiments/self-update/app/Cargo.toml --no-default-features -- -D warnings + + rehearsal: + name: Self-update rehearsal (macOS) + needs: [packager, updater] + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Rehearse both channels + env: + DEMO_WORK: ${{ runner.temp }}/self-update + run: experiments/self-update/rehearse.sh + + - name: Keep the evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: self-update-evidence + path: ${{ runner.temp }}/self-update/evidence/ + if-no-files-found: warn diff --git a/experiments/self-update/app/Cargo.toml b/experiments/self-update/app/Cargo.toml new file mode 100644 index 00000000..e9b42134 --- /dev/null +++ b/experiments/self-update/app/Cargo.toml @@ -0,0 +1,31 @@ +# The application the self-update rehearsal installs at one version and watches replace +# itself with the next. +# +# It depends on the published dioxus-compose, not the one in this checkout, because the +# published crate fetches the renderer released for its own version: building this needs +# no renderer build, and the two halves are guaranteed to be from the same schema. +[package] +name = "self-update-demo" +version = "1.0.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# Its own workspace: this directory is inside the repository's but is not one of its +# members, and the repository's lock file should not carry a crate it only rehearses with. +[workspace] + +[features] +default = ["sparkle"] +# The updater. The Mac App Store build turns default features off, and the packager +# checks that nothing of Sparkle is left in what it builds. +sparkle = ["dep:dioxus-compose-update", "dep:objc2"] + +[dependencies] +dioxus-compose = "=0.0.0" +dioxus-compose-update = { path = "../../../dioxus-compose-update", optional = true } + +# Only for the rehearsal's "quit after N seconds", which stands in for the person choosing +# Quit so that installing on quit can be watched without anyone at the keyboard. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", optional = true } diff --git a/experiments/self-update/app/Dioxus.toml b/experiments/self-update/app/Dioxus.toml new file mode 100644 index 00000000..22e6be63 --- /dev/null +++ b/experiments/self-update/app/Dioxus.toml @@ -0,0 +1,15 @@ +# dx project configuration for the self-update rehearsal. The packager reads the bundle +# name, identifier and category from here. + +[application] +name = "Self Update Demo" +asset_dir = "assets" + +[bundle] +identifier = "dev.darkpyonix.dioxus.compose.experiments.selfupdate" +publisher = "DarkPyonix" +category = "DeveloperTool" +short_description = "Installs at one version and updates itself to the next." + +[macos] +bundle_name = "Self Update Demo" diff --git a/experiments/self-update/app/assets/icon.png b/experiments/self-update/app/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..39063eec7c6db3590279c9b11ae40132f0fcfcec GIT binary patch literal 482 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU|i|x;uunK>+M~`yu$_pE`j&{ z&&!HgRj7HtftNRH=jX2bQjO0K>P^ZxUR&Q@^jVGJfiHst0|x`60s~6}g8%~)PL6c_ z3&VL=)7IC=&QDim=<2h}F8vbh=+e*U6v&XzGr#NLhlc_@@_h$>z$lKMc#(s89LM_xPout>mN@U zLYgmB{NN-wwz PbQ*)FtDnm{r-UW|bsm&5 literal 0 HcmV?d00001 diff --git a/experiments/self-update/app/src/main.rs b/experiments/self-update/app/src/main.rs new file mode 100644 index 00000000..337255e3 --- /dev/null +++ b/experiments/self-update/app/src/main.rs @@ -0,0 +1,149 @@ +//! The application the self-update rehearsal installs and watches update itself. +//! +//! It shows its own version, offers "Check for Updates...", and appends one line per +//! launch to `self-update-demo.log` beside its bundle, which is how the rehearsal tells +//! the version that was installed from the one that relaunched in its place. +//! +//! Environment, read at launch (a relaunch by Sparkle has none of it, and gets the +//! defaults): +//! +//! - `DEMO_INSTALL=on-quit` waits for the application to quit before installing, which is +//! Sparkle's default; otherwise an update found in the background is installed at once. +//! - `DEMO_CHECK_AFTER_SECS=n` checks in the background after n seconds rather than at +//! launch, so the old version can be seen first. +//! - `DEMO_QUIT_AFTER_SECS=n` quits after n seconds the way the Quit menu item does. + +#![windows_subsystem = "windows"] + +use dioxus_compose::prelude::*; + +const VERSION: &str = match option_env!("DEMO_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + +fn app() -> Element { + let title = format!("Self Update Demo {VERSION}"); + let channel = if cfg!(feature = "sparkle") { + "Updates itself with Sparkle." + } else { + "Updated by the Mac App Store." + }; + rsx! { + Column { + fill_max_width: true, + Text { text: title } + Text { text: channel } + Button { + text: "Check for Updates...", + on_click: move |_| check_for_updates(), + } + } + } +} + +fn check_for_updates() { + #[cfg(feature = "sparkle")] + dioxus_compose_update::check_for_updates(); +} + +#[cfg(feature = "sparkle")] +fn seconds_from(name: &str) -> Option { + std::env::var(name).ok()?.parse().ok() +} + +/// One line per launch, beside the bundle, so a relaunch can be told from the original. +fn record_launch() { + let Ok(executable) = std::env::current_exe() else { + return; + }; + // Name.app/Contents/MacOS/: four levels up is the directory holding the app. + let Some(beside) = executable.ancestors().nth(4) else { + return; + }; + let line = format!( + "version {VERSION} started, pid {}, from {}\n", + std::process::id(), + executable.display() + ); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(beside.join("self-update-demo.log")) + .and_then(|mut file| std::io::Write::write_all(&mut file, line.as_bytes())); + eprint!("self-update-demo: {line}"); +} + +#[cfg(feature = "sparkle")] +fn start_updates() { + use dioxus_compose_update::{Install, UpdaterOptions, check_in_background, start}; + + let install = match std::env::var("DEMO_INSTALL").as_deref() { + Ok("on-quit") => Install::OnQuit, + _ => Install::Immediately, + }; + let delay = seconds_from("DEMO_CHECK_AFTER_SECS"); + let options = UpdaterOptions { + check_on_start: delay.is_none(), + install, + }; + if let Err(error) = start(options) { + eprintln!("self-update-demo: updates are off: {error}"); + return; + } + if let Some(seconds) = delay { + // A Host worker thread: the request itself is queued to the main thread. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(seconds)); + check_in_background(); + }); + } +} + +/// `[NSApp terminate:nil]` on the main thread, which is what the Quit menu item sends. +#[cfg(all(feature = "sparkle", target_os = "macos"))] +fn quit_after(seconds: u64) { + use objc2::runtime::{AnyClass, AnyObject}; + use objc2::msg_send; + use std::ffi::c_void; + + #[repr(C)] + struct DispatchQueue { + _opaque: [u8; 0], + } + unsafe extern "C" { + static _dispatch_main_q: DispatchQueue; + fn dispatch_async_f( + queue: *const DispatchQueue, + context: *mut c_void, + work: extern "C" fn(*mut c_void), + ); + } + extern "C" fn terminate(_: *mut c_void) { + let Some(class) = AnyClass::get(c"NSApplication") else { + return; + }; + // SAFETY: on the main thread; sharedApplication exists once the renderer is up. + unsafe { + let app: *mut AnyObject = msg_send![class, sharedApplication]; + let () = msg_send![app, terminate: std::ptr::null_mut::()]; + } + } + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(seconds)); + eprintln!("self-update-demo: quitting as the Quit menu item would"); + // SAFETY: the main queue lives for the process, and `terminate` takes no context. + unsafe { dispatch_async_f(&raw const _dispatch_main_q, std::ptr::null_mut(), terminate) }; + }); +} + +fn main() { + record_launch(); + #[cfg(feature = "sparkle")] + start_updates(); + #[cfg(all(feature = "sparkle", target_os = "macos"))] + if let Some(seconds) = seconds_from("DEMO_QUIT_AFTER_SECS") { + quit_after(seconds); + } + dioxus_compose::launch(app); +} diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh new file mode 100755 index 00000000..21f4f2a9 --- /dev/null +++ b/experiments/self-update/rehearse.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# Usage: experiments/self-update/rehearse.sh +# +# Rehearses both macOS channels end to end on one machine, with no Apple identity: +# +# Sparkle: builds the demo application at 1.0.0 and 2.0.0, packages both for the +# sparkle channel (signed ad hoc), publishes 2.0.0 into an appcast served from +# 127.0.0.1, installs 1.0.0, runs it, and waits for it to replace itself with 2.0.0. +# Twice: once installing as soon as the update is ready (and relaunching), once +# installing when the application quits, which is Sparkle's default. +# +# Mac App Store: builds the same application without the updater, packages it for the +# app-store channel (sandboxed), checks nothing of Sparkle is in it, makes the .pkg, and +# runs the sandboxed application. +# +# Everything goes under $DEMO_WORK (default .scratch/self-update in this repository): +# builds, bundles, the served site, the private key, and evidence/ with logs, plists, +# signatures and screenshots. evidence/result.txt says what passed. +# +# Needs Xcode's command line tools, Python 3.11 or newer, and Rust. Sparkle's release +# archive is downloaded into $DEMO_WORK and checked against a pinned SHA-256, unless +# SPARKLE_DIR names an unpacked one. Running the application writes what any macOS +# application writes: Sparkle's preferences and download cache under ~/Library for the +# demo's bundle identifier, and the sandbox container of the store build. The last step +# lists them. + +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +work="${DEMO_WORK:-$repo/.scratch/self-update}" +port="${DEMO_PORT:-8765}" +package="$repo/tools/packager/package-macos" +app_dir="$repo/experiments/self-update/app" +evidence="$work/evidence" +name="Self Update Demo" +identifier="dev.darkpyonix.dioxus.compose.experiments.selfupdate" +executable="self-update-demo" +archive_name="SelfUpdateDemo-2.0.0.zip" + +sparkle_version="2.10.0" +sparkle_sha256="c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c" + +rm -rf "$work/build" "$work"/dist-* "$work"/install-* "$work/site" "$work/generated" "$evidence" +mkdir -p "$work/build" "$work/site" "$evidence" +result="$evidence/result.txt" +: >"$result" + +step() { printf '\n== %s\n' "$*"; } +pass() { echo "PASS $*" | tee -a "$result"; } +fail() { + echo "FAIL $*" | tee -a "$result" + exit 1 +} +shot() { + # The whole screen. A runner without screen recording permission gets a black or + # missing picture, which is noted rather than fatal: the logs are the evidence and the + # picture is for a person. + sleep "${2:-0}" + screencapture -x "$evidence/$1.png" 2>>"$evidence/screencapture.log" || + echo "screencapture failed for $1" >>"$evidence/screencapture.log" +} + +pids=() +cleanup() { + for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done + pkill -f "$work/install-" 2>/dev/null || true + pkill -f "$work/dist-store" 2>/dev/null || true +} +trap cleanup EXIT + +# Info.plist's CFBundleVersion of an installed app, or nothing while it is being replaced. +installed_build() { + /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$1/Contents/Info.plist" 2>/dev/null || true +} + +wait_for() { + local seconds="$1" + shift + local waited=0 + until "$@"; do + ((waited >= seconds)) && return 1 + sleep 1 + waited=$((waited + 1)) + done +} + +step "Sparkle $sparkle_version" +sparkle="${SPARKLE_DIR:-$work/sparkle}" +if [[ ! -x "$sparkle/bin/sign_update" ]]; then + mkdir -p "$sparkle" + curl -fsSL -o "$sparkle/Sparkle.tar.xz" \ + "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/Sparkle-$sparkle_version.tar.xz" + echo "$sparkle_sha256 $sparkle/Sparkle.tar.xz" | shasum -a 256 -c - + tar -xf "$sparkle/Sparkle.tar.xz" -C "$sparkle" +fi + +step "EdDSA key (kept in $work/keys, never in the repository)" +key="$work/keys/sparkle.key" +[[ -f "$key" ]] || "$package" keygen --out "$key" +public_key="$("$package" public-key --key "$key")" +echo "SUPublicEDKey $public_key" + +# Plain http to 127.0.0.1 is how an update is rehearsed on one machine. App Transport +# Security refuses it unless the application allows local networking. +cat >"$work/ats.plist" <<'PLIST' + + + + NSAppTransportSecurityNSAllowsLocalNetworking + +PLIST + +build() { + local version="$1" out="$2" + shift 2 + DEMO_VERSION="$version" cargo build --release --manifest-path "$app_dir/Cargo.toml" "$@" + cp "$app_dir/target/release/$executable" "$out" +} + +step "Build and package 1.0.0 and 2.0.0 for the sparkle channel" +for major in 1 2; do + build "$major.0.0" "$work/build/$executable-$major" + "$package" app --channel sparkle --manifest-dir "$app_dir" \ + --executable "$work/build/$executable-$major" \ + --version "$major.0.0" --build "$major" --out "$work/dist-$major" \ + --feed-url "http://127.0.0.1:$port/appcast.xml" --ed-key-file "$key" \ + --sparkle-framework "$sparkle/Sparkle.framework" --automatically-update \ + --plist-file "$work/ats.plist" | tee "$evidence/package-$major.txt" + plutil -p "$work/dist-$major/$name.app/Contents/Info.plist" >"$evidence/Info-$major.plist.txt" + codesign -dvv "$work/dist-$major/$name.app" >"$evidence/codesign-$major.txt" 2>&1 + otool -L "$work/dist-$major/$name.app/Contents/MacOS/$executable" >"$evidence/otool-$major.txt" +done +pass "1.0.0 and 2.0.0 packaged, signed and verified for the sparkle channel" + +step "Publish 2.0.0" +"$package" archive --app "$work/dist-2/$name.app" --out "$work/site/$archive_name" +"$package" appcast --appcast "$work/site/appcast.xml" --app "$work/dist-2/$name.app" \ + --archive "$work/site/$archive_name" --url "http://127.0.0.1:$port/$archive_name" \ + --ed-key-file "$key" --sign-update "$sparkle/bin/sign_update" +cp "$work/site/appcast.xml" "$evidence/appcast.xml" +signature="$(python3 -c ' +import sys, xml.etree.ElementTree as ET +S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" +print(ET.parse(sys.argv[1]).find("channel/item/enclosure").get(S + "edSignature")) +' "$work/site/appcast.xml")" +"$sparkle/bin/sign_update" --ed-key-file "$key" --verify "$work/site/$archive_name" "$signature" +pass "the appcast's signature verifies with Sparkle's sign_update" + +# Sparkle's own generator, given the same archive and key, must say the same thing. +mkdir -p "$work/generated" +cp "$work/site/$archive_name" "$work/generated/" +"$sparkle/bin/generate_appcast" --ed-key-file "$key" "$work/generated" >"$evidence/generate_appcast.txt" 2>&1 +cp "$work/generated/appcast.xml" "$evidence/appcast-from-generate_appcast.xml" +python3 - "$work/site/appcast.xml" "$work/generated/appcast.xml" <<'PY' | tee -a "$evidence/appcast-comparison.txt" +import sys, xml.etree.ElementTree as ET +S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" +def read(path): + item = ET.parse(path).find("channel/item") + enclosure = item.find("enclosure") + return { + "version": item.findtext(S + "version"), + "shortVersionString": item.findtext(S + "shortVersionString"), + "minimumSystemVersion": item.findtext(S + "minimumSystemVersion"), + "hardwareRequirements": item.findtext(S + "hardwareRequirements"), + "length": enclosure.get("length"), + "edSignature": enclosure.get(S + "edSignature"), + } +ours, theirs = read(sys.argv[1]), read(sys.argv[2]) +for key in ours: + print(f"{key}: ours={ours[key]} generate_appcast={theirs[key]}") +sys.exit(0 if ours == theirs else 1) +PY +pass "the appcast item matches what Sparkle's generate_appcast writes for the same archive" + +step "Serve the site on 127.0.0.1:$port" +python3 -m http.server "$port" --bind 127.0.0.1 --directory "$work/site" >"$evidence/http.log" 2>&1 & +pids+=($!) +wait_for 10 curl -fsS -o /dev/null "http://127.0.0.1:$port/appcast.xml" || fail "the server did not start" + +log stream --style compact --level debug --predicate \ + 'subsystem == "org.sparkle-project.Sparkle" OR process == "Autoupdate" OR process == "Updater" OR process == "'"$executable"'"' \ + >"$evidence/unified.log" 2>&1 & +pids+=($!) + +downloads() { grep -c "GET /$archive_name" "$evidence/http.log" || true; } + +step "Scenario 1: 1.0.0 installs 2.0.0 as soon as it is ready and relaunches" +install="$work/install-immediately" +mkdir -p "$install" +ditto "$work/dist-1/$name.app" "$install/$name.app" +DEMO_CHECK_AFTER_SECS=10 "$install/$name.app/Contents/MacOS/$executable" \ + >"$evidence/immediately-1.0.0.stderr" 2>&1 & +shot immediately-1-installed-1.0.0 7 +[[ "$(installed_build "$install/$name.app")" == 1 ]] || fail "1.0.0 was replaced before it was seen" +relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } +wait_for 180 relaunched || { + cat "$evidence/immediately-1.0.0.stderr" + fail "1.0.0 did not relaunch as 2.0.0 within three minutes" +} +[[ "$(installed_build "$install/$name.app")" == 2 ]] || fail "the installed bundle is not 2.0.0" +shot immediately-2-relaunched-2.0.0 8 +cp "$install/self-update-demo.log" "$evidence/immediately-launches.log" +plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/immediately-installed-Info.plist.txt" +codesign --verify --strict --deep --verbose=2 "$install/$name.app" >"$evidence/immediately-installed-codesign.txt" 2>&1 +pass "1.0.0 updated itself to 2.0.0 and relaunched (see immediately-launches.log)" +# The new version checked too, on its own launch. It must find nothing newer. +sleep 10 +[[ "$(downloads)" == 1 ]] || fail "the archive was downloaded $(downloads) times; 2.0.0 offered itself again" +pass "2.0.0 checked the same feed and did not download anything" +pkill -f "$install/" || true + +step "Scenario 2: 1.0.0 downloads 2.0.0 and installs it when it quits" +install="$work/install-on-quit" +mkdir -p "$install" +ditto "$work/dist-1/$name.app" "$install/$name.app" +DEMO_INSTALL=on-quit DEMO_CHECK_AFTER_SECS=3 DEMO_QUIT_AFTER_SECS=40 \ + "$install/$name.app/Contents/MacOS/$executable" >"$evidence/on-quit-1.0.0.stderr" 2>&1 & +app=$! +shot on-quit-1-running-1.0.0 8 +wait "$app" || true +echo "1.0.0 exited" >>"$evidence/on-quit-1.0.0.stderr" +updated() { [[ "$(installed_build "$install/$name.app")" == 2 ]]; } +if wait_for 120 updated; then + plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/on-quit-installed-Info.plist.txt" + pass "1.0.0 quit and Sparkle installed 2.0.0 in its place" +else + fail "1.0.0 quit and 2.0.0 was not installed within two minutes" +fi + +step "Mac App Store: the same application without the updater" +build 1.0.0 "$work/build/$executable-store" --no-default-features +"$package" app --channel app-store --manifest-dir "$app_dir" \ + --executable "$work/build/$executable-store" --version 1.0.0 --build 1 \ + --out "$work/dist-store" --uses-non-exempt-encryption no --network-client \ + --user-selected-files read-write | tee "$evidence/package-store.txt" +store_app="$work/dist-store/$name.app" +"$package" verify --app "$store_app" --channel app-store | tee -a "$evidence/package-store.txt" +codesign -d --entitlements - --xml "$store_app" >"$evidence/store-entitlements.plist" 2>/dev/null +plutil -p "$store_app/Contents/Info.plist" >"$evidence/store-Info.plist.txt" +"$package" pkg --app "$store_app" --out "$work/dist-store/SelfUpdateDemo.pkg" +pkgutil --expand-full "$work/dist-store/SelfUpdateDemo.pkg" "$work/dist-store/expanded" +(cd "$work/dist-store/expanded" && find . -maxdepth 6 | sort) >"$evidence/store-pkg-contents.txt" +if grep -ri sparkle "$evidence/store-pkg-contents.txt" >/dev/null; then + fail "the store package contains something named Sparkle" +fi +if grep -rl "SPUStandardUpdaterController\|Sparkle.framework" "$work/dist-store/expanded" >/dev/null; then + fail "a file in the store package names Sparkle" +fi +pass "the store package installs into /Applications and contains nothing of Sparkle" + +"$store_app/Contents/MacOS/$executable" >"$evidence/store.stderr" 2>&1 & +store_pid=$! +shot store-1-sandboxed-1.0.0 10 +if kill -0 "$store_pid" 2>/dev/null; then + pass "the sandboxed store build is running after ten seconds (see store-1-sandboxed-1.0.0.png)" +else + cat "$evidence/store.stderr" + fail "the sandboxed store build exited" +fi +if [[ -d "$HOME/Library/Containers/$identifier" ]]; then + pass "the system created its sandbox container" +else + fail "no sandbox container for $identifier: it did not run sandboxed" +fi +kill "$store_pid" 2>/dev/null || true + +step "What running the demo left outside $work" +{ + ls -d "$HOME/Library/Containers/$identifier" 2>/dev/null || true + ls -d "$HOME/Library/Caches/$identifier" 2>/dev/null || true + ls "$HOME/Library/Preferences/$identifier.plist" 2>/dev/null || true + ls -d "$HOME/Library/HTTPStorages/$identifier" 2>/dev/null || true +} | tee "$evidence/left-outside.txt" +echo "remove with: rm -rf ; defaults delete $identifier" + +echo +cat "$result" From 24ee461ea129b84c14962b3b403e4957e521de2a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:39:25 +0900 Subject: [PATCH 015/224] Chore: Record the Linux packager's dependencies in Cargo.lock A Flathub build runs cargo with --locked and no network, so the lock file has to name every crate before the build does. --- Cargo.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1b9b3b84..0ee0214a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -394,6 +400,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itertools" version = "0.13.0" @@ -567,6 +589,21 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "packager-linux" +version = "0.0.0" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "packager-linux-demo" +version = "0.0.0" +dependencies = [ + "dioxus-compose", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -922,6 +959,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -1044,6 +1090,45 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -1286,6 +1371,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zerocopy" version = "0.8.57" From 0315dac345a9750b5579e41bb3e8cf100552d4f9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:40:27 +0900 Subject: [PATCH 016/224] Fix: Build patchelf for a Flatpak that carries the renderer, and target 26.08 The freedesktop SDK has readelf but not patchelf, so a manifest that has to point the executable at the renderer beside it now builds patchelf in a module whose files are cleaned out of the application. The renderer's lib/ directory is flattened beside the executable, the layout it finds its companions from. The default runtime branch is 26.08, the newest on Flathub. flatpak.sh hands flatpak-builder an absolute manifest path, the demo no longer names a screenshot that does not exist (appimagetool fails on the validator's warning), and the AppImage job also runs on aarch64. --- .github/workflows/packager-linux.yml | 52 ++++++++++++++++++------- tools/packager-linux/demo/Dioxus.toml | 3 -- tools/packager-linux/flatpak.sh | 3 +- tools/packager-linux/src/flatpak.rs | 54 +++++++++++++++++++++++--- tools/packager-linux/tests/metadata.rs | 12 +++++- 5 files changed, 99 insertions(+), 25 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index c17623cc..510d7308 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -42,7 +42,11 @@ env: # companions and the executable, already pointed at each other by relative paths. CALCULATOR_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-x64.tar.gz CALCULATOR_SHA256: ebc52967d02bfbd2eb22317cb23d023417ec2029eac811f3ce6b018e1950fded - FLATPAK_RUNTIME: "25.08" + FLATPAK_RUNTIME: "26.08" + # The renderer release archive, built into the from-source Flatpak so that the step + # which points an executable at the renderer beside it runs for real. + RENDERER_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/v0.0.0/dioxus-compose-renderer-v0.0.0-linux-x64.tar.gz + RENDERER_SHA256: 5fe6ba31e359753a8e3c3a743f2f8ecadac409fda2634ba3b569e00f27ec19aa jobs: rust: @@ -98,9 +102,25 @@ jobs: run: exit 1 appimage: - name: AppImage updates itself - runs-on: ubuntu-24.04 + name: AppImage updates itself (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x86_64 + calculator: calculator-linux-x64.tar.gz + calculator_sha256: ebc52967d02bfbd2eb22317cb23d023417ec2029eac811f3ce6b018e1950fded + - runner: ubuntu-24.04-arm + arch: aarch64 + calculator: calculator-linux-arm64.tar.gz + calculator_sha256: b264dbc9efa2d7dc7e1ff9559743840d63c2aeca01f69a0e6d1ea1da9d97306f + env: + ARCH: ${{ matrix.arch }} + CALCULATOR_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/${{ matrix.calculator }} + CALCULATOR_SHA256: ${{ matrix.calculator_sha256 }} steps: - uses: actions/checkout@v4 @@ -141,7 +161,7 @@ jobs: - name: Package all three versions run: | set -euo pipefail - url=http://127.0.0.1:8080/latest/UpdateDemo-x86_64.AppImage.zsync + url=http://127.0.0.1:8080/latest/UpdateDemo-$ARCH.AppImage.zsync for v in 1.0.0 1.0.1 1.0.2; do key="$RELEASE_KEY"; [[ $v == 1.0.2 ]] && key="$IMPOSTOR_KEY" tools/packager-linux/appimage.sh \ @@ -150,15 +170,15 @@ jobs: --zsync-url "$url" --sign-key "$key" --out-dir "work/dist" done ls -l work/dist - head -c 400 work/dist/UpdateDemo-1.0.1-x86_64.AppImage.zsync | sed -n '1,/^$/p' + head -c 400 work/dist/UpdateDemo-1.0.1-$ARCH.AppImage.zsync | sed -n '1,/^$/p' - name: The AppImage carries its update information and its updater run: | set -euo pipefail - app=work/dist/UpdateDemo-1.0.0-x86_64.AppImage + app=work/dist/UpdateDemo-1.0.0-$ARCH.AppImage info="$("$app" --appimage-updateinformation)" echo "$info" - [[ "$info" == "zsync|http://127.0.0.1:8080/latest/UpdateDemo-x86_64.AppImage.zsync" ]] + [[ "$info" == "zsync|http://127.0.0.1:8080/latest/UpdateDemo-$ARCH.AppImage.zsync" ]] "$app" --appimage-signature | head -3 "$app" --appimage-extract >/dev/null test -e squashfs-root/usr/lib/appimageupdatetool/AppRun @@ -197,14 +217,14 @@ jobs: set -euo pipefail publish() { rm -f "$SERVER/latest/"* - cp "work/dist/UpdateDemo-$1-x86_64.AppImage" "$SERVER/latest/" + cp "work/dist/UpdateDemo-$1-$ARCH.AppImage" "$SERVER/latest/" # One fixed name for the newest .zsync; it names the AppImage beside it. - cp "work/dist/UpdateDemo-$1-x86_64.AppImage.zsync" "$SERVER/latest/UpdateDemo-x86_64.AppImage.zsync" + cp "work/dist/UpdateDemo-$1-$ARCH.AppImage.zsync" "$SERVER/latest/UpdateDemo-$ARCH.AppImage.zsync" } declare -f publish > work/publish.sh publish 1.0.0 mkdir -p work/installed - cp work/dist/UpdateDemo-1.0.0-x86_64.AppImage work/installed/UpdateDemo.AppImage + cp work/dist/UpdateDemo-1.0.0-$ARCH.AppImage work/installed/UpdateDemo.AppImage app=work/installed/UpdateDemo.AppImage "$app" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" "$app" --delivery @@ -226,7 +246,7 @@ jobs: "$app" --version | grep -qx "update-demo 1.0.1" "$app" --check after="$(sha256sum "$app" | cut -d' ' -f1)" - published="$(sha256sum "$SERVER/latest/UpdateDemo-1.0.1-x86_64.AppImage" | cut -d' ' -f1)" + published="$(sha256sum "$SERVER/latest/UpdateDemo-1.0.1-$ARCH.AppImage" | cut -d' ' -f1)" echo "before $before"; echo "after $after"; echo "served $published" [[ "$after" == "$published" && "$before" != "$after" ]] echo "--- requests" @@ -271,7 +291,7 @@ jobs: --payload work/calculator/calculator --version 0.1.1 \ --github darkpyonix/compose-rust --sign-key "$RELEASE_KEY" \ --out-dir work/dist - app=work/dist/Calculator-0.1.1-x86_64.AppImage + app=work/dist/Calculator-0.1.1-$ARCH.AppImage "$app" --appimage-updateinformation xvfb-run -a -s "-screen 0 1280x800x24" bash -c " '$app' > work/calculator.log 2>&1 & @@ -286,7 +306,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: appimage + name: appimage-${{ matrix.arch }} path: | work/dist/*.AppImage work/dist/*.zsync @@ -331,11 +351,16 @@ jobs: --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.0 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ + --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ --out work/flatpak-src --build work/repo --lint id=dev.darkpyonix.compose.UpdateDemo flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" flatpak run "$id" --delivery flatpak run "$id" --update --yes | tee /dev/stderr | grep -q "store" + # The renderer travelled beside the executable and the executable looks beside + # itself for libraries. + flatpak run --devel --command=sh "$id" -c 'ls /app/lib/update-demo; readelf -d /app/lib/update-demo/update-demo | grep -E "RUNPATH|RPATH|NEEDED"' + flatpak run --command=sh "$id" -c 'test -f /app/lib/update-demo/libdioxus_compose_renderer.so' cat work/flatpak-src/$id.json - name: Build 1.0.1 into the same repository, and flatpak update installs it @@ -353,6 +378,7 @@ jobs: --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.1 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ + --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ --out work/flatpak-src (cd work/flatpak-src && flatpak run --command=flatpak-builder org.flatpak.Builder \ --user --force-clean --repo="$PWD/../repo" build "$id.json") diff --git a/tools/packager-linux/demo/Dioxus.toml b/tools/packager-linux/demo/Dioxus.toml index d2a5918b..a2b5e2b7 100644 --- a/tools/packager-linux/demo/Dioxus.toml +++ b/tools/packager-linux/demo/Dioxus.toml @@ -28,6 +28,3 @@ bugtracker = "https://github.com/darkpyonix/compose-rust/issues" vcs_browser = "https://github.com/darkpyonix/compose-rust" brand_light = "#dbe7ff" brand_dark = "#1d2b4f" -screenshots = [ - { url = "https://raw.githubusercontent.com/darkpyonix/compose-rust/develop/tools/packager-linux/demo/screenshot.png", caption = "Checking for an update" }, -] diff --git a/tools/packager-linux/flatpak.sh b/tools/packager-linux/flatpak.sh index 0c53f028..50ab0c13 100755 --- a/tools/packager-linux/flatpak.sh +++ b/tools/packager-linux/flatpak.sh @@ -96,7 +96,8 @@ if [[ -n "$cargo_lock" ]]; then passthrough+=(--cargo-sources cargo-sources.json) fi -manifest="$(packager flatpak "${passthrough[@]}")" +# The packager prints the manifest's path as it was given; make it absolute. +manifest="$out/$(basename "$(packager flatpak "${passthrough[@]}")")" echo "== $manifest" builder() { diff --git a/tools/packager-linux/src/flatpak.rs b/tools/packager-linux/src/flatpak.rs index acdd4844..32e694af 100644 --- a/tools/packager-linux/src/flatpak.rs +++ b/tools/packager-linux/src/flatpak.rs @@ -22,7 +22,7 @@ use crate::icons::{Icon, IconKind}; use crate::metadata::AppMetadata; /// The runtime branch used when the application's file does not name one. -pub const DEFAULT_RUNTIME_VERSION: &str = "25.08"; +pub const DEFAULT_RUNTIME_VERSION: &str = "26.08"; /// Where a source comes from. #[derive(Debug, Clone, PartialEq, Eq)] @@ -155,7 +155,11 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { commands.push(build); commands.push(format!("mkdir -p {lib} /app/bin")); if renderer.is_some() { - commands.push(format!("cp -a renderer/. {lib}/")); + // The renderer's files go beside the executable, the layout it finds its + // companions from. A release archive keeps them in `lib/`. + commands.push(format!( + "if [ -d renderer/lib ]; then cp -a renderer/lib/. {lib}/; else cp -a renderer/. {lib}/; fi" + )); } commands.push(format!( "install -m755 target/release/{bin} {lib}/{}", @@ -231,10 +235,48 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { .collect(), ), )); - top.push(( - "modules".to_owned(), - Json::Array(vec![Json::Object(module_fields)]), - )); + let needs_patchelf = matches!( + &options.payload, + Payload::FromSource { + renderer: Some(_), + .. + } + ); + let mut modules = Vec::new(); + if needs_patchelf { + modules.push(patchelf_module()); + } + modules.push(Json::Object(module_fields)); + top.push(("modules".to_owned(), Json::Array(modules))); + render_manifest(top) +} + +/// The SDK has `readelf` but not `patchelf`, so a build that has to rewrite the +/// executable's library names builds it first. `cleanup` keeps it out of the application. +pub const PATCHELF_URL: &str = + "https://github.com/NixOS/patchelf/releases/download/0.19.1/patchelf-0.19.1.tar.gz"; +pub const PATCHELF_SHA256: &str = + "491108728f120ce05b539934b41a750235031a6df8abc6b47e57aff7de15094d"; + +fn patchelf_module() -> Json { + Json::Object(vec![ + ("name".to_owned(), Json::str("patchelf")), + ("buildsystem".to_owned(), Json::str("autotools")), + ("cleanup".to_owned(), Json::Array(vec![Json::str("*")])), + ( + "sources".to_owned(), + Json::Array(vec![archive_source( + &SourceRef::Url { + url: PATCHELF_URL.to_owned(), + sha256: PATCHELF_SHA256.to_owned(), + }, + None, + )]), + ), + ]) +} + +fn render_manifest(top: Vec<(String, Json)>) -> String { let mut out = Json::Object(top).render(0); out.push('\n'); out diff --git a/tools/packager-linux/tests/metadata.rs b/tools/packager-linux/tests/metadata.rs index 5dec14a4..1825ccbd 100644 --- a/tools/packager-linux/tests/metadata.rs +++ b/tools/packager-linux/tests/metadata.rs @@ -376,7 +376,7 @@ fn fr34_flathub_needs_a_large_enough_icon() { let small = Icon::classify(Path::new("64.png"), &png(64)).unwrap(); let large = Icon::classify(Path::new("128.png"), &png(128)).unwrap(); assert!( - check_for_flathub(&[small.clone()]) + check_for_flathub(std::slice::from_ref(&small)) .unwrap_err() .0 .contains("128") @@ -618,6 +618,7 @@ fn fr34_flatpak_manifest_for_a_prebuilt_archive() { assert!(text.contains(expected), "missing {expected} in\n{text}"); } assert!(!text.contains("rust-stable"), "{text}"); + assert!(!text.contains("patchelf"), "{text}"); assert!(!text.contains("cargo"), "{text}"); } @@ -658,13 +659,20 @@ fn fr34_flatpak_manifest_builds_from_source_offline() { "\"cargo --offline fetch --manifest-path Cargo.toml --verbose\"", "\"cargo --offline build --release --locked -p sample-calculator --bin sample-calculator --no-default-features 'it'\\\\''s'\"", "\"install -m755 target/release/sample-calculator /app/lib/sample-calculator/sample-calculator\"", - "\"cp -a renderer/. /app/lib/sample-calculator/\"", + "\"if [ -d renderer/lib ]; then cp -a renderer/lib/. /app/lib/sample-calculator/; else cp -a renderer/. /app/lib/sample-calculator/; fi\"", + "\"name\": \"patchelf\"", + "\"buildsystem\": \"autotools\"", "patchelf --set-rpath '$ORIGIN' /app/lib/sample-calculator/sample-calculator", "\"--socket=wayland\"", "\"--socket=fallback-x11\"", ] { assert!(text.contains(expected), "missing {expected} in\n{text}"); } + // patchelf is built before the application that needs it. + assert!( + text.find("\"name\": \"patchelf\"").unwrap() + < text.find("\"name\": \"sample-calculator\"").unwrap() + ); // The vendored crates come before the build that needs them. assert!( text.find("cargo --offline fetch").unwrap() < text.find("cargo --offline build").unwrap() From 3bf7769533f2e4f92cfc39f3a5fc270cbb8a2765 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:41:18 +0900 Subject: [PATCH 017/224] Fix: Write the updater's usage example without a main the lint calls needless --- dioxus-compose-update/src/lib.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dioxus-compose-update/src/lib.rs b/dioxus-compose-update/src/lib.rs index 64eccc26..c0198a6b 100644 --- a/dioxus-compose-update/src/lib.rs +++ b/dioxus-compose-update/src/lib.rs @@ -9,14 +9,12 @@ //! this crate only starts Sparkle and passes on the person's requests. //! //! ```no_run -//! fn main() { -//! // Before launch: the request waits on the main thread's queue and runs once -//! // the renderer has AppKit running there. -//! if let Err(error) = dioxus_compose_update::start(Default::default()) { -//! eprintln!("updates are off: {error}"); -//! } -//! // dioxus_compose::launch(app); +//! // In main, before launch: the request waits on the main thread's queue and runs +//! // once the renderer has AppKit running there. +//! if let Err(error) = dioxus_compose_update::start(Default::default()) { +//! eprintln!("updates are off: {error}"); //! } +//! // dioxus_compose::launch(app); //! //! // From a menu item or a button: //! // on_click: move |_| dioxus_compose_update::check_for_updates(), From 92026b932fccc340cbefea944398dd669e861db8 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:41:21 +0900 Subject: [PATCH 018/224] Feat: Ship each Linux sample as a self-updating AppImage (FR-22.6) The sample release's Linux rows wrap every staged sample with tools/packager-linux/appimage.sh, embedding gh-releases-zsync update information for this repository and publishing the .zsync beside the AppImage. They are signed when APPIMAGE_SIGNING_KEY is set and the run warns when it is not. A sample that cannot be wrapped keeps its archive. Not run here: this workflow builds the renderer and publishes a release, so it is verified at the next sample tag. The script it calls is the one packager-linux.yml exercises on x86_64 and aarch64. --- .github/workflows/samples.yml | 57 ++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 742ec26c..c8a62acf 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -117,7 +117,8 @@ jobs: libx11-dev libxext-dev libxi-dev libxrender-dev libxtst-dev libxrandr-dev \ libfontconfig1-dev libfreetype6-dev libgl1 libgl1-mesa-dri libegl1 \ fonts-noto-cjk \ - patchelf xvfb xauth dbus-x11 + patchelf xvfb xauth dbus-x11 \ + zsync desktop-file-utils appstream gnupg - name: Cache the Kotlin toolchain and Maven dependencies uses: actions/cache@v4 @@ -221,9 +222,54 @@ jobs: fi tar -czf "artifacts/$app-${{ matrix.target }}.tar.gz" -C staging "$app" done < built.txt + + - name: Wrap each Linux sample as an AppImage that updates itself + # The same staged directory, as one file that carries the address of its newer + # versions: the newest release of this repository whose assets include a matching + # .zsync. A sample that fails here keeps its archive and loses only the AppImage. + # + # Signed when the repository has a signing key. An installed AppImage that was + # signed refuses an update signed by any other key, so the first public AppImage + # decides whether every later one has to be signed. + if: runner.os == 'Linux' + shell: bash + env: + SIGNING_KEY: ${{ secrets.APPIMAGE_SIGNING_KEY }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -uo pipefail + version="${TAG#sample-v}" + sign=() + if [[ -n "$SIGNING_KEY" ]]; then + export GNUPGHOME="$PWD/.scratch/gnupg" && mkdir -p -m 700 "$GNUPGHOME" + gpg --batch --import <<<"$SIGNING_KEY" + sign=(--sign-key "$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/ {print $10; exit}')") + else + echo "::warning title=Unsigned AppImages::APPIMAGE_SIGNING_KEY is not set, so the AppImages are not signed" + fi + cargo build --release -p packager-linux + export PACKAGER_LINUX="$PWD/target/release/packager-linux" + while read -r app; do + [[ -n "$app" ]] || continue + if ! tools/packager-linux/appimage.sh \ + --dioxus-toml "samples/$app/Dioxus.toml" \ + --overlay tools/packager-linux/samples.toml \ + --exec "sample-$app" --icon "samples/$app/assets/icon.png" \ + --payload "staging/$app" --version "$version" \ + --github "${{ github.repository }}" "${sign[@]}" \ + --out-dir artifacts; then + echo "::warning title=No AppImage::sample-$app was not wrapped as an AppImage for ${{ matrix.target }}" + fi + done < built.txt + + - name: Record checksums + shell: bash + run: | + set -euo pipefail cd artifacts - if command -v sha256sum >/dev/null; then sha256sum *.tar.gz > "SHA256SUMS-${{ matrix.target }}" - else shasum -a 256 *.tar.gz > "SHA256SUMS-${{ matrix.target }}"; fi + shopt -s nullglob + if command -v sha256sum >/dev/null; then sha256sum *.tar.gz *.AppImage *.zsync > "SHA256SUMS-${{ matrix.target }}" + else shasum -a 256 *.tar.gz *.AppImage *.zsync > "SHA256SUMS-${{ matrix.target }}"; fi cat "SHA256SUMS-${{ matrix.target }}" - uses: actions/upload-artifact@v4 @@ -429,7 +475,10 @@ jobs: echo echo "Built in this release:" echo - for archive in artifacts/*.tar.gz artifacts/*.apk; do + echo "Linux also ships each sample as an AppImage. Make it executable and run it;" + echo "it checks this repository's newest release for a newer version of itself." + echo + for archive in artifacts/*.tar.gz artifacts/*.AppImage artifacts/*.apk; do [ -e "$archive" ] || continue echo "- $(basename "$archive")" done From 9512e1fcb3ebd2aad630b7cf68249a49dbf7c22f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:41:25 +0900 Subject: [PATCH 019/224] Chore: Certify a released sample with its renderer, not only the stand-in The package job now runs twice: once on the stand-in and once on a real build (the notepad archive from a published sample release, or a run's artifact when dispatched with a run id), packaged as a directory with the renderer and its companion files beside the program. Builds use the committed lock file. --- .github/workflows/package-windows.yml | 76 +++++++++++++++++---------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index 7310011f..db3f6032 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -6,11 +6,19 @@ # not refused there for the same reasons. The Store signs what it takes in itself; the # test certificate exists only so that this machine will install the package. # -# What gets packaged: by default a tiny windowed stand-in built from this repository, -# described by the notepad sample's metadata, because the pipeline is what is under test -# and the renderer takes most of an hour to build. Dispatched with a run id, it packages -# a real build instead: an artifact from that run, unpacked, with the program and the -# renderer beside it. +# Two things are packaged, side by side: +# +# stand-in a tiny windowed program built here, described by the notepad sample's +# metadata. It proves the pipeline in minutes and owes nothing to the +# renderer. +# sample a real build: the program with the renderer and its companion files +# beside it, the shape a dioxus-compose application ships in. By default the +# notepad archive from a published sample release; dispatched with a run id, +# an artifact from that run instead (a samples-windows-x64 artifact, or any +# build packed the same way). +# +# Building the renderer is not part of this workflow. It takes most of an hour and a +# released build is exactly the input the packager is meant to take. name: Windows package @@ -29,8 +37,12 @@ on: description: 'Application directory holding Dioxus.toml and Cargo.toml' default: 'samples/notepad' type: string + payload_url: + description: 'URL of a .tar.gz holding a real build (used when no run id is given)' + default: 'https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/notepad-windows-x64.tar.gz' + type: string payload_run_id: - description: 'Run id whose artifact holds the build to package (empty: the stand-in)' + description: 'Run id whose artifact holds the build to package instead of the URL' default: '' type: string payload_artifact: @@ -61,6 +73,10 @@ concurrency: env: CARGO_TERM_COLOR: always PROJECT: ${{ inputs.project || 'samples/notepad' }} + PAYLOAD_URL: ${{ inputs.payload_url || 'https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/notepad-windows-x64.tar.gz' }} + PAYLOAD_ARCHIVE: ${{ inputs.payload_archive || 'notepad-windows-x64.tar.gz' }} + PAYLOAD_ROOT: ${{ inputs.payload_root || 'notepad' }} + PAYLOAD_EXE: ${{ inputs.payload_exe || 'bin/sample-notepad.exe' }} jobs: test: @@ -82,25 +98,20 @@ jobs: run: cargo fmt -p dioxus-compose-msix -- --check - name: Clippy - run: cargo clippy -p dioxus-compose-msix --all-targets -- -D warnings + run: cargo clippy --locked -p dioxus-compose-msix --all-targets -- -D warnings - name: Tests - run: cargo test -p dioxus-compose-msix - - - name: Keep the resolved lock file - # The packager's dependencies are resolved here rather than on a developer - # machine, so the lock file that resolution produced is kept for committing. - if: runner.os == 'Linux' - uses: actions/upload-artifact@v4 - with: - name: cargo-lock - path: Cargo.lock + run: cargo test --locked -p dioxus-compose-msix package: - name: Package, sign and certify + name: Package, sign and certify (${{ matrix.payload }}) needs: test runs-on: windows-latest timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + payload: [stand-in, sample] steps: - uses: actions/checkout@v4 @@ -110,11 +121,10 @@ jobs: rustup default stable - name: Build the packager and the stand-in program - run: | - cargo build --release -p dioxus-compose-msix --bin dioxus-compose-msix --example fixture-app + run: cargo build --locked --release -p dioxus-compose-msix --bin dioxus-compose-msix --example fixture-app - - name: Fetch the build to package - if: inputs.payload_run_id != '' + - name: Fetch the build from a run + if: matrix.payload == 'sample' && inputs.payload_run_id != '' uses: actions/download-artifact@v4 with: run-id: ${{ inputs.payload_run_id }} @@ -122,6 +132,13 @@ jobs: path: payload-artifact github-token: ${{ github.token }} + - name: Fetch the build from a release + if: matrix.payload == 'sample' && inputs.payload_run_id == '' + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path payload-artifact | Out-Null + Invoke-WebRequest -Uri $env:PAYLOAD_URL -OutFile "payload-artifact\$env:PAYLOAD_ARCHIVE" + - name: Package id: pack run: | @@ -129,13 +146,14 @@ jobs: $tool = 'target\release\dioxus-compose-msix.exe' $out = Join-Path $env:RUNNER_TEMP 'msix' $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' - if ('${{ inputs.payload_run_id }}' -ne '') { + if ('${{ matrix.payload }}' -eq 'sample') { $unpacked = Join-Path $env:RUNNER_TEMP 'payload' New-Item -ItemType Directory -Force -Path $unpacked | Out-Null - tar -xzf "payload-artifact\${{ inputs.payload_archive }}" -C $unpacked + tar -xzf "payload-artifact\$env:PAYLOAD_ARCHIVE" -C $unpacked + if ($LASTEXITCODE -ne 0) { throw "could not unpack $env:PAYLOAD_ARCHIVE" } + Get-ChildItem -Recurse (Join-Path $unpacked $env:PAYLOAD_ROOT) | Select-Object -ExpandProperty FullName & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` - --payload-dir (Join-Path $unpacked '${{ inputs.payload_root }}') ` - --exe '${{ inputs.payload_exe }}' + --payload-dir (Join-Path $unpacked $env:PAYLOAD_ROOT) --exe $env:PAYLOAD_EXE } else { & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` --exe 'target\release\examples\fixture-app.exe' @@ -183,7 +201,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: wack-reports + name: wack-reports-${{ matrix.payload }} path: ${{ runner.temp }}/wack/ if-no-files-found: warn @@ -191,7 +209,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: msix-packages + name: msix-packages-${{ matrix.payload }} path: | ${{ runner.temp }}/msix/*.msix ${{ runner.temp }}/msix/*.msixbundle @@ -212,7 +230,7 @@ jobs: rustup default stable - name: Build the packager and the stand-in program - run: cargo build --release -p dioxus-compose-msix --bin dioxus-compose-msix --example fixture-app + run: cargo build --locked --release -p dioxus-compose-msix --bin dioxus-compose-msix --example fixture-app - name: Package two builds of one release for the sideload channel id: pack From 0244efaed4d5da158308e49524533af99166c8ba Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:42:55 +0900 Subject: [PATCH 020/224] Fix: Give the rehearsal application the crates the published rsx! expands to --- experiments/self-update/app/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/experiments/self-update/app/Cargo.toml b/experiments/self-update/app/Cargo.toml index e9b42134..21d889ba 100644 --- a/experiments/self-update/app/Cargo.toml +++ b/experiments/self-update/app/Cargo.toml @@ -23,6 +23,10 @@ sparkle = ["dep:dioxus-compose-update", "dep:objc2"] [dependencies] dioxus-compose = "=0.0.0" +# The published 0.0.0's rsx! expands to paths in these two crates, so an application of +# that version names them itself. Later versions re-export what the macro needs. +dioxus-core = "0.7.10" +dioxus-signals = "0.7" dioxus-compose-update = { path = "../../../dioxus-compose-update", optional = true } # Only for the rehearsal's "quit after N seconds", which stands in for the person choosing From bb0d1a120577c801a7750528416d358fe85eeedb Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:49:50 +0900 Subject: [PATCH 021/224] Test: Keep the rehearsal's bundles and say what was running when it fails --- .github/workflows/packager-macos.yml | 10 ++++++++++ experiments/self-update/rehearse.sh | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml index ceda129e..8ef6ed06 100644 --- a/.github/workflows/packager-macos.yml +++ b/.github/workflows/packager-macos.yml @@ -131,3 +131,13 @@ jobs: name: self-update-evidence path: ${{ runner.temp }}/self-update/evidence/ if-no-files-found: warn + + - name: Keep the bundles + # Zipped by ditto in the script, so the framework's links survive the upload. + if: always() + uses: actions/upload-artifact@v4 + with: + name: self-update-bundles + path: ${{ runner.temp }}/self-update/bundles/ + if-no-files-found: warn + retention-days: 7 diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh index 21f4f2a9..b2921c56 100755 --- a/experiments/self-update/rehearse.sh +++ b/experiments/self-update/rehearse.sh @@ -49,8 +49,22 @@ step() { printf '\n== %s\n' "$*"; } pass() { echo "PASS $*" | tee -a "$result"; } fail() { echo "FAIL $*" | tee -a "$result" + diagnose exit 1 } +# What a failure leaves to read: which demo processes are alive, and any crash reports. +diagnose() { + { + echo "== processes" + ps -axo pid,ppid,stat,etime,command | grep -i "self.update\|Autoupdate\|Updater" | grep -v grep || true + echo "== crash reports" + ls -la "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null | grep -i "self-update\|Autoupdate\|Updater" || true + } >"$evidence/diagnosis.txt" 2>&1 + for report in "$HOME"/Library/Logs/DiagnosticReports/*self-update*; do + [[ -f "$report" ]] && cp "$report" "$evidence/" + done + return 0 +} shot() { # The whole screen. A runner without screen recording permission gets a black or # missing picture, which is noted rather than fatal: the logs are the evidence and the @@ -129,6 +143,10 @@ for major in 1 2; do plutil -p "$work/dist-$major/$name.app/Contents/Info.plist" >"$evidence/Info-$major.plist.txt" codesign -dvv "$work/dist-$major/$name.app" >"$evidence/codesign-$major.txt" 2>&1 otool -L "$work/dist-$major/$name.app/Contents/MacOS/$executable" >"$evidence/otool-$major.txt" + # Kept whole (ditto keeps the framework's links, which an artifact upload would not), + # so a failure here can be reproduced by running the very bundle elsewhere. + mkdir -p "$work/bundles" + ditto -c -k --keepParent "$work/dist-$major/$name.app" "$work/bundles/sparkle-$major.0.0.zip" done pass "1.0.0 and 2.0.0 packaged, signed and verified for the sparkle channel" @@ -191,6 +209,7 @@ ditto "$work/dist-1/$name.app" "$install/$name.app" DEMO_CHECK_AFTER_SECS=10 "$install/$name.app/Contents/MacOS/$executable" \ >"$evidence/immediately-1.0.0.stderr" 2>&1 & shot immediately-1-installed-1.0.0 7 +ps -axo pid,stat,etime,command | grep "$install" | grep -v grep >"$evidence/immediately-processes-at-7s.txt" || true [[ "$(installed_build "$install/$name.app")" == 1 ]] || fail "1.0.0 was replaced before it was seen" relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } wait_for 180 relaunched || { @@ -234,6 +253,7 @@ build 1.0.0 "$work/build/$executable-store" --no-default-features --out "$work/dist-store" --uses-non-exempt-encryption no --network-client \ --user-selected-files read-write | tee "$evidence/package-store.txt" store_app="$work/dist-store/$name.app" +ditto -c -k --keepParent "$store_app" "$work/bundles/store-1.0.0.zip" "$package" verify --app "$store_app" --channel app-store | tee -a "$evidence/package-store.txt" codesign -d --entitlements - --xml "$store_app" >"$evidence/store-entitlements.plist" 2>/dev/null plutil -p "$store_app/Contents/Info.plist" >"$evidence/store-Info.plist.txt" From 1a1fdfbbdc26f84924792a273a2c2d6e69592de4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:49:54 +0900 Subject: [PATCH 022/224] Feat: Draw each sample icon at 256 pixels too, for Linux stores make-sample-icons.py renders the same marks at every size in SIZES. The 64 pixel icon.png every sample embeds for its window is byte for byte what it was; icon-256.png is new, because Flathub refuses icons under 128 pixels and an upscaled 64 pixel one looks it. The sample release's AppImages carry both. --- .github/workflows/samples.yml | 6 +++- samples/academic/assets/icon-256.png | Bin 0 -> 1748 bytes samples/calculator/assets/icon-256.png | Bin 0 -> 2265 bytes samples/chat/assets/icon-256.png | Bin 0 -> 2274 bytes samples/notepad/assets/icon-256.png | Bin 0 -> 1695 bytes samples/podcast/assets/icon-256.png | Bin 0 -> 3067 bytes samples/selfcare/assets/icon-256.png | Bin 0 -> 2272 bytes samples/social/assets/icon-256.png | Bin 0 -> 2117 bytes samples/statistics/assets/icon-256.png | Bin 0 -> 1926 bytes samples/store/assets/icon-256.png | Bin 0 -> 1749 bytes samples/todo/assets/icon-256.png | Bin 0 -> 2242 bytes scripts/make-sample-icons.py | 42 ++++++++++++++++--------- 12 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 samples/academic/assets/icon-256.png create mode 100644 samples/calculator/assets/icon-256.png create mode 100644 samples/chat/assets/icon-256.png create mode 100644 samples/notepad/assets/icon-256.png create mode 100644 samples/podcast/assets/icon-256.png create mode 100644 samples/selfcare/assets/icon-256.png create mode 100644 samples/social/assets/icon-256.png create mode 100644 samples/statistics/assets/icon-256.png create mode 100644 samples/store/assets/icon-256.png create mode 100644 samples/todo/assets/icon-256.png diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index c8a62acf..31e9b619 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -251,10 +251,14 @@ jobs: export PACKAGER_LINUX="$PWD/target/release/packager-linux" while read -r app; do [[ -n "$app" ]] || continue + # scripts/make-sample-icons.py draws a 256 pixel icon beside the 64 pixel + # one the window wears; a sample that brings its own may have only the one. + icons=(--icon "samples/$app/assets/icon.png") + [[ -f "samples/$app/assets/icon-256.png" ]] && icons+=(--icon "samples/$app/assets/icon-256.png") if ! tools/packager-linux/appimage.sh \ --dioxus-toml "samples/$app/Dioxus.toml" \ --overlay tools/packager-linux/samples.toml \ - --exec "sample-$app" --icon "samples/$app/assets/icon.png" \ + --exec "sample-$app" "${icons[@]}" \ --payload "staging/$app" --version "$version" \ --github "${{ github.repository }}" "${sign[@]}" \ --out-dir artifacts; then diff --git a/samples/academic/assets/icon-256.png b/samples/academic/assets/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..79dcd03fea70bdb617f66efa83f094b29319c45f GIT binary patch literal 1748 zcmc)JYfO_@7zgmvD+PgeLf8UQz=>Ke0*QcB2Bm-$8K@1~G1w@wfl4Zbi?jpV3Mh=l zg>nfxbl#{Us4(RYy&!B*?yH+fakN}iI_W?tNGWaCvc&OeW=qUYa(>VG^uGTm=Q&>= zj0iC^u{Hq!n1zyp$N)g}M+iV6^(7{?DggjwMQBh!BweETEsi50e88S4!`ws`VT?%y zc_aeVeEkI9W)Cr@iTehIaVeHPo^8^Si?2fxS6{Xb1a7t>;U2{|tR6yp2DV*0S#<@E z)|4j9WdAVOdR%cnsV?H%r~}IEha3 zebpsD-gt-)Ps&n!tW$SMO>JE^BaX+swArEU<`q-2g85FzF0X7xNp_a6^8B0A8~8oZ zxpG=N&%aK2Pa>ko~F= zKbTM?tK5obES1L~=<6)FF+yZWz~oY#9&}Q6=p;pz*Q2E{Y9fLBv%+_+#MiWa^-DYC zwz617pnoyiSOZ}vt2sy0+m|o0aygTcqpQxN*0b7J6U`Q(Mqs((!;T}pTF?qCPx}jd zDa^1#Nwd20t*l+tXNi1nRR&*0DU3z~+#bMO4l5iT#RA;I=OC~M*c*f5e}r*Z5{I3; zY@{ET|8og*3<;2iSpzLkn zt-wKGbN~BPLk?<3#Tx-w&qT$(i)8=io0|*?*a);#YN+A6NN%U=ZnWcGed!c7WP5(;oKH|l%pp62H3ulY? z_;b2?kDa%ByyTqDP6BSYQ{W7qSi6+9E{z;!PyZ@4pxXuIO@u~`+)6~xp-2JWuSo-h z`FWLOKjk7EOl2ZgRPMOiLDuQ#Is`mBqq&q4{?&_#BLtIlIcw^Oun8I3_s2?On9e5F zdXmAef2!8v2N-m$J)&h!kSly##|>@>moo0G-D$2bZB_c4>wj^8i6V7uWX_y))=$Ao zy}}+Kiq+n#-MIXCiN~?AIwfDh%jlI~@Y^ec1&P10o4rU&7DPiuj)EG9s7BjFaA$T? z=g=;n(R{tVZY$%LUX@MlV5P`2r&@+@9y3BsccJN%(q_;3nyx~zLl;+Tfw_}^>YhpD zOpT_wab!^0?QNl;?Ib_9p)ZfpCZlG?3S2RzB4eqQ%4-~2i}_A|zq(?aD5v4Y{S|3` hBT?RWdD~x5R$_Cd7Vs)!`VGTCg$73i-6K+S{sb19O;G>< literal 0 HcmV?d00001 diff --git a/samples/calculator/assets/icon-256.png b/samples/calculator/assets/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..d9a601962598a56eb5d36e365f5e81f9459845bf GIT binary patch literal 2265 zcma)+4LFl~AIJZjVJVCw?UZNM8x<3$a7tK*(2MM#Ii=asn|UibX)0}!jzx#(QY4Be zFQpwX(Y&-~(@9H&ykuS{qD;d?GTZLw=DNJ0z_enkMZ7W_CI`QQWq=$zfIJAE15vc5;bzI(CSlNxIh@K%9z zgPq7qOhsuqJOAc9sx1DdUEs;|rY07R?-Uyi5{rsjy9yrZJJnXtk6O9*y7qlyZ~3g! zQ(jwTe<6MI8THAgtc zEh`gC6MBM?G^oohx9Hcb>J@@#4)5rzG1D0JWS-<;6z5l4+{!agLwg*Wrm0>^e!R=| zE9>qRO>C1hdo8NFQ>*=~&kn}nZ=D#GHmhon6h)_2YBh)$+G^pXyWZ{Sq^hh9M@??gg_yEN{E&?t)^)JCf>=Yi3on9kdV%b5=!? zFoYhRroB3cgY=!_ubh`r)`1Zn^npz3v9WN?D+*8hC9I?Iab_kwMO*-j(a38%D#F*u1Cw`JD-s>YHSXDUJDY^2D)E68KJxFD?Vyl8Y(t|PhT{)nH2ed zF0oD#y=`VF7>w_(eVd5db8>fd3&8d3Sd_?Gao!&x59=ZqRY)kVG(X z$qysog6 z2gvVAQ*)h=If2cll2HMlU&ejGH=Ou(qR1(%(;fJRX8LhI7e`6kPQX*^DBaB^CHnnU zeuK~fpL9Q1YO^Yj$PRxmZF7r3vBH_7d=jJe-U>_2K))O-Gh)@MyzU`Q)!#`0Yb3lz^ z5A4vC98fP6Iw6HJR(KIy;3thZm!wOg3l-Km`A$fD9ii5)BuXAYI7{zv2YDGbSRayo z#wt_6H-et2Wp=P%g=B*lk?lXF)`YhueLbl3e`+@L&=%HN|2Hx)Sj4|)D$)zrFOxX( z`i;J3)3|G^(;Xs zH-Fsf?=-8y^P8#-xfs426jx0cy+wG;<8rW`VCM zVjzlGNrnsj5?j04O)%u>k8SZbMWhU-(K7vbjFPk`kKzBUwGo@N@j+(e^<)WK5*DqM z3A^dbK_A@~{M&A7CEZPFsU`%ZMs(bUef)b&{$qpsiUa04h>dq#LF00y_wA*?0|&Om zvu>O)0Q0aea|ip3VBHlY!ReLfA~vAbWoeXdH-;ke@F}m}jDqh;7?0tBpTWN6DDRBB zdaQq5CT_|dpFUy&4Ev!~+N|fZ(0!!vB6vBiWO|}aEcCX@svO2jt$<-8Mi#?*DIQTWmN z%_#ibUv3klVC(j7!Mh!Z)Ogx_>P0h(t|;1W4f#+U~!R&~T0e^P= zp!F_4W*lC8P~2&e^M(`iSoV*Tifk{l?biQFcx66m;%m>o-y%xzi6yQ#S3a&6F*Ltm y%o@TwLg&!O!&?nByqXSj%e%^Cp20QGAbpz^##eLllM?}a?B2ECwfG0x<$nVwM*PzN literal 0 HcmV?d00001 diff --git a/samples/chat/assets/icon-256.png b/samples/chat/assets/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..d6ee9e6acaea6d88e9a4afd87d538137fa09eb0e GIT binary patch literal 2274 zcmaJ?dpMNq7GK}YFvHLci8(ZkXd{%`ib|O&HeFm|yDOuMpR@H!@N# z$)zYEX6%yelS@e$mvO0d5=N5@bFt?+&p!K~^E~_gW36|s_gU**&w79B_g-*!by8Q= zRRsX`ZO#rJ0El9X06|%y0*@4h01%3|IWT@b&Yu`)4D2p<)fkP(Hf>J3uA36)h2N|j*Ne3UD^^%H(jzH7fj1?Tl|Wt8-DXvHj67b zBiMVL|I^r@sJ&+F^z7aHy8IvP_2Vuxg^LQ4J@aq;yIAGj(2-#~y|%V`TRiKk_c(KE zr+PtpL0fi;)|ZH5=`U;etB)L(s~qNaCey99KK;a*YAf*CXF{_4`F2jEZ6}nOGnW4tAe184lFMB~w>ZrsU6f#(OTqYiQAi>{RVvA!ea(XcBO8EVuIeT8)>lQbsq zUKYs9BzY&s5vnIU+AKPMlQh=Yx_i(C!@P^ld-ID;Z*2g6i%9*w3i>cT(VHUqj<1AV zIa8bY(V$=B=`uLEV^vk>e=?NWJ|>KkI3Fcm-lDVK8>_^4ZS1wEA9;x!S%UsNLQ;{f zg0DZY>$5FcpMsj{`qX<|j&;wB{oOtIXc_53HxjG-VN`CvzZ~CF{n2KBm(K#}MlP{u zcC8?@tD}vZ+3gJUZy zOXaiszmJ`78TZL;n3uM5vr7D>+`0B9?gh3U*XZ=M%}YQlVspR#3H zv}ylVRUGJ1#BRBPJ8$ww9uqML&#m_8xMMzE{)m<``=Lh_PIJsWWpS(2m+nJ&nxx74 z8-XXqYYwqeXDL%%t99UYfn--~R%sLKm>&(^`i$prC3Y?Rk@}J~T5xD;ta{p?o%0x( z!D47vFL}zx12t`JaA+Uv!mYk@7*1EBy=X?>{(wk85T=lbjHQE>_#ArF87R^%fOJ@E zOV?FeeGb%wsF@na6bN0$*paE_mj1E@;z|#NU&ZP)GaJvu$gUp;|!#U^o?H zbB`yiB`O4TWPi`={43h0CbAcUW8~<)$m-rbJ3*^bc(99Y6R%`JP=&73_i-9fr@#pb4P|Y{rV9s8#W>X%)W(EHxUt5AM=@Qvy=qR~~d~ z&2OrQV=iTvV0bg!IKc5jswMAlzKJkc2^Qc_Z~$MZBBwyb82kRGIxG&|2$MJ(;7|pW zdcfGLl92-LSdKOz3MXLbKmZ)70387knnGdd3gq=G$LhHQhwN;asfbdXpy$ur?6*@h z2D?SsPfslMg&wkTOr@dXmgT-9Av>%Pjy7E#FuWXySLD#fSi&-}!1;rs7{xGTfNw@Q zepU5cLNwxDmo<9SsI)`7`Q`c*5EeK4W5-`spz?LO5quGehgpBMDS~{tqF0f}RMkV8 z2o;pjX%bSgTme1Gsf|%FYT2n6o&~Sa{!htFo$OB-PUoe2%k!sl5-~g~V!l8?2WrK_ zrlNk2)P`eiD(D@%r?W3#{b_-Knpx2+3IeTq+Hv`0(Q(K&cTG<}q&tfGs zASr6b5cokBclQbd&gCvMKr)|1*otcD&*DGUV`#Ek`pX?>kGGf2DgjZVvcKn7k$cHVE83Wjw_I zr|OBPZ}zwAKtzmp^pTSdomMGaYI$Q}JusW~2iTIhFS7ZrLca1z>7-b1y83$!aD*$K zpD=ZJTikX|LBRQj2-fDqHn*Z0Y+5BEDZ<7Bs4D_ zy^^c}=Qm(HHe7H51lzpjK1t~LB#tFX_CNz3`k>{c#E=^4o$Vr?unpESNcP8M8m7XptX6eE0nLQKmYVQi{+KY|JsX? z$c^d-UyDnh2$mz)zui(t-DIUW#@E%hN6_G6iXO|WuTNfW+1$Ly_T-X%!R`mb&jQwd zn8zJ?%d6JRYC3eWSgybH%(m{_8jo0q)~59$QH1Tr*?AXv(gG=?Ti@YA(BW;MSlb+39m=*a G&;9{|u#8o(Gh~g?7k=cKIMacPE7h$LD7njJrR4%oAD5T zGr@Z7R$FvfQkqWfg3Ch7O5Lb6h6TCU3+BT8$#wx7e-A#tqUq|ijqwChd&ZdmiBn`! zQmtk}D*aLrypi1|2n(_wpwZ$_@_WnjcvBs<2jWs5Ptm?w`-4HAD=#d5v1hFQ!nJm@ z%hrzd;iM~9@xw!xL3vu!q)=kgwz@1cbze$#XbG|r)F_pSM(IdQ{*&V&%QkxF5?H<0 zYSRF#_=j1~Zt-lfBit0C^bkjWlPe1<8pt@3NSWEO@$JNKYV4XV|+x zIBz4k&Q~%?c<<~-&uZCrfRGl_wHC#pRJ;gT0Pc(r>`Q>p;?KE&)^^`=IjFm-txQM2 zkt;mNAWIkm49c<0C6bq{RPvT}VE_>U*iaOIO`(lg=4Lx46e2R?0u&|tOq`BuY&GL-a^T2CL&KxpI1cg0W=#T}0GSOh3#>2@p&2=los zfzTY<$OE~vb_0!wPZcQamjHpDNCiDbSQp~pW-}7Sw_NJWjEJA(fbA!X0l!*7mk&9D zAkgVfbrniYyoO;^^ETC`Kess`#HWF(wV5_zb-l^9@25a3N*Kx>pZo(=8$50ULR5q+ zPlhf5Ol(OgK_w^&Z=fky1Z3Mk^dV98DoYqbFPAr}ao{lyzk(xw-wUcPARRCP1UO!= zp#Os^B3nTH`?m34K)@axAN>fGX$T^L2V^#6anSIw1ZeQZjx@(3J!v>z7l-s%qU!2y zea$8H?&2F7!%y)=l-G~9LA!^*cV=>6ISZ|Bh-b)pNg`&;S6h1#Qa`5)0_?O|iiu4? zjLw+9Ahdhky!w=FS%-OmJ~pcy!H$JRZc5agyXVouu{qJ)s65CrH{THoYj#eZ?%qyf zDRaRKp@q4+Zj-1CRX3^DfpJR3LEF2E8m8_xo?40$JBH0Mcy=@%b9nT@F^Jl2Wz2OQ z8a7-BHXk}7KHJIVrB7Y$P6U)?;>B83aj?mj9Bq&JQZ=`iK`STcYrlN3-a&IY$=4J9&!UOmX-UN#H)pSY}Oy^*e>^5ChJLkap&)cY^pr&=jT*0 z!#o{#urhnstn2>QiK{jGJ!G9&VHEq-r2eBf0@ zxs7MZqYyc{JJLxqwx@7rx!re=KTT3hjeVXarNyLwo&KWZnBF#+ zed+l}?{E88*0+6*dx?o*nH&y&KRom1ukEoyvGdhRgY}ozS2Vdp>6)={4))^a+g8fr z_4_~R1G^dy&7({L2&om!`B=31fati~RQTr911UGN5FKeW?&;nu(lB@3K230)+F6I4&F;#J;&kf2}`Sz8K?^qlAMr2nbA!ref$EAc>t8ni6*# z8teapMBEP|aZA;dipSkb+^ae%s!iK}sQ9jGB!Zn&D19v)dj-u#brLDjgl-puk%V!H z0D&s~7bJ6NGbk(#Tn@o?A1LripXs9^yiYDFV2{4IY|Qq11lE>^>*)I+i^5802|*)T zO)R=Wl<&gugc{k?k0i{B6^9bO8_!8#MGdZfNECYs+b{5FHz%Pw#YM5#uReA%S3`!%!Fw#0o@_fQ?r?Z)UK* zGu+#(Ylz+WRgUfGfPq20M%2ADSu9ZdK1TTL$6IQAx_eXohOu+7bK`I(7=faf-#Fo` zR%BMNE9^t+;BYLyBv^34ySCwO!y)Eg6iXSt3WWBPBhZsx$Dx1CQx_OK}IL#7N$c*4GalxIO0oR+)aPVrCVfC zgT3w2m~B4n+WDezf03%x5gZD?)>Sx1S+y!6Hm<&2opOvpU<|dB*K#MrEkAqX$NahZ zTcvGO43_#mikm&?t^;?9e|uG$Pv<9o@S_q#srhQ^+2BB{KwtHB)(k*-`Y{MAtPH!l z#E^l8_D?d#OSK04dzfXDB11EFpLY(PiB^W-Vtu_cjgq^*u@!?H&DwD&SzY$<d=TggimN)o_Q%j`?> z{>x)m?*s`rIKjm?M{-*n&W8v%2_sTMQL;%_;`&>Z;o|i^hIH>!t^PWGklvc%!sy8A@ z!|&-D!0hkn6PGsQuNHkxsFfb`IWuOkE_1&2rd)2brJSFk~=>sG3KexR_u2)tn6{e zQfXnpQndQ?m{i$i^_a{3DUWXfC^L#=7 z)~R0(_NCqcSnAXP@Z?NOPlm>cfuMp9{f-202!s5rEITj$`t>`@DeBTgvl9S=JnqkH zlmz^-fdMESfT{fH0sUVukXncGMQ;Vk$X1#fBnc{5r)Z^+6lgf8V<)>L~xl3 z0J71>I_%ncV^>(ovf-5sK~*p$02#{H=orfYHDKlg87MweSacBssPa=GIY$osyv-t~ z57Aa%pF%NvD!MonXwIv7qpquWVPxx_8bS7BZ{L#AhKyC6G%v`;K&dzcKj$!~UDu^M z(D%nmYlM#rCFK&20Zs>_9XkmUw*NL7>EA}{2{;#Y-Dp2g7_mEqiyX_p=lQ^tQJUnA z=WCWj1|1(!i;jQU$g{N70D>2CjzCS>6WvnJPk{U#e;inVuBrF7+%?SuzhC#>tx!ws zV|-=uYik!~U%GEhq0hom1z5&)1qr}q=|ax7H@TJ+VI;>XCdSr%LiX~eB=!5>er@RY z8=H|7s5|oJ!|S^k?xjfrZkvs{v?6gW*L5kXvem%R>TZi2v4MLkvLjnb7}i5!ZPll+9x+mL}4Qd%Mte z5-Y{n!8mvAeE|oiVw{yRu0@Idiewyob$j?Q_h2Wh{a#wt9A0YoLGIuxXTd(wMN zQ5l7tX++<)j(!&n;m&$K*XZQabi&P=G)GZ0FvlFh<&89S?)hL2%Cywq1{NU?bk8rX~kq|XOCoXoU={XAVBc@&8u6Dxe;Fl|W_Lz08cKRX= z00gK2-~j)({TJM+10Lu@^z42tH7y;Kxg7=6P62YIDpl)jA~5)YAzLb%0Q+ASb{N(v zb6{KTZ2FHRD^3q2)@{6eERHH;uQdUK2`qV+;;;y->6e1r1U~)XEBs_;3l0N(32%=$ zAWrd^QeX|Rh#Nd}(-E^VaCEVHwY)+EXms!h%Aj5!Bbz_iR#6wxl4A`ejC#%2)yH3Y zxoIYzgj8cQjXFJQf0Rt`7c3P+kH3$|OD+1?9EzYM1KeFnb)TYjQLIr9F<`%`VuJM$ z_2aNuColMY(mCcoSg-ohg)_pUBtHpY(kmq)`l^&QN5UCRQg7EbuyUPn!NK)M6deR) zLuxY~MfsF57{sNWC74^9a|WS#t^fQu$Tl=aG^M!F9lp;5b>ezI-*=p?w>g-do~frd jhJV|)@u+^{YCKOuEMflx=VL$R0Dm1fvo@_VA;+hwpTVGEWGvaDqjHMqIb>-$KQ2*8)4A5j=+p^WvV~I3&&ZN(bPJVj zis-bUE3(9lQ=&yk2$e}?o5nV>4s*vn&vWm6o_p{2kMC!Dp3n1nKF{m>{$8Xy+L2_{ zWdVS+$KK`_07UE}K$H?2&(Pb)0f@zWY^i;`sEEe!9(kao>wU`v1-2@Iu#reXlXh!zEOW6Kr1J3Pv)(| zei4K2QAy;!Vh#S|?b{by0-R4xn_JA^^infOQQ08ZaVtB{;vskN_33TydC?*AT^R+P zuRnUIZAnor$xYN7J##wkS#@tm*ev@G-ozI&aW~N;VqrPd%`|K({aVk|`!U|g+DM@w zZu3T&xWup}&n9|g>6a4+o)<7~q$x1G-Cj`XqjCuuMeZNf4c2~6J=ai{5weY`m=(7= zDU2&fPLnEG@HT1V9U3ZCp1=wuZ7Xztu3B$jBcRRQWI3z;9c{MWS2Q!$0faif)%^aH1j!fystb?*kZcM@9e*q8dX zucS@3O-J@Ce8^T9Ub7JE5mPra(UIPEcxaKpagwku-ap$@;~`-^ONx}iuf@k2yz*RX z(C2inO+jA?V>hDyD97WB?g7{QQQ^5%f;;b|EPhFfaWBDB%qcaSWIz-BQ0TO88s1 zWKL9z*_9K&4$<_xodElUa4TrkA`MfZh8p6#SJ(gy00lDO00hwaCocah)jP#E>u;gY zEf>#!{oF3>-q~{B+%Bw)@xGt~=J>+&H-4G0Guh0JH_dzM zlhmq`-yXRheA||y6$V@L>K#ftN|o|Wca@JjLRmspx8vjqkwpPxPU~zIiZd+hb;$6| z{4LWixH?>@&^SKWg+bZCRY%(|6z|)&gHvvxt}8=<%!_B#8^Wj z;_H&5(%dk$nxhDoY%&JJ zLTf-+0y%_$zcwO};rQZCEes0hb7%>H36=0vHlQZqNK6;s&w0f_aZqEK$S=pMiKGEaOJBQHA}ZY_X0fht>_21 z?wp_Zuk9QXd>h%PWq5G%gSnj~c6Y2lnpM7jQ@zH}Z7^QrJEefIOx#9{P|nI@M!YH9 zQ6`ifEz6}VLLTwzydAjd9cyPo8*y<=nfv`n(`kN|sR-qCeX11hhAL&_X*z6o>M~p89&gjA`>8f!%DCD(2p@qMTx6QVEM8JHa?pJl6l>=Rt1W$-! z#NlR3HjvTb6<^FD9FR8dGlISMl&A&|Ynq$F&LdQZM%Ldy&AyCwo|C}`y5!xk>7XOb z-t!1Ilanr<*@SzC3HnjYh8#2X)czLj^C)guso6SGCox(3uEqnXZsg%P6ps%g)l9n# z%-xG$5C@&bK7yA`1$uUplh+2y8-1n^%YZ!9fl@xwWT2mvua$45$OLXPQ}MG`Eg!4m zPfV8Yc%}q;yQ~wfv(+-2Mr)!7s@s&c8b!5BVUv*q@L>NfPpk^4Y9~_* zvx)fc@%Xy^l0eZ0B@$wL-3C%j2oygGV%#RKTM@APx-9!s$_eo}>JLH8M!KgzS^vfN z51_VU=Yn9BDUtG9lo^~+CnUYOJElE|usDl!0Z=EFM7cm!fb}~?G)45MR0;QNXP!I{ zW#J1V64KU#tByO;tvz5hssD&CXnaYAI zy#Rv0_p70QO_k2wc0Xe04n^Gh__Emr+}1k%<;{F8RA)wZV!?PgGn?5Z99KjuOV literal 0 HcmV?d00001 diff --git a/samples/social/assets/icon-256.png b/samples/social/assets/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..4134f6bd756970071b38a216993f4ba5f98b2dd4 GIT binary patch literal 2117 zcmbu9{WsM67RUGZYw$IO87>V*%_vDJDlT%$3`xlGbP^}YbSRF6oNCHbGhZW=Jl)fi zmXbs{WaO$m6;o-Hhgg*>4Q(x;Xg&Ac`*nn2I7DI97ZF03*3NIqZ$;nSB2^jpnDPGFU?hysI?#4jU5e;+qG}9klN=yq~4!@&e#hNJ_3$2Tf^}PSVOI&u?E5fyOW!nqOv3Qld z)gfJ_+w8`!&)Qh}cD@-N9pE{e4PQEcsk=F0GVZY2rt1FU&`Qqx^LDl+hGo`&J!uX( zTzjl&^=ogx2|EK0Cx2uWFS%=Qy%9xB(Tos3<5Y#;{OXnSyf%vbKuo5|x|@sK9JRf> z+O4Ej!E62Ex6Tty4D2z!>t%tgkE$8acHYq7?v86fELl{8@T|nT4qd?;j(rFz@Eb_>3UGItD?9Ya z;+4_Fq7nE6&XFJQzs%)g_$`ryyo&4XJ{+^|)%sUu;@O{4m06$6c_&i{X#V9p?LlFz zC0ObZ>uzT7YqF7o9Y2!chOI(65#?bhol3way=Cc??2n+ zUi!9g_gj6$>#TKukY{(nD-3rm?s#hi>GCaP27H!16R?oMQF@=DF-S&cE`)BdeBkt6 z3--Z1Z3gtg8CntnU(4F4Oaug1Xaog>p->6R0t^V>0H&C%PietXp)(_bpbZbvV6%G= zZApnz(S|5h(WDR+DmhHEC67X3&B)+Le$2LF z*i2&02SN1~%m&n;4y{=7gd9@ipEn@-I3-G^1*c+XC6^9DK)KA@hJe>7Hld8`BCS$& zSVHf8v4GE-jRGdZHpK-EU>a#UJ@|oY;Iwt-dgv#KI+UWu*XF1&s^nt-g$k;15^1uE zPxV5kW!G;AgJ8u)a7xF5GB~n^sgR>*^nt8nrkvQM7Bk-3f)08isMc8<(X7+%YU7Dxd7RBCXv&bmM_mV#5lVMWe5!;#Gpv#-p2_96;gpRP_ zb6xIDb)&s8ywlQNdZqXGQxMVIPXq;jmMpxi>~*Uz4_GO;;F@r)x$C%9EgA*l*?;_h z2mQAbeDeiXEmZHLWWVMRga;g0ZpRULsl>j`U=;mK5GM2Si{`6AL_~cIc0;-jq0p*i z$cvI4O%~P#&(Zye1fQB768;C8xc7E0ve}!F-5g>GDhjN>H#apFTx?}FWH{F(ry*=6 zgzT3OXrt=MO1lE~)@jZwv-r#Mc$8g?=Z8HDAE=Nd?nx%>s=fQgZXff+?fiU~&`zV& zmn;{n#j3Rsp^@TKCHtg|mo^-PYRQyFV$IW}gSmbjvKg8kGRpJBs$DU+n(;f%&{&B+ zvL{JKBuVkIEDXugOy%nP{kSRx%`Tp32sR!3!=WS6FN3puYP(fq0-QM2$1LgD-m1#_ z{Wp0Y#n$g#$NI6(^;xuX-Bdx2FMXJTX5XJtM_l?BlEL_>)DeES;g%|SeT?9>okp$I z9cFVAM85RQxlD)Nl$$k^k8hQDHc9#s8_JV^+5C0?Vbwr3oLHPw5&FuF9VqR3*OvH) zO2cTOzT-`8y}L6l5<`=Ea^fr=2tYH|N}QzwdXyd+%>K zzkBW%+XFYTEZ(pH0I;@f-uONM5Je$?no$sX_-Z%+jBQ&suHTWM9_n5h-MNdW`e@Pm zVCg)sR0966dVgb9x{GTWvat7^?Dl|=Rk08LqPo-{xGpuLz-a(=6Zu+ib@H!h-h_=A zt!9WdhnP>#j<{qc7G3+~a_10+yNAR^b*n4RwHC&*7xFSP6raZbb|ow(=<)TysoIPxRo*_Y4RAnPIeGlS{_e=Op1M2rztw~#MSo-T8qNLQn_<5G za#(WYQ{#-+9iAuZpeACVJjul_a`HVc} zz}2Z@cB@%<*!ek2%Y=eQbU({)=QLRb15J1@gC5-dwU@3^!uLq~zKQK99+RT7IeXX{ zOCxGXO?OJk$hd8ctMFpdnEUdvlp<@(6^FMO&OW>4@#Vfd3Ld1bTV&J3NDG~o&O)~? z>T8Bij&`$U?@TgsVfVxn`6;P2F6KI&oJ~W1t<)C2sdXsKhc%HH z=g#oM2m^~*zfv(_y_^+CvI|FOHw-6L(-70FzL&()Y8PqRosfyF;Rdvo0X?=z^Ey;k)#ElWP+nV~D*%n2$}xfp16l z8Glk^kT_Gy@2`t8atO#Vre*Mee(eU{%_%lyqsdc!7JcAKsomJQHmV*pyT-v=17-Hl zW_GS1{(%`}a88rDS%6!2Go|M^K+}n~2&;)YC z2OzZu%z~H%oI%pawd%0t$;y%^JDz_Aam1wYENEtN0@OFXD$7MN5t-tw;bQ*}X8rtW z^z5>dyz5m9(UfSzMU=Qw=h1dW*$S(!DHTBb0$&5C*GYq-ilf$!pn^KiNEJ$~2u=2r8k)z0 z8glm!T=^bUFY5c-?y!#P1=4@8HsJS?tlMN2!wQ;@0CAra3oFV=H*?hzr2^6xW1G=m zJk4w|F@H%*`o7hZLsJb-4bu{h_QP|Q*OscC5!npnVsOeelPp^q)ODQSrgTKc+ttAk zH@RHcFx3@+>;GPqPWL-?6uKXNNs}_szl?Ow(4}b4Mq>!|zk)4(fg6AD6{r3iDzyZb literal 0 HcmV?d00001 diff --git a/samples/store/assets/icon-256.png b/samples/store/assets/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..b9947d9a8ac3c53e335a143a1f1d75ddb5e31c0c GIT binary patch literal 1749 zcmcJQe^AnQ7{@;!}(2zFZ^8SroCBEID2vn#b7zwA};zCBE|DNcK#| zvME~5_oBhQM%|{)OZ-dmY8`GuLt0(URNqx4YC|VD>$_K%Jl|3^_!B?Yhf1z-G_+r< zD%$$u4~gEDPJ^3BzkTdBndEg@^*N3Nd==Tn{L ztaN4I{m6Y0(vh6zz__AvTytuDji-ZVb4hFJjl{A;a++Ri@7T#0YidVmwYL&tWZ3Yy zh;_6lR^@PEskuty?$D`Q=O)m#9^|#3GgC0G%p<~E1S)4Z8(*g)yqI}>zeFo+9=*3I zeCjsUNi>>Nw*B(0gpC@TtI@02`6NIv$XE+0yEKGKEBcT^yN`M2<$h#P;Uu=Ek2+@J zX7zJT!N41fzk1(yxbqQ?*k5(s4ftTKV!BPERzVPBvT3dG6SOB^b_N*F#J6?y9E8> zsSdQE&iZdaHuPs5N@(xGF39Nf3|JqF5(*?XUdH0IBNbzt!1_4;6{nk<-b%I30FoL|_-0R-3jN$&_Z803;+pqij#J`7v z0G9t7=ko8wH&5^FS^ul&zwN)p)rW1SWqTWKWf}wto|r9cv!DbZdTts#b*Qq0H07s(&7hr>hV`695{yfD@v7NPfTVzI> z4((7Penmy{*j4seXN6p9168kd*cCUfa|apYKGr}o<>{iBM;Fyp*70@+fh@L3N`eS8 z{FV|XACiQlaZo11wtoxS>VXmhWKp@z_4ni+p5++gQw4haqA<-aYnz%eWYK4H>K1|y z-4e ziUyTnz#Gat++)hic$PF%U7e)3l9;X;)>Mc(R?&TWAZoh4skHsR!DkW3HRt1GzR$^mRn4Nj?^0g`c3!Z67S%GPN z7lbV4P)B3KXC2x`QF5vu$s<8_we%6S^r&Wa3Vg7bWpw>rold6vU&w8)*jsm48MEg` mOWy9Vi5Q>f{4p`}F$zheN&_-@sXto2D-aSG7EtNW$@&YeK?fq8h_?Bct@CNv#C@w!J|oF)E4(tzvxjfoN!@SPbuoLJ>#l0~w1* zJ8aR&C`MEc8&1xyy+kuUCZ;qqWi&o#&THB~cF%R4^ZxPt?)&%LAHVm0p6kik6F=}_s3*(hkdd~KmqvGrVv{3%ncAVl zorg|~pKA??fAZBBG3FUulvG!(dwAdDQ~y4KG|U0n=_7g_nxmBHIuyNRtib#3(2gPosWeYd^p z^7n>`wKmn+k>mQ#cJGpYynN@_!!VH~m`lVo zVZ3)TqcZCdkKptWweB$%qMYR_~zRw2C%14&@}h zv~}M0@t-Y(ejh-Q0olVKLLVIuoF$r8IFkB;015yQAOHjaEckyE-yv{S3ol;UcV%Tz zvSNDVN{h;mo+BTZ$B6~6dTwmQP^f`Ti@%S&2ucY&W&f(@j^MjE2)Vo~?)~wPAD$3b zHs#2h#M`jo_}hwx=v~~Gg0toQF0~r~sqC3Rp0NgkkV)>Md(mwF2dr_RJ`5gw{I#p6 z;lYfr6bFNei4np01p(K@?<@u6rZ)vK?{9B++2Di$>te>Inl90DTwXdsNVo0m+Jy2P zDh6m4%yU=YWgG?+mZ+JrKNSKxn-mOdEHI!;Rsaa$iyW?_AQU;+Vl)A(i`j{&$!v-m zIDGXUUSkIVN0&@(|K)rhTjW55jRB0hdBICT%~k!25D+zsT$=Jaq?v$pywNf{mS{XT zk47Dl?2lXMQ(0n!V8h`?%k;cwmcSG3Y>E*%U_d7m0$1m;z)XQtv+Tid0Hx6bATcEX zpaCeN7RsO;d$1DS^^|y{*=7oJ1rAB7h|X{gT5+v;W7OHMLS>P1^P+!wblyQUAY`Av z*yw1@6k|)p_1kC%N{0RGdAi_^VABuw)o2-~f7cg2sw)mGD`(tAJM{K`B_HrXn}mns z65{+&Bdzx>_4XNfNf`dYRx7V`QbSz<8VL{ERWzIRb5P|ZxBQ;c=sLWB>hlS zGC}UR^Wi7EvL%6R)v)c4{1&CszLSY22u&OBR}5YRaKM0lH!XXcAVBWh)*09<`h$ zD}9y-=@EM+W;x&*Sn!yYn9B5DN_>eRok;b=N~EvV{|FO2~QzN}E3 z+DWz1K}QNwFD%^+QaGygzrkub%P1W^Ya|n111?WOS>oPuOhN$ZyBuXNpGb+H zrL}?U%N;9Q_Nxk#pVP^Idu~X;4TAs2+jCQkKj=(xnKA(0D==htO@PHCnVCqgsW~{{ zj!lcb1qFv2lgdMM5Eja69a>`wL7xFVXD+3vkN}I4wt?rSSf|ysTKtYQidorjVB#hL zuJGmPD=;MCbjv&-%*>^10CEjV`JCz&;l|CiW&=O-BH}j#NUn;8k!D=#Pt*6@%akl@ z^ij6EciZM2(az?eXZdJE;czGk%bRU51@&6AyPTGD7~y2JA?Q{b(SjpBks|vd`b&)g zRk9EbWAHetIeu}H0K|1r5_8qq31z#rDlrYYqJQzR?mg3s`!R6+bjyerzfz0ZP;ARZ zZ5nM5k4OR)_-dPr^BBSyFNFpd-8eOIHUC}iXh(k*&DX8EaFr8C-JDDYt%fZGM0hC( zrpTVv+JW>p9|ZS3D0PIbf-&o#V^($LP#JB#^h7DU^Q1+|IvH3#IUDQ|`DAf3j$a^% z0!7pKudrfkt;#pShc|MvN2}#O$R|u*guOW(Z^lVK_%#EDEEbSus;vDQf_-iVVPx){ zJ(k*XF2a{m8a5w$O2LtTD9n7mCadEyE37DBg~#5m3=n+a literal 0 HcmV?d00001 diff --git a/scripts/make-sample-icons.py b/scripts/make-sample-icons.py index b477c3ed..8843384d 100755 --- a/scripts/make-sample-icons.py +++ b/scripts/make-sample-icons.py @@ -14,7 +14,8 @@ Marks are drawn from squares, circles and bars only. An icon is read at 16 pixels more often than at 64, and a drawing that needs detail to be recognised is not one. -Run from the repository root. Writes samples//assets/icon.png. +Run from the repository root. Writes samples//assets/icon.png (64 pixels) and +icon-256.png. """ import math @@ -22,7 +23,11 @@ import struct import zlib +# The marks are drawn in a 64 unit square. Each icon is rendered at every size in SIZES: +# 64 is what the window wears, and 256 is what a Linux store asks for (Flathub refuses +# anything under 128, and an icon scaled up from 64 is the first thing a reviewer sees). SIZE = 64 +SIZES = (64, 256) CORNER = 14 @@ -31,11 +36,13 @@ def blend(dst, src, alpha): class Canvas: - def __init__(self): - self.pixels = [[(0, 0, 0, 0)] * SIZE for _ in range(SIZE)] + def __init__(self, size=SIZE): + self.size = size + self.scale = size / SIZE + self.pixels = [[(0, 0, 0, 0)] * size for _ in range(size)] def put(self, x, y, colour, alpha=1.0): - if not (0 <= x < SIZE and 0 <= y < SIZE) or alpha <= 0: + if not (0 <= x < self.size and 0 <= y < self.size) or alpha <= 0: return r, g, b = colour existing = self.pixels[y][x] @@ -55,13 +62,14 @@ def coverage(self, x, y, inside): hits = 0 for sy in range(4): for sx in range(4): - if inside(x + (sx + 0.5) / 4, y + (sy + 0.5) / 4): + if inside((x + (sx + 0.5) / 4) / self.scale, + (y + (sy + 0.5) / 4) / self.scale): hits += 1 return hits / 16 def fill(self, inside, colour): - for y in range(SIZE): - for x in range(SIZE): + for y in range(self.size): + for x in range(self.size): a = self.coverage(x, y, inside) if a > 0: self.put(x, y, colour, a) @@ -131,7 +139,8 @@ def chunk(kind, payload): return (struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)) - header = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) + size = len(pixels) + header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0) data = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) @@ -196,13 +205,16 @@ def marks(): def main(): for name, (background, mark, shapes) in marks().items(): - canvas = Canvas() - canvas.fill(rounded_square(), background) - for shape in shapes: - canvas.fill(shape, mark) - path = os.path.join("samples", name, "assets", "icon.png") - size = write_png(path, canvas.pixels) - print(f"{path} ({size} bytes)") + for size in SIZES: + canvas = Canvas(size) + canvas.fill(rounded_square(), background) + for shape in shapes: + canvas.fill(shape, mark) + # icon.png keeps its name: every sample embeds it for its window. + file = "icon.png" if size == SIZE else f"icon-{size}.png" + path = os.path.join("samples", name, "assets", file) + written = write_png(path, canvas.pixels) + print(f"{path} ({written} bytes)") if __name__ == "__main__": From 366b728772999097bbf240fdbd09fcaf1963b2cd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:49:54 +0900 Subject: [PATCH 023/224] Fix: Find the renderer beside an executable built without an rpath The released calculator sample names libdioxus_compose_renderer.so with no RUNPATH, so inside the AppImage it exited with status 127. AppRun now puts the payload directory on LD_LIBRARY_PATH, and a Flatpak built from a prebuilt archive sets the rpath to $ORIGIN with patchelf when the executable has none. Also: prebuilt and renderer archives can be given once per architecture (only-arches), so one manifest serves x86_64 and aarch64 as Flathub builds both; --app-id overrides the identifier for a listing under another ID; and the tests are named after FR-35, the packaging requirement's number (FR-34 belongs to another draft). --- dioxus-compose/tests/appimage_update.rs | 22 +++---- tools/packager-linux/src/appimage.rs | 4 ++ tools/packager-linux/src/flatpak.rs | 55 ++++++++++------ tools/packager-linux/src/main.rs | 86 +++++++++++++++++-------- tools/packager-linux/tests/metadata.rs | 77 +++++++++++++--------- 5 files changed, 159 insertions(+), 85 deletions(-) diff --git a/dioxus-compose/tests/appimage_update.rs b/dioxus-compose/tests/appimage_update.rs index f15c2d04..b02dcf66 100644 --- a/dioxus-compose/tests/appimage_update.rs +++ b/dioxus-compose/tests/appimage_update.rs @@ -133,7 +133,7 @@ fn scratch(test: &str) -> PathBuf { const LOCAL: &str = "zsync|http://127.0.0.1:8080/Demo-x86_64.AppImage.zsync"; #[test] -fn fr34_reads_update_information_from_every_elf_layout() { +fn fr35_reads_update_information_from_every_elf_layout() { for wide in [true, false] { for big in [false, true] { let file = elf( @@ -156,7 +156,7 @@ fn fr34_reads_update_information_from_every_elf_layout() { } #[test] -fn fr34_an_empty_or_missing_section_means_no_update_information() { +fn fr35_an_empty_or_missing_section_means_no_update_information() { let reserved = elf(true, false, &[(".upd_info", &[0; 1024])]); assert_eq!( read_update_information(&mut Cursor::new(reserved)).unwrap(), @@ -170,7 +170,7 @@ fn fr34_an_empty_or_missing_section_means_no_update_information() { } #[test] -fn fr34_a_file_that_is_not_elf_is_an_error_not_a_panic() { +fn fr35_a_file_that_is_not_elf_is_an_error_not_a_panic() { for bytes in [ b"#!/bin/sh\necho hi\n".to_vec(), Vec::new(), @@ -186,7 +186,7 @@ fn fr34_a_file_that_is_not_elf_is_an_error_not_a_panic() { } #[test] -fn fr34_update_information_parses_the_three_forms_and_prints_them_back() { +fn fr35_update_information_parses_the_three_forms_and_prints_them_back() { let lines = [ LOCAL, "gh-releases-zsync|darkpyonix|compose-rust|latest|Calculator-*-x86_64.AppImage.zsync", @@ -209,7 +209,7 @@ fn fr34_update_information_parses_the_three_forms_and_prints_them_back() { } #[test] -fn fr34_malformed_update_information_says_what_is_wrong() { +fn fr35_malformed_update_information_says_what_is_wrong() { for line in [ "", "zsync|", @@ -237,7 +237,7 @@ fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Op } #[test] -fn fr34_delivery_tells_an_appimage_from_a_flatpak_from_neither() { +fn fr35_delivery_tells_an_appimage_from_a_flatpak_from_neither() { assert_eq!( delivery_from( env_of(&[ @@ -265,7 +265,7 @@ fn fr34_delivery_tells_an_appimage_from_a_flatpak_from_neither() { } #[test] -fn fr34_a_flatpak_never_offers_to_update_itself() { +fn fr35_a_flatpak_never_offers_to_update_itself() { // Even an AppImage variable leaking into the sandbox does not make it one. let inside = delivery_from( env_of(&[ @@ -286,7 +286,7 @@ fn fr34_a_flatpak_never_offers_to_update_itself() { } #[test] -fn fr34_the_bundled_updater_wins_over_path_and_the_override_wins_over_both() { +fn fr35_the_bundled_updater_wins_over_path_and_the_override_wins_over_both() { let dir = scratch("find_tool"); let mount = dir.join("mount"); let bin = dir.join("bin"); @@ -312,7 +312,7 @@ fn fr34_the_bundled_updater_wins_over_path_and_the_override_wins_over_both() { } #[test] -fn fr34_an_appimage_without_update_information_cannot_be_updated() { +fn fr35_an_appimage_without_update_information_cannot_be_updated() { let dir = scratch("no_information"); let appimage = dir.join("Demo.AppImage"); std::fs::write(&appimage, elf(true, false, &[(".upd_info", &[0; 1024])])).unwrap(); @@ -369,7 +369,7 @@ exit 2 } #[test] - fn fr34_check_then_apply_replaces_the_appimage_with_the_published_one() { + fn fr35_check_then_apply_replaces_the_appimage_with_the_published_one() { let (_dir, appimage, tool) = setup("check_apply"); let updater = AppImageUpdater::with_tool(&appimage, &tool).unwrap(); assert_eq!(updater.update_information().to_string(), LOCAL); @@ -395,7 +395,7 @@ exit 2 } #[test] - fn fr34_a_failing_updater_is_reported_with_its_output() { + fn fr35_a_failing_updater_is_reported_with_its_output() { let (_dir, appimage, tool) = setup("failure"); std::fs::write( &tool, diff --git a/tools/packager-linux/src/appimage.rs b/tools/packager-linux/src/appimage.rs index 81cdd8bf..8ec7b343 100644 --- a/tools/packager-linux/src/appimage.rs +++ b/tools/packager-linux/src/appimage.rs @@ -132,6 +132,10 @@ pub fn apprun(meta: &AppMetadata) -> String { # file's directory lets the unpacked AppDir run too.\n\ APPDIR=\"${{APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}}\"\n\ export APPDIR\n\ + # The renderer sits beside the executable. An executable built with an rpath of\n\ + # $ORIGIN finds it on its own; this also covers one built without.\n\ + LD_LIBRARY_PATH=\"$APPDIR/usr/lib/{exec}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"\n\ + export LD_LIBRARY_PATH\n\ exec \"$APPDIR/usr/lib/{exec}/{exec}\" \"$@\"\n", name = meta.name.replace('\n', " "), exec = meta.exec diff --git a/tools/packager-linux/src/flatpak.rs b/tools/packager-linux/src/flatpak.rs index 32e694af..3ce471ba 100644 --- a/tools/packager-linux/src/flatpak.rs +++ b/tools/packager-linux/src/flatpak.rs @@ -28,7 +28,12 @@ pub const DEFAULT_RUNTIME_VERSION: &str = "26.08"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SourceRef { /// Fetched by `flatpak-builder`, checked against the digest. What Flathub requires. - Url { url: String, sha256: String }, + /// `arch` limits it to one architecture, for an archive built per architecture. + Url { + url: String, + sha256: String, + arch: Option, + }, /// A file beside the manifest. For local builds and tests; Flathub refuses it for /// anything but small packaging files. Path(String), @@ -36,8 +41,9 @@ pub enum SourceRef { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Payload { - /// An archive whose top directory holds the executable and everything beside it. - Prebuilt { archive: SourceRef }, + /// An archive whose top directory holds the executable and everything beside it, one + /// per architecture. + Prebuilt { archives: Vec }, /// A source archive of a Cargo workspace. FromSource { source: SourceRef, @@ -47,8 +53,8 @@ pub enum Payload { bin: String, /// Extra arguments for `cargo build`, such as `--no-default-features`. cargo_args: Vec, - /// The renderer's release archive, when the program draws. - renderer: Option, + /// The renderer's release archive, when the program draws, one per architecture. + renderer: Vec, }, } @@ -102,10 +108,18 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { let mut top_extra: Vec<(String, Json)> = Vec::new(); match &options.payload { - Payload::Prebuilt { archive } => { - sources.push(archive_source(archive, Some("payload"))); + Payload::Prebuilt { archives } => { + for archive in archives { + sources.push(archive_source(archive, Some("payload"))); + } commands.push(format!("mkdir -p {lib} /app/bin")); commands.push(format!("cp -a payload/. {lib}/")); + // A release archive whose executable was built without an rpath only finds the + // renderer beside it when told to look there. + commands.push(format!( + "readelf -d {lib}/{exec} | grep -qE '\\((RPATH|RUNPATH)\\)' || patchelf --set-rpath '$ORIGIN' {lib}/{exec}", + exec = meta.exec + )); } Payload::FromSource { source, @@ -136,8 +150,10 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { ]; sources.push(archive_source(source, None)); sources.push(Json::str(cargo_sources)); - if let Some(renderer) = renderer { - sources.push(archive_source(renderer, Some("renderer"))); + for archive in renderer { + sources.push(archive_source(archive, Some("renderer"))); + } + if !renderer.is_empty() { env.push(( "DIOXUS_COMPOSE_RENDERER_DIR".to_owned(), Json::str(&format!("/run/build/{module}/renderer")), @@ -154,7 +170,7 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { commands.push("cargo --offline fetch --manifest-path Cargo.toml --verbose".into()); commands.push(build); commands.push(format!("mkdir -p {lib} /app/bin")); - if renderer.is_some() { + if !renderer.is_empty() { // The renderer's files go beside the executable, the layout it finds its // companions from. A release archive keeps them in `lib/`. commands.push(format!( @@ -165,7 +181,7 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { "install -m755 target/release/{bin} {lib}/{}", meta.exec )); - if renderer.is_some() { + if !renderer.is_empty() { // The build records where the renderer was inside the build sandbox. Point // the executable at the copy installed beside it instead. commands.push(format!( @@ -235,13 +251,10 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { .collect(), ), )); - let needs_patchelf = matches!( - &options.payload, - Payload::FromSource { - renderer: Some(_), - .. - } - ); + let needs_patchelf = match &options.payload { + Payload::Prebuilt { .. } => true, + Payload::FromSource { renderer, .. } => !renderer.is_empty(), + }; let mut modules = Vec::new(); if needs_patchelf { modules.push(patchelf_module()); @@ -269,6 +282,7 @@ fn patchelf_module() -> Json { &SourceRef::Url { url: PATCHELF_URL.to_owned(), sha256: PATCHELF_SHA256.to_owned(), + arch: None, }, None, )]), @@ -285,9 +299,12 @@ fn render_manifest(top: Vec<(String, Json)>) -> String { fn archive_source(source: &SourceRef, dest: Option<&str>) -> Json { let mut fields = vec![("type".to_owned(), Json::str("archive"))]; match source { - SourceRef::Url { url, sha256 } => { + SourceRef::Url { url, sha256, arch } => { fields.push(("url".to_owned(), Json::str(url))); fields.push(("sha256".to_owned(), Json::str(sha256))); + if let Some(arch) = arch { + fields.push(("only-arches".to_owned(), Json::Array(vec![Json::str(arch)]))); + } } SourceRef::Path(path) => fields.push(("path".to_owned(), Json::str(path))), } diff --git a/tools/packager-linux/src/main.rs b/tools/packager-linux/src/main.rs index 41c1f14b..343181d3 100644 --- a/tools/packager-linux/src/main.rs +++ b/tools/packager-linux/src/main.rs @@ -33,6 +33,7 @@ common options: --exec installed executable name; overrides [linux.store] exec --icon a square PNG or an SVG (repeatable); overrides [bundle] icon --arch x86_64 or aarch64; defaults to this machine's + --app-id overrides the identifier, for a store listing under another ID appdir: --payload the executable, or a directory holding it and its renderer @@ -45,10 +46,12 @@ update-information: flatpak: --out - --prebuilt-url --prebuilt-sha256 | --prebuilt-path + --prebuilt-url --prebuilt-sha256 [--prebuilt-arch ] (repeatable, + one per architecture) | --prebuilt-path --source-url --source-sha256 | --source-path with --cargo-sources --package --bin [--cargo-arg ]... - and optionally --renderer-url --renderer-sha256 | --renderer-path + and optionally --renderer-url --renderer-sha256 [--renderer-arch ] + (repeatable) | --renderer-path --wayland Wayland socket with X11 as fallback, for a Wayland-capable renderer "; @@ -134,6 +137,7 @@ const COMMON: &[&str] = &[ "exec", "icon", "arch", + "app-id", ]; fn run(args: &[String]) -> Result<(), String> { @@ -151,6 +155,7 @@ fn run(args: &[String]) -> Result<(), String> { "prebuilt-url", "prebuilt-sha256", "prebuilt-path", + "prebuilt-arch", "source-url", "source-sha256", "source-path", @@ -161,6 +166,7 @@ fn run(args: &[String]) -> Result<(), String> { "renderer-url", "renderer-sha256", "renderer-path", + "renderer-arch", "wayland", ], "help" | "--help" | "-h" => { @@ -257,11 +263,17 @@ fn run(args: &[String]) -> Result<(), String> { fn load(options: &Options) -> Result<(AppMetadata, Vec), String> { let toml_path = PathBuf::from(options.required("dioxus-toml")?); let text = read_text(&toml_path)?; - let overlays: Vec = options + let mut overlays: Vec = options .all("overlay") .iter() .map(|path| read_text(Path::new(path))) .collect::>()?; + if let Some(id) = options.one("app-id")? { + if id.contains(['"', '\\', '\n']) { + return Err(format!("--app-id `{id}` is not an application identifier")); + } + overlays.push(format!("[linux]\nidentifier = \"{id}\"\n")); + } let overlay_refs: Vec<&str> = overlays.iter().map(String::as_str).collect(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -295,33 +307,55 @@ fn load(options: &Options) -> Result<(AppMetadata, Vec), String> { Ok((meta, icons)) } -fn source_ref(options: &Options, prefix: &str) -> Result, String> { - let url = options.one(&format!("{prefix}-url"))?; - let sha = options.one(&format!("{prefix}-sha256"))?; +/// The sources named by `---url`/`---sha256` (repeatable, with an +/// optional `---arch` for each) or by one `---path`. +fn sources(options: &Options, prefix: &str) -> Result, String> { + let urls = options.all(&format!("{prefix}-url")); + let shas = options.all(&format!("{prefix}-sha256")); + let arches = options.all(&format!("{prefix}-arch")); let path = options.one(&format!("{prefix}-path"))?; - match (url, sha, path) { - (None, None, None) => Ok(None), - (Some(url), Some(sha256), None) => { + if let Some(path) = path { + if !urls.is_empty() || !shas.is_empty() || !arches.is_empty() { + return Err(format!("--{prefix}-path stands alone")); + } + return Ok(vec![SourceRef::Path(path)]); + } + if urls.len() != shas.len() { + return Err(format!( + "every --{prefix}-url needs its own --{prefix}-sha256, in the same order" + )); + } + if !arches.is_empty() && arches.len() != urls.len() { + return Err(format!( + "give --{prefix}-arch for every --{prefix}-url or for none" + )); + } + if urls.len() > 1 && arches.is_empty() { + return Err(format!( + "several --{prefix}-url need a --{prefix}-arch each" + )); + } + urls.into_iter() + .zip(shas) + .enumerate() + .map(|(index, (url, sha256))| { if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!("--{prefix}-sha256 must be 64 hex digits")); } - Ok(Some(SourceRef::Url { + Ok(SourceRef::Url { url, sha256: sha256.to_ascii_lowercase(), - })) - } - (None, None, Some(path)) => Ok(Some(SourceRef::Path(path))), - _ => Err(format!( - "pass --{prefix}-url with --{prefix}-sha256, or --{prefix}-path alone" - )), - } + arch: arches.get(index).cloned(), + }) + }) + .collect() } fn flatpak_payload(options: &Options) -> Result { - let prebuilt = source_ref(options, "prebuilt")?; - let source = source_ref(options, "source")?; - match (prebuilt, source) { - (Some(archive), None) => { + let prebuilt = sources(options, "prebuilt")?; + let source = sources(options, "source")?; + match (prebuilt.is_empty(), source.len()) { + (false, 0) => { for name in [ "cargo-sources", "package", @@ -334,17 +368,17 @@ fn flatpak_payload(options: &Options) -> Result { return Err(format!("--{name} belongs to a build from source")); } } - Ok(Payload::Prebuilt { archive }) + Ok(Payload::Prebuilt { archives: prebuilt }) } - (None, Some(source)) => Ok(Payload::FromSource { - source, + (true, 1) => Ok(Payload::FromSource { + source: source.into_iter().next().expect("one source"), cargo_sources: options.required("cargo-sources")?, package: options.required("package")?, bin: options.required("bin")?, cargo_args: options.all("cargo-arg"), - renderer: source_ref(options, "renderer")?, + renderer: sources(options, "renderer")?, }), - _ => Err("pass a prebuilt archive or a source archive, not both and not neither".into()), + _ => Err("pass prebuilt archives or one source archive, not both and not neither".into()), } } diff --git a/tools/packager-linux/tests/metadata.rs b/tools/packager-linux/tests/metadata.rs index 1825ccbd..78432324 100644 --- a/tools/packager-linux/tests/metadata.rs +++ b/tools/packager-linux/tests/metadata.rs @@ -75,7 +75,7 @@ fn resolve_error(text: &str, overlays: &[&str]) -> String { } #[test] -fn fr34_metadata_comes_from_dioxus_toml() { +fn fr35_metadata_comes_from_dioxus_toml() { let meta = meta(); assert_eq!(meta.id, "dev.example.Calculator"); assert_eq!(meta.name, "Calculator"); @@ -99,7 +99,7 @@ fn fr34_metadata_comes_from_dioxus_toml() { } #[test] -fn fr34_dx_linux_keys_override_the_shared_bundle_table() { +fn fr35_dx_linux_keys_override_the_shared_bundle_table() { let overlay = r#" [linux] identifier = "dev.example.CalculatorLinux" @@ -113,7 +113,7 @@ publisher = "Linux Desk" } #[test] -fn fr34_an_overlay_adds_what_the_application_file_lacks() { +fn fr35_an_overlay_adds_what_the_application_file_lacks() { // The shape of a sample's own file: nothing Linux-specific at all. let bare = r#" [application] @@ -140,7 +140,7 @@ release_notes = ["Now updates itself."] } #[test] -fn fr34_every_sample_resolves_with_an_overlay() { +fn fr35_every_sample_resolves_with_an_overlay() { let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); let overlay = r#" [linux] @@ -165,7 +165,7 @@ homepage = "https://github.com/darkpyonix/compose-rust" } #[test] -fn fr34_missing_store_facts_name_the_key_to_set() { +fn fr35_missing_store_facts_name_the_key_to_set() { let without = |key: &str| { DIOXUS_TOML .lines() @@ -192,7 +192,7 @@ fn fr34_missing_store_facts_name_the_key_to_set() { } #[test] -fn fr34_bad_values_are_refused_before_any_file_is_written() { +fn fr35_bad_values_are_refused_before_any_file_is_written() { for (overlay, expected) in [ ("[linux.store]\nhomepage = \"example.dev\"", "http(s) URL"), ("[linux.store]\nbrand_light = \"blue\"", "#rrggbb"), @@ -221,7 +221,7 @@ fn fr34_bad_values_are_refused_before_any_file_is_written() { } #[test] -fn fr34_application_ids_follow_flatpak_and_dbus_rules() { +fn fr35_application_ids_follow_flatpak_and_dbus_rules() { for good in [ "dev.darkpyonix.dioxus.compose.samples.calculator", "org.example.My_App", @@ -242,7 +242,7 @@ fn fr34_application_ids_follow_flatpak_and_dbus_rules() { } #[test] -fn fr34_desktop_entry_names_the_icon_and_executable() { +fn fr35_desktop_entry_names_the_icon_and_executable() { let meta = meta(); assert_eq!(desktop_file_name(&meta), "dev.example.Calculator.desktop"); assert_eq!( @@ -261,7 +261,7 @@ fn fr34_desktop_entry_names_the_icon_and_executable() { } #[test] -fn fr34_desktop_entry_escapes_what_the_format_reserves() { +fn fr35_desktop_entry_escapes_what_the_format_reserves() { let mut meta = meta(); meta.name = "Back\\slash\nNew".into(); meta.keywords = vec!["semi;colon".into()]; @@ -276,7 +276,7 @@ fn fr34_desktop_entry_escapes_what_the_format_reserves() { } #[test] -fn fr34_metainfo_carries_what_flathub_requires() { +fn fr35_metainfo_carries_what_flathub_requires() { let meta = meta(); assert_eq!( metainfo_file_name(&meta), @@ -315,7 +315,7 @@ fn fr34_metainfo_carries_what_flathub_requires() { } #[test] -fn fr34_metainfo_writes_release_notes_and_content_rating() { +fn fr35_metainfo_writes_release_notes_and_content_rating() { let overlay = r#" [linux.store] release_notes = ["Updates itself & more."] @@ -344,7 +344,7 @@ fn png(size: u32) -> Vec { } #[test] -fn fr34_icons_go_where_their_real_size_says() { +fn fr35_icons_go_where_their_real_size_says() { assert_eq!(png_size(&png(256)), Some((256, 256))); let icon = Icon::classify(Path::new("whatever.png"), &png(256)).unwrap(); assert_eq!(icon.kind, IconKind::Png(256)); @@ -372,7 +372,7 @@ fn fr34_icons_go_where_their_real_size_says() { } #[test] -fn fr34_flathub_needs_a_large_enough_icon() { +fn fr35_flathub_needs_a_large_enough_icon() { let small = Icon::classify(Path::new("64.png"), &png(64)).unwrap(); let large = Icon::classify(Path::new("128.png"), &png(128)).unwrap(); assert!( @@ -387,7 +387,7 @@ fn fr34_flathub_needs_a_large_enough_icon() { } #[test] -fn fr34_appimage_update_information_points_at_zsync_files() { +fn fr35_appimage_update_information_points_at_zsync_files() { let meta = meta(); assert_eq!(appimage_arch("x86_64"), Some("x86_64")); assert_eq!(appimage_arch("aarch64"), Some("aarch64")); @@ -431,10 +431,13 @@ fn fr34_appimage_update_information_points_at_zsync_files() { } #[test] -fn fr34_apprun_starts_the_payload_from_the_mount_point() { +fn fr35_apprun_starts_the_payload_from_the_mount_point() { let script = apprun(&meta()); assert!(script.starts_with("#!/bin/sh\n")); assert!(script.contains("APPDIR=\"${APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}\"\n")); + assert!(script.contains( + "LD_LIBRARY_PATH=\"$APPDIR/usr/lib/sample-calculator${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\"\nexport LD_LIBRARY_PATH\n" + )); assert!( script.ends_with("exec \"$APPDIR/usr/lib/sample-calculator/sample-calculator\" \"$@\"\n") ); @@ -450,7 +453,7 @@ fn scratch(test: &str) -> PathBuf { } #[test] -fn fr34_appdir_holds_entry_icon_metainfo_payload_and_updater() { +fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { let dir = scratch("appdir"); let meta = meta(); let payload = dir.join("payload"); @@ -524,7 +527,7 @@ fn fr34_appdir_holds_entry_icon_metainfo_payload_and_updater() { } #[test] -fn fr34_appdir_refuses_a_payload_without_the_executable() { +fn fr35_appdir_refuses_a_payload_without_the_executable() { let dir = scratch("appdir_missing"); let payload = dir.join("payload"); std::fs::create_dir_all(&payload).unwrap(); @@ -546,7 +549,7 @@ fn fr34_appdir_refuses_a_payload_without_the_executable() { } #[test] -fn fr34_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { +fn fr35_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { let meta = meta(); assert_eq!( finish_args(&meta, false), @@ -580,17 +583,25 @@ fn icons_256() -> Vec { } #[test] -fn fr34_flatpak_manifest_for_a_prebuilt_archive() { +fn fr35_flatpak_manifest_for_a_prebuilt_archive() { let meta = meta(); let icons = icons_256(); let text = manifest( &meta, &FlatpakOptions { payload: Payload::Prebuilt { - archive: SourceRef::Url { - url: "https://example.dev/calculator-linux-x64.tar.gz".into(), - sha256: "ab".repeat(32), - }, + archives: vec![ + SourceRef::Url { + url: "https://example.dev/calculator-linux-x64.tar.gz".into(), + sha256: "ab".repeat(32), + arch: Some("x86_64".into()), + }, + SourceRef::Url { + url: "https://example.dev/calculator-linux-arm64.tar.gz".into(), + sha256: "ef".repeat(32), + arch: Some("aarch64".into()), + }, + ], }, icons: &icons, wayland: false, @@ -606,6 +617,8 @@ fn fr34_flatpak_manifest_for_a_prebuilt_archive() { "\"--device=dri\"", "\"type\": \"archive\"", "\"url\": \"https://example.dev/calculator-linux-x64.tar.gz\"", + "\"url\": \"https://example.dev/calculator-linux-arm64.tar.gz\"", + "\"only-arches\": [\n \"aarch64\"\n ]", "\"dest\": \"payload\"", "\"cp -a payload/. /app/lib/sample-calculator/\"", "\"test -x /app/lib/sample-calculator/sample-calculator\"", @@ -618,12 +631,17 @@ fn fr34_flatpak_manifest_for_a_prebuilt_archive() { assert!(text.contains(expected), "missing {expected} in\n{text}"); } assert!(!text.contains("rust-stable"), "{text}"); - assert!(!text.contains("patchelf"), "{text}"); + // An executable without an rpath is pointed at the renderer beside it. + assert!(text.contains("\"name\": \"patchelf\""), "{text}"); + assert!( + text.contains("readelf -d /app/lib/sample-calculator/sample-calculator | grep -qE '\\\\((RPATH|RUNPATH)\\\\)' || patchelf --set-rpath '$ORIGIN' /app/lib/sample-calculator/sample-calculator"), + "{text}" + ); assert!(!text.contains("cargo"), "{text}"); } #[test] -fn fr34_flatpak_manifest_builds_from_source_offline() { +fn fr35_flatpak_manifest_builds_from_source_offline() { let mut meta = meta(); meta.runtime_version = Some("24.08".into()); let icons = icons_256(); @@ -636,10 +654,11 @@ fn fr34_flatpak_manifest_builds_from_source_offline() { package: "sample-calculator".into(), bin: "sample-calculator".into(), cargo_args: vec!["--no-default-features".into(), "it's".into()], - renderer: Some(SourceRef::Url { + renderer: vec![SourceRef::Url { url: "https://example.dev/renderer.tar.gz".into(), sha256: "cd".repeat(32), - }), + arch: None, + }], }, icons: &icons, wayland: true, @@ -680,7 +699,7 @@ fn fr34_flatpak_manifest_builds_from_source_offline() { } #[test] -fn fr34_json_and_shell_quoting_survive_awkward_text() { +fn fr35_json_and_shell_quoting_survive_awkward_text() { assert_eq!( Json::Object(vec![( "k\"ey".into(), @@ -695,7 +714,7 @@ fn fr34_json_and_shell_quoting_survive_awkward_text() { } #[test] -fn fr34_release_dates_are_reproducible() { +fn fr35_release_dates_are_reproducible() { assert_eq!(release_date(None, 0), "1970-01-01"); assert_eq!(release_date(None, 1_790_985_600), "2026-10-03"); assert_eq!(release_date(Some("951782400"), 1_790_985_600), "2000-02-29"); From 7510067be7da5bf47b19d693a3e32f4c28d60279 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:49:54 +0900 Subject: [PATCH 024/224] Chore: Run Flathub's builder on a session bus and prepare a submission org.flatpak.Builder reaches the host's flatpak through the session bus, which a runner does not have, so installing build dependencies failed. Every builder call now runs under dbus-run-session. The workflow generates the Flathub submission for a placeholder ID, lints it and uploads it; FLATHUB.md is the checklist for submitting it under the real ID. --- .github/workflows/packager-linux.yml | 55 ++++++++++++++++++----- tools/packager-linux/FLATHUB.md | 67 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 tools/packager-linux/FLATHUB.md diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 510d7308..17acd152 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -42,6 +42,8 @@ env: # companions and the executable, already pointed at each other by relative paths. CALCULATOR_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-x64.tar.gz CALCULATOR_SHA256: ebc52967d02bfbd2eb22317cb23d023417ec2029eac811f3ce6b018e1950fded + CALCULATOR_ARM64_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-arm64.tar.gz + CALCULATOR_ARM64_SHA256: b264dbc9efa2d7dc7e1ff9559743840d63c2aeca01f69a0e6d1ea1da9d97306f FLATPAK_RUNTIME: "26.08" # The renderer release archive, built into the from-source Flatpak so that the step # which points an executable at the renderer beside it runs for real. @@ -288,6 +290,7 @@ jobs: --dioxus-toml samples/calculator/Dioxus.toml \ --overlay tools/packager-linux/samples.toml \ --exec sample-calculator --icon samples/calculator/assets/icon.png \ + --icon samples/calculator/assets/icon-256.png \ --payload work/calculator/calculator --version 0.1.1 \ --github darkpyonix/compose-rust --sign-key "$RELEASE_KEY" \ --out-dir work/dist @@ -328,7 +331,7 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak xvfb xauth imagemagick python3-pip + sudo apt-get install -y --no-install-recommends flatpak dbus-daemon xvfb xauth imagemagick python3-pip pip install --user 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1' flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true @@ -347,7 +350,9 @@ jobs: mkdir -p work/flatpak-src # What a release publishes: the tagged source as an archive. git archive --format=tar.gz --prefix=compose-rust/ -o work/flatpak-src/source.tar.gz HEAD - tools/packager-linux/flatpak.sh \ + # Flathub's builder runs in its own sandbox and reaches the host's flatpak + # through the session bus, which a runner has none of until one is started. + dbus-run-session -- tools/packager-linux/flatpak.sh \ --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.0 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ @@ -374,14 +379,16 @@ jobs: flatpak --user uninstall --noninteractive "$id" flatpak --user install --noninteractive local-repo "$id" flatpak run "$id" --version | grep -qx "update-demo 1.0.0" - tools/packager-linux/flatpak.sh \ + # Flathub's builder runs in its own sandbox and reaches the host's flatpak + # through the session bus, which a runner has none of until one is started. + dbus-run-session -- tools/packager-linux/flatpak.sh \ --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.1 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ --out work/flatpak-src - (cd work/flatpak-src && flatpak run --command=flatpak-builder org.flatpak.Builder \ - --user --force-clean --repo="$PWD/../repo" build "$id.json") + (cd work/flatpak-src && dbus-run-session -- flatpak run --command=flatpak-builder org.flatpak.Builder \ + --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") flatpak --user update --noninteractive "$id" flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" flatpak --user info "$id" @@ -391,13 +398,15 @@ jobs: run: | set -euo pipefail mkdir -p work/flatpak-calculator - # Flathub needs an icon of at least 128 pixels; the sample's is 64. - convert samples/calculator/assets/icon.png -filter point -resize 256x256 work/calculator-256.png - tools/packager-linux/flatpak.sh \ + # Flathub's builder runs in its own sandbox and reaches the host's flatpak + # through the session bus, which a runner has none of until one is started. + dbus-run-session -- tools/packager-linux/flatpak.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ --overlay tools/packager-linux/samples.toml \ - --exec sample-calculator --icon work/calculator-256.png --version 0.1.1 \ - --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" \ + --exec sample-calculator --icon samples/calculator/assets/icon.png \ + --icon samples/calculator/assets/icon-256.png --version 0.1.1 \ + --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" --prebuilt-arch x86_64 \ + --prebuilt-url "$CALCULATOR_ARM64_URL" --prebuilt-sha256 "$CALCULATOR_ARM64_SHA256" --prebuilt-arch aarch64 \ --out work/flatpak-calculator --build work/repo-calculator --lint id=dev.darkpyonix.dioxus.compose.samples.calculator cat "work/flatpak-calculator/$id.json" @@ -411,6 +420,30 @@ jobs: cat work/calculator-flatpak.log grep -q 'still running' work/calculator-flatpak-run.txt + - name: Prepare the Flathub submission under a placeholder ID + # Everything a flathub/ repository holds, generated for an ID nobody owns + # yet. Submitting means running the same command with the chosen ID; the + # checklist for the rest is in tools/packager-linux/FLATHUB.md. + run: | + set -euo pipefail + dbus-run-session -- tools/packager-linux/flatpak.sh \ + --dioxus-toml samples/calculator/Dioxus.toml \ + --overlay tools/packager-linux/samples.toml \ + --app-id dev.darkpyonix.PlaceholderAppId \ + --exec sample-calculator --icon samples/calculator/assets/icon.png \ + --icon samples/calculator/assets/icon-256.png --version 0.1.1 \ + --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" --prebuilt-arch x86_64 \ + --prebuilt-url "$CALCULATOR_ARM64_URL" --prebuilt-sha256 "$CALCULATOR_ARM64_SHA256" --prebuilt-arch aarch64 \ + --out work/flathub-submission --lint + rm -rf work/flathub-submission/.flatpak-builder work/flathub-submission/build + find work/flathub-submission -type f | sort + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: flathub-submission-placeholder + path: work/flathub-submission/ + - uses: actions/upload-artifact@v4 if: always() with: @@ -422,5 +455,7 @@ jobs: work/flatpak-calculator/*.json work/flatpak-calculator/*.xml work/flatpak-calculator/*.desktop + work/flatpak-src/lint-*.json + work/flatpak-calculator/lint-*.json work/*.log work/*.png diff --git a/tools/packager-linux/FLATHUB.md b/tools/packager-linux/FLATHUB.md new file mode 100644 index 00000000..b8090771 --- /dev/null +++ b/tools/packager-linux/FLATHUB.md @@ -0,0 +1,67 @@ +# Submitting to Flathub + +Everything Flathub reviews is generated by `tools/packager-linux/flatpak.sh`. The +`Linux packaging` workflow generates it for the calculator sample under the placeholder ID +`dev.darkpyonix.PlaceholderAppId` and uploads it as the `flathub-submission-placeholder` +artifact. It also lints the result with Flathub's own linter, and builds and starts the same +manifest under the sample's real ID. + +What is still open is the application ID. Once the owner has chosen it, the submission is the +same command with that ID. + +## 1. Before the pull request (owner) + +- [ ] Choose the application and its ID. Flathub does not accept tech demos, so a sample + goes in only if it is meant to be used. +- [ ] Prove control of the ID's domain. `dev.darkpyonix.*` means `darkpyonix.dev`. After the + app is accepted, verify it on flathub.org (Developer settings, Verification), which asks + for a token at `https://darkpyonix.dev/.well-known/org.flathub.VerifiedApps.txt`. + An `io.github..*` ID is verified through the GitHub account instead. +- [ ] Publish the release the manifest points at. Each architecture needs one archive, with + the executable and the renderer at its top level, and the SHA-256 of each. +- [ ] Give the listing at least one screenshot: `[linux.store] screenshots` in an overlay, + as a stable URL (a release asset or a file in the repository at a tag). Flathub's + linter refuses a listing without one. +- [ ] Decide the release notes for this version (`[linux.store] release_notes`). + +## 2. Generate the submission + +```sh +cargo build --release -p packager-linux +PACKAGER_LINUX=target/release/packager-linux tools/packager-linux/flatpak.sh \ + --dioxus-toml samples/calculator/Dioxus.toml \ + --overlay tools/packager-linux/samples.toml --overlay store.toml \ + --app-id \ + --exec sample-calculator \ + --icon samples/calculator/assets/icon.png --icon samples/calculator/assets/icon-256.png \ + --version \ + --prebuilt-url --prebuilt-sha256 --prebuilt-arch x86_64 \ + --prebuilt-url --prebuilt-sha256 --prebuilt-arch aarch64 \ + --out flathub/ --build repo/ --lint +``` + +`store.toml` holds what is specific to this submission (screenshots and release notes). For an +application built from source, pass `--source-url`, `--source-sha256` and `--cargo-lock` +instead of the prebuilt archives, and `--renderer-url`, `--renderer-sha256` and +`--renderer-arch` for each architecture's renderer. + +`flathub/` then holds `.json`, `.desktop`, `.metainfo.xml`, the icons under +`icons/`, and `cargo-sources.json` for a build from source. These are the files of the +`flathub/` repository. + +## 3. Check before opening the pull request + +- [ ] `lint-manifest.json` and `lint-repo.json` in `flathub/` show no errors other than the + ones only Flathub can clear (the ID's domain not verified yet). +- [ ] `flatpak run ` opens the window. Under a virtual display, the workflow's + calculator step shows how. +- [ ] `appstreamcli validate --no-net flathub/.metainfo.xml` passes. +- [ ] The runtime branch (`[linux.store] flatpak_runtime_version`, default 26.08) is the + newest one Flathub offers. + +## 4. Pull request + +Fork `flathub/flathub`, branch from `new-pr`, add the files from `flathub/` at the +repository root, and open the pull request against `new-pr` titled `Add `. A reviewer +answers there. Once it is merged, Flathub creates `flathub/` and the owner is given +write access. Every later release is a commit there that changes the archive URLs and digests. From 4ba4b89d942cdd7062c4008a956adc3cafa1c9c2 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:50:00 +0900 Subject: [PATCH 025/224] Fix: Tell optional WACK failures apart from required ones The released notepad sample fails the kit's optional Blocked executables test (the renderer DLL references CreateProcessW, and ICU data matches blocked names), which leaves the overall result at WARNING. The script annotated it as an error while passing the step. It now reports optional failures as warnings, lists them in the summary, and fails the step on any required failure. --- tools/msix/scripts/run-wack.ps1 | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tools/msix/scripts/run-wack.ps1 b/tools/msix/scripts/run-wack.ps1 index f08e7257..f073182f 100644 --- a/tools/msix/scripts/run-wack.ps1 +++ b/tools/msix/scripts/run-wack.ps1 @@ -9,9 +9,9 @@ launches it, inspects the binaries and the manifest, and writes an XML report. T package has to be signed by a certificate the machine trusts, or the install step fails before any test runs. -Warnings do not fail the run; they are printed so that they are read. The Store accepts -a package with warnings, and some of them (a test that does not apply to a full-trust -application) cannot be avoided. +Warnings and failures of tests the kit marks optional do not fail the run; they are +printed, and listed in the step summary, so that they are read. The Store accepts a +package with them. A required test failing fails the run. .PARAMETER Package The signed .msix or .msixbundle. @@ -44,25 +44,37 @@ if (-not (Test-Path $Report)) { [xml]$xml = Get-Content -Raw -Path $Report $overall = $xml.REPORT.OVERALL_RESULT $failed = @() +$optionalFailed = @() foreach ($test in $xml.SelectNodes('//TEST')) { $resultNode = $test.SelectSingleNode('RESULT') $result = if ($resultNode) { $resultNode.InnerText.Trim() } else { '' } - $line = '{0,-8} {1}' -f $result, $test.NAME - if ($result -eq 'FAIL') { + $optional = $test.OPTIONAL -eq 'TRUE' + $label = if ($optional) { "$result (optional)" } else { $result } + if ($result -eq 'FAIL' -and -not $optional) { $failed += $test Write-Host "::error title=WACK::$($test.NAME)" + } elseif ($result -eq 'FAIL') { + $optionalFailed += $test + Write-Host "::warning title=WACK optional test failed::$($test.NAME)" } elseif ($result -eq 'WARNING') { Write-Host "::warning title=WACK::$($test.NAME)" } - Write-Host $line + Write-Host ('{0,-18} {1}' -f $label, $test.NAME) foreach ($message in $test.SelectNodes('MESSAGES/MESSAGE')) { - Write-Host " $($message.TEXT)" + Write-Host " $($message.TEXT)" } } Write-Host "overall result: $overall (appcert exit $exit)" if ($env:GITHUB_STEP_SUMMARY) { - Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "WACK on $(Split-Path -Leaf $Package): **$overall**" -Encoding utf8 + $line = "WACK on $(Split-Path -Leaf $Package): **$overall**" + if ($optionalFailed.Count -gt 0) { + $line += " (optional tests failed: $(($optionalFailed | ForEach-Object { $_.NAME }) -join ', '))" + } + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $line -Encoding utf8 } -if ($overall -ne 'PASS' -and $overall -ne 'WARNING') { - throw "the certification kit reported $overall with $($failed.Count) failing test(s)" +# The kit marks some tests optional. Their failures make the overall result WARNING, +# and the Store accepts a package with them; a required test failing is a FAIL overall +# and is what a submission would be refused for. +if ($failed.Count -gt 0 -or ($overall -ne 'PASS' -and $overall -ne 'WARNING')) { + throw "the certification kit reported $overall with $($failed.Count) failing required test(s)" } From 99d0f14456093242c08922b4115d511f54122b6d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:57:53 +0900 Subject: [PATCH 026/224] Docs: Say what the certification kit does with a desktop package From the command line the kit inspects a full-trust package without launching it, which the script's description claimed it did. --- tools/msix/scripts/run-wack.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/msix/scripts/run-wack.ps1 b/tools/msix/scripts/run-wack.ps1 index f073182f..aa5aa2bb 100644 --- a/tools/msix/scripts/run-wack.ps1 +++ b/tools/msix/scripts/run-wack.ps1 @@ -4,10 +4,10 @@ Runs the Windows App Certification Kit against a package and fails on a failed t .DESCRIPTION The kit is the check Partner Center runs when a package is submitted, so a package that -passes it here is not refused for the same reasons there. It installs the package, -launches it, inspects the binaries and the manifest, and writes an XML report. The -package has to be signed by a certificate the machine trusts, or the install step fails -before any test runs. +passes it here is not refused for the same reasons there. It inspects the manifest, the +binaries and the package contents and writes an XML report; for a full-trust desktop +package run from the command line it does not launch the program. Sign the package +with a certificate this machine trusts first, as a machine installing it would. Warnings and failures of tests the kit marks optional do not fail the run; they are printed, and listed in the step summary, so that they are read. The Store accepts a From efbe97abd42481d9aab35e06f26a653ede770c9d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:58:10 +0900 Subject: [PATCH 027/224] Fix: Package an executable that names the renderer through @rpath The first published dioxus-compose links the renderer as @rpath/... and adds no rpath to the application, so the packager cannot find the renderer from the executable. --renderer-dir names it; without it the packager says so instead of building a bundle with no renderer. The rehearsal application turns on the published crate's native-renderer feature (off by default in 0.0.0) and the script fetches and checks the released renderer it needs. --- experiments/self-update/app/Cargo.toml | 10 +++-- experiments/self-update/rehearse.sh | 19 +++++++++- tools/packager/dxc_packager/bundle.py | 51 +++++++++++++++++++------- tools/packager/dxc_packager/cli.py | 4 ++ tools/packager/tests/test_bundle.py | 30 +++++++++++++++ 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/experiments/self-update/app/Cargo.toml b/experiments/self-update/app/Cargo.toml index 21d889ba..85214fb8 100644 --- a/experiments/self-update/app/Cargo.toml +++ b/experiments/self-update/app/Cargo.toml @@ -1,9 +1,11 @@ # The application the self-update rehearsal installs at one version and watches replace # itself with the next. # -# It depends on the published dioxus-compose, not the one in this checkout, because the -# published crate fetches the renderer released for its own version: building this needs -# no renderer build, and the two halves are guaranteed to be from the same schema. +# It depends on the published dioxus-compose, not the one in this checkout, and +# rehearse.sh fetches the renderer released for that version: building this needs no +# renderer build, and the two halves are guaranteed to be from the same schema. 0.0.0 does +# not fetch its renderer by itself, so the script does and names it with +# DIOXUS_COMPOSE_RENDERER_DIR. [package] name = "self-update-demo" version = "1.0.0" @@ -22,7 +24,7 @@ default = ["sparkle"] sparkle = ["dep:dioxus-compose-update", "dep:objc2"] [dependencies] -dioxus-compose = "=0.0.0" +dioxus-compose = { version = "=0.0.0", features = ["native-renderer"] } # The published 0.0.0's rsx! expands to paths in these two crates, so an application of # that version names them itself. Later versions re-export what the macro needs. dioxus-core = "0.7.10" diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh index b2921c56..5b5b07aa 100755 --- a/experiments/self-update/rehearse.sh +++ b/experiments/self-update/rehearse.sh @@ -37,6 +37,7 @@ identifier="dev.darkpyonix.dioxus.compose.experiments.selfupdate" executable="self-update-demo" archive_name="SelfUpdateDemo-2.0.0.zip" +renderer_version="0.0.0" sparkle_version="2.10.0" sparkle_sha256="c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c" @@ -108,6 +109,19 @@ if [[ ! -x "$sparkle/bin/sign_update" ]]; then tar -xf "$sparkle/Sparkle.tar.xz" -C "$sparkle" fi +step "Renderer $renderer_version, as released for the dioxus-compose the demo depends on" +renderer="$work/renderer" +if [[ ! -f "$renderer/lib/libdioxus_compose_renderer.dylib" ]]; then + artifact="dioxus-compose-renderer-v$renderer_version-macos-aarch64.tar.gz" + release="https://github.com/DarkPyonix/dioxus-compose/releases/download/v$renderer_version" + mkdir -p "$work/downloads" "$renderer" + curl -fsSL -o "$work/downloads/$artifact" "$release/$artifact" + curl -fsSL -o "$work/downloads/$artifact.sha256" "$release/$artifact.sha256" + (cd "$work/downloads" && shasum -a 256 -c "$artifact.sha256") + tar -xzf "$work/downloads/$artifact" -C "$renderer" +fi +export DIOXUS_COMPOSE_RENDERER_DIR="$renderer" + step "EdDSA key (kept in $work/keys, never in the repository)" key="$work/keys/sparkle.key" [[ -f "$key" ]] || "$package" keygen --out "$key" @@ -135,7 +149,7 @@ step "Build and package 1.0.0 and 2.0.0 for the sparkle channel" for major in 1 2; do build "$major.0.0" "$work/build/$executable-$major" "$package" app --channel sparkle --manifest-dir "$app_dir" \ - --executable "$work/build/$executable-$major" \ + --executable "$work/build/$executable-$major" --renderer-dir "$renderer/lib" \ --version "$major.0.0" --build "$major" --out "$work/dist-$major" \ --feed-url "http://127.0.0.1:$port/appcast.xml" --ed-key-file "$key" \ --sparkle-framework "$sparkle/Sparkle.framework" --automatically-update \ @@ -249,7 +263,8 @@ fi step "Mac App Store: the same application without the updater" build 1.0.0 "$work/build/$executable-store" --no-default-features "$package" app --channel app-store --manifest-dir "$app_dir" \ - --executable "$work/build/$executable-store" --version 1.0.0 --build 1 \ + --executable "$work/build/$executable-store" --renderer-dir "$renderer/lib" \ + --version 1.0.0 --build 1 \ --out "$work/dist-store" --uses-non-exempt-encryption no --network-client \ --user-selected-files read-write | tee "$evidence/package-store.txt" store_app="$work/dist-store/$name.app" diff --git a/tools/packager/dxc_packager/bundle.py b/tools/packager/dxc_packager/bundle.py index 8420e118..44838a66 100644 --- a/tools/packager/dxc_packager/bundle.py +++ b/tools/packager/dxc_packager/bundle.py @@ -83,19 +83,46 @@ def load_commands(path): ] -def renderer_dir_of(executable): - """The directory the executable loads the renderer from, or None if it does not.""" +def renderer_reference(executable): + """How the executable names the renderer library, or None if it does not load one.""" for path in load_commands(executable): if path.endswith("/" + RENDERER_LIBRARY): - if not path.startswith("/"): - raise PackagingError( - f"{executable} already loads the renderer by a relative path ({path}); " - f"package the executable cargo built, not one taken from a bundle" - ) - return Path(path).parent + return path return None +def renderer_dir_of(executable, explicit=None): + """The directory to copy the renderer from, or None for an executable without one. + + The crate's build records the renderer's absolute path in the executable, and that + path is where the directory is. An executable that names it through @rpath (what the + first published crate did) does not say where it is, and `explicit` has to. + """ + reference = renderer_reference(executable) + if reference is None: + if explicit is not None: + raise PackagingError( + f"--renderer-dir was given, but {executable} does not load " + f"{RENDERER_LIBRARY}; it is either the whole application already or not a " + f"dioxus-compose executable" + ) + return None + if explicit is not None: + return Path(explicit) + if reference.startswith("@executable_path/"): + raise PackagingError( + f"{executable} already loads the renderer relative to itself ({reference}); " + f"package the executable cargo built, not one taken from a bundle" + ) + if not reference.startswith("/"): + raise PackagingError( + f"{executable} loads the renderer as {reference}, which does not say where the " + f"renderer is. Pass --renderer-dir with the directory that holds " + f"{RENDERER_LIBRARY}." + ) + return Path(reference).parent + + def is_mach_o(path): try: with open(path, "rb") as handle: @@ -158,6 +185,7 @@ def assemble( uses_non_exempt_encryption=None, extra_plist=None, provisioning_profile=None, + renderer_dir=None, ): """Build Name.app in `out_dir` and return its path. Replaces one already there.""" executable = Path(executable) @@ -199,13 +227,10 @@ def assemble( shutil.copy2(executable, staged) os.chmod(staged, 0o755) - renderer = renderer_dir_of(executable) + renderer = renderer_dir_of(executable, renderer_dir) if renderer is not None: if not (renderer / RENDERER_LIBRARY).exists(): - raise PackagingError( - f"{executable} was linked against {renderer / RENDERER_LIBRARY}, which is " - f"no longer there" - ) + raise PackagingError(f"there is no {RENDERER_LIBRARY} in {renderer}") # Contents/Frameworks may hold only code. codesign seals anything else there as an # unsigned subcomponent and refuses the bundle, so say which file it is now rather # than leave the person to read that out of a codesign failure. The macOS renderer diff --git a/tools/packager/dxc_packager/cli.py b/tools/packager/dxc_packager/cli.py index 238a34ca..89c3f847 100644 --- a/tools/packager/dxc_packager/cli.py +++ b/tools/packager/dxc_packager/cli.py @@ -46,6 +46,9 @@ def _add_app_arguments(parser): help="directory holding the application's Cargo.toml and Dioxus.toml") parser.add_argument("--executable", required=True, type=Path, help="the executable cargo built (not one already taken from a bundle)") + parser.add_argument("--renderer-dir", type=Path, + help="the renderer's lib directory, when the executable names it " + "through @rpath rather than by its path") parser.add_argument("--channel", required=True, choices=plist.CHANNELS) parser.add_argument("--out", required=True, type=Path, help="directory to put Name.app in") parser.add_argument("--version", help="CFBundleShortVersionString; default from Cargo.toml") @@ -117,6 +120,7 @@ def command_app(args): uses_non_exempt_encryption=args.uses_non_exempt_encryption, extra_plist=extra, provisioning_profile=args.provisioning_profile, + renderer_dir=args.renderer_dir, ) capabilities = plist.Capabilities( network_client=args.network_client, diff --git a/tools/packager/tests/test_bundle.py b/tools/packager/tests/test_bundle.py index 623da9d8..cd43f6bb 100644 --- a/tools/packager/tests/test_bundle.py +++ b/tools/packager/tests/test_bundle.py @@ -162,6 +162,36 @@ def test_fr34_1_renderer_directory_travels_whole_and_is_loaded_relatively(self): self.assertIn("@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, loads) self.assertEqual(bundle.absolute_references(self.app), []) + def test_fr34_1_a_renderer_named_through_rpath_needs_its_directory_given(self): + renderer = self.scratch / "rpath-renderer" / "lib" + renderer.mkdir(parents=True) + library = renderer / bundle.RENDERER_LIBRARY + compile_c( + "int dioxus_compose_renderer_stand_in(void) { return 0; }", + library, "-dynamiclib", "-install_name", "@rpath/" + bundle.RENDERER_LIBRARY, + ) + executable = self.scratch / "rpath-demo" + compile_c( + "int dioxus_compose_renderer_stand_in(void);\n" + "int main(void) { return dioxus_compose_renderer_stand_in(); }", + executable, str(library), + ) + meta = metadata.with_overrides( + metadata.load(project(CARGO, DIOXUS)), executable="sample-demo" + ) + store = dict(uses_non_exempt_encryption=False) + with self.assertRaisesRegex(metadata.PackagingError, "--renderer-dir"): + bundle.assemble(meta, executable, self.scratch / "rpath", plist.APP_STORE, **store) + app = bundle.assemble( + meta, executable, self.scratch / "rpath", plist.APP_STORE, renderer_dir=renderer, + **store, + ) + self.assertTrue((app / "Contents/Frameworks/lib" / bundle.RENDERER_LIBRARY).is_file()) + self.assertIn( + "@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, + bundle.load_commands(app / "Contents/MacOS/sample-demo"), + ) + def test_fr34_1_a_renderer_directory_holding_data_is_refused_by_name(self): renderer = self.scratch / "renderer" / "lib" (renderer / "fonts.conf").write_text("data") From 967ba316b244922b9f91d0a867f5111d8dbeb66e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:58:25 +0900 Subject: [PATCH 028/224] Test: Lint the rehearsal application where its renderer has been fetched --- .github/workflows/packager-macos.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml index 8ef6ed06..c673e2ef 100644 --- a/.github/workflows/packager-macos.yml +++ b/.github/workflows/packager-macos.yml @@ -95,13 +95,6 @@ jobs: - name: Lint run: cargo clippy -p dioxus-compose-update --all-targets -- -D warnings - - name: Lint the rehearsal application - # It depends on the published dioxus-compose, so this fetches the released renderer. - if: runner.os == 'macOS' - run: | - cargo clippy --manifest-path experiments/self-update/app/Cargo.toml -- -D warnings - cargo clippy --manifest-path experiments/self-update/app/Cargo.toml --no-default-features -- -D warnings - rehearsal: name: Self-update rehearsal (macOS) needs: [packager, updater] @@ -116,7 +109,7 @@ jobs: - name: Install the Rust toolchain run: | - rustup toolchain install stable --profile minimal + rustup toolchain install stable --profile minimal --component clippy rustup default stable - name: Rehearse both channels @@ -124,6 +117,15 @@ jobs: DEMO_WORK: ${{ runner.temp }}/self-update run: experiments/self-update/rehearse.sh + - name: Lint the rehearsal application + # After the rehearsal, which fetched the released renderer its build script needs. + if: success() || failure() + env: + DIOXUS_COMPOSE_RENDERER_DIR: ${{ runner.temp }}/self-update/renderer + run: | + cargo clippy --manifest-path experiments/self-update/app/Cargo.toml -- -D warnings + cargo clippy --manifest-path experiments/self-update/app/Cargo.toml --no-default-features -- -D warnings + - name: Keep the evidence if: always() uses: actions/upload-artifact@v4 From 39bc6a39279eb30b387a5fd41487c83593c6a464 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:09 +0900 Subject: [PATCH 029/224] Test: Name the packager tests after FR-35 The update packaging requirement is FR-35, not FR-34. --- tools/msix/src/appinstaller.rs | 6 +++--- tools/msix/src/assets.rs | 12 ++++++------ tools/msix/src/lib.rs | 4 ++-- tools/msix/src/manifest.rs | 14 +++++++------- tools/msix/src/metadata.rs | 16 ++++++++-------- tools/msix/src/sdk.rs | 2 +- tools/msix/src/version.rs | 18 +++++++++--------- tools/msix/tests/stage.rs | 12 ++++++------ 8 files changed, 42 insertions(+), 42 deletions(-) diff --git a/tools/msix/src/appinstaller.rs b/tools/msix/src/appinstaller.rs index 7126aa91..eb4ebbf7 100644 --- a/tools/msix/src/appinstaller.rs +++ b/tools/msix/src/appinstaller.rs @@ -93,7 +93,7 @@ mod tests { } #[test] - fn fr34_feed_points_at_the_bundle_and_at_itself() { + fn fr35_feed_points_at_the_bundle_and_at_itself() { let v = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(12)).unwrap(); let feed = Feed::beside( "https://example.com/ember/", @@ -114,14 +114,14 @@ mod tests { } #[test] - fn fr34_feed_on_a_file_share_uses_backslashes() { + fn fr35_feed_on_a_file_share_uses_backslashes() { let feed = Feed::beside("\\\\host\\share\\", "a.appinstaller", "b.msixbundle").unwrap(); assert_eq!(feed.feed_uri, "\\\\host\\share\\a.appinstaller"); assert_eq!(feed.bundle_uri, "\\\\host\\share\\b.msixbundle"); } #[test] - fn fr34_feed_refuses_a_relative_address() { + fn fr35_feed_refuses_a_relative_address() { assert!(Feed::beside("ember", "a.appinstaller", "b.msixbundle").is_err()); } } diff --git a/tools/msix/src/assets.rs b/tools/msix/src/assets.rs index 7cf65cee..4efdd348 100644 --- a/tools/msix/src/assets.rs +++ b/tools/msix/src/assets.rs @@ -295,7 +295,7 @@ mod tests { } #[test] - fn fr34_every_asset_has_its_exact_size() { + fn fr35_every_asset_has_its_exact_size() { let icon = solid(64, 64, [200, 80, 40, 255]); for spec in ALL { let img = render(&icon, spec); @@ -311,7 +311,7 @@ mod tests { } #[test] - fn fr34_resizing_keeps_a_solid_colour_solid() { + fn fr35_resizing_keeps_a_solid_colour_solid() { let icon = solid(64, 64, [10, 120, 230, 255]); for (w, h) in [(44, 44), (150, 150), (50, 50), (3, 3), (256, 256)] { let r = icon.resize(w, h); @@ -324,7 +324,7 @@ mod tests { } #[test] - fn fr34_transparent_surround_does_not_darken_the_edge() { + fn fr35_transparent_surround_does_not_darken_the_edge() { // Left half opaque red, right half fully transparent black. let mut icon = solid(8, 8, [0, 0, 0, 0]); for y in 0..8 { @@ -340,7 +340,7 @@ mod tests { } #[test] - fn fr34_wide_tile_centres_the_icon() { + fn fr35_wide_tile_centres_the_icon() { let icon = solid(16, 16, [0, 255, 0, 255]); let wide = render(&icon, WIDE_310); assert_eq!(wide.pixel(0, 75)[3], 0); @@ -349,7 +349,7 @@ mod tests { } #[test] - fn fr34_small_icons_are_reported() { + fn fr35_small_icons_are_reported() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples/notepad/assets/icon.png"); let bytes = std::fs::read(dir).unwrap(); @@ -359,7 +359,7 @@ mod tests { } #[test] - fn fr34_gray_and_rgb_pngs_decode() { + fn fr35_gray_and_rgb_pngs_decode() { for (color, channels) in [ (png::ColorType::Rgb, 3usize), (png::ColorType::Grayscale, 1), diff --git a/tools/msix/src/lib.rs b/tools/msix/src/lib.rs index 7f3f57c4..7419870b 100644 --- a/tools/msix/src/lib.rs +++ b/tools/msix/src/lib.rs @@ -304,7 +304,7 @@ mod tests { use super::*; #[test] - fn fr34_executable_paths_use_backslashes() { + fn fr35_executable_paths_use_backslashes() { let p = Payload::Directory { root: "/x".into(), executable: Path::new("bin").join("app.exe"), @@ -315,7 +315,7 @@ mod tests { } #[test] - fn fr34_executable_must_stay_inside_the_payload() { + fn fr35_executable_must_stay_inside_the_payload() { let p = Payload::Directory { root: "/x".into(), executable: Path::new("..").join("app.exe"), diff --git a/tools/msix/src/manifest.rs b/tools/msix/src/manifest.rs index f72f141a..927704b7 100644 --- a/tools/msix/src/manifest.rs +++ b/tools/msix/src/manifest.rs @@ -217,7 +217,7 @@ mod tests { } #[test] - fn fr34_manifest_carries_identity_and_version() { + fn fr35_manifest_carries_identity_and_version() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); assert!(xml.contains( "" @@ -227,7 +227,7 @@ mod tests { } #[test] - fn fr34_manifest_declares_a_full_trust_desktop_application() { + fn fr35_manifest_declares_a_full_trust_desktop_application() { let xml = render(&meta(), version(), Arch::Arm64, "bin\\ember.exe"); assert!(xml.contains("EntryPoint=\"Windows.FullTrustApplication\"")); assert!(xml.contains("Executable=\"bin\\ember.exe\"")); @@ -238,14 +238,14 @@ mod tests { } #[test] - fn fr34_manifest_escapes_text() { + fn fr35_manifest_escapes_text() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); assert!(xml.contains("Description=\"Notes & <things>\"")); assert!(!xml.contains("Notes & <")); } #[test] - fn fr34_manifest_names_every_asset_it_ships() { + fn fr35_manifest_names_every_asset_it_ships() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); for spec in assets::ALL { assert!( @@ -257,7 +257,7 @@ mod tests { } #[test] - fn fr34_capabilities_are_ordered_and_namespaced() { + fn fr35_capabilities_are_ordered_and_namespaced() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); let foundation = xml.find("")); assert!(xml.contains("")); } #[test] - fn fr34_application_id_is_alphanumeric() { + fn fr35_application_id_is_alphanumeric() { assert_eq!(application_id("Ember"), "Ember"); assert_eq!(application_id("My App 2"), "MyApp2"); assert_eq!(application_id("2048"), "App"); diff --git a/tools/msix/src/metadata.rs b/tools/msix/src/metadata.rs index 2b867ae6..55c239ef 100644 --- a/tools/msix/src/metadata.rs +++ b/tools/msix/src/metadata.rs @@ -384,7 +384,7 @@ path = "src/main.rs" "#; #[test] - fn fr34_metadata_comes_from_dioxus_toml_and_cargo_toml() { + fn fr35_metadata_comes_from_dioxus_toml_and_cargo_toml() { let (d, c) = tables(NOTEPAD, CARGO); let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "0.1.0".into()).unwrap(); assert_eq!(m.display_name, "Notepad"); @@ -405,7 +405,7 @@ path = "src/main.rs" } #[test] - fn fr34_store_identity_overrides_the_derived_one() { + fn fr35_store_identity_overrides_the_derived_one() { let dioxus = format!( "{NOTEPAD}\n[windows.msix]\nidentity_name = \"DarkPyonix.Ember\"\npublisher = \"CN=1F2E3D4C-0000-1111-2222-333344445555\"\ndisplay_name = \"Ember\"\nlanguages = [\"en-us\", \"ko-kr\"]\n" ); @@ -419,7 +419,7 @@ path = "src/main.rs" } #[test] - fn fr34_windows_section_overrides_bundle() { + fn fr35_windows_section_overrides_bundle() { let dioxus = format!( "{NOTEPAD}\n[windows]\npublisher = \"Dark Pyonix, Inc.\"\nshort_description = \"Windows text\"\ncapabilities = [\"internetClient\"]\n" ); @@ -432,7 +432,7 @@ path = "src/main.rs" } #[test] - fn fr34_missing_publisher_is_an_error() { + fn fr35_missing_publisher_is_an_error() { let (d, c) = tables( "[application]\nname = \"X\"\n[bundle]\nidentifier = \"a.b.c\"\n", CARGO, @@ -443,7 +443,7 @@ path = "src/main.rs" } #[test] - fn fr34_identity_names_are_made_valid() { + fn fr35_identity_names_are_made_valid() { assert_eq!( identity_from_identifier("com.example.my_app").unwrap(), "com.example.my-app" @@ -460,14 +460,14 @@ path = "src/main.rs" } #[test] - fn fr34_publisher_must_be_a_distinguished_name() { + fn fr35_publisher_must_be_a_distinguished_name() { assert!(validate_publisher("CN=DarkPyonix").is_ok()); assert!(validate_publisher("O=Org, CN=Name").is_ok()); assert!(validate_publisher("DarkPyonix").is_err()); } #[test] - fn fr34_executable_follows_the_explicit_override() { + fn fr35_executable_follows_the_explicit_override() { let dioxus = format!("{NOTEPAD}\n[windows.msix]\nexecutable = \"ember\"\n"); let (d, c) = tables(&dioxus, CARGO); let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); @@ -475,7 +475,7 @@ path = "src/main.rs" } #[test] - fn fr34_the_real_samples_produce_valid_metadata() { + fn fr35_the_real_samples_produce_valid_metadata() { let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); let mut seen = 0; for entry in std::fs::read_dir(&samples).unwrap() { diff --git a/tools/msix/src/sdk.rs b/tools/msix/src/sdk.rs index 846def15..0b84aa7c 100644 --- a/tools/msix/src/sdk.rs +++ b/tools/msix/src/sdk.rs @@ -91,7 +91,7 @@ mod tests { use super::*; #[test] - fn fr34_sdk_versions_order_numerically() { + fn fr35_sdk_versions_order_numerically() { let mut v = [ sdk_version("10.0.9999.0").unwrap(), sdk_version("10.0.22621.0").unwrap(), diff --git a/tools/msix/src/version.rs b/tools/msix/src/version.rs index c78d748c..acac8356 100644 --- a/tools/msix/src/version.rs +++ b/tools/msix/src/version.rs @@ -137,44 +137,44 @@ mod tests { use super::*; #[test] - fn fr34_store_version_is_the_crate_version_with_revision_zero() { + fn fr35_store_version_is_the_crate_version_with_revision_zero() { let v = PackageVersion::from_semver("1.4.2", Channel::Store, None).unwrap(); assert_eq!(v.to_string(), "1.4.2.0"); } #[test] - fn fr34_store_refuses_a_nonzero_revision() { + fn fr35_store_refuses_a_nonzero_revision() { assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(7)).is_err()); assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(0)).is_ok()); } #[test] - fn fr34_store_refuses_a_prerelease() { + fn fr35_store_refuses_a_prerelease() { let err = PackageVersion::from_semver("1.4.2-beta.1", Channel::Store, None).unwrap_err(); assert!(err.to_string().contains("pre-release"), "{err}"); } #[test] - fn fr34_sideload_revision_is_the_build_number() { + fn fr35_sideload_revision_is_the_build_number() { let v = PackageVersion::from_semver("1.4.2-beta.1+abc", Channel::Sideload, Some(31)).unwrap(); assert_eq!(v.to_string(), "1.4.2.31"); } #[test] - fn fr34_build_metadata_is_ignored() { + fn fr35_build_metadata_is_ignored() { let v = PackageVersion::from_semver("2.0.1+git.abc", Channel::Store, None).unwrap(); assert_eq!(v.to_string(), "2.0.1.0"); } #[test] - fn fr34_parts_above_sixteen_bits_are_refused() { + fn fr35_parts_above_sixteen_bits_are_refused() { assert!(PackageVersion::from_semver("1.70000.0", Channel::Store, None).is_err()); assert!(PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(70_000)).is_err()); } #[test] - fn fr34_malformed_versions_are_refused() { + fn fr35_malformed_versions_are_refused() { for bad in ["1.0", "1.0.0.0", "a.b.c", "", "1..0"] { assert!( PackageVersion::from_semver(bad, Channel::Store, None).is_err(), @@ -184,7 +184,7 @@ mod tests { } #[test] - fn fr34_later_releases_map_to_greater_versions() { + fn fr35_later_releases_map_to_greater_versions() { let order = ["0.9.9", "1.0.0", "1.0.1", "1.1.0", "2.0.0"]; let mapped: Vec<_> = order .iter() @@ -198,7 +198,7 @@ mod tests { } #[test] - fn fr34_channel_names_parse() { + fn fr35_channel_names_parse() { assert_eq!(Channel::parse("store").unwrap(), Channel::Store); assert_eq!(Channel::parse("sideload").unwrap(), Channel::Sideload); assert!(Channel::parse("beta").is_err()); diff --git a/tools/msix/tests/stage.rs b/tools/msix/tests/stage.rs index ed134131..9107d1bb 100644 --- a/tools/msix/tests/stage.rs +++ b/tools/msix/tests/stage.rs @@ -34,7 +34,7 @@ fn plan(meta: AppMetadata, payload: Payload) -> Plan { } #[test] -fn fr34_a_single_executable_is_staged_with_manifest_and_images() { +fn fr35_a_single_executable_is_staged_with_manifest_and_images() { let dir = scratch("single"); let exe = dir.join("sample-notepad.exe"); std::fs::write(&exe, b"MZ not really").unwrap(); @@ -75,7 +75,7 @@ fn fr34_a_single_executable_is_staged_with_manifest_and_images() { } #[test] -fn fr34_a_payload_directory_is_copied_whole() { +fn fr35_a_payload_directory_is_copied_whole() { let dir = scratch("directory"); let payload = dir.join("payload"); std::fs::create_dir_all(payload.join("bin")).unwrap(); @@ -117,7 +117,7 @@ fn fr34_a_payload_directory_is_copied_whole() { } #[test] -fn fr34_a_payload_that_already_has_a_manifest_is_refused() { +fn fr35_a_payload_that_already_has_a_manifest_is_refused() { let dir = scratch("reserved"); let payload = dir.join("payload"); std::fs::create_dir_all(&payload).unwrap(); @@ -136,7 +136,7 @@ fn fr34_a_payload_that_already_has_a_manifest_is_refused() { } #[test] -fn fr34_a_missing_executable_is_refused() { +fn fr35_a_missing_executable_is_refused() { let dir = scratch("missing"); let meta = AppMetadata::load(¬epad()).unwrap(); let p = plan(meta, Payload::Executable(dir.join("nothing.exe"))); @@ -144,7 +144,7 @@ fn fr34_a_missing_executable_is_refused() { } #[test] -fn fr34_workspace_version_is_followed() { +fn fr35_workspace_version_is_followed() { let root = scratch("workspace"); let project = root.join("app"); std::fs::create_dir_all(&project).unwrap(); @@ -170,7 +170,7 @@ fn fr34_workspace_version_is_followed() { } #[test] -fn fr34_every_sample_stages() { +fn fr35_every_sample_stages() { let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); let dir = scratch("every"); let mut staged = 0; From fcf34d81514288b6705b6bf7f08ad4ab14a06be8 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:10 +0900 Subject: [PATCH 030/224] Feat: Embed a per-monitor v2 manifest in every Windows executable (FR-35) The certification kit reads DPI awareness from the executable's embedded manifest and reported every application as not DPI aware, because the renderer only declares it at run time. The crate's build script now writes the manifest as a compiled resource file and names it as a native library, which, unlike a linker argument, reaches the final link of every program that depends on the crate; the MSVC linker recognises it as a resource by its contents. The manifest declares PerMonitorV2, the awareness the renderer asks for, and asInvoker. On by default through the windows-manifest feature. An application that embeds its own manifest leaves the feature out, since two manifest resources fail the link. --- dioxus-compose/Cargo.toml | 9 +++- dioxus-compose/build.rs | 45 ++++++++++++++++ dioxus-compose/build/application.manifest | 16 ++++++ dioxus-compose/build/windows_manifest.rs | 62 ++++++++++++++++++++++ dioxus-compose/tests/windows_manifest.rs | 64 +++++++++++++++++++++++ 5 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 dioxus-compose/build/application.manifest create mode 100644 dioxus-compose/build/windows_manifest.rs create mode 100644 dioxus-compose/tests/windows_manifest.rs diff --git a/dioxus-compose/Cargo.toml b/dioxus-compose/Cargo.toml index 1dfc651f..700e27aa 100644 --- a/dioxus-compose/Cargo.toml +++ b/dioxus-compose/Cargo.toml @@ -33,11 +33,18 @@ bench = false # `default-features = false` for a headless or documentation build, and know that the # resulting binary says so on standard error and exits non-zero rather than opening no # window and returning 0. -default = ["native-renderer"] +default = ["native-renderer", "windows-manifest"] # Drives the boundary with no renderer at all, for tests and for embedding the Host in # something that draws for itself. Suppresses the renderer download. mock-renderer = [] native-renderer = [] +# Embeds an application manifest in every Windows executable built on this crate, +# declaring per-monitor v2 DPI awareness (the awareness the renderer asks for at startup) +# so that Windows, and the Store's certification checks, see it before any code runs. An +# application that embeds a manifest of its own leaves this out, because two manifest +# resources fail the link: `default-features = false, features = ["native-renderer"]`. +# Does nothing on other platforms. +windows-manifest = [] [dependencies] dioxus-core.workspace = true diff --git a/dioxus-compose/build.rs b/dioxus-compose/build.rs index 7ebeeca3..56a9fdad 100644 --- a/dioxus-compose/build.rs +++ b/dioxus-compose/build.rs @@ -8,6 +8,10 @@ mod renderer_dir { include!("build/renderer_dir.rs"); } +mod windows_manifest { + include!("build/windows_manifest.rs"); +} + use renderer_dir::{ CACHE_DIR_ENV, FetchError, RENDERER_DIR_ENV, RendererLinkage, RendererSource, Request, acquire_renderer, artifact_target, default_cache_root, renderer_linkage, @@ -48,6 +52,10 @@ fn main() { println!("cargo:rerun-if-changed=build/renderer_dir.rs"); println!("cargo:rerun-if-changed=build/sha256.rs"); println!("cargo:rerun-if-changed=build/elf.rs"); + println!("cargo:rerun-if-changed=build/windows_manifest.rs"); + println!("cargo:rerun-if-changed=build/application.manifest"); + + embed_application_manifest(); if std::env::var_os("CARGO_FEATURE_NATIVE_RENDERER").is_none() { return; @@ -316,6 +324,43 @@ fn main() { println!("cargo:rustc-link-arg={export_dynamic}"); } +/// Gives every Windows executable that links this crate the application manifest in +/// `build/application.manifest` (per-monitor v2 DPI awareness, `asInvoker`). +/// +/// A build script's link arguments stop at its own package, which is why this is not a +/// `/MANIFESTINPUT:` argument: it would reach this crate's examples and no application. +/// A native library does travel, to the final link of everything that depends on the +/// crate. So the manifest is written as a compiled resource file and named as one. The +/// file is called `.lib` because that is the name the link line asks for, and the MSVC +/// linker identifies its inputs by their contents, so it takes the file as the resource +/// it is and always includes it, where an object inside a real library would be dropped +/// for defining no symbol anyone asked for. +/// +/// An application that embeds a manifest of its own (with `/MANIFEST:EMBED`, or a +/// resource script through embed-resource or winres) would then have two, and the link +/// fails with CVT1100, duplicate resource MANIFEST. It turns this off by leaving the +/// `windows-manifest` feature out: +/// `dioxus-compose = { version = "...", default-features = false, features = ["native-renderer"] }`. +/// Its own manifest should then declare per-monitor v2 awareness itself. +fn embed_application_manifest() { + if std::env::var_os("CARGO_FEATURE_WINDOWS_MANIFEST").is_none() { + return; + } + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR")); + let resource = + windows_manifest::manifest_resource(windows_manifest::APPLICATION_MANIFEST.as_bytes()); + let path = out_dir.join("dioxus_compose_manifest.lib"); + std::fs::write(&path, resource) + .unwrap_or_else(|e| panic!("dioxus-compose: cannot write {}: {e}", path.display())); + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=dylib=dioxus_compose_manifest"); +} + /// Where downloads are cached. This is a property of the machine running the build, not /// of the target being built for, so it reads the host's own environment. fn cache_root() -> Option { diff --git a/dioxus-compose/build/application.manifest b/dioxus-compose/build/application.manifest new file mode 100644 index 00000000..3afbd67a --- /dev/null +++ b/dioxus-compose/build/application.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + true/pm + PerMonitorV2 + + + diff --git a/dioxus-compose/build/windows_manifest.rs b/dioxus-compose/build/windows_manifest.rs new file mode 100644 index 00000000..670654b8 --- /dev/null +++ b/dioxus-compose/build/windows_manifest.rs @@ -0,0 +1,62 @@ +// The application manifest every Windows program built on this crate carries, and the +// compiled resource file that carries it. +// +// Windows reads a program's DPI awareness from the manifest embedded in the executable, +// before any of its code runs. The renderer also asks for per-monitor v2 awareness at +// startup, and that works for drawing, but it is a request made at run time and so +// invisible to anything that inspects the file: the Windows App Certification Kit +// reports such a program as not DPI aware, which is what a Store submission is checked +// against. Declaring it in the manifest says the same thing earlier. Once a process has +// its awareness from the manifest, the renderer's own calls fail harmlessly (awareness +// cannot be changed after it is set), so the two agree rather than compete. +// +// The resource is written here rather than compiled with rc.exe. A `.res` file is a +// short fixed format, and writing it needs no Windows SDK on the build machine, so it +// works the same for a cross build through lld-link. +// +// Included from build.rs and from tests/windows_manifest.rs, which is how it is tested. + +/// The manifest: per-monitor v2 DPI awareness, the same awareness the renderer asks for, +/// and `asInvoker`, so Windows never guesses from a file name that the program is an +/// installer wanting elevation. +pub const APPLICATION_MANIFEST: &str = include_str!("application.manifest"); + +/// `RT_MANIFEST`. +const RT_MANIFEST: u16 = 24; +/// `CREATEPROCESS_MANIFEST_RESOURCE_ID`, the manifest the loader reads for an executable. +const MANIFEST_ID: u16 = 1; +/// MOVEABLE | PURE | DISCARDABLE, what rc.exe writes for a manifest. +const MEMORY_FLAGS: u16 = 0x1030; +/// English (United States), what rc.exe writes when the script names no language. +const LANGUAGE: u16 = 0x0409; + +fn entry(out: &mut Vec, kind: u16, name: u16, flags: u16, language: u16, data: &[u8]) { + let header_size: u32 = 32; + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(&header_size.to_le_bytes()); + // Type and name as ordinals: 0xFFFF followed by the number. + out.extend_from_slice(&0xFFFFu16.to_le_bytes()); + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&0xFFFFu16.to_le_bytes()); + out.extend_from_slice(&name.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // DataVersion + out.extend_from_slice(&flags.to_le_bytes()); + out.extend_from_slice(&language.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // Version + out.extend_from_slice(&0u32.to_le_bytes()); // Characteristics + out.extend_from_slice(data); + while out.len() % 4 != 0 { + out.push(0); + } +} + +/// A `.res` file holding `manifest` as the executable's manifest resource. +/// +/// It opens with the empty entry every `.res` file starts with, which is also how the +/// MSVC linker recognises the file as a resource file whatever it is named. +pub fn manifest_resource(manifest: &[u8]) -> Vec { + let mut out = Vec::with_capacity(64 + manifest.len() + 3); + entry(&mut out, 0, 0, 0, 0, &[]); + entry(&mut out, RT_MANIFEST, MANIFEST_ID, MEMORY_FLAGS, LANGUAGE, manifest); + out +} diff --git a/dioxus-compose/tests/windows_manifest.rs b/dioxus-compose/tests/windows_manifest.rs new file mode 100644 index 00000000..f27530a1 --- /dev/null +++ b/dioxus-compose/tests/windows_manifest.rs @@ -0,0 +1,64 @@ +//! The manifest every Windows executable built on this crate carries, and the resource +//! file the build script writes it into. +//! +//! The build script is never compiled for test, so the module it includes is included +//! here as well, and the bytes it would hand the linker are checked field by field. + +mod windows_manifest { + include!("../build/windows_manifest.rs"); +} + +use windows_manifest::{APPLICATION_MANIFEST, manifest_resource}; + +fn u16_at(bytes: &[u8], at: usize) -> u16 { + u16::from_le_bytes([bytes[at], bytes[at + 1]]) +} + +fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +#[test] +fn fr35_the_manifest_declares_per_monitor_v2_awareness() { + // Both elements: `dpiAwareness` is what Windows 10 1607 and later read, `dpiAware` + // with `/pm` is the fallback for anything older, and the certification kit accepts + // either. + assert!(APPLICATION_MANIFEST.contains( + "PerMonitorV2" + )); + assert!(APPLICATION_MANIFEST.contains( + "true/pm" + )); + assert!(APPLICATION_MANIFEST.contains("level=\"asInvoker\"")); + assert!(APPLICATION_MANIFEST.starts_with("")); +} + +#[test] +fn fr35_the_resource_file_starts_with_the_empty_entry() { + let res = manifest_resource(b""); + // DataSize 0, HeaderSize 32, type ordinal 0, name ordinal 0, and zeros after. The + // linker recognises a resource file by exactly these 32 bytes. + let mut expected = vec![0u8; 32]; + expected[4] = 0x20; + expected[8..12].copy_from_slice(&[0xFF, 0xFF, 0, 0]); + expected[12..16].copy_from_slice(&[0xFF, 0xFF, 0, 0]); + assert_eq!(&res[..32], &expected[..]); +} + +#[test] +fn fr35_the_resource_file_holds_the_manifest_as_resource_one() { + let manifest = APPLICATION_MANIFEST.as_bytes(); + let res = manifest_resource(manifest); + let e = 32; + assert_eq!(u32_at(&res, e) as usize, manifest.len(), "DataSize"); + assert_eq!(u32_at(&res, e + 4), 32, "HeaderSize"); + assert_eq!(u16_at(&res, e + 8), 0xFFFF); + assert_eq!(u16_at(&res, e + 10), 24, "RT_MANIFEST"); + assert_eq!(u16_at(&res, e + 12), 0xFFFF); + assert_eq!(u16_at(&res, e + 14), 1, "the executable's manifest id"); + assert_eq!(u16_at(&res, e + 22), 0x0409, "language"); + assert_eq!(&res[e + 32..e + 32 + manifest.len()], manifest); + assert_eq!(res.len() % 4, 0, "entries are padded to four bytes"); + assert!(res.len() - (e + 32 + manifest.len()) < 4); +} From e2816f00824e9e2f03c9aa127337c356b406d399 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:10 +0900 Subject: [PATCH 031/224] Feat: Explain each blocked-executables finding in the WACK output The kit names only a file and a short string. The script now finds those bytes in the package and prints the offset, the encoding, the surrounding text and whether it is a standalone name, part of a longer word or binary data, and writes the same to a findings file beside the report and to the step summary. It also takes -RequirePass for tests that must pass outright. --- tools/msix/scripts/run-wack.ps1 | 166 ++++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 6 deletions(-) diff --git a/tools/msix/scripts/run-wack.ps1 b/tools/msix/scripts/run-wack.ps1 index aa5aa2bb..bdb89664 100644 --- a/tools/msix/scripts/run-wack.ps1 +++ b/tools/msix/scripts/run-wack.ps1 @@ -11,18 +11,31 @@ with a certificate this machine trusts first, as a machine installing it would. Warnings and failures of tests the kit marks optional do not fail the run; they are printed, and listed in the step summary, so that they are read. The Store accepts a -package with them. A required test failing fails the run. +package with them. A required test failing fails the run, and so does any test named in +-RequirePass that did not pass. + +The optional "Blocked executables" test reports only a file name and a short string. +That is not enough to answer a reviewer who asks about it, so for each such finding this +script opens the package, finds the bytes in the named file, and prints where they are +and what surrounds them: whether the string is an imported function, part of a longer +word, or a few bytes of compressed data that happen to spell a blocked name. .PARAMETER Package The signed .msix or .msixbundle. .PARAMETER Report -Where the XML report is written. +Where the XML report is written. The explanations go beside it, in +.findings.txt. + +.PARAMETER RequirePass +Names of tests that must pass outright, not merely avoid failing (for example +DPIAwarenessValidation, which is otherwise only a warning). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Package, - [Parameter(Mandatory)][string]$Report + [Parameter(Mandatory)][string]$Report, + [string[]]$RequirePass = @() ) $ErrorActionPreference = 'Stop' @@ -41,15 +54,126 @@ if (-not (Test-Path $Report)) { throw "the certification kit wrote no report (exit $exit)" } +# --- Explaining "Blocked executables" findings ------------------------------------------- + +$script:unpacked = $null + +function Get-UnpackedPackage { + if ($script:unpacked) { return $script:unpacked } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("wack-" + [guid]::NewGuid().ToString('N')) + [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $Package).Path, $root) + # A bundle holds packages; open those too so a file name finds the file inside them. + Get-ChildItem -Path $root -Filter '*.msix' -File | ForEach-Object { + [System.IO.Compression.ZipFile]::ExtractToDirectory($_.FullName, (Join-Path $root $_.BaseName)) + } + $script:unpacked = $root + return $root +} + +function Find-PackageFile([string]$name) { + $root = Get-UnpackedPackage + $leaf = Split-Path -Leaf $name + $candidates = @(Get-ChildItem -Path $root -Recurse -File -Filter $leaf) + $suffix = $name -replace '/', '\' + $exact = @($candidates | Where-Object { $_.FullName.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) }) + if ($exact.Count -gt 0) { return $exact[0] } + if ($candidates.Count -gt 0) { return $candidates[0] } + return $null +} + +function Format-Context([byte[]]$bytes, [int]$at, [int]$length, [int]$step) { + # Printable ASCII as itself, everything else as a dot. $step is 2 for UTF-16 text, + # where every other byte is the zero half of a character. + $from = [Math]::Max(0, $at - 32 * $step) + $to = [Math]::Min($bytes.Length, $at + ($length + 32) * $step) + $sb = [System.Text.StringBuilder]::new() + for ($i = $from; $i -lt $to; $i += $step) { + if ($i -eq $at) { [void]$sb.Append('[') } + if ($i -eq $at + $length * $step) { [void]$sb.Append(']') } + $b = $bytes[$i] + if ($b -ge 0x20 -and $b -lt 0x7F) { [void]$sb.Append([char]$b) } else { [void]$sb.Append('.') } + } + return $sb.ToString() +} + +function Get-Occurrences([byte[]]$bytes, [string]$needle) { + $latin1 = [System.Text.Encoding]::Latin1 + $haystack = $latin1.GetString($bytes) + $found = @() + foreach ($encoding in @(@{ Name = 'ASCII'; Step = 1; Text = $needle }, + @{ Name = 'UTF-16'; Step = 2; Text = $latin1.GetString([System.Text.Encoding]::Unicode.GetBytes($needle)) })) { + $at = 0 + while ($found.Count -lt 3) { + $at = $haystack.IndexOf($encoding.Text, $at, [StringComparison]::Ordinal) + if ($at -lt 0) { break } + $found += [pscustomobject]@{ Offset = $at; Encoding = $encoding.Name; Step = $encoding.Step } + $at += 1 + } + } + return $found +} + +function Get-Kind([byte[]]$bytes, $hit, [int]$length) { + # A string that is a whole word between non-letters reads as a name; one with letters + # on either side is part of a longer word; one with no text around it is data. + $before = if ($hit.Offset - $hit.Step -ge 0) { $bytes[$hit.Offset - $hit.Step] } else { 0 } + $afterAt = $hit.Offset + $length * $hit.Step + $after = if ($afterAt -lt $bytes.Length) { $bytes[$afterAt] } else { 0 } + $isLetter = { param($b) ($b -ge 0x41 -and $b -le 0x5A) -or ($b -ge 0x61 -and $b -le 0x7A) } + $isText = { param($b) $b -ge 0x20 -and $b -lt 0x7F } + if ((& $isLetter $before) -or (& $isLetter $after)) { return 'inside a longer word' } + if (-not (& $isText $before) -and -not (& $isText $after)) { return 'binary data that happens to spell it' } + return 'a standalone name' +} + +function Explain-Message([string]$text) { + $lines = @() + if ($text -match '^File (.+?) contains a reference to a "Launch Process" related API (.+?)!(\S+)') { + $file = $Matches[1]; $dll = $Matches[2]; $api = $Matches[3] + $lines += " $file imports $api from $dll. The kit flags any import of a process-launch API; it does not mean the function is called." + $found = Find-PackageFile $file + if ($found) { + $bytes = [System.IO.File]::ReadAllBytes($found.FullName) + foreach ($hit in (Get-Occurrences $bytes $api | Select-Object -First 1)) { + $lines += (' at 0x{0:X} ({1}): {2}' -f $hit.Offset, $hit.Encoding, (Format-Context $bytes $hit.Offset $api.Length $hit.Step)) + } + } + } elseif ($text -match '^File (.+?) contains a blocked executable reference to "(.+?)"') { + $file = $Matches[1]; $needle = $Matches[2] + $found = Find-PackageFile $file + if (-not $found) { + $lines += " $file is not in the package as named; nothing to show." + } else { + $bytes = [System.IO.File]::ReadAllBytes($found.FullName) + $hits = @(Get-Occurrences $bytes $needle) + if ($hits.Count -eq 0) { + $lines += " `"$needle`" does not occur verbatim in $file; the kit matched it ignoring case or in a form this script does not search." + } + foreach ($hit in $hits) { + $kind = Get-Kind $bytes $hit $needle.Length + $lines += (' "{0}" in {1} at 0x{2:X} ({3}, {4}): {5}' -f $needle, $file, $hit.Offset, $hit.Encoding, $kind, + (Format-Context $bytes $hit.Offset $needle.Length $hit.Step)) + } + } + } + return $lines +} + +# --- Reading the report ------------------------------------------------------------------ + [xml]$xml = Get-Content -Raw -Path $Report $overall = $xml.REPORT.OVERALL_RESULT $failed = @() $optionalFailed = @() +$notPassed = @{} +$findings = @() foreach ($test in $xml.SelectNodes('//TEST')) { $resultNode = $test.SelectSingleNode('RESULT') $result = if ($resultNode) { $resultNode.InnerText.Trim() } else { '' } $optional = $test.OPTIONAL -eq 'TRUE' $label = if ($optional) { "$result (optional)" } else { $result } + if ($result -ne 'PASS') { $notPassed[$test.NAME] = $result } if ($result -eq 'FAIL' -and -not $optional) { $failed += $test Write-Host "::error title=WACK::$($test.NAME)" @@ -62,15 +186,42 @@ foreach ($test in $xml.SelectNodes('//TEST')) { Write-Host ('{0,-18} {1}' -f $label, $test.NAME) foreach ($message in $test.SelectNodes('MESSAGES/MESSAGE')) { Write-Host " $($message.TEXT)" + if ($result -ne 'PASS') { + $explained = @(Explain-Message $message.TEXT) + foreach ($line in $explained) { Write-Host " $line" } + if ($explained.Count -gt 0) { + $findings += "$($test.NAME): $($message.TEXT)" + $findings += $explained + } + } } } + +if ($findings.Count -gt 0) { + $findingsPath = "$Report.findings.txt" + $findings | Set-Content -Path $findingsPath -Encoding utf8 + Write-Host "explanations written to $findingsPath" +} +if ($script:unpacked) { Remove-Item -Recurse -Force $script:unpacked -ErrorAction SilentlyContinue } + Write-Host "overall result: $overall (appcert exit $exit)" if ($env:GITHUB_STEP_SUMMARY) { - $line = "WACK on $(Split-Path -Leaf $Package): **$overall**" + $summary = @("WACK on $(Split-Path -Leaf $Package): **$overall**") if ($optionalFailed.Count -gt 0) { - $line += " (optional tests failed: $(($optionalFailed | ForEach-Object { $_.NAME }) -join ', '))" + $summary[0] += " (optional tests failed: $(($optionalFailed | ForEach-Object { $_.NAME }) -join ', '))" + } + if ($findings.Count -gt 0) { + $summary += '' + $summary += '```' + $summary += $findings + $summary += '```' } - Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $line -Encoding utf8 + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 +} + +$missing = @($RequirePass | Where-Object { $notPassed.ContainsKey($_) }) +foreach ($name in $missing) { + Write-Host "::error title=WACK::$name is $($notPassed[$name]), and this run requires it to pass" } # The kit marks some tests optional. Their failures make the overall result WARNING, # and the Store accepts a package with them; a required test failing is a FAIL overall @@ -78,3 +229,6 @@ if ($env:GITHUB_STEP_SUMMARY) { if ($failed.Count -gt 0 -or ($overall -ne 'PASS' -and $overall -ne 'WARNING')) { throw "the certification kit reported $overall with $($failed.Count) failing required test(s)" } +if ($missing.Count -gt 0) { + throw "required to pass and did not: $($missing -join ', ')" +} From 5382a585a235c942c2f1f49381734e01ee0f9cdc Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:10 +0900 Subject: [PATCH 032/224] Chore: Build a sample host on the runner and require it to pass DPI awareness A new job builds the notepad sample from this checkout against the released renderer, shows the manifest embedded in it, packages it beside the renderer and runs the certification kit with DPIAwarenessValidation required to pass. The test job also checks the manifest resource bytes. --- .github/workflows/package-windows.yml | 109 ++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index db3f6032..dbecf5a7 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -11,6 +11,10 @@ # stand-in a tiny windowed program built here, described by the notepad sample's # metadata. It proves the pipeline in minutes and owes nothing to the # renderer. +# host a sample built here from this checkout, against the renderer from that +# same release, so that what the crate now puts into every executable (its +# application manifest) is in the package the kit inspects. This one has +# to pass the kit's DPI awareness check outright. # sample a real build: the program with the renderer and its companion files # beside it, the shape a dioxus-compose application ships in. By default the # notepad archive from a published sample release; dispatched with a run id, @@ -26,10 +30,16 @@ on: push: paths: - 'tools/msix/**' + - 'dioxus-compose/build.rs' + - 'dioxus-compose/build/**' + - 'dioxus-compose/tests/windows_manifest.rs' - '.github/workflows/package-windows.yml' pull_request: paths: - 'tools/msix/**' + - 'dioxus-compose/build.rs' + - 'dioxus-compose/build/**' + - 'dioxus-compose/tests/windows_manifest.rs' - '.github/workflows/package-windows.yml' workflow_dispatch: inputs: @@ -103,6 +113,12 @@ jobs: - name: Tests run: cargo test --locked -p dioxus-compose-msix + - name: Application manifest resource + # The resource the crate's build script writes into every Windows program. The + # renderer is not needed to check its bytes, so the crate is built without it. + if: runner.os == 'Linux' + run: cargo test --locked -p dioxus-compose --no-default-features --test windows_manifest + package: name: Package, sign and certify (${{ matrix.payload }}) needs: test @@ -216,6 +232,99 @@ jobs: ${{ runner.temp }}/cert/test-signing.cer if-no-files-found: warn + host: + name: Build a sample here, package and certify it + needs: test + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Fetch a released renderer + # The renderer is not built here. The released notepad archive carries the + # renderer DLL, its import library and the files AWT reads, which is everything + # the crate needs to link a host against. It predates schema hashes, so the build + # warns that it cannot tell whether the two agree; the drawing is not what this + # job checks, the executable and its manifest are. + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path payload-artifact | Out-Null + Invoke-WebRequest -Uri $env:PAYLOAD_URL -OutFile "payload-artifact\$env:PAYLOAD_ARCHIVE" + $unpacked = Join-Path $env:RUNNER_TEMP 'renderer' + New-Item -ItemType Directory -Force -Path $unpacked | Out-Null + tar -xzf "payload-artifact\$env:PAYLOAD_ARCHIVE" -C $unpacked + if ($LASTEXITCODE -ne 0) { throw "could not unpack $env:PAYLOAD_ARCHIVE" } + Add-Content -Path $env:GITHUB_ENV -Value "DIOXUS_COMPOSE_RENDERER_DIR=$(Join-Path $unpacked $env:PAYLOAD_ROOT)" -Encoding utf8 + + - name: Build the packager and the notepad sample + run: | + cargo build --locked --release -p dioxus-compose-msix --bin dioxus-compose-msix + if ($LASTEXITCODE -ne 0) { throw "the packager did not build" } + cargo build --locked --release -p sample-notepad --bin sample-notepad + if ($LASTEXITCODE -ne 0) { throw "the sample did not build" } + + - name: Show the manifest embedded in the sample + run: | + $ErrorActionPreference = 'Stop' + . ./tools/msix/scripts/common.ps1 + $mt = Find-SdkTool 'mt.exe' + $exe = 'target\release\sample-notepad.exe' + & $mt -nologo "-inputresource:$exe;#1" "-out:$env:RUNNER_TEMP\embedded.manifest" + if ($LASTEXITCODE -ne 0) { throw "sample-notepad.exe has no embedded manifest" } + $text = Get-Content -Raw "$env:RUNNER_TEMP\embedded.manifest" + Write-Host $text + if ($text -notmatch 'PerMonitorV2') { throw "the embedded manifest does not declare PerMonitorV2" } + + - name: Lay the sample out beside the renderer and package it + id: pack + run: | + $ErrorActionPreference = 'Stop' + $payload = Join-Path $env:RUNNER_TEMP 'host-payload' + New-Item -ItemType Directory -Force -Path $payload | Out-Null + foreach ($child in 'bin', 'lib') { + Copy-Item -Recurse -Path (Join-Path $env:DIOXUS_COMPOSE_RENDERER_DIR $child) -Destination $payload + } + Copy-Item -Force 'target\release\sample-notepad.exe' (Join-Path $payload 'bin\sample-notepad.exe') + $out = Join-Path $env:RUNNER_TEMP 'msix' + $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' + & 'target\release\dioxus-compose-msix.exe' --project $env:PROJECT --out $out --summary $summary ` + --test-identity --payload-dir $payload --exe 'bin\sample-notepad.exe' + if ($LASTEXITCODE -ne 0) { throw "packaging failed" } + Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + + - name: Create and trust a test certificate + id: cert + run: > + ./tools/msix/scripts/new-test-certificate.ps1 + -Publisher '${{ steps.pack.outputs.publisher }}' + -OutDir "$env:RUNNER_TEMP\cert" -Trust + + - name: Sign + run: > + ./tools/msix/scripts/sign.ps1 + -Path '${{ steps.pack.outputs.package }}','${{ steps.pack.outputs.bundle }}' + -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' + + - name: Windows App Certification Kit (DPI awareness must pass) + run: > + ./tools/msix/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.bundle }}' + -Report "$env:RUNNER_TEMP\wack\host-bundle-report.xml" + -RequirePass DPIAwarenessValidation + + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: wack-reports-host + path: ${{ runner.temp }}/wack/ + if-no-files-found: warn + update: name: App Installer update needs: test From ff316d351db1556c16c0d292faeab869f5fb894e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:17:51 +0900 Subject: [PATCH 033/224] Fix: Keep the renderer in lib/ beside the executable AWT looks for its companions in the parent of the renderer's directory plus lib, so a renderer flattened beside the executable stops at libjawt.so (seen on aarch64 in run 37079403998). The Flatpak build keeps the renderer's lib/ and points the executable at $ORIGIN/lib; a prebuilt executable with no rpath gets $ORIGIN:$ORIGIN/lib, and AppRun puts both directories on LD_LIBRARY_PATH. --- tools/packager-linux/src/appimage.rs | 6 ++-- tools/packager-linux/src/flatpak.rs | 11 +++---- tools/packager-linux/tests/metadata.rs | 41 +++++++++++++++++++++----- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/tools/packager-linux/src/appimage.rs b/tools/packager-linux/src/appimage.rs index 8ec7b343..65b0c660 100644 --- a/tools/packager-linux/src/appimage.rs +++ b/tools/packager-linux/src/appimage.rs @@ -132,9 +132,9 @@ pub fn apprun(meta: &AppMetadata) -> String { # file's directory lets the unpacked AppDir run too.\n\ APPDIR=\"${{APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}}\"\n\ export APPDIR\n\ - # The renderer sits beside the executable. An executable built with an rpath of\n\ - # $ORIGIN finds it on its own; this also covers one built without.\n\ - LD_LIBRARY_PATH=\"$APPDIR/usr/lib/{exec}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"\n\ + # The renderer sits in lib/ beside the executable (or, in older packages, beside\n\ + # it). An executable with an rpath finds it on its own; this covers one without.\n\ + LD_LIBRARY_PATH=\"$APPDIR/usr/lib/{exec}/lib:$APPDIR/usr/lib/{exec}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"\n\ export LD_LIBRARY_PATH\n\ exec \"$APPDIR/usr/lib/{exec}/{exec}\" \"$@\"\n", name = meta.name.replace('\n', " "), diff --git a/tools/packager-linux/src/flatpak.rs b/tools/packager-linux/src/flatpak.rs index 3ce471ba..0a054ed5 100644 --- a/tools/packager-linux/src/flatpak.rs +++ b/tools/packager-linux/src/flatpak.rs @@ -117,7 +117,7 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { // A release archive whose executable was built without an rpath only finds the // renderer beside it when told to look there. commands.push(format!( - "readelf -d {lib}/{exec} | grep -qE '\\((RPATH|RUNPATH)\\)' || patchelf --set-rpath '$ORIGIN' {lib}/{exec}", + "readelf -d {lib}/{exec} | grep -qE '\\((RPATH|RUNPATH)\\)' || patchelf --set-rpath '$ORIGIN:$ORIGIN/lib' {lib}/{exec}", exec = meta.exec )); } @@ -171,10 +171,11 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { commands.push(build); commands.push(format!("mkdir -p {lib} /app/bin")); if !renderer.is_empty() { - // The renderer's files go beside the executable, the layout it finds its - // companions from. A release archive keeps them in `lib/`. + // The renderer keeps its own `lib` directory beside the executable. AWT + // looks for its companions in the parent of the renderer's directory plus + // `lib`, so the files flattened beside the executable are not found. commands.push(format!( - "if [ -d renderer/lib ]; then cp -a renderer/lib/. {lib}/; else cp -a renderer/. {lib}/; fi" + "mkdir -p {lib}/lib && if [ -d renderer/lib ]; then cp -a renderer/lib/. {lib}/lib/; else cp -a renderer/. {lib}/lib/; fi" )); } commands.push(format!( @@ -188,7 +189,7 @@ pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { "recorded=\"$(readelf -d {lib}/{exec} | sed -n 's/.*(NEEDED).*\\[\\(.*libdioxus_compose_renderer.so\\)\\]/\\1/p')\"; \ if [ -n \"$recorded\" ] && [ \"$recorded\" != libdioxus_compose_renderer.so ]; then \ patchelf --replace-needed \"$recorded\" libdioxus_compose_renderer.so {lib}/{exec}; fi; \ - patchelf --set-rpath '$ORIGIN' {lib}/{exec}", + patchelf --set-rpath '$ORIGIN/lib' {lib}/{exec}", exec = meta.exec )); } diff --git a/tools/packager-linux/tests/metadata.rs b/tools/packager-linux/tests/metadata.rs index 78432324..a7aa4ba6 100644 --- a/tools/packager-linux/tests/metadata.rs +++ b/tools/packager-linux/tests/metadata.rs @@ -19,7 +19,7 @@ use packager_linux::flatpak::{ shell_quote, }; use packager_linux::icons::{Icon, IconKind, best, check_for_flathub, png_size}; -use packager_linux::metadata::{check_app_id, paragraphs, release_date}; +use packager_linux::metadata::{check_app_id, check_screenshot_url, paragraphs, release_date}; use packager_linux::{AppMetadata, BuildFacts}; const DIOXUS_TOML: &str = r##" @@ -436,7 +436,7 @@ fn fr35_apprun_starts_the_payload_from_the_mount_point() { assert!(script.starts_with("#!/bin/sh\n")); assert!(script.contains("APPDIR=\"${APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}\"\n")); assert!(script.contains( - "LD_LIBRARY_PATH=\"$APPDIR/usr/lib/sample-calculator${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\"\nexport LD_LIBRARY_PATH\n" + "LD_LIBRARY_PATH=\"$APPDIR/usr/lib/sample-calculator/lib:$APPDIR/usr/lib/sample-calculator${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\"\nexport LD_LIBRARY_PATH\n" )); assert!( script.ends_with("exec \"$APPDIR/usr/lib/sample-calculator/sample-calculator\" \"$@\"\n") @@ -459,7 +459,12 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { let payload = dir.join("payload"); std::fs::create_dir_all(&payload).unwrap(); std::fs::write(payload.join("sample-calculator"), b"#!/bin/sh\necho hi\n").unwrap(); - std::fs::write(payload.join("libdioxus_compose_renderer.so"), b"\x7fELF").unwrap(); + std::fs::create_dir_all(payload.join("lib")).unwrap(); + std::fs::write( + payload.join("lib/libdioxus_compose_renderer.so"), + b"\x7fELF", + ) + .unwrap(); let updater = dir.join("squashfs-root"); std::fs::create_dir_all(updater.join("usr/bin")).unwrap(); std::fs::write(updater.join("usr/bin/appimageupdatetool"), b"tool").unwrap(); @@ -490,7 +495,7 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { "usr/share/metainfo/dev.example.Calculator.appdata.xml", "usr/share/icons/hicolor/256x256/apps/dev.example.Calculator.png", "usr/lib/sample-calculator/sample-calculator", - "usr/lib/sample-calculator/libdioxus_compose_renderer.so", + "usr/lib/sample-calculator/lib/libdioxus_compose_renderer.so", ] { assert!(out.join(path).is_file(), "missing {path}"); } @@ -634,7 +639,7 @@ fn fr35_flatpak_manifest_for_a_prebuilt_archive() { // An executable without an rpath is pointed at the renderer beside it. assert!(text.contains("\"name\": \"patchelf\""), "{text}"); assert!( - text.contains("readelf -d /app/lib/sample-calculator/sample-calculator | grep -qE '\\\\((RPATH|RUNPATH)\\\\)' || patchelf --set-rpath '$ORIGIN' /app/lib/sample-calculator/sample-calculator"), + text.contains("readelf -d /app/lib/sample-calculator/sample-calculator | grep -qE '\\\\((RPATH|RUNPATH)\\\\)' || patchelf --set-rpath '$ORIGIN:$ORIGIN/lib' /app/lib/sample-calculator/sample-calculator"), "{text}" ); assert!(!text.contains("cargo"), "{text}"); @@ -678,10 +683,10 @@ fn fr35_flatpak_manifest_builds_from_source_offline() { "\"cargo --offline fetch --manifest-path Cargo.toml --verbose\"", "\"cargo --offline build --release --locked -p sample-calculator --bin sample-calculator --no-default-features 'it'\\\\''s'\"", "\"install -m755 target/release/sample-calculator /app/lib/sample-calculator/sample-calculator\"", - "\"if [ -d renderer/lib ]; then cp -a renderer/lib/. /app/lib/sample-calculator/; else cp -a renderer/. /app/lib/sample-calculator/; fi\"", + "\"mkdir -p /app/lib/sample-calculator/lib && if [ -d renderer/lib ]; then cp -a renderer/lib/. /app/lib/sample-calculator/lib/; else cp -a renderer/. /app/lib/sample-calculator/lib/; fi\"", "\"name\": \"patchelf\"", "\"buildsystem\": \"autotools\"", - "patchelf --set-rpath '$ORIGIN' /app/lib/sample-calculator/sample-calculator", + "patchelf --set-rpath '$ORIGIN/lib' /app/lib/sample-calculator/sample-calculator", "\"--socket=wayland\"", "\"--socket=fallback-x11\"", ] { @@ -721,3 +726,25 @@ fn fr35_release_dates_are_reproducible() { assert_eq!(release_date(Some("not a number"), 86_400), "1970-01-02"); assert_eq!(paragraphs("a\nb\n\n\n c "), ["a b", "c"]); } + +#[test] +fn fr35_screenshots_are_pinned_to_a_tag_or_commit() { + for good in [ + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/v1.0.0/docs/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/refs/tags/v1.0.0/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/0123456789abcdef0123456789abcdef01234567/shot.png", + "https://example.dev/shot.png", + ] { + check_screenshot_url(good).unwrap_or_else(|e| panic!("{good}: {e}")); + } + for bad in [ + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/main/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/refs/heads/v1/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/shot.png", + "ftp://example.dev/shot.png", + ] { + assert!(check_screenshot_url(bad).is_err(), "{bad}"); + } + let overlay = "[linux.store]\nscreenshots = [{ url = \"https://raw.githubusercontent.com/a/b/develop/s.png\" }]"; + assert!(resolve_error(DIOXUS_TOML, &[overlay]).contains("pin it to a tag")); +} From 8161f2ec14f6fc6e6c9cfdf67a8e3d33ccb05540 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:17:52 +0900 Subject: [PATCH 034/224] Feat: Take store screenshots on the command line, pinned to a tag --screenshot and --screenshot-caption replace [linux.store] screenshots. A raw.githubusercontent.com URL that follows a branch is refused, because the listing would show whatever the branch holds next (FR-35). --- tools/packager-linux/src/main.rs | 30 ++++++++++++++++++-- tools/packager-linux/src/metadata.rs | 42 +++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/tools/packager-linux/src/main.rs b/tools/packager-linux/src/main.rs index 343181d3..1d1b5b14 100644 --- a/tools/packager-linux/src/main.rs +++ b/tools/packager-linux/src/main.rs @@ -13,7 +13,9 @@ use packager_linux::appstream::{metainfo, metainfo_file_name}; use packager_linux::desktop::{desktop_entry, desktop_file_name}; use packager_linux::flatpak::{FlatpakOptions, Payload, SourceRef, icon_source_path, manifest}; use packager_linux::icons::{Icon, check_for_flathub}; -use packager_linux::metadata::{AppMetadata, BuildFacts, release_date}; +use packager_linux::metadata::{ + AppMetadata, BuildFacts, Screenshot, check_screenshot_url, release_date, +}; const USAGE: &str = "\ usage: packager-linux --dioxus-toml [common options] [command options] @@ -34,6 +36,10 @@ common options: --icon a square PNG or an SVG (repeatable); overrides [bundle] icon --arch x86_64 or aarch64; defaults to this machine's --app-id overrides the identifier, for a store listing under another ID + --screenshot a screenshot for the store listing (repeatable, first is the + default); replaces [linux.store] screenshots. A raw GitHub URL + must be pinned to a tag or a commit + --screenshot-caption a caption for each --screenshot, in the same order appdir: --payload the executable, or a directory holding it and its renderer @@ -138,6 +144,8 @@ const COMMON: &[&str] = &[ "icon", "arch", "app-id", + "screenshot", + "screenshot-caption", ]; fn run(args: &[String]) -> Result<(), String> { @@ -288,7 +296,25 @@ fn load(options: &Options) -> Result<(AppMetadata, Vec), String> { date, exec: options.one("exec")?, }; - let meta = AppMetadata::resolve(&text, &overlay_refs, &facts).map_err(|e| e.to_string())?; + let mut meta = AppMetadata::resolve(&text, &overlay_refs, &facts).map_err(|e| e.to_string())?; + let shots = options.all("screenshot"); + let captions = options.all("screenshot-caption"); + if !captions.is_empty() && captions.len() != shots.len() { + return Err("give --screenshot-caption for every --screenshot or for none".into()); + } + if !shots.is_empty() { + meta.screenshots = shots + .into_iter() + .enumerate() + .map(|(index, url)| { + check_screenshot_url(&url).map_err(|e| e.to_string())?; + Ok(Screenshot { + url, + caption: captions.get(index).cloned(), + }) + }) + .collect::>()?; + } let given = options.all("icon"); let paths: Vec = if given.is_empty() { diff --git a/tools/packager-linux/src/metadata.rs b/tools/packager-linux/src/metadata.rs index b3186506..871b025f 100644 --- a/tools/packager-linux/src/metadata.rs +++ b/tools/packager-linux/src/metadata.rs @@ -288,7 +288,7 @@ impl AppMetadata { } } for shot in &linux.screenshots { - check_url("screenshots.url", &shot.url)?; + check_screenshot_url(&shot.url)?; } let developer_name = linux @@ -475,6 +475,46 @@ fn check_date(date: &str) -> Result<(), MetadataError> { Ok(()) } +/// A screenshot URL a store can keep showing. Flathub copies the image when it builds, but +/// a listing that points at a branch shows whatever the branch holds next, so a raw GitHub +/// URL has to name a tag or a commit. +pub fn check_screenshot_url(url: &str) -> Result<(), MetadataError> { + check_url("screenshots.url", url)?; + let Some(rest) = url + .strip_prefix("https://raw.githubusercontent.com/") + .or_else(|| url.strip_prefix("http://raw.githubusercontent.com/")) + else { + return Ok(()); + }; + let parts: Vec<&str> = rest.split('/').collect(); + // owner / repository / ref / path..., or owner / repository / refs/tags/ / path... + let reference = match parts.as_slice() { + [_, _, "refs", "tags", tag, _, ..] => return check_tag(url, tag), + [_, _, "refs", "heads", branch, _, ..] => branch, + [_, _, reference, _, ..] => reference, + _ => { + return error(format!( + "the screenshot `{url}` is not owner/repository/ref/path on raw.githubusercontent.com" + )); + } + }; + if ["main", "master", "develop", "HEAD", "trunk"].contains(reference) + || url.contains("/refs/heads/") + { + return error(format!( + "the screenshot `{url}` follows the branch `{reference}`; pin it to a tag or a commit" + )); + } + Ok(()) +} + +fn check_tag(url: &str, tag: &str) -> Result<(), MetadataError> { + if tag.is_empty() { + return error(format!("the screenshot `{url}` names an empty tag")); + } + Ok(()) +} + fn check_url(key: &str, url: &str) -> Result<(), MetadataError> { if !(url.starts_with("https://") || url.starts_with("http://")) || url.contains(' ') { return error(format!( From 3615a5e611f73b7adc0c546350e9eb8f6bef6950 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:17:52 +0900 Subject: [PATCH 035/224] Feat: Prepare the Flathub submission for dev.darkpyonix.Ember The owner chose dev.darkpyonix.Ember. flathub/dev.darkpyonix.Ember.toml is its listing and flathub/ember.sh generates the submission from an Ember release: archives and digests per architecture, icons and the screenshot pinned to the release tag. Nothing is submitted. README.md documents where updates come from and the two secrets the owner creates, with a section for the AppImage signing key's fingerprint. --- tools/packager-linux/FLATHUB.md | 97 ++++++++--------- tools/packager-linux/README.md | 80 ++++++++++++++ .../flathub/dev.darkpyonix.Ember.toml | 34 ++++++ tools/packager-linux/flathub/ember.sh | 100 ++++++++++++++++++ 4 files changed, 261 insertions(+), 50 deletions(-) create mode 100644 tools/packager-linux/README.md create mode 100644 tools/packager-linux/flathub/dev.darkpyonix.Ember.toml create mode 100755 tools/packager-linux/flathub/ember.sh diff --git a/tools/packager-linux/FLATHUB.md b/tools/packager-linux/FLATHUB.md index b8090771..84ee5809 100644 --- a/tools/packager-linux/FLATHUB.md +++ b/tools/packager-linux/FLATHUB.md @@ -1,67 +1,64 @@ -# Submitting to Flathub +# Submitting Ember to Flathub -Everything Flathub reviews is generated by `tools/packager-linux/flatpak.sh`. The -`Linux packaging` workflow generates it for the calculator sample under the placeholder ID -`dev.darkpyonix.PlaceholderAppId` and uploads it as the `flathub-submission-placeholder` -artifact. It also lints the result with Flathub's own linter, and builds and starts the same -manifest under the sample's real ID. +The application ID is `dev.darkpyonix.Ember`. The owner controls the domain `darkpyonix.dev` +and serves its verification file from a Cloudflare Worker. -What is still open is the application ID. Once the owner has chosen it, the submission is the -same command with that ID. +Everything Flathub reviews is generated by `tools/packager-linux/flatpak.sh`. +`flathub/ember.sh` drives it for one Ember release. The `Linux packaging` workflow +generates and lints the submission on every run and uploads it as the +`flathub-submission-ember` artifact. Ember has no release yet, so two of the workflow's +inputs are stand-ins: the archives are the published calculator samples, and the +screenshot is Ember's large icon. Everything else is what will be submitted. + +**The submission is on hold.** Nothing here opens a pull request. ## 1. Before the pull request (owner) -- [ ] Choose the application and its ID. Flathub does not accept tech demos, so a sample - goes in only if it is meant to be used. -- [ ] Prove control of the ID's domain. `dev.darkpyonix.*` means `darkpyonix.dev`. After the - app is accepted, verify it on flathub.org (Developer settings, Verification), which asks - for a token at `https://darkpyonix.dev/.well-known/org.flathub.VerifiedApps.txt`. - An `io.github..*` ID is verified through the GitHub account instead. -- [ ] Publish the release the manifest points at. Each architecture needs one archive, with - the executable and the renderer at its top level, and the SHA-256 of each. -- [ ] Give the listing at least one screenshot: `[linux.store] screenshots` in an overlay, - as a stable URL (a release asset or a file in the repository at a tag). Flathub's - linter refuses a listing without one. -- [ ] Decide the release notes for this version (`[linux.store] release_notes`). +- [x] Application ID: `dev.darkpyonix.Ember`. +- [ ] Domain verification. Once the app is accepted, flathub.org (Developer settings, + Verification) issues a token for + `https://darkpyonix.dev/.well-known/org.flathub.VerifiedApps.txt`; the Worker + serves it. +- [ ] Publish an Ember release on `DarkPyonix/darkpyonix-ember` with one archive per + architecture. Each archive holds a top directory with the `ember` executable and the + renderer in `lib/` beside it. Default asset names: `ember-linux-x64.tar.gz` and + `ember-linux-arm64.tar.gz`. +- [ ] Commit a screenshot to the Ember repository and tag the release. The listing points at + it through raw.githubusercontent.com pinned to that tag; the packager refuses a URL + that follows a branch. +- [ ] Rewrite the summary and description in `flathub/dev.darkpyonix.Ember.toml` if the + repository's one-line description is not what the store page should say. Move the file + into the Ember repository as its `Dioxus.toml` when Ember gets one. +- [ ] Release notes for the version (`[linux.store] release_notes`). ## 2. Generate the submission ```sh cargo build --release -p packager-linux -PACKAGER_LINUX=target/release/packager-linux tools/packager-linux/flatpak.sh \ - --dioxus-toml samples/calculator/Dioxus.toml \ - --overlay tools/packager-linux/samples.toml --overlay store.toml \ - --app-id \ - --exec sample-calculator \ - --icon samples/calculator/assets/icon.png --icon samples/calculator/assets/icon-256.png \ - --version \ - --prebuilt-url --prebuilt-sha256 --prebuilt-arch x86_64 \ - --prebuilt-url --prebuilt-sha256 --prebuilt-arch aarch64 \ - --out flathub/ --build repo/ --lint +PACKAGER_LINUX=target/release/packager-linux tools/packager-linux/flathub/ember.sh \ + --tag v1.0.0 --version 1.0.0 \ + --screenshot docs/screenshot.png --screenshot-caption "The console" \ + --out flathub-ember --build repo --lint ``` -`store.toml` holds what is specific to this submission (screenshots and release notes). For an -application built from source, pass `--source-url`, `--source-sha256` and `--cargo-lock` -instead of the prebuilt archives, and `--renderer-url`, `--renderer-sha256` and -`--renderer-arch` for each architecture's renderer. - -`flathub/` then holds `.json`, `.desktop`, `.metainfo.xml`, the icons under -`icons/`, and `cargo-sources.json` for a build from source. These are the files of the -`flathub/` repository. +The script downloads the release archives to compute their SHA-256, takes the icons from +`src-tauri/icons/` at the tag (or `--icon`), and writes into `flathub-ember/` the files of +the `flathub/dev.darkpyonix.Ember` repository: `dev.darkpyonix.Ember.json`, the desktop +entry, the metainfo and `icons/`. ## 3. Check before opening the pull request -- [ ] `lint-manifest.json` and `lint-repo.json` in `flathub/` show no errors other than the - ones only Flathub can clear (the ID's domain not verified yet). -- [ ] `flatpak run ` opens the window. Under a virtual display, the workflow's - calculator step shows how. -- [ ] `appstreamcli validate --no-net flathub/.metainfo.xml` passes. -- [ ] The runtime branch (`[linux.store] flatpak_runtime_version`, default 26.08) is the - newest one Flathub offers. +- [ ] `lint-manifest.json` and `lint-repo.json` show no errors other than the ones only + Flathub can clear (the domain not yet verified). +- [ ] `flatpak run dev.darkpyonix.Ember` opens the window. The workflow's calculator step + shows how to do that under a virtual display. +- [ ] `appstreamcli validate --no-net flathub-ember/dev.darkpyonix.Ember.metainfo.xml` passes. +- [ ] The runtime branch (default 26.08) is the newest one Flathub offers. -## 4. Pull request +## 4. Pull request (when the hold is lifted) -Fork `flathub/flathub`, branch from `new-pr`, add the files from `flathub/` at the -repository root, and open the pull request against `new-pr` titled `Add `. A reviewer -answers there. Once it is merged, Flathub creates `flathub/` and the owner is given -write access. Every later release is a commit there that changes the archive URLs and digests. +Fork `flathub/flathub`, branch from `new-pr`, add the files from `flathub-ember/` at the +repository root, and open the pull request against `new-pr` titled +`Add dev.darkpyonix.Ember`. After it is merged, every later release is a commit in +`flathub/dev.darkpyonix.Ember` that changes the archive URLs and their digests: run +`ember.sh` again with the new tag. diff --git a/tools/packager-linux/README.md b/tools/packager-linux/README.md new file mode 100644 index 00000000..299d745b --- /dev/null +++ b/tools/packager-linux/README.md @@ -0,0 +1,80 @@ +# packager-linux + +Packages a built compose-rust application for Linux in two ways, both described by the +application's `Dioxus.toml`: + +- an **AppImage** that updates itself. It carries zsync update information and + `appimageupdatetool`, and the application asks before it updates, through + `dioxus_compose::update`; +- a **Flatpak** manifest in the form Flathub reviews. A Flatpak is updated by the store that + installed it. + +| File | What it does | +|---|---| +| `src/` | The `packager-linux` command: desktop entry, AppStream metainfo, icons, AppDir, update information, Flatpak manifest | +| `appimage.sh` | Builds an AppImage and its `.zsync` with pinned AppImage tools | +| `flatpak.sh` | Writes the manifest, then builds, installs and lints it the way Flathub does | +| `flathub/` | Ember's Flathub listing (`dev.darkpyonix.Ember`) and `ember.sh`, which generates the submission from an Ember release | +| `FLATHUB.md` | Checklist for submitting to Flathub | +| `samples.toml` | Store facts shared by the sample applications, passed as an overlay | +| `demo/` | The small program the packaging workflow updates from 1.0.0 to 1.0.1 | +| `ci/` | Helpers the packaging workflow uses | + +Run `packager-linux` with no arguments for its options. `.github/workflows/packager-linux.yml` +runs everything here on GitHub's Ubuntu runners. + +## Where updates come from + +Each application updates from the releases of its own repository. Its AppImage carries +`gh-releases-zsync|||latest|-*-.AppImage.zsync`, which matches the +newest release that is not a pre-release. Ember updates from `DarkPyonix/darkpyonix-ember`. + +The samples are released to `DarkPyonix/compose-rust-samples`, not to this repository. That +way `latest` means the newest samples rather than the newest library release. + +## Secrets + +The owner creates these in this repository's settings (Settings, Secrets and variables, +Actions). + +### SAMPLES_RELEASE_TOKEN + +The `Sample apps` workflow publishes to `DarkPyonix/compose-rust-samples`. The token a +workflow is given can only write to its own repository, so it needs another one. + +1. On GitHub, open Settings, Developer settings, Personal access tokens, Fine-grained + tokens, and choose Generate new token. +2. Resource owner: DarkPyonix. Repository access: Only select repositories, then + `DarkPyonix/compose-rust-samples` only. +3. Repository permissions: Contents, Read and write. Leave everything else at No access. +4. Save the token as the Actions secret `SAMPLES_RELEASE_TOKEN` in `DarkPyonix/compose-rust`. + +Without it, the workflow's first job fails within seconds and names the missing secret, +before any build runs. Fine-grained tokens expire, so renew it before the expiry date. + +### APPIMAGE_SIGNING_KEY + +The armoured private key of the AppImage signing key (see below). While it is missing, the +`Sample apps` workflow builds AppImages unsigned and warns that it did. + +## AppImage signing key + +An installed AppImage that was signed refuses any update that was not signed by the same key. +That makes the key a long-term commitment: + +- Sign from the first public AppImage onwards. An AppImage that was installed unsigned + accepts any file. +- Changing the key breaks updates for everyone who installed an AppImage signed with the old + one; they have to download the new one by hand. +- Make the key without an expiry date, keep an offline backup, and store only the armoured + private key in `APPIMAGE_SIGNING_KEY`. + +```sh +gpg --quick-gen-key "DarkPyonix AppImage signing" ed25519 sign never +gpg --armor --export-secret-keys # the value of APPIMAGE_SIGNING_KEY +gpg --armor --export # the public key, to publish +``` + +Fingerprint: `` + +Until the key exists, AppImages are built unsigned and only for testing. diff --git a/tools/packager-linux/flathub/dev.darkpyonix.Ember.toml b/tools/packager-linux/flathub/dev.darkpyonix.Ember.toml new file mode 100644 index 00000000..db6bc54c --- /dev/null +++ b/tools/packager-linux/flathub/dev.darkpyonix.Ember.toml @@ -0,0 +1,34 @@ +# Ember's store listing for Flathub, in the shape of a Dioxus.toml. +# +# Ember lives in DarkPyonix/darkpyonix-ember. Once that repository has its own Dioxus.toml, +# pass that file with --dioxus-toml and this one with --overlay; until then this file is +# both. What changes per release (the version, the archives and their digests, the +# screenshot pinned to the release tag) is passed on the command line, as +# tools/packager-linux/flathub/ember.sh does. +# +# The summary and description restate the repository's own one-line description. They +# are the owner's to rewrite before the listing goes public. + +[application] +name = "Ember" + +[bundle] +identifier = "dev.darkpyonix.Ember" +publisher = "DarkPyonix" +short_description = "DarkPyonix Ember Console" +long_description = """ +Ember is the DarkPyonix console, compatible with Visual Studio Code extensions and +settings.""" + +[linux] +categories = ["Development"] +keywords = ["console", "terminal", "editor"] + +[linux.store] +exec = "ember" +license = "MIT" +homepage = "https://github.com/DarkPyonix/darkpyonix-ember" +bugtracker = "https://github.com/DarkPyonix/darkpyonix-ember/issues" +vcs_browser = "https://github.com/DarkPyonix/darkpyonix-ember" +developer_id = "dev.darkpyonix" +developer_name = "DarkPyonix" diff --git a/tools/packager-linux/flathub/ember.sh b/tools/packager-linux/flathub/ember.sh new file mode 100755 index 00000000..7ad58683 --- /dev/null +++ b/tools/packager-linux/flathub/ember.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Generates the Flathub submission for Ember (dev.darkpyonix.Ember) from one of its releases. +# +# tools/packager-linux/flathub/ember.sh --tag v1.0.0 --version 1.0.0 \ +# --screenshot docs/screenshot.png --out flathub-ember [--build repo] [--lint] +# +# writes into --out what the flathub/dev.darkpyonix.Ember repository holds: the manifest, +# the desktop entry, the metainfo and the icons. Nothing is submitted; opening the +# pull request is a separate, deliberate step (tools/packager-linux/FLATHUB.md). +# +# Options: +# --tag the Ember release to package +# --version the version that release carries +# --screenshot a screenshot's path inside the Ember repository; the listing +# points at it pinned to --tag (repeatable) +# --screenshot-caption one per --screenshot +# --x64-asset the release asset for x86_64 (default ember-linux-x64.tar.gz) +# --arm64-asset the release asset for aarch64 (default ember-linux-arm64.tar.gz) +# --icon overrides the icons fetched from the repository at --tag +# --out , --build , --lint as flatpak.sh takes them +# +# The archives are downloaded once to compute their digests, into PACKAGER_LINUX_CACHE +# (default .scratch/packager-linux/tools in this repository). + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../../.." && pwd)" +ember=DarkPyonix/darkpyonix-ember +app_id=dev.darkpyonix.Ember + +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do echo " $line" >&2; done + exit 1 +} + +tag="" +version="" +x64_asset=ember-linux-x64.tar.gz +arm64_asset=ember-linux-arm64.tar.gz +shots=() +icons=() +rest=() +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) tag="$2"; shift 2 ;; + --version) version="$2"; shift 2 ;; + --x64-asset) x64_asset="$2"; shift 2 ;; + --arm64-asset) arm64_asset="$2"; shift 2 ;; + --screenshot) shots+=(--screenshot "https://raw.githubusercontent.com/$ember/$tag/$2"); shift 2 ;; + --screenshot-caption) shots+=(--screenshot-caption "$2"); shift 2 ;; + --icon) icons+=(--icon "$2"); shift 2 ;; + --lint) rest+=("$1"); shift ;; + --out | --build) rest+=("$1" "$2"); shift 2 ;; + *) fail "unknown option $1" "See the comment at the top of $0." ;; + esac +done +[[ -n "$tag" ]] || fail "--tag is required" +[[ -n "$version" ]] || fail "--version is required" +[[ ${#shots[@]} -gt 0 ]] || fail "--screenshot is required" \ + "Flathub's linter refuses a listing without one." +case " ${shots[*]} " in + *"/$ember//"*) fail "--tag must come before --screenshot" ;; +esac + +cache="${PACKAGER_LINUX_CACHE:-$repo_root/.scratch/packager-linux/tools}/ember-$tag" +mkdir -p "$cache" + +digest() { + local name="$1" url="https://github.com/$ember/releases/download/$tag/$1" + if [[ ! -f "$cache/$name" ]]; then + curl --fail --location --silent --show-error --retry 3 -o "$cache/$name.part" "$url" || + fail "could not download $url" "Is $name an asset of the $tag release?" + mv "$cache/$name.part" "$cache/$name" + fi + sha256sum "$cache/$name" | cut -d' ' -f1 +} + +if [[ ${#icons[@]} -eq 0 ]]; then + for icon in 128x128.png 128x128@2x.png; do + curl --fail --location --silent --show-error --retry 3 -o "$cache/$icon" \ + "https://raw.githubusercontent.com/$ember/$tag/src-tauri/icons/$icon" || + fail "no src-tauri/icons/$icon in $ember at $tag" "Pass --icon instead." + icons+=(--icon "$cache/$icon") + done +fi + +x64_sha="$(digest "$x64_asset")" +arm64_sha="$(digest "$arm64_asset")" + +exec "$here/../flatpak.sh" \ + --dioxus-toml "$here/$app_id.toml" --app-id "$app_id" --version "$version" \ + "${icons[@]}" "${shots[@]}" \ + --prebuilt-url "https://github.com/$ember/releases/download/$tag/$x64_asset" \ + --prebuilt-sha256 "$x64_sha" --prebuilt-arch x86_64 \ + --prebuilt-url "https://github.com/$ember/releases/download/$tag/$arm64_asset" \ + --prebuilt-sha256 "$arm64_sha" --prebuilt-arch aarch64 \ + "${rest[@]}" From 114db0c718ff687b81b5c47933263fd074e43df7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:17:52 +0900 Subject: [PATCH 036/224] Fix: Build Flatpaks with the host's flatpak-builder and start a working calculator org.flatpak.Builder keeps its own user installation, so it neither saw the runtimes installed on the host nor could install them. The build now uses the host's flatpak-builder; Flathub's builder still lints. The published sample archives cannot start (no rpath, a renderer from another commit, the renderer flattened out of lib/), so ci/calculator-v0.sh builds the calculator at the v0.0.0 tag against that tag's released renderer and the workflow starts that one, unsigned. The Flathub submission step now generates dev.darkpyonix.Ember. --- .github/workflows/packager-linux.yml | 98 ++++++++++++++---------- tools/packager-linux/ci/calculator-v0.sh | 50 ++++++++++++ tools/packager-linux/flatpak.sh | 19 ++++- 3 files changed, 123 insertions(+), 44 deletions(-) create mode 100755 tools/packager-linux/ci/calculator-v0.sh diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 17acd152..b8826f43 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -6,13 +6,15 @@ # AppImages carrying zsync update information, serves the newer one's .zsync # from nginx on the runner, and has the installed 1.0.0 update itself. Then a # 1.0.2 signed by a different key is published and the update must be refused. -# Last, a real sample (the calculator release, renderer and all) is wrapped -# and started under a virtual display. +# Last, a real calculator with its renderer (built at the v0.0.0 tag against +# that tag's released renderer, x86_64) is wrapped and started under a +# virtual display. # - flatpak: builds the same program from source with flatpak-builder, offline, with # every crate vendored, installs it, then builds 1.0.1 into the same repository -# and updates the installation with `flatpak update`. Then the calculator -# release is packaged as a prebuilt archive, started in the sandbox under a -# virtual display, and both manifests go through Flathub's linter. +# and updates the installation with `flatpak update`. Then the same calculator +# is packaged as a prebuilt archive and started in the sandbox under a virtual +# display, and the Flathub submission for dev.darkpyonix.Ember is generated. +# Every manifest goes through Flathub's linter. # # Runs when the packaging code changes, or by hand. It downloads Flatpak SDKs and builds a # Rust program inside one, which is not something to repeat on every push. @@ -130,7 +132,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - zsync desktop-file-utils appstream gnupg nginx-light fuse3 libfuse2t64 \ + zsync desktop-file-utils appstream gnupg nginx-light fuse3 libfuse2t64 patchelf \ xvfb xauth imagemagick libgl1 libegl1 libgl1-mesa-dri fontconfig \ libxtst6 libxi6 libxrender1 libxext6 libxrandr2 fonts-dejavu-core rustup toolchain install stable --profile minimal && rustup default stable @@ -279,22 +281,28 @@ jobs: ls -la work/installed echo "The impostor's 1.0.2 was refused and 1.0.1 still runs." >> "$GITHUB_STEP_SUMMARY" - - name: Wrap the calculator release as an AppImage and start it + - name: Wrap a calculator as an AppImage and start it + # A calculator whose executable and renderer belong together: the v0.0.0 tag + # built against its own released renderer (ci/calculator-v0.sh says why the + # published sample archives cannot be used). That renderer exists for x86_64 + # only, so on aarch64 there is nothing that can start, and the step says so. run: | set -euo pipefail - mkdir -p work/calculator - curl -fsSL -o work/calculator.tar.gz "$CALCULATOR_URL" - echo "$CALCULATOR_SHA256 work/calculator.tar.gz" | sha256sum -c - tar -xzf work/calculator.tar.gz -C work/calculator + if [[ "$ARCH" != x86_64 ]]; then + echo "::notice title=No aarch64 calculator::no renderer was published for linux-arm64 at v0.0.0; the self-update steps above ran on aarch64" + exit 0 + fi + tools/packager-linux/ci/calculator-v0.sh work + # Unsigned: these are test builds, and the signing key is not created yet. tools/packager-linux/appimage.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ --overlay tools/packager-linux/samples.toml \ --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png \ - --payload work/calculator/calculator --version 0.1.1 \ - --github darkpyonix/compose-rust --sign-key "$RELEASE_KEY" \ + --payload work/calculator/calculator --version 0.0.0 \ + --github DarkPyonix/compose-rust-samples \ --out-dir work/dist - app=work/dist/Calculator-0.1.1-$ARCH.AppImage + app=work/dist/Calculator-0.0.0-$ARCH.AppImage "$app" --appimage-updateinformation xvfb-run -a -s "-screen 0 1280x800x24" bash -c " '$app' > work/calculator.log 2>&1 & @@ -331,7 +339,7 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak dbus-daemon xvfb xauth imagemagick python3-pip + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder dbus-daemon patchelf xvfb xauth imagemagick python3-pip pip install --user 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1' flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true @@ -350,8 +358,8 @@ jobs: mkdir -p work/flatpak-src # What a release publishes: the tagged source as an archive. git archive --format=tar.gz --prefix=compose-rust/ -o work/flatpak-src/source.tar.gz HEAD - # Flathub's builder runs in its own sandbox and reaches the host's flatpak - # through the session bus, which a runner has none of until one is started. + # flatpak talks to its system helper over the session bus, which a runner has + # none of until one is started. dbus-run-session -- tools/packager-linux/flatpak.sh \ --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.0 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ @@ -379,34 +387,35 @@ jobs: flatpak --user uninstall --noninteractive "$id" flatpak --user install --noninteractive local-repo "$id" flatpak run "$id" --version | grep -qx "update-demo 1.0.0" - # Flathub's builder runs in its own sandbox and reaches the host's flatpak - # through the session bus, which a runner has none of until one is started. + # flatpak talks to its system helper over the session bus, which a runner has + # none of until one is started. dbus-run-session -- tools/packager-linux/flatpak.sh \ --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.1 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ --out work/flatpak-src - (cd work/flatpak-src && dbus-run-session -- flatpak run --command=flatpak-builder org.flatpak.Builder \ + (cd work/flatpak-src && flatpak-builder \ --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") flatpak --user update --noninteractive "$id" flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" flatpak --user info "$id" echo "### Flatpak 1.0.0 updated to 1.0.1 with flatpak update" >> "$GITHUB_STEP_SUMMARY" - - name: Package the calculator release as a prebuilt archive and start it in the sandbox + - name: Package a calculator as a prebuilt archive and start it in the sandbox run: | set -euo pipefail + tools/packager-linux/ci/calculator-v0.sh work mkdir -p work/flatpak-calculator - # Flathub's builder runs in its own sandbox and reaches the host's flatpak - # through the session bus, which a runner has none of until one is started. + cp work/calculator-linux-x64.tar.gz work/flatpak-calculator/ + # flatpak talks to its system helper over the session bus, which a runner has + # none of until one is started. dbus-run-session -- tools/packager-linux/flatpak.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ --overlay tools/packager-linux/samples.toml \ --exec sample-calculator --icon samples/calculator/assets/icon.png \ - --icon samples/calculator/assets/icon-256.png --version 0.1.1 \ - --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" --prebuilt-arch x86_64 \ - --prebuilt-url "$CALCULATOR_ARM64_URL" --prebuilt-sha256 "$CALCULATOR_ARM64_SHA256" --prebuilt-arch aarch64 \ + --icon samples/calculator/assets/icon-256.png --version 0.0.0 \ + --prebuilt-path calculator-linux-x64.tar.gz \ --out work/flatpak-calculator --build work/repo-calculator --lint id=dev.darkpyonix.dioxus.compose.samples.calculator cat "work/flatpak-calculator/$id.json" @@ -420,29 +429,38 @@ jobs: cat work/calculator-flatpak.log grep -q 'still running' work/calculator-flatpak-run.txt - - name: Prepare the Flathub submission under a placeholder ID - # Everything a flathub/ repository holds, generated for an ID nobody owns - # yet. Submitting means running the same command with the chosen ID; the - # checklist for the rest is in tools/packager-linux/FLATHUB.md. + - name: Prepare the Flathub submission for dev.darkpyonix.Ember + # What flathub/dev.darkpyonix.Ember will hold, generated and linted. Ember has no + # release yet, so two inputs are stand-ins and the listing is not submitted: + # the archives are the published calculator samples, and the screenshot is + # Ember's large icon, both pinned. For a real release, + # tools/packager-linux/flathub/ember.sh takes the tag and fills in Ember's own + # archives, digests and the screenshot pinned to that tag. + env: + EMBER_COMMIT: faa430a4b75ec45928623130ea2f874b628a7224 run: | set -euo pipefail + raw="https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/$EMBER_COMMIT" + mkdir -p work/ember-icons + curl -fsSL -o work/ember-icons/128.png "$raw/src-tauri/icons/128x128.png" + curl -fsSL -o work/ember-icons/256.png "$raw/src-tauri/icons/128x128@2x.png" dbus-run-session -- tools/packager-linux/flatpak.sh \ - --dioxus-toml samples/calculator/Dioxus.toml \ - --overlay tools/packager-linux/samples.toml \ - --app-id dev.darkpyonix.PlaceholderAppId \ - --exec sample-calculator --icon samples/calculator/assets/icon.png \ - --icon samples/calculator/assets/icon-256.png --version 0.1.1 \ + --dioxus-toml tools/packager-linux/flathub/dev.darkpyonix.Ember.toml \ + --app-id dev.darkpyonix.Ember --version 0.0.0 \ + --icon work/ember-icons/128.png --icon work/ember-icons/256.png \ + --screenshot "$raw/src-tauri/icons/128x128@2x.png" --screenshot-caption "Ember" \ --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" --prebuilt-arch x86_64 \ --prebuilt-url "$CALCULATOR_ARM64_URL" --prebuilt-sha256 "$CALCULATOR_ARM64_SHA256" --prebuilt-arch aarch64 \ - --out work/flathub-submission --lint - rm -rf work/flathub-submission/.flatpak-builder work/flathub-submission/build - find work/flathub-submission -type f | sort + --out work/flathub-ember --lint + rm -rf work/flathub-ember/.flatpak-builder work/flathub-ember/build + find work/flathub-ember -type f | sort + cat work/flathub-ember/dev.darkpyonix.Ember.metainfo.xml - uses: actions/upload-artifact@v4 if: always() with: - name: flathub-submission-placeholder - path: work/flathub-submission/ + name: flathub-submission-ember + path: work/flathub-ember/ - uses: actions/upload-artifact@v4 if: always() diff --git a/tools/packager-linux/ci/calculator-v0.sh b/tools/packager-linux/ci/calculator-v0.sh new file mode 100755 index 00000000..74057c27 --- /dev/null +++ b/tools/packager-linux/ci/calculator-v0.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Stages a calculator whose executable and renderer belong together, for the packaging +# workflow to wrap and start. x86_64 only. +# +# tools/packager-linux/ci/calculator-v0.sh +# +# leaves /calculator/ (the executable, and the renderer in lib/ beside it) and +# /calculator-linux-x64.tar.gz (the same directory as a release archive). +# +# Why this and not a published sample: the sample-v0.1.1 Linux archives do not start. +# Their executables carry no rpath, the x86_64 renderer asks the executable for a host +# function it does not export (a renderer and host from different commits), and the +# renderer sits beside the executable rather than in lib/, where AWT looks for its +# companions. So the workflow builds the calculator at the v0.0.0 tag of +# DarkPyonix/dioxus-compose against that tag's own released renderer. That is a Rust +# build only; the renderer is the published artifact, checked by its digest. + +set -euo pipefail + +work="$1" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +renderer_url=https://github.com/DarkPyonix/dioxus-compose/releases/download/v0.0.0/dioxus-compose-renderer-v0.0.0-linux-x64.tar.gz +renderer_sha256=5fe6ba31e359753a8e3c3a743f2f8ecadac409fda2634ba3b569e00f27ec19aa + +[[ "$(uname -m)" == x86_64 ]] || { echo "error: the v0.0.0 renderer was published for x86_64 only" >&2; exit 1; } +mkdir -p "$work" +work="$(cd "$work" && pwd)" + +if [[ ! -d "$work/dioxus-compose" ]]; then + git clone --quiet --depth 1 --branch v0.0.0 https://github.com/DarkPyonix/dioxus-compose "$work/dioxus-compose" +fi +mkdir -p "$work/renderer" +curl --fail --location --silent --show-error --retry 3 -o "$work/renderer.tar.gz" "$renderer_url" +echo "$renderer_sha256 $work/renderer.tar.gz" | sha256sum -c - +tar -xzf "$work/renderer.tar.gz" -C "$work/renderer" + +(cd "$work/dioxus-compose" && + DIOXUS_COMPOSE_RENDERER_DIR="$work/renderer" cargo build --quiet --release -p sample-calculator) + +stage="$work/calculator/calculator" +rm -rf "$work/calculator" +mkdir -p "$stage/lib" +cp "$work/dioxus-compose/target/release/sample-calculator" "$stage/" +cp -a "$work/renderer/lib/." "$stage/lib/" +"$repo_root/scripts/bundle-renderer.sh" "$stage/sample-calculator" lib +# bundle-renderer.sh only sets the rpath when it had a path to rewrite. +patchelf --set-rpath '$ORIGIN/lib' "$stage/sample-calculator" +readelf -d "$stage/sample-calculator" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' +tar -czf "$work/calculator-linux-x64.tar.gz" -C "$work/calculator" calculator +sha256sum "$work/calculator-linux-x64.tar.gz" diff --git a/tools/packager-linux/flatpak.sh b/tools/packager-linux/flatpak.sh index 50ab0c13..111501a4 100755 --- a/tools/packager-linux/flatpak.sh +++ b/tools/packager-linux/flatpak.sh @@ -20,8 +20,9 @@ # them (an app ID whose domain is not verified yet) are only # settled by Flathub # -# The builder is Flathub's own, org.flatpak.Builder from Flathub, which also carries the -# linter. It is installed for the user when missing. flatpak itself must be installed. +# The build uses flatpak-builder from the host (FLATPAK_BUILDER overrides it). The linter +# is Flathub's own, from org.flatpak.Builder, installed for the user when missing. flatpak +# itself must be installed. # # Downloads (the generator) go to PACKAGER_LINUX_CACHE, default .scratch/packager-linux/tools # in this repository. packager-linux is run through `cargo run` unless PACKAGER_LINUX @@ -100,8 +101,18 @@ fi manifest="$out/$(basename "$(packager flatpak "${passthrough[@]}")")" echo "== $manifest" +# The build runs with the host's flatpak-builder when there is one. Flathub's own builder, +# org.flatpak.Builder, runs in a sandbox whose user installation is its own, so the +# runtimes installed on the host are invisible to it and it cannot install them either. +# It is still what lints, because the linter only reads files. builder() { - flatpak run --command=flatpak-builder org.flatpak.Builder "$@" + if [[ -n "${FLATPAK_BUILDER:-}" ]]; then + $FLATPAK_BUILDER "$@" + elif command -v flatpak-builder >/dev/null; then + flatpak-builder "$@" + else + flatpak run --command=flatpak-builder org.flatpak.Builder "$@" + fi } if [[ -n "$build_repo" || $lint -eq 1 ]]; then @@ -116,7 +127,7 @@ if [[ -n "$build_repo" ]]; then echo "== building into $build_repo" ( cd "$out" - builder --user --force-clean --install-deps-from=flathub --ccache \ + builder --user --force-clean --install-deps-from=flathub \ --repo="$build_repo" --install --state-dir="$out/.flatpak-builder" \ "$out/build" "$manifest" ) From 9725a828010a030233d421484d252644f3b00852 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:17:52 +0900 Subject: [PATCH 037/224] Feat: Release the samples to DarkPyonix/compose-rust-samples Sample AppImages carry gh-releases-zsync|DarkPyonix|compose-rust-samples and the release is published there, so latest means the newest samples. A preflight job fails within seconds, naming the secret, when SAMPLES_RELEASE_TOKEN is missing or cannot reach that repository. The first release seeds the empty repository with a README so there is a commit to tag. --- .github/workflows/samples.yml | 70 +++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 31e9b619..51aae7eb 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -1,5 +1,7 @@ # Sample apps -- builds every sample for every desktop platform when a `sample-v*` tag is -# pushed, and attaches the binaries to a GitHub Release. +# pushed, and publishes the binaries as a release of DarkPyonix/compose-rust-samples, the +# repository the samples' AppImages update from. Writing there needs the +# SAMPLES_RELEASE_TOKEN secret (tools/packager-linux/README.md). # # A sample that does not build on a platform is skipped and the release goes ahead with # the rest. Eleven samples across three platforms is thirty-three builds, and holding all @@ -31,7 +33,32 @@ permissions: contents: read jobs: + preflight: + # The release goes to DarkPyonix/compose-rust-samples, the repository the samples' + # AppImages look in for their updates, and the token this workflow gets cannot write + # anywhere but here. Checked first, so that a missing token stops the run in seconds + # rather than after two hours of builds. tools/packager-linux/README.md says how to + # create it. + name: Release token + runs-on: ubuntu-latest + steps: + - name: SAMPLES_RELEASE_TOKEN can reach the samples repository + env: + GH_TOKEN: ${{ secrets.SAMPLES_RELEASE_TOKEN }} + SAMPLES_REPO: DarkPyonix/compose-rust-samples + run: | + set -euo pipefail + if [[ -z "$GH_TOKEN" ]]; then + echo "::error title=SAMPLES_RELEASE_TOKEN is missing::Create a fine-grained personal access token with Contents: read and write on $SAMPLES_REPO only, and save it as the repository secret SAMPLES_RELEASE_TOKEN (tools/packager-linux/README.md)." + exit 1 + fi + if ! gh api "repos/$SAMPLES_REPO" --jq .full_name; then + echo "::error title=SAMPLES_RELEASE_TOKEN cannot see $SAMPLES_REPO::The token exists but GitHub refused it for $SAMPLES_REPO. Give it access to that repository (tools/packager-linux/README.md)." + exit 1 + fi + build: + needs: preflight name: ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 @@ -225,8 +252,9 @@ jobs: - name: Wrap each Linux sample as an AppImage that updates itself # The same staged directory, as one file that carries the address of its newer - # versions: the newest release of this repository whose assets include a matching - # .zsync. A sample that fails here keeps its archive and loses only the AppImage. + # versions: the newest release of DarkPyonix/compose-rust-samples whose assets + # include a matching .zsync. The samples are released there rather than here so + # that `latest` means the newest samples, not the newest library release. A sample that fails here keeps its archive and loses only the AppImage. # # Signed when the repository has a signing key. An installed AppImage that was # signed refuses an update signed by any other key, so the first public AppImage @@ -260,7 +288,7 @@ jobs: --overlay tools/packager-linux/samples.toml \ --exec "sample-$app" "${icons[@]}" \ --payload "staging/$app" --version "$version" \ - --github "${{ github.repository }}" "${sign[@]}" \ + --github DarkPyonix/compose-rust-samples "${sign[@]}" \ --out-dir artifacts; then echo "::warning title=No AppImage::sample-$app was not wrapped as an AppImage for ${{ matrix.target }}" fi @@ -284,6 +312,7 @@ jobs: android: name: android + needs: preflight runs-on: ubuntu-24.04 timeout-minutes: 120 steps: @@ -344,6 +373,7 @@ jobs: ios: name: ios-simulator + needs: preflight runs-on: macos-26 timeout-minutes: 120 steps: @@ -398,6 +428,7 @@ jobs: web: name: web + needs: preflight runs-on: ubuntu-24.04 timeout-minutes: 120 steps: @@ -482,6 +513,8 @@ jobs: echo "Linux also ships each sample as an AppImage. Make it executable and run it;" echo "it checks this repository's newest release for a newer version of itself." echo + echo "Built from DarkPyonix/compose-rust at $(git rev-parse --short HEAD)." + echo for archive in artifacts/*.tar.gz artifacts/*.AppImage artifacts/*.apk; do [ -e "$archive" ] || continue echo "- $(basename "$archive")" @@ -493,20 +526,33 @@ jobs: } > notes.md cat notes.md - - name: Create or update the release + - name: Create or update the release in the samples repository # Published rather than drafted. A draft is invisible to everyone but the people - # with write access to this repository, so a release nobody can download is the - # same as no release at all. + # with write access, so a release nobody can download is the same as no release + # at all. It goes to DarkPyonix/compose-rust-samples, whose newest release is what + # every installed sample AppImage checks for an update. env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.SAMPLES_RELEASE_TOKEN }} + SAMPLES_REPO: DarkPyonix/compose-rust-samples TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail - if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" artifacts/* --clobber - gh release edit "$TAG" --draft=false --notes-file notes.md + [[ -n "$GH_TOKEN" ]] || { echo "::error::SAMPLES_RELEASE_TOKEN is missing (tools/packager-linux/README.md)"; exit 1; } + # A release needs a commit to tag, and the repository starts empty. + if ! gh api "repos/$SAMPLES_REPO/commits?per_page=1" >/dev/null 2>&1; then + readme="$(printf '%s\n' \ + '# compose-rust samples' '' \ + 'Release downloads and self-update feeds for the sample applications of' \ + '[compose-rust](https://github.com/DarkPyonix/compose-rust). The releases are' \ + 'published by its Sample apps workflow.' | base64 -w0)" + gh api -X PUT "repos/$SAMPLES_REPO/contents/README.md" \ + -f message="Describe the repository" -f content="$readme" >/dev/null + fi + if gh release view "$TAG" --repo "$SAMPLES_REPO" >/dev/null 2>&1; then + gh release upload "$TAG" artifacts/* --repo "$SAMPLES_REPO" --clobber + gh release edit "$TAG" --repo "$SAMPLES_REPO" --draft=false --latest --notes-file notes.md else - gh release create "$TAG" \ + gh release create "$TAG" --repo "$SAMPLES_REPO" --latest \ --title "Sample apps $TAG" \ --notes-file notes.md \ artifacts/* From ad62edc70d2269bd513e449ae8d300869ac1c0b2 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:18:50 +0900 Subject: [PATCH 038/224] Feat: Sign every macOS bundle ad hoc and need no Apple account (FR-35) By owner decision the project ships without an Apple developer account, so the packager drops the Mac App Store package, Developer ID signing and notarization. Every bundle is signed ad hoc, inside out: each renderer library, each piece of Sparkle.framework, then the app. Apple silicon runs no unsigned arm64 code, and rewriting the renderer's load command breaks the linker's signature, so this is not optional. No hardened runtime and no entitlements: neither is needed without notarization, and the renderer needs none to run. --updater sparkle|none replaces the channel. The disk image carries install notes that say how to open an app Gatekeeper cannot attribute to a developer. A feed may be file:// for rehearsing without a server. --renderer-dir names the renderer of an executable that links it through @rpath. Test names follow the draft FR-35 criteria (35.1 to 35.5). --- tools/packager/dxc_packager/bundle.py | 192 +++-------- tools/packager/dxc_packager/cli.py | 132 ++------ tools/packager/dxc_packager/install-notes.txt | 31 ++ tools/packager/dxc_packager/plist.py | 128 ++------ tools/packager/tests/test_appcast.py | 16 +- tools/packager/tests/test_bundle.py | 310 ++++++++---------- tools/packager/tests/test_ed25519.py | 10 +- tools/packager/tests/test_metadata.py | 18 +- tools/packager/tests/test_plist.py | 138 ++------ tools/packager/tests/test_signing.py | 10 +- 10 files changed, 343 insertions(+), 642 deletions(-) create mode 100644 tools/packager/dxc_packager/install-notes.txt diff --git a/tools/packager/dxc_packager/bundle.py b/tools/packager/dxc_packager/bundle.py index 44838a66..caee9412 100644 --- a/tools/packager/dxc_packager/bundle.py +++ b/tools/packager/dxc_packager/bundle.py @@ -7,15 +7,21 @@ MacOS/ Frameworks/lib/ the renderer's own lib directory, whole, when the executable loads the renderer as a library - Frameworks/Sparkle.framework sparkle channel only + Frameworks/Sparkle.framework when the bundle updates itself Resources/AppIcon.icns - embedded.provisionprofile app-store channel, when given The renderer directory goes to Frameworks/lib and not straight into Frameworks because AWT reads its companions from the parent of the renderer's directory plus "lib"; that is scripts/bundle-renderer.sh's finding, and that script is what points the executable at the copy. An executable that is the whole application (one native image with the renderer linked in) has no renderer library, and then there is nothing to copy. + +Every bundle is signed ad hoc (`codesign --sign -`). That is not optional: Apple silicon +runs no arm64 code without a signature, and copying a library or rewriting its load +commands invalidates the one the linker left. An ad hoc signature names no developer, so +Gatekeeper does not let a downloaded copy open without the person's say-so (see +install-notes.txt), but once it is open nothing else differs, and Sparkle's updates are +verified by their EdDSA signature rather than by who signed the code. """ import os @@ -31,11 +37,12 @@ RENDERER_LIBRARY = "libdioxus_compose_renderer.dylib" REPOSITORY = Path(__file__).resolve().parents[3] BUNDLE_RENDERER = REPOSITORY / "scripts" / "bundle-renderer.sh" +INSTALL_NOTES = Path(__file__).resolve().parent / "install-notes.txt" -# What the Sparkle framework contains that is code, innermost first. Signing has to go -# from the inside out, each piece before whatever contains it, and `codesign --deep` is -# not a substitute: it signs everything with the outer entitlements, which gives Sparkle's -# Downloader service the wrong ones. +# What the Sparkle framework contains that is code, innermost first. Signing goes from the +# inside out, each piece before whatever contains it, so that every seal covers signatures +# that are already final. `codesign --deep` would reach the same pieces but in an order +# it chooses and without keeping the Downloader service's own entitlements. SPARKLE_NESTED = ( ("Versions/B/XPCServices/Installer.xpc", False), ("Versions/B/XPCServices/Downloader.xpc", True), @@ -43,11 +50,6 @@ ("Versions/B/Updater.app", False), ) -# Strings that are in any binary which can drive Sparkle. The store build is checked for -# them as well as for the framework, because a host compiled with the updater still names -# them even when the framework was left out, and App Review reads binaries. -SPARKLE_MARKERS = (b"SPUStandardUpdaterController", b"Sparkle.framework") - def run(command, **kwargs): try: @@ -155,8 +157,8 @@ def make_icns(source, destination): width = int(run(["sips", "-g", "pixelWidth", str(source)]).stdout.split()[-1]) if width < 1024: print( - f"warning: {source} is {width} pixels wide. The Mac App Store asks for a 1024 " - f"pixel icon (512 points at 2x), so this one is scaled up." + f"warning: {source} is {width} pixels wide and is scaled up to the 1024 pixels " + f"a Retina Dock and Finder draw it at; give a 1024 pixel icon to keep it sharp." ) with tempfile.TemporaryDirectory() as scratch: iconset = Path(scratch) / "AppIcon.iconset" @@ -178,41 +180,34 @@ def assemble( metadata, executable, out_dir, - channel, *, sparkle=None, sparkle_framework=None, - uses_non_exempt_encryption=None, extra_plist=None, - provisioning_profile=None, renderer_dir=None, ): - """Build Name.app in `out_dir` and return its path. Replaces one already there.""" + """Build Name.app in `out_dir` and return its path. Replaces one already there. + + With `sparkle` settings the bundle updates itself, and `sparkle_framework` (from a + Sparkle 2 release archive) goes inside it. Without them it is a plain application. + """ executable = Path(executable) if not executable.is_file(): raise PackagingError(f"no executable at {executable}") - info = plists.info_plist( - metadata, - channel, - sparkle=sparkle, - uses_non_exempt_encryption=uses_non_exempt_encryption, - extra=extra_plist, - ) - if channel == plists.SPARKLE: + info = plists.info_plist(metadata, sparkle=sparkle, extra=extra_plist) + if sparkle is not None: if sparkle_framework is None: raise PackagingError( - "the sparkle channel needs --sparkle-framework, the Sparkle.framework " - "from a Sparkle 2 release archive" + "a bundle that updates itself needs --sparkle-framework, the " + "Sparkle.framework from a Sparkle 2 release archive" ) sparkle_framework = Path(sparkle_framework) if not (sparkle_framework / "Versions" / "B" / "Sparkle").exists(): raise PackagingError(f"{sparkle_framework} is not a Sparkle 2 framework") elif sparkle_framework is not None: - raise PackagingError("the app-store build must not contain Sparkle.framework") - if channel == plists.APP_STORE and provisioning_profile is None: - print( - "warning: no provisioning profile. The bundle runs and can be checked, but App " - "Store Connect refuses an upload without the profile for this identifier." + raise PackagingError( + "Sparkle.framework was given for a bundle with no feed to update from; pass " + "--updater sparkle with its settings, or leave the framework out" ) app = Path(out_dir) / f"{metadata.name}.app" @@ -261,80 +256,40 @@ def assemble( if metadata.icon is not None: make_icns(metadata.icon, contents / "Resources" / "AppIcon.icns") - if provisioning_profile is not None: - shutil.copyfile(provisioning_profile, contents / "embedded.provisionprofile") (contents / "Info.plist").write_bytes(plists.dumps(info)) (contents / "PkgInfo").write_text("APPL????") return app -def _codesign( - path, identity, *, entitlements=None, runtime=True, preserve_entitlements=False, keychain=None -): - command = ["codesign", "--force", "--sign", identity] - if keychain is not None: - command += ["--keychain", str(keychain)] - if identity != "-": - # A secure timestamp is required for notarization. Ad-hoc signatures have no - # certificate to timestamp. - command.append("--timestamp") - if runtime: - command += ["--options", "runtime"] - if entitlements is not None: - command += ["--entitlements", str(entitlements)] - elif preserve_entitlements: +def _codesign(path, *, preserve_entitlements=False): + command = ["codesign", "--force", "--sign", "-"] + if preserve_entitlements: command.append("--preserve-metadata=entitlements") command.append(str(path)) run(command) -def sign(app, identity, channel, entitlements, *, keychain=None): - """Sign every piece of code in `app`, innermost first, then the app itself. - - `identity` is "-" for an ad-hoc signature, otherwise the name or hash of a certificate: - "Developer ID Application: ..." for the sparkle channel, "3rd Party Mac Developer - Application: ..." or "Apple Distribution: ..." for the app-store channel. - """ +def sign(app): + """Sign every piece of code in `app` ad hoc, innermost first, then the app itself.""" app = Path(app) contents = app / "Contents" - # The store build runs sandboxed and the store re-checks it; the hardened runtime is - # what notarization requires of the other one. Both get it: the sandboxed build loses - # nothing by it and one signing path is one fewer thing to get wrong. - runtime = True libraries = contents / "Frameworks" / "lib" if libraries.is_dir(): for path in sorted(libraries.rglob("*")): if path.is_file() and not path.is_symlink() and is_mach_o(path): - _codesign(path, identity, runtime=runtime, keychain=keychain) + _codesign(path) framework = contents / "Frameworks" / "Sparkle.framework" if framework.exists(): - if channel == plists.APP_STORE: - raise PackagingError(f"{framework} is in an app-store build") for relative, keep_entitlements in SPARKLE_NESTED: nested = framework / relative if nested.exists(): - _codesign( - nested, - identity, - runtime=runtime, - preserve_entitlements=keep_entitlements, - keychain=keychain, - ) - _codesign(framework, identity, runtime=runtime, keychain=keychain) + _codesign(nested, preserve_entitlements=keep_entitlements) + _codesign(framework) - handle, name = tempfile.mkstemp(suffix=".entitlements") - os.close(handle) - entitlements_file = Path(name) - try: - entitlements_file.write_bytes(plists.dumps(entitlements)) - _codesign( - app, identity, entitlements=entitlements_file, runtime=runtime, keychain=keychain - ) - finally: - entitlements_file.unlink() + _codesign(app) run(["codesign", "--verify", "--strict", "--deep", "--verbose=2", str(app)]) return app @@ -349,7 +304,8 @@ def zip_for_sparkle(app, out): def dmg(app, out, volume_name): - """A disk image holding the app and a link to /Applications, for handing out directly.""" + """A disk image for the first install: the app, a link to /Applications, and the notes + that say how to open an application macOS cannot attribute to a developer.""" out = Path(out) if out.exists(): out.unlink() @@ -358,6 +314,7 @@ def dmg(app, out, volume_name): staging.mkdir() run(["ditto", str(app), str(staging / Path(app).name)]) (staging / "Applications").symlink_to("/Applications") + shutil.copyfile(INSTALL_NOTES, staging / "If macOS will not open the app.txt") run( [ "hdiutil", "create", "-volname", volume_name, "-srcfolder", str(staging), @@ -367,57 +324,8 @@ def dmg(app, out, volume_name): return out -def pkg(app, out, installer_identity=None): - """The installer package App Store Connect takes, installing the app into /Applications.""" - out = Path(out) - if out.exists(): - out.unlink() - command = ["productbuild", "--component", str(app), "/Applications"] - if installer_identity: - command += ["--sign", installer_identity] - command.append(str(out)) - run(command) - return out - - -def notarize(path, keychain_profile): - """Submit to Apple's notary service, wait, and staple the ticket to the app or disk image. - - `keychain_profile` is the name given to `xcrun notarytool store-credentials`, which - keeps the Apple ID app password or App Store Connect API key out of command lines. - """ - run( - [ - "xcrun", "notarytool", "submit", str(path), - "--keychain-profile", keychain_profile, "--wait", - ] - ) - - -def staple(path): - run(["xcrun", "stapler", "staple", str(path)]) - - -def sparkle_residue(app): - """Everything in `app` that would make it an application that updates itself.""" - app = Path(app) - problems = [] - info_path = app / "Contents" / "Info.plist" - if info_path.exists(): - info = plistlib.loads(info_path.read_bytes()) - problems += [f"Info.plist sets {key}" for key in sorted(info) if key.startswith("SU")] - for path in sorted(app.rglob("*")): - if path.name == "Sparkle.framework": - problems.append(f"{path.relative_to(app)} is in the bundle") - if path.is_file() and not path.is_symlink() and is_mach_o(path): - data = path.read_bytes() - for marker in SPARKLE_MARKERS: - if marker in data: - problems.append( - f"{path.relative_to(app)} contains {marker.decode()}; build the " - f"store executable without the updater" - ) - return problems +def sparkle_in(app): + return (Path(app) / "Contents" / "Frameworks" / "Sparkle.framework").exists() def absolute_references(app): @@ -433,9 +341,15 @@ def absolute_references(app): return problems -def entitlements_of(app): - output = run(["codesign", "-d", "--entitlements", "-", "--xml", str(app)]).stdout - if not output.strip(): - return {} - start = output.find("= 0 else {} +def unsigned_code(app): + """Code in the bundle that has no valid signature, which Apple silicon will not run.""" + app = Path(app) + problems = [] + for path in sorted(app.rglob("*")): + if path.is_file() and not path.is_symlink() and is_mach_o(path): + result = subprocess.run( + ["codesign", "--verify", "--strict", str(path)], capture_output=True, text=True + ) + if result.returncode != 0: + problems.append(f"{path.relative_to(app)}: {result.stderr.strip()}") + return problems diff --git a/tools/packager/dxc_packager/cli.py b/tools/packager/dxc_packager/cli.py index 89c3f847..56c5c3e0 100644 --- a/tools/packager/dxc_packager/cli.py +++ b/tools/packager/dxc_packager/cli.py @@ -1,27 +1,22 @@ """package-macos: bundle, sign and publish a dioxus-compose application for macOS. -The two channels, end to end: +Every bundle is signed ad hoc, which needs no Apple developer account: Apple silicon runs +it, Gatekeeper asks the person once before the first launch of a downloaded copy (the +disk image carries the notes that say how), and Sparkle verifies updates by their EdDSA +signature. - Sparkle (Developer ID, updates itself): +A bundle that updates itself: package-macos keygen --out keys/sparkle.key # once, ever; keep the key - package-macos app --channel sparkle --manifest-dir . --executable target/release/app \\ + package-macos app --updater sparkle --manifest-dir . --executable target/release/app \\ --feed-url https://example.com/appcast.xml --ed-key-file keys/sparkle.key \\ - --sparkle-framework Sparkle.framework --sign "Developer ID Application: ..." \\ - --out dist - package-macos archive --app dist/App.app --out dist/App-1.2.0.zip - package-macos notarize --path dist/App-1.2.0.zip --keychain-profile notary --staple dist/App.app - package-macos archive --app dist/App.app --out dist/App-1.2.0.zip # again, stapled + --sparkle-framework Sparkle.framework --out dist + package-macos dmg --app dist/App.app --out dist/App-1.2.0.dmg # first install + package-macos archive --app dist/App.app --out site/App-1.2.0.zip # what Sparkle fetches package-macos appcast --appcast site/appcast.xml --app dist/App.app \\ - --archive dist/App-1.2.0.zip --url https://example.com/App-1.2.0.zip \\ + --archive site/App-1.2.0.zip --url https://example.com/App-1.2.0.zip \\ --ed-key-file keys/sparkle.key --sign-update Sparkle/bin/sign_update - Mac App Store (sandboxed, updated by the store): - package-macos app --channel app-store --manifest-dir . --executable target/release/app \\ - --uses-non-exempt-encryption no --network-client --user-selected-files read-write \\ - --provisioning-profile App.provisionprofile --team-id ABCDE12345 \\ - --sign "3rd Party Mac Developer Application: ..." --out dist-store - package-macos pkg --app dist-store/App.app --out dist-store/App.pkg \\ - --sign "3rd Party Mac Developer Installer: ..." +A plain application (no updates): --updater none, and no Sparkle options. """ import argparse @@ -49,7 +44,8 @@ def _add_app_arguments(parser): parser.add_argument("--renderer-dir", type=Path, help="the renderer's lib directory, when the executable names it " "through @rpath rather than by its path") - parser.add_argument("--channel", required=True, choices=plist.CHANNELS) + parser.add_argument("--updater", required=True, choices=plist.UPDATERS, + help="sparkle: the app updates itself from an appcast; none: it does not") parser.add_argument("--out", required=True, type=Path, help="directory to put Name.app in") parser.add_argument("--version", help="CFBundleShortVersionString; default from Cargo.toml") parser.add_argument("--build", help="CFBundleVersion; default the version") @@ -59,11 +55,8 @@ def _add_app_arguments(parser): help="an extra Info.plist entry; repeatable") parser.add_argument("--plist-file", type=Path, help="a plist whose entries are added to Info.plist") - parser.add_argument("--sign", default="-", - help='codesign identity; "-" (the default) signs ad hoc') - parser.add_argument("--keychain", type=Path, help="keychain holding the identity") - sparkle = parser.add_argument_group("sparkle channel") + sparkle = parser.add_argument_group("--updater sparkle") sparkle.add_argument("--feed-url") key = sparkle.add_mutually_exclusive_group() key.add_argument("--public-ed-key", help="SUPublicEDKey, base64") @@ -75,15 +68,6 @@ def _add_app_arguments(parser): sparkle.add_argument("--automatically-update", action="store_true", help="download and install without asking (SUAutomaticallyUpdate)") - store = parser.add_argument_group("app-store channel") - store.add_argument("--uses-non-exempt-encryption", type=_yes_no) - store.add_argument("--provisioning-profile", type=Path) - store.add_argument("--team-id") - store.add_argument("--network-client", action="store_true") - store.add_argument("--network-server", action="store_true") - store.add_argument("--user-selected-files", choices=("read-only", "read-write")) - store.add_argument("--downloads-folder", choices=("read-only", "read-write")) - def command_app(args): meta = metadata.load(args.manifest_dir, version=args.version, build=args.build) @@ -95,12 +79,16 @@ def command_app(args): extra.update(plistlib.loads(args.plist_file.read_bytes())) sparkle = None - if args.channel == plist.SPARKLE: + if args.updater == plist.SPARKLE: public_key = args.public_ed_key if args.ed_key_file: public_key = signing.public_key_of(args.ed_key_file) if not args.feed_url or not public_key: - raise PackagingError("the sparkle channel needs --feed-url and a key") + raise PackagingError( + "--updater sparkle needs --feed-url and a key (--ed-key-file or " + "--public-ed-key): without them the application has nowhere to look for " + "updates and nothing to check them against" + ) sparkle = plist.SparkleSettings( feed_url=args.feed_url, public_ed_key=public_key, @@ -108,75 +96,45 @@ def command_app(args): check_interval=args.check_interval, automatically_update=args.automatically_update, ) + elif args.feed_url or args.public_ed_key or args.ed_key_file or args.sparkle_framework: + raise PackagingError("Sparkle options were given with --updater none") args.out.mkdir(parents=True, exist_ok=True) app = bundle.assemble( meta, args.executable, args.out, - args.channel, sparkle=sparkle, sparkle_framework=args.sparkle_framework, - uses_non_exempt_encryption=args.uses_non_exempt_encryption, extra_plist=extra, - provisioning_profile=args.provisioning_profile, renderer_dir=args.renderer_dir, ) - capabilities = plist.Capabilities( - network_client=args.network_client, - network_server=args.network_server, - user_selected_files=args.user_selected_files, - downloads_folder=args.downloads_folder, - ) - entitlements = plist.entitlements( - args.channel, capabilities=capabilities, team_id=args.team_id, identifier=meta.identifier - ) - if args.sign == "-": - print("note: signing ad hoc. This bundle runs here and cannot be distributed.") - bundle.sign(app, args.sign, args.channel, entitlements, keychain=args.keychain) - problems = _verify(app, args.channel) + bundle.sign(app) + problems = _verify(app, args.updater) if problems: raise PackagingError("the bundle is not shippable:\n " + "\n ".join(problems)) print(app) -def _verify(app, channel): - problems = bundle.absolute_references(app) - granted = bundle.entitlements_of(app) - if channel == plist.APP_STORE: - problems += bundle.sparkle_residue(app) - if not granted.get("com.apple.security.app-sandbox"): - problems.append("the app-store build is not sandboxed") - else: - if not (app / "Contents" / "Frameworks" / "Sparkle.framework").exists(): - problems.append("the sparkle build has no Sparkle.framework") - if granted.get("com.apple.security.app-sandbox"): - problems.append("the sparkle build is sandboxed, and Sparkle cannot replace it") +def _verify(app, updater): + problems = bundle.unsigned_code(app) + bundle.absolute_references(app) + if updater == plist.SPARKLE and not bundle.sparkle_in(app): + problems.append("the bundle updates itself but has no Sparkle.framework") + if updater == plist.NONE and bundle.sparkle_in(app): + problems.append("the bundle does not update itself but carries Sparkle.framework") return problems def command_verify(args): - problems = _verify(args.app, args.channel) + problems = _verify(args.app, args.updater) for problem in problems: print(f"error: {problem}", file=sys.stderr) if problems: return 1 - print(f"ok {args.app} is a {args.channel} bundle") + print(f"ok {args.app}: every piece of code is signed and loads nothing from outside") return 0 -def command_pkg(args): - problems = _verify(args.app, plist.APP_STORE) - if problems: - raise PackagingError( - "only an app-store bundle goes into the store package:\n " + "\n ".join(problems) - ) - if not args.sign: - print("note: the package is unsigned. App Store Connect needs it signed with a " - "3rd Party Mac Developer Installer (or Mac Installer Distribution) identity.") - print(bundle.pkg(args.app, args.out, args.sign)) - - def command_archive(args): print(bundle.zip_for_sparkle(args.app, args.out)) @@ -185,12 +143,6 @@ def command_dmg(args): print(bundle.dmg(args.app, args.out, args.volume_name or args.app.stem)) -def command_notarize(args): - bundle.notarize(args.path, args.keychain_profile) - if args.staple: - bundle.staple(args.staple) - - def command_keygen(args): public = signing.keygen(args.out) print(f"private key written to {args.out}; keep it out of the repository") @@ -238,38 +190,26 @@ def parser(): ) commands = root.add_subparsers(dest="command", required=True) - app = commands.add_parser("app", help="make and sign Name.app for one channel") + app = commands.add_parser("app", help="make Name.app and sign it ad hoc") _add_app_arguments(app) app.set_defaults(run=command_app) - verify = commands.add_parser("verify", help="check a bundle is what its channel needs") + verify = commands.add_parser("verify", help="check a bundle is signed and self-contained") verify.add_argument("--app", required=True, type=Path) - verify.add_argument("--channel", required=True, choices=plist.CHANNELS) + verify.add_argument("--updater", required=True, choices=plist.UPDATERS) verify.set_defaults(run=command_verify) - pkg = commands.add_parser("pkg", help="the installer package for App Store Connect") - pkg.add_argument("--app", required=True, type=Path) - pkg.add_argument("--out", required=True, type=Path) - pkg.add_argument("--sign", help="installer identity") - pkg.set_defaults(run=command_pkg) - archive = commands.add_parser("archive", help="the zip Sparkle downloads") archive.add_argument("--app", required=True, type=Path) archive.add_argument("--out", required=True, type=Path) archive.set_defaults(run=command_archive) - dmg = commands.add_parser("dmg", help="a disk image for handing the app out directly") + dmg = commands.add_parser("dmg", help="a disk image for the first install, with install notes") dmg.add_argument("--app", required=True, type=Path) dmg.add_argument("--out", required=True, type=Path) dmg.add_argument("--volume-name") dmg.set_defaults(run=command_dmg) - notarize = commands.add_parser("notarize", help="notarize a zip, dmg or pkg and staple") - notarize.add_argument("--path", required=True, type=Path) - notarize.add_argument("--keychain-profile", required=True) - notarize.add_argument("--staple", type=Path, help="app or dmg to staple the ticket to") - notarize.set_defaults(run=command_notarize) - keygen = commands.add_parser("keygen", help="a new EdDSA key pair for Sparkle") keygen.add_argument("--out", required=True, type=Path) keygen.set_defaults(run=command_keygen) diff --git a/tools/packager/dxc_packager/install-notes.txt b/tools/packager/dxc_packager/install-notes.txt new file mode 100644 index 00000000..fea17acd --- /dev/null +++ b/tools/packager/dxc_packager/install-notes.txt @@ -0,0 +1,31 @@ +If macOS will not open the app +============================== + +This app is signed, but not by a developer Apple has verified, so the first time you open +a copy you downloaded, macOS asks you to confirm. You do this once. After that the app +opens normally, and the updates it installs itself open without asking again. + +First drag the app into Applications. Opening it straight from the disk image or the +Downloads folder works too, but macOS then runs it from a hidden temporary copy, where it +cannot update itself. + +macOS 15 (Sequoia) and newer +---------------------------- +1. Open the app. macOS says it could not verify the app and does not open it. + Click Done. +2. Open System Settings, choose Privacy & Security, and scroll down to Security. + Next to the line saying the app was blocked, click Open Anyway. + The button is there for about an hour after step 1; if it is gone, repeat step 1. +3. Click Open Anyway again in the dialog that follows, and enter your login password. + +macOS 14 (Sonoma) and older +--------------------------- +Control-click (or right-click) the app in Applications, choose Open, then click Open in +the dialog. + +Any version, from Terminal +-------------------------- + xattr -dr com.apple.quarantine "/Applications/.app" + +This removes the mark your browser put on the download, and macOS then opens the app +without asking. Only do this for an app you downloaded from a source you trust. diff --git a/tools/packager/dxc_packager/plist.py b/tools/packager/dxc_packager/plist.py index 08e46cd1..1cc0c832 100644 --- a/tools/packager/dxc_packager/plist.py +++ b/tools/packager/dxc_packager/plist.py @@ -1,14 +1,13 @@ -"""Info.plist and entitlements for the two macOS channels. +"""Info.plist for a dioxus-compose application bundle. -There are two ways an application reaches someone, and they want different bundles: +A bundle either updates itself with Sparkle or does not update at all (the plain .app a +sample release hands out). Everything that differs between the two is decided here, from +whether Sparkle settings were given, so a bundle cannot carry half of Sparkle. -- `sparkle`: signed with a Developer ID, notarized, handed out as a download, and updated - in place by Sparkle from an appcast. Hardened runtime, no sandbox, Sparkle inside. -- `app-store`: signed for the Mac App Store, sandboxed, installed and updated by the store. - It must not contain Sparkle or anything else that updates the application by itself. - -Everything that differs between them is decided here, from the channel, so a bundle -cannot be half one and half the other. +Nothing here asks for an entitlement. The bundle is signed ad hoc without the hardened +runtime and without a sandbox, which is what runs on a Mac without an Apple developer +account behind it, and the renderer needs no entitlement to run that way: it is compiled +ahead of time, so nothing generates code at run time, and Skia draws through Metal. """ import plistlib @@ -19,8 +18,8 @@ from .metadata import PackagingError SPARKLE = "sparkle" -APP_STORE = "app-store" -CHANNELS = (SPARKLE, APP_STORE) +NONE = "none" +UPDATERS = (SPARKLE, NONE) # Sparkle checks once a day unless told otherwise, and will not check more often than # once an hour however it is told. @@ -48,17 +47,17 @@ def check_feed_url(url): # the EdDSA signature protects the archive, but not the appcast that says which # archive is newest or what its release notes say. return url + if parsed.scheme == "file" and parsed.path: + # A feed on this disk: the same rehearsal with no server at all. + return url raise PackagingError( - f"SUFeedURL {url!r} must be https. Plain http is accepted only for localhost, " - f"which is for rehearsing an update on one machine." + f"SUFeedURL {url!r} must be https. Plain http to localhost and file:// are " + f"accepted for rehearsing an update on one machine." ) -def info_plist(metadata, channel, *, sparkle=None, uses_non_exempt_encryption=None, extra=None): - """The Info.plist dictionary for `metadata` packaged for `channel`.""" - if channel not in CHANNELS: - raise PackagingError(f"channel {channel!r} is not one of {', '.join(CHANNELS)}") - +def info_plist(metadata, *, sparkle=None, extra=None): + """The Info.plist dictionary for `metadata`, updating itself when `sparkle` is given.""" plist = { "CFBundleDevelopmentRegion": "en", "CFBundleDisplayName": metadata.display_name, @@ -85,13 +84,7 @@ def info_plist(metadata, channel, *, sparkle=None, uses_non_exempt_encryption=No if metadata.category: plist["LSApplicationCategoryType"] = metadata.category - if channel == SPARKLE: - if sparkle is None: - raise PackagingError( - "the sparkle channel needs a feed URL and the public EdDSA key " - "(--feed-url and --public-ed-key): an application without them has " - "nowhere to look for updates and nothing to check them against" - ) + if sparkle is not None: ed25519.decode_public_key(sparkle.public_ed_key) if sparkle.check_interval < MINIMUM_CHECK_INTERVAL: raise PackagingError( @@ -107,25 +100,6 @@ def info_plist(metadata, channel, *, sparkle=None, uses_non_exempt_encryption=No "SUAutomaticallyUpdate": sparkle.automatically_update, } ) - else: - if sparkle is not None: - raise PackagingError( - "the app-store channel cannot carry Sparkle settings: the store updates " - "the application, and App Review rejects one that updates itself" - ) - if not metadata.category: - raise PackagingError( - "the Mac App Store needs LSApplicationCategoryType; set [bundle] category " - "in Dioxus.toml or pass --category" - ) - if uses_non_exempt_encryption is None: - raise PackagingError( - "the Mac App Store asks every build whether it uses non-exempt encryption. " - "Answer once with --uses-non-exempt-encryption yes|no so App Store Connect " - "does not hold each upload for the question. HTTPS through the system " - "alone is exempt (no)." - ) - plist["ITSAppUsesNonExemptEncryption"] = bool(uses_non_exempt_encryption) for key, value in (extra or {}).items(): if key in plist and plist[key] != value: @@ -133,71 +107,15 @@ def info_plist(metadata, channel, *, sparkle=None, uses_non_exempt_encryption=No f"the extra Info.plist entries set {key}, which the packager already " f"derives ({plist[key]!r}); change it where it comes from instead" ) - if channel == APP_STORE and key.startswith("SU"): - raise PackagingError(f"{key} is a Sparkle setting and the app-store build has no Sparkle") + if sparkle is None and key.startswith("SU"): + raise PackagingError( + f"{key} is a Sparkle setting, and this bundle has no Sparkle; package it " + f"with --updater sparkle" + ) plist[key] = value return plist -@dataclass(frozen=True) -class Capabilities: - """What a sandboxed application asks the system to let it do. - - `user_selected_files` is None, "read-only" or "read-write": access to files the person - chose in an open or save panel or dropped on the window, and nothing else. - """ - - network_client: bool = False - network_server: bool = False - user_selected_files: str | None = None - downloads_folder: str | None = None - - -def entitlements(channel, *, capabilities=None, team_id=None, identifier=None): - """The entitlements dictionary for `channel`. - - Neither channel needs any hardened runtime exception. The renderer is compiled ahead of - time by GraalVM native-image, so nothing generates code at run time (no allow-jit, no - allow-unsigned-executable-memory), Skia draws through Metal, which needs no - entitlement, and every library the application loads is inside the bundle and signed - by the same team, so library validation stays on. - """ - capabilities = capabilities or Capabilities() - if channel == SPARKLE: - # No sandbox: Sparkle replaces the application in place, and the sandboxed form of - # that needs Sparkle's XPC installer services and a different signing arrangement. - # The store build is the sandboxed one. Network and file access need no - # entitlement outside the sandbox, so capabilities have nothing to add here. - return {} - if channel != APP_STORE: - raise PackagingError(f"channel {channel!r} is not one of {', '.join(CHANNELS)}") - - result = {"com.apple.security.app-sandbox": True} - if capabilities.network_client: - result["com.apple.security.network.client"] = True - if capabilities.network_server: - result["com.apple.security.network.server"] = True - for name, value in ( - ("files.user-selected", capabilities.user_selected_files), - ("files.downloads", capabilities.downloads_folder), - ): - if value is None: - continue - if value not in ("read-only", "read-write"): - raise PackagingError(f"{name} is read-only or read-write, not {value!r}") - result[f"com.apple.security.{name}.{value}"] = True - - if team_id is not None: - # The store matches these against the provisioning profile. Xcode writes them for - # an Xcode project; a bundle made outside Xcode has to say them itself, or App - # Store Connect rejects the upload for a missing application identifier. - if identifier is None: - raise PackagingError("the application identifier needs the bundle identifier") - result["com.apple.application-identifier"] = f"{team_id}.{identifier}" - result["com.apple.developer.team-identifier"] = team_id - return result - - def dumps(dictionary): return plistlib.dumps(dictionary, fmt=plistlib.FMT_XML, sort_keys=True) diff --git a/tools/packager/tests/test_appcast.py b/tools/packager/tests/test_appcast.py index 561e6d28..67586569 100644 --- a/tools/packager/tests/test_appcast.py +++ b/tools/packager/tests/test_appcast.py @@ -34,7 +34,7 @@ def feed_with(*items): class Appcast(unittest.TestCase): - def test_fr34_9_item_carries_what_sparkle_2_reads(self): + def test_fr35_5_item_carries_what_sparkle_2_reads(self): rss = feed_with(item("2", release_notes_url="https://example.com/2.html")) element = rss.find("channel/item") self.assertEqual(element.findtext(f"{S}version"), "2") @@ -48,22 +48,22 @@ def test_fr34_9_item_carries_what_sparkle_2_reads(self): self.assertEqual(enclosure.get(f"{S}edSignature"), "c2lnbmF0dXJl") self.assertIsNone(element.find(f"{S}criticalUpdate")) - def test_fr34_9_output_is_a_feed_declaring_the_sparkle_namespace(self): + def test_fr35_5_output_is_a_feed_declaring_the_sparkle_namespace(self): text = appcast.dumps(appcast.add(appcast.new_feed("Demo"), item("1"))) self.assertTrue(text.startswith('')) self.assertIn('xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"', text) self.assertEqual(ET.fromstring(text).get("version"), "2.0") - def test_fr34_9_newest_first_comparing_numbers_not_text(self): + def test_fr35_5_newest_first_comparing_numbers_not_text(self): rss = feed_with(item("9"), item("10"), item("1.2")) self.assertEqual(appcast.versions(rss), ["10", "9", "1.2"]) - def test_fr34_9_publishing_a_version_again_replaces_it(self): + def test_fr35_5_publishing_a_version_again_replaces_it(self): rss = feed_with(item("1"), item("2"), item("2", length=99)) self.assertEqual(appcast.versions(rss), ["2", "1"]) self.assertEqual(rss.find("channel/item/enclosure").get("length"), "99") - def test_fr34_9_existing_items_and_their_unknown_elements_survive(self): + def test_fr35_5_existing_items_and_their_unknown_elements_survive(self): existing = """ @@ -90,16 +90,16 @@ def test_fr34_9_existing_items_and_their_unknown_elements_survive(self): self.assertEqual(old.findtext(f"{S}channel"), "beta") self.assertEqual(rss.findtext("channel/title"), "Demo") - def test_fr34_9_an_apple_silicon_only_update_says_so(self): + def test_fr35_5_an_apple_silicon_only_update_says_so(self): rss = feed_with(item("4", hardware_requirements="arm64")) self.assertEqual(rss.findtext(f"channel/item/{S}hardwareRequirements"), "arm64") self.assertIsNone(feed_with(item("4")).find(f"channel/item/{S}hardwareRequirements")) - def test_fr34_9_critical_updates_are_marked(self): + def test_fr35_5_critical_updates_are_marked(self): rss = feed_with(item("3", critical=True)) self.assertIsNotNone(rss.find(f"channel/item/{S}criticalUpdate")) - def test_fr34_9_something_that_is_not_a_feed_is_refused(self): + def test_fr35_5_something_that_is_not_a_feed_is_refused(self): with self.assertRaisesRegex(PackagingError, "not XML"): appcast.parse("") with self.assertRaisesRegex(PackagingError, "RSS"): diff --git a/tools/packager/tests/test_bundle.py b/tools/packager/tests/test_bundle.py index cd43f6bb..cf0c459a 100644 --- a/tools/packager/tests/test_bundle.py +++ b/tools/packager/tests/test_bundle.py @@ -1,13 +1,11 @@ -"""Assembling, signing and wrapping real bundles. macOS only: this is codesign and productbuild. +"""Assembling, signing and wrapping real bundles. macOS only: codesign, ditto and hdiutil. -The executable is a copy of /usr/bin/true, which is a real Mach-O program that needs -nothing beside it, so these run in seconds and need no Rust build. What a dioxus-compose -executable adds, a renderer loaded from an absolute path, is made by pointing the copy's -load command at a stand-in library. +The executables are built from one line of C, so these run in seconds and need no Rust +build. What a dioxus-compose executable adds, a renderer library loaded from where the +build found it, is made with a stand-in library linked the same way. """ import plistlib -import shutil import subprocess import sys import tempfile @@ -22,13 +20,6 @@ ICON = REPOSITORY / "samples" / "calculator" / "assets" / "icon.png" -def executable_in(directory): - path = Path(directory) / "sample-demo" - shutil.copyfile("/usr/bin/true", path) - path.chmod(0o755) - return path - - def compile_c(source, out, *flags): subprocess.run( # The flags come before `-x c`, which would otherwise read a library as C source. @@ -37,200 +28,187 @@ def compile_c(source, out, *flags): ) -def store_app(out, executable): - meta = metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), icon=ICON) - app = bundle.assemble(meta, executable, out, plist.APP_STORE, uses_non_exempt_encryption=False) - granted = plist.entitlements( - plist.APP_STORE, capabilities=plist.Capabilities(network_client=True) +def renderer_and_executable(scratch, install_name=None): + """A stand-in renderer directory and an executable linked against it. + + By default the library is named by its absolute path, as the crate's build names the + real one; both have header room for the longer relative name, which rustc asks for too. + """ + renderer = Path(scratch) / "renderer" / "lib" + renderer.mkdir(parents=True) + library = renderer / bundle.RENDERER_LIBRARY + compile_c( + "int dioxus_compose_renderer_stand_in(void) { return 0; }", + library, "-dynamiclib", "-install_name", install_name or str(library), + ) + compile_c( + "int skiko_stand_in(void) { return 0; }", + renderer / "libskiko-stand-in.dylib", "-dynamiclib", + ) + executable = Path(scratch) / "sample-demo" + compile_c( + "int dioxus_compose_renderer_stand_in(void);\n" + "int main(void) { return dioxus_compose_renderer_stand_in(); }", + executable, str(library), + ) + return renderer, executable + + +def settings(): + return plist.SparkleSettings( + feed_url="https://example.com/appcast.xml", public_ed_key=PUBLIC_KEY ) - return bundle.sign(app, "-", plist.APP_STORE, granted) -@unittest.skipUnless(sys.platform == "darwin", "codesign and productbuild are macOS tools") -class StoreBundle(unittest.TestCase): +@unittest.skipUnless(sys.platform == "darwin", "codesign and hdiutil are macOS tools") +class PlainBundle(unittest.TestCase): def setUp(self): - self.scratch = Path(tempfile.mkdtemp(prefix="packager-bundle-")) - self.app = store_app(self.scratch / "out", executable_in(self.scratch)) + self.scratch = Path(tempfile.mkdtemp(prefix="packager-plain-")) + self.renderer, self.executable = renderer_and_executable(self.scratch) + meta = metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), icon=ICON) + self.app = bundle.sign(bundle.assemble(meta, self.executable, self.scratch / "out")) - def test_fr34_1_layout_and_info_plist(self): + def test_fr35_1_layout_and_info_plist(self): contents = self.app / "Contents" self.assertEqual(self.app.name, "Demo App.app") self.assertTrue((contents / "MacOS" / "sample-demo").is_file()) self.assertTrue((contents / "Resources" / "AppIcon.icns").is_file()) self.assertEqual((contents / "PkgInfo").read_text(), "APPL????") + self.assertFalse((contents / "Frameworks" / "Sparkle.framework").exists()) info = plistlib.loads((contents / "Info.plist").read_bytes()) self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") self.assertEqual(info["CFBundleIconFile"], "AppIcon") self.assertIs(info["NSHighResolutionCapable"], True) + self.assertFalse([key for key in info if key.startswith("SU")]) - def test_fr34_5_signature_verifies_with_the_hardened_runtime(self): - subprocess.run( - ["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True + def test_fr35_3_renderer_directory_travels_whole_and_is_loaded_relatively(self): + lib = self.app / "Contents" / "Frameworks" / "lib" + self.assertTrue((lib / bundle.RENDERER_LIBRARY).is_file()) + self.assertTrue((lib / "libskiko-stand-in.dylib").is_file()) + self.assertIn( + "@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, + bundle.load_commands(self.app / "Contents" / "MacOS" / "sample-demo"), ) + self.assertEqual(bundle.absolute_references(self.app), []) + + def test_fr35_3_every_piece_of_code_is_signed_ad_hoc(self): + subprocess.run(["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True) + self.assertEqual(bundle.unsigned_code(self.app), []) + for path in ( + self.app, + self.app / "Contents/Frameworks/lib" / bundle.RENDERER_LIBRARY, + self.app / "Contents/Frameworks/lib/libskiko-stand-in.dylib", + ): + details = subprocess.run( + ["codesign", "-dv", str(path)], capture_output=True, text=True + ).stderr + self.assertIn("Signature=adhoc", details, path) details = subprocess.run( ["codesign", "-dv", str(self.app)], capture_output=True, text=True ).stderr - self.assertIn("runtime", details) self.assertIn("Identifier=dev.example.demo", details) - - def test_fr34_4_store_bundle_is_sandboxed_with_declared_capabilities(self): - self.assertEqual( - bundle.entitlements_of(self.app), - { - "com.apple.security.app-sandbox": True, - "com.apple.security.network.client": True, - }, - ) - self.assertEqual(cli._verify(self.app, plist.APP_STORE), []) - - def test_fr34_6_store_bundle_has_no_sparkle_and_a_host_naming_it_is_caught(self): - self.assertEqual(bundle.sparkle_residue(self.app), []) - executable = self.app / "Contents" / "MacOS" / "sample-demo" - with open(executable, "ab") as handle: - handle.write(b"\0SPUStandardUpdaterController\0") - problems = bundle.sparkle_residue(self.app) - self.assertEqual(len(problems), 1) - self.assertIn("without the updater", problems[0]) - - def test_fr34_7_pkg_installs_the_app_into_applications(self): - package = bundle.pkg(self.app, self.scratch / "Demo.pkg") - expanded = self.scratch / "expanded" - subprocess.run(["pkgutil", "--expand-full", str(package), str(expanded)], check=True) - distribution = (expanded / "Distribution").read_text() - self.assertIn("dev.example.demo", distribution) - payloads = list(expanded.glob("*.pkg/Payload/Demo App.app/Contents/Info.plist")) - self.assertEqual(len(payloads), 1) - package_info = next(expanded.glob("*.pkg/PackageInfo")).read_text() - self.assertIn('install-location="/Applications"', package_info) - - def test_fr34_7_pkg_refuses_a_bundle_that_is_not_for_the_store(self): - info_path = self.app / "Contents" / "Info.plist" - info = plistlib.loads(info_path.read_bytes()) - info["SUFeedURL"] = "https://example.com/appcast.xml" - info_path.write_bytes(plistlib.dumps(info)) - status = cli.main( - ["pkg", "--app", str(self.app), "--out", str(self.scratch / "Demo.pkg")] - ) - self.assertEqual(status, 1) - self.assertFalse((self.scratch / "Demo.pkg").exists()) - - -@unittest.skipUnless(sys.platform == "darwin", "codesign is a macOS tool") -@unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") -class SparkleBundle(unittest.TestCase): - def setUp(self): - self.scratch = Path(tempfile.mkdtemp(prefix="packager-sparkle-")) - # A stand-in renderer and an executable linked against it by its absolute path, - # the way the crate's build links a real one: the library is named by where it - # is, and both have header room for the longer relative name (rustc asks for - # that room too). - renderer = self.scratch / "renderer" / "lib" - renderer.mkdir(parents=True) - library = renderer / bundle.RENDERER_LIBRARY - compile_c( - "int dioxus_compose_renderer_stand_in(void) { return 0; }", - library, "-dynamiclib", "-install_name", str(library), - ) - compile_c("int skiko_stand_in(void) { return 0; }", renderer / "libskiko-stand-in.dylib", - "-dynamiclib") - self.executable = self.scratch / "sample-demo" - compile_c( - "int dioxus_compose_renderer_stand_in(void);\n" - "int main(void) { return dioxus_compose_renderer_stand_in(); }", - self.executable, str(library), + self.assertEqual(cli._verify(self.app, plist.NONE), []) + + def test_fr35_3_the_signed_application_runs(self): + # The point of signing at all: Apple silicon kills unsigned arm64 code on sight, + # and rewriting the load command left the linker's signature invalid. + result = subprocess.run([str(self.app / "Contents" / "MacOS" / "sample-demo")]) + self.assertEqual(result.returncode, 0) + + def test_fr35_3_code_whose_signature_broke_is_reported(self): + library = self.app / "Contents/Frameworks/lib/libskiko-stand-in.dylib" + with open(library, "ab") as handle: + handle.write(b"\0changed after signing") + problems = bundle.unsigned_code(self.app) + self.assertTrue(any("libskiko-stand-in.dylib" in problem for problem in problems)) + + def test_fr35_3_a_renderer_named_through_rpath_needs_its_directory_given(self): + scratch = self.scratch / "rpath" + renderer, executable = renderer_and_executable( + scratch, install_name="@rpath/" + bundle.RENDERER_LIBRARY ) meta = metadata.load(project(CARGO, DIOXUS)) - settings = plist.SparkleSettings( - feed_url="https://example.com/appcast.xml", public_ed_key=PUBLIC_KEY - ) - self.app = bundle.assemble( - meta, self.executable, self.scratch / "out", plist.SPARKLE, sparkle=settings, - sparkle_framework=SPARKLE_DIR / "Sparkle.framework", - ) - bundle.sign(self.app, "-", plist.SPARKLE, plist.entitlements(plist.SPARKLE)) - - def test_fr34_1_renderer_directory_travels_whole_and_is_loaded_relatively(self): - lib = self.app / "Contents" / "Frameworks" / "lib" - self.assertTrue((lib / bundle.RENDERER_LIBRARY).is_file()) - self.assertTrue((lib / "libskiko-stand-in.dylib").is_file()) - subprocess.run(["codesign", "--verify", "--strict", str(lib / "libskiko-stand-in.dylib")], - check=True) - loads = subprocess.run( - ["otool", "-L", str(self.app / "Contents" / "MacOS" / "sample-demo")], - capture_output=True, text=True, check=True, - ).stdout - self.assertIn("@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, loads) - self.assertEqual(bundle.absolute_references(self.app), []) - - def test_fr34_1_a_renderer_named_through_rpath_needs_its_directory_given(self): - renderer = self.scratch / "rpath-renderer" / "lib" - renderer.mkdir(parents=True) - library = renderer / bundle.RENDERER_LIBRARY - compile_c( - "int dioxus_compose_renderer_stand_in(void) { return 0; }", - library, "-dynamiclib", "-install_name", "@rpath/" + bundle.RENDERER_LIBRARY, - ) - executable = self.scratch / "rpath-demo" - compile_c( - "int dioxus_compose_renderer_stand_in(void);\n" - "int main(void) { return dioxus_compose_renderer_stand_in(); }", - executable, str(library), - ) - meta = metadata.with_overrides( - metadata.load(project(CARGO, DIOXUS)), executable="sample-demo" - ) - store = dict(uses_non_exempt_encryption=False) with self.assertRaisesRegex(metadata.PackagingError, "--renderer-dir"): - bundle.assemble(meta, executable, self.scratch / "rpath", plist.APP_STORE, **store) - app = bundle.assemble( - meta, executable, self.scratch / "rpath", plist.APP_STORE, renderer_dir=renderer, - **store, - ) - self.assertTrue((app / "Contents/Frameworks/lib" / bundle.RENDERER_LIBRARY).is_file()) + bundle.assemble(meta, executable, scratch / "out") + app = bundle.assemble(meta, executable, scratch / "out", renderer_dir=renderer) self.assertIn( "@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, bundle.load_commands(app / "Contents/MacOS/sample-demo"), ) - def test_fr34_1_a_renderer_directory_holding_data_is_refused_by_name(self): - renderer = self.scratch / "renderer" / "lib" - (renderer / "fonts.conf").write_text("data") + def test_fr35_3_a_renderer_directory_holding_data_is_refused_by_name(self): + (self.renderer / "fonts.conf").write_text("data") meta = metadata.load(project(CARGO, DIOXUS)) - settings = plist.SparkleSettings( - feed_url="https://example.com/appcast.xml", public_ed_key=PUBLIC_KEY - ) with self.assertRaisesRegex(metadata.PackagingError, "fonts.conf"): + bundle.assemble(meta, self.executable, self.scratch / "again") + + def test_fr35_4_disk_image_carries_the_app_applications_and_install_notes(self): + image = bundle.dmg(self.app, self.scratch / "Demo.dmg", "Demo App") + mount = self.scratch / "mount" + attached = subprocess.run( + ["hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", str(mount), str(image)], + capture_output=True, text=True, + ) + if attached.returncode != 0: + # A sandboxed shell may make images but not mount them; CI can do both. + self.skipTest(f"this shell cannot mount a disk image: {attached.stderr.strip()}") + try: + self.assertTrue((mount / "Demo App.app" / "Contents" / "Info.plist").is_file()) + self.assertEqual(str((mount / "Applications").readlink()), "/Applications") + notes = (mount / "If macOS will not open the app.txt").read_text() + self.assertIn("Open Anyway", notes) + self.assertIn("xattr -dr com.apple.quarantine", notes) + subprocess.run( + ["codesign", "--verify", "--strict", "--deep", str(mount / "Demo App.app")], + check=True, + ) + finally: + subprocess.run(["hdiutil", "detach", str(mount)], capture_output=True) + + def test_fr35_1_sparkle_options_without_a_feed_are_refused(self): + meta = metadata.load(project(CARGO, DIOXUS)) + with self.assertRaisesRegex(metadata.PackagingError, "--updater sparkle"): bundle.assemble( - meta, self.executable, self.scratch / "again", plist.SPARKLE, sparkle=settings, + meta, self.executable, self.scratch / "x", sparkle_framework=Path("Sparkle.framework") + ) + + +@unittest.skipUnless(sys.platform == "darwin", "codesign is a macOS tool") +@unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") +class SparkleBundle(unittest.TestCase): + def setUp(self): + self.scratch = Path(tempfile.mkdtemp(prefix="packager-sparkle-")) + _, self.executable = renderer_and_executable(self.scratch) + meta = metadata.load(project(CARGO, DIOXUS)) + self.app = bundle.sign( + bundle.assemble( + meta, self.executable, self.scratch / "out", sparkle=settings(), sparkle_framework=SPARKLE_DIR / "Sparkle.framework", ) + ) - def test_fr34_5_framework_and_everything_in_it_is_signed(self): + def test_fr35_3_framework_and_everything_in_it_is_signed_ad_hoc(self): framework = self.app / "Contents" / "Frameworks" / "Sparkle.framework" self.assertTrue((framework / "Versions" / "Current").is_symlink()) - for nested in ("Versions/B/Autoupdate", "Versions/B/Updater.app", - "Versions/B/XPCServices/Downloader.xpc"): - subprocess.run( - ["codesign", "--verify", "--strict", str(framework / nested)], check=True - ) - subprocess.run( - ["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True - ) + for nested in ("Versions/B/Sparkle", "Versions/B/Autoupdate", "Versions/B/Updater.app", + "Versions/B/XPCServices/Downloader.xpc", + "Versions/B/XPCServices/Installer.xpc"): + details = subprocess.run( + ["codesign", "-dv", str(framework / nested)], capture_output=True, text=True + ).stderr + self.assertIn("Signature=adhoc", details, nested) + subprocess.run(["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True) self.assertEqual(cli._verify(self.app, plist.SPARKLE), []) - def test_fr34_6_a_sparkle_bundle_is_not_a_store_bundle(self): - problems = bundle.sparkle_residue(self.app) - self.assertTrue(any("Sparkle.framework is in the bundle" in p for p in problems)) - self.assertTrue(any("SUFeedURL" in p for p in problems)) - self.assertTrue(cli._verify(self.app, plist.APP_STORE)) + def test_fr35_3_a_sparkle_bundle_is_not_a_plain_one(self): + self.assertTrue(cli._verify(self.app, plist.NONE)) - def test_fr34_9_hardware_requirement_follows_the_executable(self): - expected = "arm64" if subprocess.run( - ["uname", "-m"], capture_output=True, text=True - ).stdout.strip() == "arm64" else None + def test_fr35_5_hardware_requirement_follows_the_executable(self): + machine = subprocess.run(["uname", "-m"], capture_output=True, text=True).stdout.strip() + expected = "arm64" if machine == "arm64" else None self.assertEqual(bundle.hardware_requirements(self.app), expected) - def test_fr34_8_archive_round_trips_with_links_intact(self): + def test_fr35_4_archive_round_trips_with_links_and_signatures_intact(self): archive = bundle.zip_for_sparkle(self.app, self.scratch / "Demo-1.4.2.zip") unpacked = self.scratch / "unpacked" subprocess.run(["ditto", "-x", "-k", str(archive), str(unpacked)], check=True) diff --git a/tools/packager/tests/test_ed25519.py b/tools/packager/tests/test_ed25519.py index 3c36b539..be82a25e 100644 --- a/tools/packager/tests/test_ed25519.py +++ b/tools/packager/tests/test_ed25519.py @@ -30,7 +30,7 @@ class Rfc8032(unittest.TestCase): - def test_fr34_9_public_key_and_signature_match_the_rfc_vectors(self): + def test_fr35_5_public_key_and_signature_match_the_rfc_vectors(self): for secret, public, message, signature in VECTORS: secret = bytes.fromhex(secret) message = bytes.fromhex(message) @@ -40,28 +40,28 @@ def test_fr34_9_public_key_and_signature_match_the_rfc_vectors(self): ed25519.verify(bytes.fromhex(public), message, bytes.fromhex(signature)) ) - def test_fr34_9_a_changed_byte_does_not_verify(self): + def test_fr35_5_a_changed_byte_does_not_verify(self): secret, public, _, _ = VECTORS[1] signature = ed25519.sign(bytes.fromhex(secret), b"archive") self.assertFalse(ed25519.verify(bytes.fromhex(public), b"archivf", signature)) tampered = bytes([signature[0] ^ 1]) + signature[1:] self.assertFalse(ed25519.verify(bytes.fromhex(public), b"archive", tampered)) - def test_fr34_9_a_key_that_is_not_a_seed_is_refused_with_the_way_out(self): + def test_fr35_5_a_key_that_is_not_a_seed_is_refused_with_the_way_out(self): with tempfile.TemporaryDirectory() as scratch: path = Path(scratch) / "key" path.write_text(base64.b64encode(bytes(64)).decode()) with self.assertRaisesRegex(ed25519.InvalidKey, "generate_keys -x"): ed25519.read_private_key_file(path) - def test_fr34_9_public_key_must_be_32_bytes(self): + def test_fr35_5_public_key_must_be_32_bytes(self): with self.assertRaisesRegex(ValueError, "32"): ed25519.decode_public_key(base64.b64encode(bytes(31)).decode()) @unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") class AgreesWithSparkle(unittest.TestCase): - def test_fr34_9_signature_is_the_one_sign_update_makes(self): + def test_fr35_5_signature_is_the_one_sign_update_makes(self): with tempfile.TemporaryDirectory() as scratch: key = Path(scratch) / "sparkle.key" signing.keygen(key) diff --git a/tools/packager/tests/test_metadata.py b/tools/packager/tests/test_metadata.py index 7322f3d4..895e7e4f 100644 --- a/tools/packager/tests/test_metadata.py +++ b/tools/packager/tests/test_metadata.py @@ -9,7 +9,7 @@ class Load(unittest.TestCase): - def test_fr34_1_metadata_comes_from_the_project_files(self): + def test_fr35_1_metadata_comes_from_the_project_files(self): meta = metadata.load(project(CARGO, DIOXUS)) self.assertEqual(meta.identifier, "dev.example.demo") self.assertEqual(meta.name, "Demo App") @@ -20,7 +20,7 @@ def test_fr34_1_metadata_comes_from_the_project_files(self): self.assertEqual(meta.copyright, "Copyright Example") self.assertEqual(meta.minimum_system_version, "13.0") - def test_fr34_1_every_sample_in_the_repository_loads(self): + def test_fr35_1_every_sample_in_the_repository_loads(self): samples = sorted(path for path in (REPOSITORY / "samples").iterdir() if (path / "Dioxus.toml").exists()) self.assertTrue(samples) @@ -29,7 +29,7 @@ def test_fr34_1_every_sample_in_the_repository_loads(self): self.assertTrue(meta.identifier.startswith("dev.darkpyonix."), sample) self.assertTrue(meta.executable.startswith("sample-"), sample) - def test_fr34_1_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(self): + def test_fr35_1_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(self): dioxus = DIOXUS.replace("[macos]\nbundle_name = \"Demo App\"", "") + ( "\n[bundle.macos]\nbundle_name = \"Other\"\nminimum_system_version = \"14.0\"\n" ) @@ -37,33 +37,33 @@ def test_fr34_1_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(se self.assertEqual(meta.name, "Other") self.assertEqual(meta.minimum_system_version, "14.0") - def test_fr34_1_overrides_win_and_build_follows_version(self): + def test_fr35_1_overrides_win_and_build_follows_version(self): meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0") self.assertEqual((meta.version, meta.build), ("2.0.0", "2.0.0")) meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0", build="57") self.assertEqual(meta.build, "57") - def test_fr34_1_icon_falls_back_to_the_asset_directory(self): + def test_fr35_1_icon_falls_back_to_the_asset_directory(self): directory = project(CARGO, DIOXUS, files=[("assets/icon.png", b"png")]) self.assertEqual(metadata.load(directory).icon, directory / "assets" / "icon.png") - def test_fr34_1_missing_identifier_is_an_error_not_a_guess(self): + def test_fr35_1_missing_identifier_is_an_error_not_a_guess(self): with self.assertRaisesRegex(PackagingError, "identifier"): metadata.load(project(CARGO, "[application]\nname = \"X\"\n")) - def test_fr34_1_prerelease_version_needs_explicit_numbers(self): + def test_fr35_1_prerelease_version_needs_explicit_numbers(self): cargo = CARGO.replace("1.4.2", "1.4.2-beta.1") with self.assertRaisesRegex(PackagingError, "one to three dot separated integers"): metadata.load(project(cargo, DIOXUS)) meta = metadata.load(project(cargo, DIOXUS), version="1.4.2", build="142") self.assertEqual(meta.version, "1.4.2") - def test_fr34_1_workspace_version_has_to_be_given(self): + def test_fr35_1_workspace_version_has_to_be_given(self): cargo = CARGO.replace('version = "1.4.2"', "version.workspace = true") with self.assertRaisesRegex(PackagingError, "--version"): metadata.load(project(cargo, DIOXUS)) - def test_fr34_1_categories_accept_both_spellings_and_refuse_others(self): + def test_fr35_1_categories_accept_both_spellings_and_refuse_others(self): self.assertEqual(metadata.category_type("Utility"), "public.app-category.utilities") self.assertEqual( metadata.category_type("public.app-category.music"), "public.app-category.music" diff --git a/tools/packager/tests/test_plist.py b/tools/packager/tests/test_plist.py index b888e375..a2b4a047 100644 --- a/tools/packager/tests/test_plist.py +++ b/tools/packager/tests/test_plist.py @@ -1,4 +1,4 @@ -"""Info.plist and entitlements for the sparkle and app-store channels.""" +"""Info.plist for bundles that update themselves with Sparkle and bundles that do not.""" import unittest @@ -21,8 +21,8 @@ def sparkle(**changes): class InfoPlist(unittest.TestCase): - def test_fr34_1_common_keys_come_from_the_metadata(self): - info = plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle()) + def test_fr35_1_common_keys_come_from_the_metadata(self): + info = plist.info_plist(meta(), sparkle=sparkle()) self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") self.assertEqual(info["CFBundleExecutable"], "sample-demo") self.assertEqual(info["CFBundleName"], "Demo App") @@ -33,143 +33,63 @@ def test_fr34_1_common_keys_come_from_the_metadata(self): self.assertEqual(info["LSApplicationCategoryType"], "public.app-category.developer-tools") self.assertIs(info["NSHighResolutionCapable"], True) - def test_fr34_2_sparkle_build_carries_feed_key_and_schedule(self): + def test_fr35_1_a_bundle_without_updates_has_no_sparkle_keys(self): + info = plist.info_plist(meta()) + self.assertFalse([key for key in info if key.startswith("SU")]) + with self.assertRaisesRegex(PackagingError, "--updater sparkle"): + plist.info_plist(meta(), extra={"SUFeedURL": "https://example.com/appcast.xml"}) + + def test_fr35_2_sparkle_bundle_carries_feed_key_and_schedule(self): info = plist.info_plist( - meta(), plist.SPARKLE, sparkle=sparkle(check_interval=7200, automatically_update=True) + meta(), sparkle=sparkle(check_interval=7200, automatically_update=True) ) self.assertEqual(info["SUFeedURL"], "https://example.com/appcast.xml") self.assertEqual(info["SUPublicEDKey"], PUBLIC_KEY) self.assertIs(info["SUEnableAutomaticChecks"], True) self.assertEqual(info["SUScheduledCheckInterval"], 7200) self.assertIs(info["SUAutomaticallyUpdate"], True) - self.assertNotIn("ITSAppUsesNonExemptEncryption", info) - - def test_fr34_2_sparkle_build_needs_its_settings(self): - with self.assertRaisesRegex(PackagingError, "--feed-url"): - plist.info_plist(meta(), plist.SPARKLE) - def test_fr34_2_feed_must_be_https_except_on_loopback(self): + def test_fr35_2_feed_must_be_https_except_on_this_machine(self): for url in ("http://example.com/appcast.xml", "ftp://example.com/a", "appcast.xml"): with self.assertRaisesRegex(PackagingError, "https"): - plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(feed_url=url)) - for url in ("http://127.0.0.1:8000/appcast.xml", "http://localhost/appcast.xml"): - info = plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(feed_url=url)) + plist.info_plist(meta(), sparkle=sparkle(feed_url=url)) + for url in ( + "http://127.0.0.1:8000/appcast.xml", + "http://localhost/appcast.xml", + "file:///Users/someone/site/appcast.xml", + ): + info = plist.info_plist(meta(), sparkle=sparkle(feed_url=url)) self.assertEqual(info["SUFeedURL"], url) - def test_fr34_2_public_key_that_is_not_an_ed25519_key_is_refused(self): + def test_fr35_2_public_key_that_is_not_an_ed25519_key_is_refused(self): with self.assertRaisesRegex(ValueError, "32"): - plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(public_ed_key="AAAA")) + plist.info_plist(meta(), sparkle=sparkle(public_ed_key="AAAA")) - def test_fr34_2_check_interval_below_sparkles_floor_is_refused(self): + def test_fr35_2_check_interval_below_sparkles_floor_is_refused(self): with self.assertRaisesRegex(PackagingError, "3600"): - plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle(check_interval=60)) + plist.info_plist(meta(), sparkle=sparkle(check_interval=60)) - def test_fr34_3_store_build_has_no_sparkle_keys_and_answers_export_compliance(self): - info = plist.info_plist(meta(), plist.APP_STORE, uses_non_exempt_encryption=False) - self.assertFalse([key for key in info if key.startswith("SU")]) - self.assertIs(info["ITSAppUsesNonExemptEncryption"], False) - - def test_fr34_3_store_build_refuses_sparkle_settings(self): - with self.assertRaisesRegex(PackagingError, "updates itself"): - plist.info_plist( - meta(), plist.APP_STORE, sparkle=sparkle(), uses_non_exempt_encryption=False - ) - with self.assertRaisesRegex(PackagingError, "Sparkle setting"): - plist.info_plist( - meta(), plist.APP_STORE, uses_non_exempt_encryption=False, - extra={"SUFeedURL": "https://example.com"}, - ) - - def test_fr34_3_store_build_needs_category_and_encryption_answer(self): - directory = project(CARGO, DIOXUS.replace('category = "DeveloperTool"\n', "")) - with self.assertRaisesRegex(PackagingError, "LSApplicationCategoryType"): - plist.info_plist( - metadata.load(directory), plist.APP_STORE, uses_non_exempt_encryption=False - ) - with self.assertRaisesRegex(PackagingError, "non-exempt encryption"): - plist.info_plist(meta(), plist.APP_STORE) - - def test_fr34_1_extra_entries_are_added_but_cannot_contradict_derived_ones(self): + def test_fr35_1_extra_entries_are_added_but_cannot_contradict_derived_ones(self): info = plist.info_plist( - meta(), plist.SPARKLE, sparkle=sparkle(), + meta(), sparkle=sparkle(), extra={"NSAppTransportSecurity": {"NSAllowsLocalNetworking": True}}, ) self.assertEqual(info["NSAppTransportSecurity"], {"NSAllowsLocalNetworking": True}) with self.assertRaisesRegex(PackagingError, "CFBundleIdentifier"): - plist.info_plist( - meta(), plist.SPARKLE, sparkle=sparkle(), - extra={"CFBundleIdentifier": "other.id"}, - ) + plist.info_plist(meta(), sparkle=sparkle(), extra={"CFBundleIdentifier": "other.id"}) - def test_fr34_1_command_line_entries_are_typed(self): + def test_fr35_1_command_line_entries_are_typed(self): self.assertEqual( plist.parse_extra(["A=true", "B=12", "C=text"]), {"A": True, "B": 12, "C": "text"} ) with self.assertRaisesRegex(PackagingError, "KEY=VALUE"): plist.parse_extra(["nothing"]) - def test_fr34_1_plist_serialises_as_xml(self): - data = plist.dumps(plist.info_plist(meta(), plist.SPARKLE, sparkle=sparkle())) + def test_fr35_1_plist_serialises_as_xml(self): + data = plist.dumps(plist.info_plist(meta(), sparkle=sparkle())) self.assertTrue(data.startswith(b"SUPublicEDKey", data) -class Entitlements(unittest.TestCase): - def test_fr34_4_sparkle_build_asks_for_nothing(self): - self.assertEqual(plist.entitlements(plist.SPARKLE), {}) - self.assertEqual( - plist.entitlements( - plist.SPARKLE, capabilities=plist.Capabilities(network_client=True) - ), - {}, - ) - - def test_fr34_4_store_build_is_sandboxed_with_only_what_was_declared(self): - self.assertEqual( - plist.entitlements(plist.APP_STORE), {"com.apple.security.app-sandbox": True} - ) - granted = plist.entitlements( - plist.APP_STORE, - capabilities=plist.Capabilities( - network_client=True, user_selected_files="read-write" - ), - ) - self.assertEqual( - granted, - { - "com.apple.security.app-sandbox": True, - "com.apple.security.network.client": True, - "com.apple.security.files.user-selected.read-write": True, - }, - ) - - def test_fr34_4_no_channel_asks_for_a_hardened_runtime_exception(self): - everything = plist.Capabilities( - network_client=True, network_server=True, user_selected_files="read-only", - downloads_folder="read-write", - ) - for channel in plist.CHANNELS: - granted = plist.entitlements( - channel, capabilities=everything, team_id="ABCDE12345", - identifier="dev.example.demo", - ) - self.assertFalse([key for key in granted if key.startswith("com.apple.security.cs.")]) - - def test_fr34_4_team_id_names_the_application_for_the_store(self): - granted = plist.entitlements( - plist.APP_STORE, team_id="ABCDE12345", identifier="dev.example.demo" - ) - self.assertEqual( - granted["com.apple.application-identifier"], "ABCDE12345.dev.example.demo" - ) - self.assertEqual(granted["com.apple.developer.team-identifier"], "ABCDE12345") - - def test_fr34_4_file_access_is_read_only_or_read_write(self): - with self.assertRaisesRegex(PackagingError, "read-only or read-write"): - plist.entitlements( - plist.APP_STORE, capabilities=plist.Capabilities(user_selected_files="all") - ) - - if __name__ == "__main__": unittest.main() diff --git a/tools/packager/tests/test_signing.py b/tools/packager/tests/test_signing.py index 18e615c6..8be72d66 100644 --- a/tools/packager/tests/test_signing.py +++ b/tools/packager/tests/test_signing.py @@ -21,19 +21,19 @@ def setUp(self): self.archive = self.scratch / "Demo-2.zip" self.archive.write_bytes(os.urandom(4096)) - def test_fr34_9_keygen_writes_a_private_file_and_prints_the_matching_public_key(self): + def test_fr35_5_keygen_writes_a_private_file_and_prints_the_matching_public_key(self): self.assertEqual(stat.S_IMODE(self.key.stat().st_mode), 0o600) self.assertEqual(len(base64.b64decode(self.key.read_text())), 32) self.assertEqual(signing.public_key_of(self.key), self.public) self.assertEqual(len(ed25519.decode_public_key(self.public)), 32) - def test_fr34_9_keygen_never_overwrites_a_key(self): + def test_fr35_5_keygen_never_overwrites_a_key(self): before = self.key.read_bytes() with self.assertRaisesRegex(PackagingError, "never overwrites"): signing.keygen(self.key) self.assertEqual(self.key.read_bytes(), before) - def test_fr34_9_signature_verifies_under_the_public_key(self): + def test_fr35_5_signature_verifies_under_the_public_key(self): signature = signing.sign_archive(self.archive, self.key, public_key=self.public) self.assertTrue( ed25519.verify( @@ -43,13 +43,13 @@ def test_fr34_9_signature_verifies_under_the_public_key(self): ) ) - def test_fr34_9_signing_with_a_key_the_app_does_not_trust_is_refused(self): + def test_fr35_5_signing_with_a_key_the_app_does_not_trust_is_refused(self): other = signing.keygen(self.scratch / "other.key") with self.assertRaisesRegex(PackagingError, "every installed copy would refuse"): signing.sign_archive(self.archive, self.key, public_key=other) @unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") - def test_fr34_9_sign_update_is_used_when_given_and_checked(self): + def test_fr35_5_sign_update_is_used_when_given_and_checked(self): signature = signing.sign_archive( self.archive, self.key, sign_update=SPARKLE_DIR / "bin" / "sign_update", public_key=self.public, From a715692b3fa9f31f306abc93699c153f8180943b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:18:50 +0900 Subject: [PATCH 039/224] Refactor: Describe the updater for applications signed ad hoc (FR-35) --- dioxus-compose-update/src/lib.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/dioxus-compose-update/src/lib.rs b/dioxus-compose-update/src/lib.rs index c0198a6b..d42b71d4 100644 --- a/dioxus-compose-update/src/lib.rs +++ b/dioxus-compose-update/src/lib.rs @@ -5,7 +5,7 @@ //! an appcast, shows Sparkle's own update window (or installs silently when the person //! allowed that), verifies the archive's EdDSA signature against the key in its //! Info.plist, and replaces itself. Everything about where to look and which key to trust -//! is in the bundle, written by `tools/packager/package-macos app --channel sparkle`; +//! is in the bundle, written by `tools/packager/package-macos app --updater sparkle`; //! this crate only starts Sparkle and passes on the person's requests. //! //! ```no_run @@ -20,10 +20,11 @@ //! // on_click: move |_| dioxus_compose_update::check_for_updates(), //! ``` //! -//! A build for the Mac App Store must not contain this crate: the store updates the -//! application, App Review rejects one that updates itself, and the packager refuses a -//! store bundle whose executable names Sparkle. Put the dependency behind a Cargo feature -//! that the store build leaves off. +//! Sparkle checks every update against the EdDSA key in the bundle's Info.plist, not +//! against who signed the code, so this works for an application signed ad hoc with no +//! Apple developer account behind it. An application that does not update itself (one a +//! store or package manager updates) leaves this crate out, and is packaged with +//! `--updater none`. //! //! Elsewhere (Windows, Linux, iOS, Android, the browser) [`start`] answers //! [`UpdaterError::Unsupported`] and the other functions do nothing, so calling code @@ -84,7 +85,7 @@ impl fmt::Display for UpdaterError { Self::FrameworkMissing(path) => write!( f, "there is no Sparkle framework at {}; package the application with \ - `package-macos app --channel sparkle --sparkle-framework ...`", + `package-macos app --updater sparkle --sparkle-framework ...`", path.display() ), Self::AlreadyStarted => write!(f, "the updater was already started"), @@ -166,7 +167,7 @@ mod tests { use super::*; #[test] - fn fr34_10_framework_is_found_beside_the_executable_in_its_bundle() { + fn fr35_6_framework_is_found_beside_the_executable_in_its_bundle() { assert_eq!( framework_path(Path::new("/Applications/Demo.app/Contents/MacOS/demo")), Ok(PathBuf::from( @@ -176,7 +177,7 @@ mod tests { } #[test] - fn fr34_10_an_executable_outside_a_bundle_is_named_in_the_error() { + fn fr35_6_an_executable_outside_a_bundle_is_named_in_the_error() { for path in [ "/usr/local/bin/demo", "/Applications/Demo.app/Contents/demo", @@ -190,7 +191,7 @@ mod tests { } #[test] - fn fr34_10_a_directory_without_the_binary_is_not_a_framework() { + fn fr35_6_a_directory_without_the_binary_is_not_a_framework() { let scratch = std::env::temp_dir().join(format!( "dioxus-compose-update-test-{}", std::process::id() @@ -204,7 +205,7 @@ mod tests { } #[test] - fn fr34_10_an_executable_outside_a_bundle_cannot_start_the_updater() { + fn fr35_6_an_executable_outside_a_bundle_cannot_start_the_updater() { // The test binary lives in target/, not in a bundle, which is exactly the case of // running an application with `cargo run`. That must be an error the caller can // print, not a crash and not a silent success. @@ -217,7 +218,7 @@ mod tests { } #[test] - fn fr34_10_checks_before_start_do_nothing() { + fn fr35_6_checks_before_start_do_nothing() { // Neither may panic or block: on macOS they queue work for the main thread, which // in a test binary never runs, and the queued work finds no controller. check_for_updates(); From 3b7032182f719aa7c2ccd5e9a0482d8b60bd48e5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:18:50 +0900 Subject: [PATCH 040/224] Docs: Say how to ship a macOS app and open it without notarization (FR-35) README and its Korean translation gain a section on the packager and on what macOS asks before it opens an ad hoc signed download: Open Anyway in Privacy & Security on macOS 15 and newer (Control-click Open no longer bypasses Gatekeeper there), Control-click Open on 14 and older, and xattr -dr com.apple.quarantine on any version. Checked on macOS 26.5.1: a quarantined copy is held at launch and run from App Translocation, and after xattr -dr it launches in place. --- README.md | 42 +++++++++++++++++++++++++++++++++++++++ docs/locales/README_ko.md | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/README.md b/README.md index b499722e..2755ec0b 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,48 @@ A build with no network says which two files to put where, and putting them ther running a binary built that way prints what is missing and exits non-zero rather than opening no window and returning 0. +### Shipping a macOS application + +`tools/packager/package-macos` turns the executable `cargo build --release` made into an +application bundle, using the name, identifier and version your `Dioxus.toml` and +`Cargo.toml` already state. It needs Python 3.11 and Xcode's command line tools, and no Apple +developer account. + +```sh +tools/packager/package-macos keygen --out ~/keys/sparkle.key # once; keep it safe +tools/packager/package-macos app --updater sparkle --manifest-dir . \ + --executable target/release/my-app --out dist \ + --feed-url https://example.com/appcast.xml --ed-key-file ~/keys/sparkle.key \ + --sparkle-framework Sparkle.framework +tools/packager/package-macos dmg --app "dist/My App.app" --out dist/MyApp-1.0.0.dmg +tools/packager/package-macos archive --app "dist/My App.app" --out site/MyApp-1.0.0.zip +tools/packager/package-macos appcast --appcast site/appcast.xml --app "dist/My App.app" \ + --archive site/MyApp-1.0.0.zip --url https://example.com/MyApp-1.0.0.zip \ + --ed-key-file ~/keys/sparkle.key +``` + +The application then updates itself with [Sparkle 2](https://sparkle-project.org): add the +`dioxus-compose-update` crate, call `dioxus_compose_update::start(Default::default())` before +`launch`, and `check_for_updates()` from a menu item. Every update is checked against the +EdDSA key the bundle carries; `--updater none` makes a bundle that does not update. + +Every bundle is signed ad hoc. Apple silicon needs a signature to run anything, and an ad hoc +one needs no account, but it does not name a developer Apple has verified, so **macOS asks +before it opens a downloaded copy for the first time**: + +- **macOS 15 (Sequoia) and newer:** open the app and click Done when macOS says it could not + verify it. Then in System Settings, Privacy & Security, scroll to Security and click + **Open Anyway** next to the app (the button stays for about an hour), confirm, and enter + your password. +- **macOS 14 and older:** Control-click the app, choose **Open**, then **Open** again. +- **Any version:** `xattr -dr com.apple.quarantine "/Applications/My App.app"` removes the + download mark, and macOS opens it without asking. + +Move the app into Applications before the first launch: run from the disk image or Downloads, +macOS starts a hidden temporary copy that cannot update itself. Updates Sparkle installs open +without asking again. The disk image carries these steps as `If macOS will not open the +app.txt`. + Everything below this point is about working on **this repository**, which needs the renderer toolchain as well. diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index f9d89f3c..18968146 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -340,6 +340,47 @@ dioxus-compose = "0.0.0" 만든 바이너리를 실행하면 무엇이 없는지 말하고 0이 아닌 상태로 끝납니다. 창을 열지 않은 채 0을 반환하지 않습니다. +### macOS 애플리케이션 배포하기 + +`tools/packager/package-macos`는 `cargo build --release`가 만든 실행 파일을 애플리케이션 +번들로 만듭니다. 이름, 식별자, 버전은 `Dioxus.toml`과 `Cargo.toml`에 이미 적힌 것을 씁니다. +Python 3.11과 Xcode 명령줄 도구가 필요하고, Apple 개발자 계정은 필요하지 않습니다. + +```sh +tools/packager/package-macos keygen --out ~/keys/sparkle.key # 한 번만, 잘 보관 +tools/packager/package-macos app --updater sparkle --manifest-dir . \ + --executable target/release/my-app --out dist \ + --feed-url https://example.com/appcast.xml --ed-key-file ~/keys/sparkle.key \ + --sparkle-framework Sparkle.framework +tools/packager/package-macos dmg --app "dist/My App.app" --out dist/MyApp-1.0.0.dmg +tools/packager/package-macos archive --app "dist/My App.app" --out site/MyApp-1.0.0.zip +tools/packager/package-macos appcast --appcast site/appcast.xml --app "dist/My App.app" \ + --archive site/MyApp-1.0.0.zip --url https://example.com/MyApp-1.0.0.zip \ + --ed-key-file ~/keys/sparkle.key +``` + +이렇게 만든 애플리케이션은 [Sparkle 2](https://sparkle-project.org)로 스스로 갱신합니다. +`dioxus-compose-update` 크레이트를 추가하고, `launch` 전에 +`dioxus_compose_update::start(Default::default())`를, 메뉴 항목에서 `check_for_updates()`를 +부르면 됩니다. 모든 갱신은 번들이 가진 EdDSA 키로 검증됩니다. `--updater none`은 갱신하지 +않는 번들을 만듭니다. + +모든 번들은 임시(ad hoc) 서명됩니다. Apple 실리콘은 서명 없는 코드를 실행하지 않고, 임시 +서명에는 계정이 필요 없습니다. 다만 Apple이 확인한 개발자를 가리키지 않으므로, **내려받은 +사본을 처음 열 때 macOS가 확인을 요구합니다**. + +- **macOS 15(Sequoia) 이상:** 앱을 열고, macOS가 확인할 수 없다고 하면 완료를 누릅니다. + 그다음 시스템 설정 > 개인정보 보호 및 보안에서 보안 항목까지 내려가 앱 옆의 + **그래도 열기**를 누르고(버튼은 약 한 시간 동안 남습니다), 다시 확인한 뒤 암호를 입력합니다. +- **macOS 14 이하:** 앱을 Control-클릭하고 **열기**를 고른 뒤 다시 **열기**를 누릅니다. +- **모든 버전:** `xattr -dr com.apple.quarantine "/Applications/My App.app"`은 다운로드 + 표시를 지우고, 그러면 macOS가 묻지 않고 엽니다. + +처음 실행하기 전에 앱을 응용 프로그램 폴더로 옮기십시오. 디스크 이미지나 다운로드 폴더에서 +실행하면 macOS가 숨겨진 임시 사본을 실행하고, 그 사본은 스스로 갱신할 수 없습니다. Sparkle이 +설치한 갱신은 다시 묻지 않고 열립니다. 디스크 이미지에는 이 절차가 +`If macOS will not open the app.txt`로 들어 있습니다. + 아래는 전부 **이 저장소에서 작업할 때** 필요한 내용이며, 렌더러 툴체인까지 갖춰야 합니다. ### 0. 개발 환경 점검 From e3e83e7400a18a6f7c2f27915485ecdb23b6e9af Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:18:50 +0900 Subject: [PATCH 041/224] Test: Rehearse an ad hoc signed update past Gatekeeper on CI (FR-35) The rehearsal now launches through LaunchServices, so Gatekeeper is involved: a copy marked as downloaded is held, the same copy launches after xattr -dr com.apple.quarantine, updates itself to 2.0.0 by EdDSA signature alone, relaunches in place, and the installed update carries no quarantine mark. The store build is gone with the store. The demo application gains a build script for the published 0.0.0, whose own link arguments never reached an application and so left the Host's entry points out ("symbol not found in flat namespace"). The served site skips http.server's name lookup, which asked for local network access. --- .github/workflows/packager-macos.yml | 13 +- experiments/self-update/app/Cargo.toml | 4 +- experiments/self-update/app/build.rs | 27 ++ experiments/self-update/app/src/main.rs | 2 +- experiments/self-update/rehearse.sh | 327 +++++++++++++----------- 5 files changed, 216 insertions(+), 157 deletions(-) create mode 100644 experiments/self-update/app/build.rs diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml index c673e2ef..309fe2f1 100644 --- a/.github/workflows/packager-macos.yml +++ b/.github/workflows/packager-macos.yml @@ -1,14 +1,15 @@ # macOS packaging: the packager's tests, the updater crate's tests, and the rehearsal that # installs one version of an application and watches it update itself to the next. # -# The rehearsal is the only place the two halves meet a real renderer: it builds the demo -# against the published dioxus-compose, whose build script fetches the renderer released for -# its version, so nothing here builds the renderer. It signs ad hoc, because no Apple -# identity exists on a runner; what that leaves unproven (Developer ID signing, notarization, -# App Store Connect accepting the package) is the owner's to run with real certificates. +# Everything is signed ad hoc, which is what the project ships without an Apple developer +# account, and Sparkle trusts an update by its EdDSA signature alone. The rehearsal is the +# only place the two halves meet a real renderer: it builds the demo against the published +# dioxus-compose and the renderer released for it, so nothing here builds the renderer. # # Its evidence (logs, Info.plists, signatures, the appcast, screenshots) is uploaded as the -# self-update-evidence artifact whether or not it passed. +# self-update-evidence artifact whether or not it passed, and the bundles and served site +# as self-update-bundles, so the same update can be watched on a Mac that built nothing +# (DEMO_REUSE=1 experiments/self-update/rehearse.sh). name: macOS packaging diff --git a/experiments/self-update/app/Cargo.toml b/experiments/self-update/app/Cargo.toml index 85214fb8..9ac41a43 100644 --- a/experiments/self-update/app/Cargo.toml +++ b/experiments/self-update/app/Cargo.toml @@ -19,8 +19,8 @@ publish = false [features] default = ["sparkle"] -# The updater. The Mac App Store build turns default features off, and the packager -# checks that nothing of Sparkle is left in what it builds. +# The updater. Without it (default features off) the application does not update itself, +# and the packager makes it a bundle with no Sparkle in it. sparkle = ["dep:dioxus-compose-update", "dep:objc2"] [dependencies] diff --git a/experiments/self-update/app/build.rs b/experiments/self-update/app/build.rs new file mode 100644 index 00000000..86940482 --- /dev/null +++ b/experiments/self-update/app/build.rs @@ -0,0 +1,27 @@ +//! Makes the Host's entry points visible to the renderer, for the published 0.0.0 only. +//! +//! The renderer finds `dioxus_compose_host_*` in the executable by name when it is loaded. +//! dioxus-compose 0.0.0 asked for that with link arguments in its own build script, and +//! Cargo does not pass a dependency's link arguments on to the application, so an +//! application of that version starts with "symbol not found in flat namespace +//! '_dioxus_compose_host_dispatch_event'". Later versions keep the symbols from inside the +//! crate and need none of this. + +const HOST_ENTRY_POINTS: [&str; 5] = [ + "dioxus_compose_host_init", + "dioxus_compose_host_dispatch_event", + "dioxus_compose_host_render_frame", + "dioxus_compose_host_release_batch", + "dioxus_compose_host_shutdown", +]; + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { + return; + } + println!("cargo:rustc-link-arg-bins=-Wl,-export_dynamic"); + for symbol in HOST_ENTRY_POINTS { + // Kept even though nothing in the program calls them: the renderer does. + println!("cargo:rustc-link-arg-bins=-Wl,-u,_{symbol}"); + } +} diff --git a/experiments/self-update/app/src/main.rs b/experiments/self-update/app/src/main.rs index 337255e3..dc0f5f17 100644 --- a/experiments/self-update/app/src/main.rs +++ b/experiments/self-update/app/src/main.rs @@ -27,7 +27,7 @@ fn app() -> Element { let channel = if cfg!(feature = "sparkle") { "Updates itself with Sparkle." } else { - "Updated by the Mac App Store." + "Does not update itself." }; rsx! { Column { diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh index 5b5b07aa..5ed354c4 100755 --- a/experiments/self-update/rehearse.sh +++ b/experiments/self-update/rehearse.sh @@ -1,34 +1,42 @@ #!/usr/bin/env bash # Usage: experiments/self-update/rehearse.sh # -# Rehearses both macOS channels end to end on one machine, with no Apple identity: +# Rehearses a Sparkle self-update end to end on one machine with no Apple developer +# account: every bundle is signed ad hoc, and the only thing that vouches for an update is +# its EdDSA signature. # -# Sparkle: builds the demo application at 1.0.0 and 2.0.0, packages both for the -# sparkle channel (signed ad hoc), publishes 2.0.0 into an appcast served from -# 127.0.0.1, installs 1.0.0, runs it, and waits for it to replace itself with 2.0.0. -# Twice: once installing as soon as the update is ready (and relaunching), once -# installing when the application quits, which is Sparkle's default. -# -# Mac App Store: builds the same application without the updater, packages it for the -# app-store channel (sandboxed), checks nothing of Sparkle is in it, makes the .pkg, and -# runs the sandboxed application. +# 1. Builds the demo application at 1.0.0 and 2.0.0 and packages both (ad hoc). +# 2. Publishes 2.0.0: a zip, and an appcast signed with a key made here, checked against +# Sparkle's own sign_update and generate_appcast. +# 3. Gatekeeper: a copy marked as downloaded (com.apple.quarantine, as a browser leaves +# it) is held at launch; after `xattr -dr com.apple.quarantine`, the step the install +# notes give, the same copy launches. +# 4. That copy, 1.0.0, finds 2.0.0 in the appcast served from 127.0.0.1, installs it at +# once and relaunches as 2.0.0. The new bundle carries no quarantine mark, so +# Gatekeeper lets the relaunch through, and 2.0.0 checks the feed and downloads +# nothing more. +# 5. A second 1.0.0 installs the update when it quits, which is Sparkle's default. # # Everything goes under $DEMO_WORK (default .scratch/self-update in this repository): # builds, bundles, the served site, the private key, and evidence/ with logs, plists, # signatures and screenshots. evidence/result.txt says what passed. # -# Needs Xcode's command line tools, Python 3.11 or newer, and Rust. Sparkle's release -# archive is downloaded into $DEMO_WORK and checked against a pinned SHA-256, unless -# SPARKLE_DIR names an unpacked one. Running the application writes what any macOS +# DEMO_REUSE=1 skips steps 1 and 2 and uses the dist-1, dist-2 and site directories already +# in $DEMO_WORK, for example ones unpacked from the CI artifact, so the update can be +# watched on a Mac that has not built anything. +# +# Needs Xcode's command line tools, Python 3.11 or newer, and (unless reusing) Rust. +# Sparkle's release archive and the released renderer are downloaded into $DEMO_WORK and +# checked against pinned SHA-256 sums. Running the application writes what any macOS # application writes: Sparkle's preferences and download cache under ~/Library for the -# demo's bundle identifier, and the sandbox container of the store build. The last step -# lists them. +# demo's bundle identifier. The last step lists them. set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" work="${DEMO_WORK:-$repo/.scratch/self-update}" port="${DEMO_PORT:-8765}" +reuse="${DEMO_REUSE:-0}" package="$repo/tools/packager/package-macos" app_dir="$repo/experiments/self-update/app" evidence="$work/evidence" @@ -41,7 +49,10 @@ renderer_version="0.0.0" sparkle_version="2.10.0" sparkle_sha256="c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c" -rm -rf "$work/build" "$work"/dist-* "$work"/install-* "$work/site" "$work/generated" "$evidence" +rm -rf "$work"/install-* "$evidence" +if [[ "$reuse" != 1 ]]; then + rm -rf "$work/build" "$work"/dist-* "$work/site" "$work/generated" "$work/bundles" +fi mkdir -p "$work/build" "$work/site" "$evidence" result="$evidence/result.txt" : >"$result" @@ -67,9 +78,9 @@ diagnose() { return 0 } shot() { - # The whole screen. A runner without screen recording permission gets a black or - # missing picture, which is noted rather than fatal: the logs are the evidence and the - # picture is for a person. + # The whole screen. A machine where this shell may not record the screen gets no + # picture, which is noted rather than fatal: the logs are the evidence and the picture + # is for a person. sleep "${2:-0}" screencapture -x "$evidence/$1.png" 2>>"$evidence/screencapture.log" || echo "screencapture failed for $1" >>"$evidence/screencapture.log" @@ -79,7 +90,7 @@ pids=() cleanup() { for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done pkill -f "$work/install-" 2>/dev/null || true - pkill -f "$work/dist-store" 2>/dev/null || true + pkill -f "AppTranslocation/.*/$name.app" 2>/dev/null || true } trap cleanup EXIT @@ -99,6 +110,22 @@ wait_for() { done } +# Mark a bundle the way a browser and Archive Utility leave a download. +quarantine() { + local value + value="0083;$(printf %x "$(date +%s)");Safari;$(uuidgen)" + xattr -w -r com.apple.quarantine "$value" "$1" +} + +# Launch through LaunchServices, as a double click does, so that Gatekeeper is consulted. +launch() { + local app="$1" log="$2" + shift 2 + local env_args=() + for assignment in "$@"; do env_args+=(--env "$assignment"); done + open -n ${env_args[@]+"${env_args[@]}"} --stdout "$log" --stderr "$log" "$app" +} + step "Sparkle $sparkle_version" sparkle="${SPARKLE_DIR:-$work/sparkle}" if [[ ! -x "$sparkle/bin/sign_update" ]]; then @@ -109,28 +136,28 @@ if [[ ! -x "$sparkle/bin/sign_update" ]]; then tar -xf "$sparkle/Sparkle.tar.xz" -C "$sparkle" fi -step "Renderer $renderer_version, as released for the dioxus-compose the demo depends on" -renderer="$work/renderer" -if [[ ! -f "$renderer/lib/libdioxus_compose_renderer.dylib" ]]; then - artifact="dioxus-compose-renderer-v$renderer_version-macos-aarch64.tar.gz" - release="https://github.com/DarkPyonix/dioxus-compose/releases/download/v$renderer_version" - mkdir -p "$work/downloads" "$renderer" - curl -fsSL -o "$work/downloads/$artifact" "$release/$artifact" - curl -fsSL -o "$work/downloads/$artifact.sha256" "$release/$artifact.sha256" - (cd "$work/downloads" && shasum -a 256 -c "$artifact.sha256") - tar -xzf "$work/downloads/$artifact" -C "$renderer" -fi -export DIOXUS_COMPOSE_RENDERER_DIR="$renderer" +if [[ "$reuse" != 1 ]]; then + step "Renderer $renderer_version, as released for the dioxus-compose the demo depends on" + renderer="$work/renderer" + if [[ ! -f "$renderer/lib/libdioxus_compose_renderer.dylib" ]]; then + artifact="dioxus-compose-renderer-v$renderer_version-macos-aarch64.tar.gz" + release="https://github.com/DarkPyonix/dioxus-compose/releases/download/v$renderer_version" + mkdir -p "$work/downloads" "$renderer" + curl -fsSL -o "$work/downloads/$artifact" "$release/$artifact" + curl -fsSL -o "$work/downloads/$artifact.sha256" "$release/$artifact.sha256" + (cd "$work/downloads" && shasum -a 256 -c "$artifact.sha256") + tar -xzf "$work/downloads/$artifact" -C "$renderer" + fi + export DIOXUS_COMPOSE_RENDERER_DIR="$renderer" -step "EdDSA key (kept in $work/keys, never in the repository)" -key="$work/keys/sparkle.key" -[[ -f "$key" ]] || "$package" keygen --out "$key" -public_key="$("$package" public-key --key "$key")" -echo "SUPublicEDKey $public_key" + step "EdDSA key (kept in $work/keys, never in the repository)" + key="$work/keys/sparkle.key" + [[ -f "$key" ]] || "$package" keygen --out "$key" + echo "SUPublicEDKey $("$package" public-key --key "$key")" -# Plain http to 127.0.0.1 is how an update is rehearsed on one machine. App Transport -# Security refuses it unless the application allows local networking. -cat >"$work/ats.plist" <<'PLIST' + # Plain http to 127.0.0.1 is how an update is rehearsed on one machine. App Transport + # Security refuses it unless the application allows local networking. + cat >"$work/ats.plist" <<'PLIST' @@ -138,52 +165,43 @@ cat >"$work/ats.plist" <<'PLIST' PLIST -build() { - local version="$1" out="$2" - shift 2 - DEMO_VERSION="$version" cargo build --release --manifest-path "$app_dir/Cargo.toml" "$@" - cp "$app_dir/target/release/$executable" "$out" -} - -step "Build and package 1.0.0 and 2.0.0 for the sparkle channel" -for major in 1 2; do - build "$major.0.0" "$work/build/$executable-$major" - "$package" app --channel sparkle --manifest-dir "$app_dir" \ - --executable "$work/build/$executable-$major" --renderer-dir "$renderer/lib" \ - --version "$major.0.0" --build "$major" --out "$work/dist-$major" \ - --feed-url "http://127.0.0.1:$port/appcast.xml" --ed-key-file "$key" \ - --sparkle-framework "$sparkle/Sparkle.framework" --automatically-update \ - --plist-file "$work/ats.plist" | tee "$evidence/package-$major.txt" - plutil -p "$work/dist-$major/$name.app/Contents/Info.plist" >"$evidence/Info-$major.plist.txt" - codesign -dvv "$work/dist-$major/$name.app" >"$evidence/codesign-$major.txt" 2>&1 - otool -L "$work/dist-$major/$name.app/Contents/MacOS/$executable" >"$evidence/otool-$major.txt" - # Kept whole (ditto keeps the framework's links, which an artifact upload would not), - # so a failure here can be reproduced by running the very bundle elsewhere. + step "Build and package 1.0.0 and 2.0.0, signed ad hoc" mkdir -p "$work/bundles" - ditto -c -k --keepParent "$work/dist-$major/$name.app" "$work/bundles/sparkle-$major.0.0.zip" -done -pass "1.0.0 and 2.0.0 packaged, signed and verified for the sparkle channel" + for major in 1 2; do + DEMO_VERSION="$major.0.0" cargo build --release --manifest-path "$app_dir/Cargo.toml" + cp "$app_dir/target/release/$executable" "$work/build/$executable-$major" + "$package" app --updater sparkle --manifest-dir "$app_dir" \ + --executable "$work/build/$executable-$major" --renderer-dir "$renderer/lib" \ + --version "$major.0.0" --build "$major" --out "$work/dist-$major" \ + --feed-url "http://127.0.0.1:$port/appcast.xml" --ed-key-file "$key" \ + --sparkle-framework "$sparkle/Sparkle.framework" --automatically-update \ + --plist-file "$work/ats.plist" | tee "$evidence/package-$major.txt" + # Kept whole (ditto keeps the framework's links, which an artifact upload would + # not), so the update can be watched again on another Mac without building. + ditto -c -k --keepParent "$work/dist-$major/$name.app" "$work/bundles/$name-$major.0.0.zip" + done + "$package" dmg --app "$work/dist-1/$name.app" --out "$work/bundles/$name-1.0.0.dmg" + pass "1.0.0 and 2.0.0 packaged; every piece of code in them is signed ad hoc" -step "Publish 2.0.0" -"$package" archive --app "$work/dist-2/$name.app" --out "$work/site/$archive_name" -"$package" appcast --appcast "$work/site/appcast.xml" --app "$work/dist-2/$name.app" \ - --archive "$work/site/$archive_name" --url "http://127.0.0.1:$port/$archive_name" \ - --ed-key-file "$key" --sign-update "$sparkle/bin/sign_update" -cp "$work/site/appcast.xml" "$evidence/appcast.xml" -signature="$(python3 -c ' + step "Publish 2.0.0" + "$package" archive --app "$work/dist-2/$name.app" --out "$work/site/$archive_name" + "$package" appcast --appcast "$work/site/appcast.xml" --app "$work/dist-2/$name.app" \ + --archive "$work/site/$archive_name" --url "http://127.0.0.1:$port/$archive_name" \ + --ed-key-file "$key" --sign-update "$sparkle/bin/sign_update" + signature="$(python3 -c ' import sys, xml.etree.ElementTree as ET S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" print(ET.parse(sys.argv[1]).find("channel/item/enclosure").get(S + "edSignature")) ' "$work/site/appcast.xml")" -"$sparkle/bin/sign_update" --ed-key-file "$key" --verify "$work/site/$archive_name" "$signature" -pass "the appcast's signature verifies with Sparkle's sign_update" + "$sparkle/bin/sign_update" --ed-key-file "$key" --verify "$work/site/$archive_name" "$signature" + pass "the appcast's signature verifies with Sparkle's sign_update" -# Sparkle's own generator, given the same archive and key, must say the same thing. -mkdir -p "$work/generated" -cp "$work/site/$archive_name" "$work/generated/" -"$sparkle/bin/generate_appcast" --ed-key-file "$key" "$work/generated" >"$evidence/generate_appcast.txt" 2>&1 -cp "$work/generated/appcast.xml" "$evidence/appcast-from-generate_appcast.xml" -python3 - "$work/site/appcast.xml" "$work/generated/appcast.xml" <<'PY' | tee -a "$evidence/appcast-comparison.txt" + # Sparkle's own generator, given the same archive and key, must say the same thing. + mkdir -p "$work/generated" + cp "$work/site/$archive_name" "$work/generated/" + "$sparkle/bin/generate_appcast" --ed-key-file "$key" "$work/generated" >"$evidence/generate_appcast.txt" 2>&1 + cp "$work/generated/appcast.xml" "$evidence/appcast-from-generate_appcast.xml" + python3 - "$work/site/appcast.xml" "$work/generated/appcast.xml" <<'PY' | tee "$evidence/appcast-comparison.txt" import sys, xml.etree.ElementTree as ET S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" def read(path): @@ -202,109 +220,122 @@ for key in ours: print(f"{key}: ours={ours[key]} generate_appcast={theirs[key]}") sys.exit(0 if ours == theirs else 1) PY -pass "the appcast item matches what Sparkle's generate_appcast writes for the same archive" + pass "the appcast item matches what Sparkle's generate_appcast writes for the same archive" + cp -R "$work/site" "$work/bundles/site" +fi + +for major in 1 2; do + plutil -p "$work/dist-$major/$name.app/Contents/Info.plist" >"$evidence/Info-$major.plist.txt" + codesign -dvv "$work/dist-$major/$name.app" >"$evidence/codesign-$major.txt" 2>&1 + codesign --verify --strict --deep --verbose=2 "$work/dist-$major/$name.app" \ + >>"$evidence/codesign-$major.txt" 2>&1 + find "$work/dist-$major/$name.app" -type f \( -perm +111 -o -name "*.dylib" \) | while read -r code; do + printf '%s: ' "${code#"$work/dist-$major/"}" + codesign -dv "$code" 2>&1 | grep -E "^Signature=" || echo "not signed" + done >"$evidence/signatures-$major.txt" + "$package" verify --app "$work/dist-$major/$name.app" --updater sparkle >>"$evidence/codesign-$major.txt" +done +cp "$work/site/appcast.xml" "$evidence/appcast.xml" step "Serve the site on 127.0.0.1:$port" -python3 -m http.server "$port" --bind 127.0.0.1 --directory "$work/site" >"$evidence/http.log" 2>&1 & +# http.server's own server_bind looks the address up by name, which on macOS 15 and newer +# asks the person whether Python may use the local network. Nothing here needs a name. +python3 - "$work/site" "$port" >"$evidence/http.log" 2>&1 <<'PY' & +import functools, http.server, socketserver, sys +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=sys.argv[1]) +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] +Server(("127.0.0.1", int(sys.argv[2])), handler).serve_forever() +PY pids+=($!) wait_for 10 curl -fsS -o /dev/null "http://127.0.0.1:$port/appcast.xml" || fail "the server did not start" -log stream --style compact --level debug --predicate \ - 'subsystem == "org.sparkle-project.Sparkle" OR process == "Autoupdate" OR process == "Updater" OR process == "'"$executable"'"' \ +/usr/bin/log stream --style compact --level debug --predicate \ + 'subsystem == "org.sparkle-project.Sparkle" OR process == "Autoupdate" OR process == "Updater" OR process == "'"$executable"'" OR (process == "syspolicyd" AND eventMessage CONTAINS "selfupdate")' \ >"$evidence/unified.log" 2>&1 & pids+=($!) downloads() { grep -c "GET /$archive_name" "$evidence/http.log" || true; } +running_from() { pgrep -f "$1/Contents/MacOS/$executable" >/dev/null; } -step "Scenario 1: 1.0.0 installs 2.0.0 as soon as it is ready and relaunches" +step "Gatekeeper holds a downloaded copy until its quarantine mark is removed" install="$work/install-immediately" mkdir -p "$install" ditto "$work/dist-1/$name.app" "$install/$name.app" -DEMO_CHECK_AFTER_SECS=10 "$install/$name.app/Contents/MacOS/$executable" \ - >"$evidence/immediately-1.0.0.stderr" 2>&1 & -shot immediately-1-installed-1.0.0 7 -ps -axo pid,stat,etime,command | grep "$install" | grep -v grep >"$evidence/immediately-processes-at-7s.txt" || true +quarantine "$install/$name.app" +xattr -p com.apple.quarantine "$install/$name.app" >"$evidence/quarantine-before.txt" +spctl --assess --type execute -vv "$install/$name.app" >>"$evidence/quarantine-before.txt" 2>&1 || true +# Checked after 20 seconds: a copy Gatekeeper holds has a process, parked until the person +# answers, but it never reaches main, so it never writes its launch line. +launch "$install/$name.app" "$evidence/held-1.0.0.log" DEMO_CHECK_AFTER_SECS=1000 +shot gatekeeper-1-quarantined-copy-held 20 +if grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; then + fail "a quarantined ad hoc signed copy started without being approved" +fi +pass "a copy marked as downloaded does not start until the person approves it" +pkill -f "$name.app/Contents/MacOS/$executable" || true +sleep 2 + +xattr -dr com.apple.quarantine "$install/$name.app" +xattr -l "$install/$name.app" >"$evidence/quarantine-after-xattr.txt" + +step "1.0.0 installs 2.0.0 as soon as it is ready and relaunches" +launch "$install/$name.app" "$evidence/immediately-1.0.0.log" DEMO_CHECK_AFTER_SECS=10 +started() { grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } +wait_for 30 started || fail "1.0.0 did not start after its quarantine mark was removed" +pass "after xattr -dr com.apple.quarantine, the same copy starts" +shot immediately-1-installed-1.0.0 5 +ps -axo pid,stat,etime,command | grep "$install" | grep -v grep >"$evidence/immediately-processes-1.0.0.txt" || true [[ "$(installed_build "$install/$name.app")" == 1 ]] || fail "1.0.0 was replaced before it was seen" relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } -wait_for 180 relaunched || { - cat "$evidence/immediately-1.0.0.stderr" - fail "1.0.0 did not relaunch as 2.0.0 within three minutes" -} +wait_for 180 relaunched || fail "1.0.0 did not relaunch as 2.0.0 within three minutes" [[ "$(installed_build "$install/$name.app")" == 2 ]] || fail "the installed bundle is not 2.0.0" shot immediately-2-relaunched-2.0.0 8 cp "$install/self-update-demo.log" "$evidence/immediately-launches.log" +ps -axo pid,stat,etime,command | grep "$install" | grep -v grep >"$evidence/immediately-processes-2.0.0.txt" || true +running_from "$install/$name.app" || fail "2.0.0 is not running from where 1.0.0 was installed" +pass "1.0.0 updated itself to 2.0.0 and relaunched in place (see immediately-launches.log)" plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/immediately-installed-Info.plist.txt" codesign --verify --strict --deep --verbose=2 "$install/$name.app" >"$evidence/immediately-installed-codesign.txt" 2>&1 -pass "1.0.0 updated itself to 2.0.0 and relaunched (see immediately-launches.log)" -# The new version checked too, on its own launch. It must find nothing newer. -sleep 10 +xattr -lr "$install/$name.app" >"$evidence/immediately-installed-xattr.txt" 2>&1 || true +if grep -q "com.apple.quarantine" "$evidence/immediately-installed-xattr.txt"; then + fail "the update Sparkle installed carries a quarantine mark" +fi +pass "the installed 2.0.0 carries no quarantine mark, so Gatekeeper let its relaunch through" +# The new version checks too, on its own launch. It must find nothing newer. +sleep 15 [[ "$(downloads)" == 1 ]] || fail "the archive was downloaded $(downloads) times; 2.0.0 offered itself again" -pass "2.0.0 checked the same feed and did not download anything" +pass "2.0.0 checked the same feed and downloaded nothing" pkill -f "$install/" || true -step "Scenario 2: 1.0.0 downloads 2.0.0 and installs it when it quits" +step "1.0.0 downloads 2.0.0 and installs it when it quits" install="$work/install-on-quit" mkdir -p "$install" ditto "$work/dist-1/$name.app" "$install/$name.app" -DEMO_INSTALL=on-quit DEMO_CHECK_AFTER_SECS=3 DEMO_QUIT_AFTER_SECS=40 \ - "$install/$name.app/Contents/MacOS/$executable" >"$evidence/on-quit-1.0.0.stderr" 2>&1 & -app=$! +launch "$install/$name.app" "$evidence/on-quit-1.0.0.log" \ + DEMO_INSTALL=on-quit DEMO_CHECK_AFTER_SECS=3 DEMO_QUIT_AFTER_SECS=40 shot on-quit-1-running-1.0.0 8 -wait "$app" || true -echo "1.0.0 exited" >>"$evidence/on-quit-1.0.0.stderr" +quit() { ! running_from "$install/$name.app"; } +wait_for 90 quit || fail "1.0.0 did not quit" updated() { [[ "$(installed_build "$install/$name.app")" == 2 ]]; } -if wait_for 120 updated; then - plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/on-quit-installed-Info.plist.txt" - pass "1.0.0 quit and Sparkle installed 2.0.0 in its place" -else - fail "1.0.0 quit and 2.0.0 was not installed within two minutes" -fi - -step "Mac App Store: the same application without the updater" -build 1.0.0 "$work/build/$executable-store" --no-default-features -"$package" app --channel app-store --manifest-dir "$app_dir" \ - --executable "$work/build/$executable-store" --renderer-dir "$renderer/lib" \ - --version 1.0.0 --build 1 \ - --out "$work/dist-store" --uses-non-exempt-encryption no --network-client \ - --user-selected-files read-write | tee "$evidence/package-store.txt" -store_app="$work/dist-store/$name.app" -ditto -c -k --keepParent "$store_app" "$work/bundles/store-1.0.0.zip" -"$package" verify --app "$store_app" --channel app-store | tee -a "$evidence/package-store.txt" -codesign -d --entitlements - --xml "$store_app" >"$evidence/store-entitlements.plist" 2>/dev/null -plutil -p "$store_app/Contents/Info.plist" >"$evidence/store-Info.plist.txt" -"$package" pkg --app "$store_app" --out "$work/dist-store/SelfUpdateDemo.pkg" -pkgutil --expand-full "$work/dist-store/SelfUpdateDemo.pkg" "$work/dist-store/expanded" -(cd "$work/dist-store/expanded" && find . -maxdepth 6 | sort) >"$evidence/store-pkg-contents.txt" -if grep -ri sparkle "$evidence/store-pkg-contents.txt" >/dev/null; then - fail "the store package contains something named Sparkle" -fi -if grep -rl "SPUStandardUpdaterController\|Sparkle.framework" "$work/dist-store/expanded" >/dev/null; then - fail "a file in the store package names Sparkle" -fi -pass "the store package installs into /Applications and contains nothing of Sparkle" - -"$store_app/Contents/MacOS/$executable" >"$evidence/store.stderr" 2>&1 & -store_pid=$! -shot store-1-sandboxed-1.0.0 10 -if kill -0 "$store_pid" 2>/dev/null; then - pass "the sandboxed store build is running after ten seconds (see store-1-sandboxed-1.0.0.png)" -else - cat "$evidence/store.stderr" - fail "the sandboxed store build exited" -fi -if [[ -d "$HOME/Library/Containers/$identifier" ]]; then - pass "the system created its sandbox container" -else - fail "no sandbox container for $identifier: it did not run sandboxed" -fi -kill "$store_pid" 2>/dev/null || true +wait_for 120 updated || fail "1.0.0 quit and 2.0.0 was not installed within two minutes" +plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/on-quit-installed-Info.plist.txt" +pass "1.0.0 quit and Sparkle installed 2.0.0 in its place" +launch "$install/$name.app" "$evidence/on-quit-2.0.0.log" +on_quit_relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } +wait_for 30 on_quit_relaunched || fail "the 2.0.0 installed on quit does not start" +cp "$install/self-update-demo.log" "$evidence/on-quit-launches.log" +pass "the 2.0.0 installed on quit starts" +pkill -f "$install/" || true step "What running the demo left outside $work" { - ls -d "$HOME/Library/Containers/$identifier" 2>/dev/null || true ls -d "$HOME/Library/Caches/$identifier" 2>/dev/null || true ls "$HOME/Library/Preferences/$identifier.plist" 2>/dev/null || true ls -d "$HOME/Library/HTTPStorages/$identifier" 2>/dev/null || true + ls -d "$HOME/Library/Saved Application State/$identifier.savedState" 2>/dev/null || true } | tee "$evidence/left-outside.txt" echo "remove with: rm -rf ; defaults delete $identifier" From e236f478d24ae6c63e68b6a14e85ef76a0b0f879 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:18:57 +0900 Subject: [PATCH 042/224] Fix: Stage each Linux sample with its renderer in lib/ (FR-22.6) AWT looks for its companions in the parent of the renderer's directory plus lib. With the renderer flattened beside the executable, the sample-v0.1.1 arm64 calculator stopped at libjawt.so. Linux samples now carry the renderer in lib/ and bundle-renderer.sh points the executable at it. Verified at the next sample tag; the packaging workflow checks the same layout with the v0.0.0 calculator. --- .github/workflows/samples.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 51aae7eb..8298ed34 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -232,7 +232,16 @@ jobs: [[ -n "$app" ]] || continue stage="staging/$app" mkdir -p "$stage" - cp -R "${{ matrix.dist }}/." "$stage/" + if [[ "${{ runner.os }}" == "Linux" ]]; then + # Linux keeps the renderer in lib/ beside the executable. AWT looks for its + # companions in the parent of the renderer's directory plus lib, so a + # renderer flattened beside the executable stops at libjawt.so; the + # sample-v0.1.1 arm64 archive did exactly that. + mkdir -p "$stage/lib" + cp -R "${{ matrix.dist }}/." "$stage/lib/" + else + cp -R "${{ matrix.dist }}/." "$stage/" + fi if [[ "${{ runner.os }}" == "Windows" ]]; then # The executable goes in beside the DLL rather than above it. Windows # searches the directory the executable is in, and the renderer's DLL lives @@ -245,7 +254,11 @@ jobs: # Everywhere else the library records the absolute path it was built at, # which means nothing on the machine this is unpacked on, so each executable # is pointed at the copy travelling with it. - ./scripts/bundle-renderer.sh "$stage/sample-$app" . + if [[ "${{ runner.os }}" == "Linux" ]]; then + ./scripts/bundle-renderer.sh "$stage/sample-$app" lib + else + ./scripts/bundle-renderer.sh "$stage/sample-$app" . + fi fi tar -czf "artifacts/$app-${{ matrix.target }}.tar.gz" -C staging "$app" done < built.txt From e9250e293e7554d9a85d03cb8cb961fc4ddb03a6 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:21:11 +0900 Subject: [PATCH 043/224] Fix: Build the v0.0.0 calculator with its renderer, and give flatpak-builder eu-strip At v0.0.0 the renderer is a feature that is off by default, so the calculator was built without it and exited at once without a window; the script now turns it on and refuses an executable that does not name the renderer. The host's flatpak-builder strips debug information with eu-strip, which is in elfutils. --- .github/workflows/packager-linux.yml | 2 +- tools/packager-linux/ci/calculator-v0.sh | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index b8826f43..c1d5625d 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -339,7 +339,7 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder dbus-daemon patchelf xvfb xauth imagemagick python3-pip + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils dbus-daemon patchelf xvfb xauth imagemagick python3-pip pip install --user 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1' flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true diff --git a/tools/packager-linux/ci/calculator-v0.sh b/tools/packager-linux/ci/calculator-v0.sh index 74057c27..9f47a4fb 100755 --- a/tools/packager-linux/ci/calculator-v0.sh +++ b/tools/packager-linux/ci/calculator-v0.sh @@ -12,7 +12,8 @@ # function it does not export (a renderer and host from different commits), and the # renderer sits beside the executable rather than in lib/, where AWT looks for its # companions. So the workflow builds the calculator at the v0.0.0 tag of -# DarkPyonix/dioxus-compose against that tag's own released renderer. That is a Rust +# DarkPyonix/dioxus-compose against that tag's own released renderer. At that tag the +# renderer is a feature that is off by default, so the build turns it on. That is a Rust # build only; the renderer is the published artifact, checked by its digest. set -euo pipefail @@ -35,7 +36,7 @@ echo "$renderer_sha256 $work/renderer.tar.gz" | sha256sum -c - tar -xzf "$work/renderer.tar.gz" -C "$work/renderer" (cd "$work/dioxus-compose" && - DIOXUS_COMPOSE_RENDERER_DIR="$work/renderer" cargo build --quiet --release -p sample-calculator) + DIOXUS_COMPOSE_RENDERER_DIR="$work/renderer" cargo build --quiet --release -p sample-calculator --features dioxus-compose/native-renderer) stage="$work/calculator/calculator" rm -rf "$work/calculator" @@ -46,5 +47,10 @@ cp -a "$work/renderer/lib/." "$stage/lib/" # bundle-renderer.sh only sets the rpath when it had a path to rewrite. patchelf --set-rpath '$ORIGIN/lib' "$stage/sample-calculator" readelf -d "$stage/sample-calculator" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' +# The renderer is not optional: an executable built without it exits without a window. +readelf -d "$stage/sample-calculator" | grep -q 'libdioxus_compose_renderer.so' || { + echo "error: the calculator was built without the renderer" >&2 + exit 1 +} tar -czf "$work/calculator-linux-x64.tar.gz" -C "$work/calculator" calculator sha256sum "$work/calculator-linux-x64.tar.gz" From 1d10ef3454e439696e1e8a42409d6f11dd8c7eb7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:26:31 +0900 Subject: [PATCH 044/224] Fix: Keep the rehearsal from waiting on a Gatekeeper dialog nobody answers On the runner the launch Gatekeeper holds for a person stalled the whole run until the job timed out. Launches go to the background, the held-copy check is opt-in (it was made on a Mac with someone at it), and the step has a timeout. --- .github/workflows/packager-macos.yml | 3 ++- experiments/self-update/rehearse.sh | 35 +++++++++++++++++----------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml index 309fe2f1..d0ac9b0e 100644 --- a/.github/workflows/packager-macos.yml +++ b/.github/workflows/packager-macos.yml @@ -113,7 +113,8 @@ jobs: rustup toolchain install stable --profile minimal --component clippy rustup default stable - - name: Rehearse both channels + - name: Rehearse the update + timeout-minutes: 25 env: DEMO_WORK: ${{ runner.temp }}/self-update run: experiments/self-update/rehearse.sh diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh index 5ed354c4..877f97fa 100755 --- a/experiments/self-update/rehearse.sh +++ b/experiments/self-update/rehearse.sh @@ -8,9 +8,10 @@ # 1. Builds the demo application at 1.0.0 and 2.0.0 and packages both (ad hoc). # 2. Publishes 2.0.0: a zip, and an appcast signed with a key made here, checked against # Sparkle's own sign_update and generate_appcast. -# 3. Gatekeeper: a copy marked as downloaded (com.apple.quarantine, as a browser leaves -# it) is held at launch; after `xattr -dr com.apple.quarantine`, the step the install -# notes give, the same copy launches. +# 3. Gatekeeper: a copy is marked as downloaded (com.apple.quarantine, as a browser +# leaves it), and after `xattr -dr com.apple.quarantine`, the step the install notes +# give, it launches. DEMO_GATEKEEPER_HELD=1 also shows the marked copy being held at +# launch first, which leaves a dialog on the screen for someone to close. # 4. That copy, 1.0.0, finds 2.0.0 in the appcast served from 127.0.0.1, installs it at # once and relaunches as 2.0.0. The new bundle carries no quarantine mark, so # Gatekeeper lets the relaunch through, and 2.0.0 checks the feed and downloads @@ -123,7 +124,9 @@ launch() { shift 2 local env_args=() for assignment in "$@"; do env_args+=(--env "$assignment"); done - open -n ${env_args[@]+"${env_args[@]}"} --stdout "$log" --stderr "$log" "$app" + # In the background: open waits for the launch to finish, and a launch Gatekeeper holds + # for the person never finishes on a machine with nobody at it. + open -n ${env_args[@]+"${env_args[@]}"} --stdout "$log" --stderr "$log" "$app" & } step "Sparkle $sparkle_version" @@ -260,23 +263,27 @@ pids+=($!) downloads() { grep -c "GET /$archive_name" "$evidence/http.log" || true; } running_from() { pgrep -f "$1/Contents/MacOS/$executable" >/dev/null; } -step "Gatekeeper holds a downloaded copy until its quarantine mark is removed" +step "Gatekeeper and a downloaded copy" install="$work/install-immediately" mkdir -p "$install" ditto "$work/dist-1/$name.app" "$install/$name.app" quarantine "$install/$name.app" xattr -p com.apple.quarantine "$install/$name.app" >"$evidence/quarantine-before.txt" spctl --assess --type execute -vv "$install/$name.app" >>"$evidence/quarantine-before.txt" 2>&1 || true -# Checked after 20 seconds: a copy Gatekeeper holds has a process, parked until the person -# answers, but it never reaches main, so it never writes its launch line. -launch "$install/$name.app" "$evidence/held-1.0.0.log" DEMO_CHECK_AFTER_SECS=1000 -shot gatekeeper-1-quarantined-copy-held 20 -if grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; then - fail "a quarantined ad hoc signed copy started without being approved" +if [[ "${DEMO_GATEKEEPER_HELD:-0}" == 1 ]]; then + # Opt in: this leaves Gatekeeper's dialog on the screen, which on an unattended runner + # nobody answers and which has stalled a whole run. Run it where someone can close it. + # A held copy has a process, parked until the person answers, but it never reaches + # main, so it never writes its launch line. + launch "$install/$name.app" "$evidence/held-1.0.0.log" DEMO_CHECK_AFTER_SECS=1000 + shot gatekeeper-1-quarantined-copy-held 20 + if grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; then + fail "a quarantined ad hoc signed copy started without being approved" + fi + pass "a copy marked as downloaded does not start until the person approves it" + pkill -f "$name.app/Contents/MacOS/$executable" || true + sleep 2 fi -pass "a copy marked as downloaded does not start until the person approves it" -pkill -f "$name.app/Contents/MacOS/$executable" || true -sleep 2 xattr -dr com.apple.quarantine "$install/$name.app" xattr -l "$install/$name.app" >"$evidence/quarantine-after-xattr.txt" From e8646ebec2065699254669418cb8bc682c1d498d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:35:06 +0900 Subject: [PATCH 045/224] Fix: Start the rehearsal's launches without open's --env, which did not arrive On the runner the variables given to open --env never reached the application, so 1.0.0 checked at once and was already 2.0.0 when the script looked. The LaunchServices launch takes no settings; the on-quit run, which needs them, starts the executable directly. --- experiments/self-update/rehearse.sh | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh index 877f97fa..407e97b9 100755 --- a/experiments/self-update/rehearse.sh +++ b/experiments/self-update/rehearse.sh @@ -119,14 +119,13 @@ quarantine() { } # Launch through LaunchServices, as a double click does, so that Gatekeeper is consulted. +# The application's own launch log (beside its bundle) is the record. open's --stdout fails +# with -10810 on some machines and its --env did not reach the application on the runner, +# so neither is used: a launch that needs settings runs the executable directly. launch() { - local app="$1" log="$2" - shift 2 - local env_args=() - for assignment in "$@"; do env_args+=(--env "$assignment"); done # In the background: open waits for the launch to finish, and a launch Gatekeeper holds # for the person never finishes on a machine with nobody at it. - open -n ${env_args[@]+"${env_args[@]}"} --stdout "$log" --stderr "$log" "$app" & + open -n "$1" & } step "Sparkle $sparkle_version" @@ -275,7 +274,7 @@ if [[ "${DEMO_GATEKEEPER_HELD:-0}" == 1 ]]; then # nobody answers and which has stalled a whole run. Run it where someone can close it. # A held copy has a process, parked until the person answers, but it never reaches # main, so it never writes its launch line. - launch "$install/$name.app" "$evidence/held-1.0.0.log" DEMO_CHECK_AFTER_SECS=1000 + launch "$install/$name.app" shot gatekeeper-1-quarantined-copy-held 20 if grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; then fail "a quarantined ad hoc signed copy started without being approved" @@ -289,13 +288,12 @@ xattr -dr com.apple.quarantine "$install/$name.app" xattr -l "$install/$name.app" >"$evidence/quarantine-after-xattr.txt" step "1.0.0 installs 2.0.0 as soon as it is ready and relaunches" -launch "$install/$name.app" "$evidence/immediately-1.0.0.log" DEMO_CHECK_AFTER_SECS=10 +# Through LaunchServices, so Gatekeeper decides; the update follows within seconds. +launch "$install/$name.app" started() { grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } wait_for 30 started || fail "1.0.0 did not start after its quarantine mark was removed" pass "after xattr -dr com.apple.quarantine, the same copy starts" -shot immediately-1-installed-1.0.0 5 -ps -axo pid,stat,etime,command | grep "$install" | grep -v grep >"$evidence/immediately-processes-1.0.0.txt" || true -[[ "$(installed_build "$install/$name.app")" == 1 ]] || fail "1.0.0 was replaced before it was seen" +shot immediately-1-launched-1.0.0 1 relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } wait_for 180 relaunched || fail "1.0.0 did not relaunch as 2.0.0 within three minutes" [[ "$(installed_build "$install/$name.app")" == 2 ]] || fail "the installed bundle is not 2.0.0" @@ -321,8 +319,8 @@ step "1.0.0 downloads 2.0.0 and installs it when it quits" install="$work/install-on-quit" mkdir -p "$install" ditto "$work/dist-1/$name.app" "$install/$name.app" -launch "$install/$name.app" "$evidence/on-quit-1.0.0.log" \ - DEMO_INSTALL=on-quit DEMO_CHECK_AFTER_SECS=3 DEMO_QUIT_AFTER_SECS=40 +DEMO_INSTALL=on-quit DEMO_CHECK_AFTER_SECS=3 DEMO_QUIT_AFTER_SECS=40 \ + "$install/$name.app/Contents/MacOS/$executable" >"$evidence/on-quit-1.0.0.log" 2>&1 & shot on-quit-1-running-1.0.0 8 quit() { ! running_from "$install/$name.app"; } wait_for 90 quit || fail "1.0.0 did not quit" @@ -330,7 +328,7 @@ updated() { [[ "$(installed_build "$install/$name.app")" == 2 ]]; } wait_for 120 updated || fail "1.0.0 quit and 2.0.0 was not installed within two minutes" plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/on-quit-installed-Info.plist.txt" pass "1.0.0 quit and Sparkle installed 2.0.0 in its place" -launch "$install/$name.app" "$evidence/on-quit-2.0.0.log" +launch "$install/$name.app" on_quit_relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } wait_for 30 on_quit_relaunched || fail "the 2.0.0 installed on quit does not start" cp "$install/self-update-demo.log" "$evidence/on-quit-launches.log" From 4b16ea8a877a3a6fbdb03fa5827292668b43baa7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:46:16 +0900 Subject: [PATCH 046/224] Chore: Move the Linux packager from tools/ to scripts/packager-linux No new root folder without the owner's approval. The packager, its demo, its README and the Flathub material move under scripts/, and the workspace members, workflows and scripts that named the old path follow. --- .github/workflows/packager-linux.yml | 32 +++++++++--------- .github/workflows/samples.yml | 14 ++++---- Cargo.toml | 4 +-- {tools => scripts}/packager-linux/Cargo.toml | 0 {tools => scripts}/packager-linux/FLATHUB.md | 4 +-- {tools => scripts}/packager-linux/README.md | 0 {tools => scripts}/packager-linux/appimage.sh | 2 +- .../packager-linux/ci/calculator-v0.sh | 2 +- .../packager-linux/demo/Cargo.toml | 0 .../packager-linux/demo/Dioxus.toml | 0 .../packager-linux/demo/icon.png | Bin .../packager-linux/demo/src/main.rs | 0 .../flathub/dev.darkpyonix.Ember.toml | 2 +- .../packager-linux/flathub/ember.sh | 4 +-- {tools => scripts}/packager-linux/flatpak.sh | 2 +- .../packager-linux/samples.toml | 0 .../packager-linux/src/appimage.rs | 0 .../packager-linux/src/appstream.rs | 0 .../packager-linux/src/desktop.rs | 0 .../packager-linux/src/flatpak.rs | 0 .../packager-linux/src/icons.rs | 0 {tools => scripts}/packager-linux/src/lib.rs | 0 {tools => scripts}/packager-linux/src/main.rs | 0 .../packager-linux/src/metadata.rs | 0 .../packager-linux/tests/metadata.rs | 0 25 files changed, 33 insertions(+), 33 deletions(-) rename {tools => scripts}/packager-linux/Cargo.toml (100%) rename {tools => scripts}/packager-linux/FLATHUB.md (95%) rename {tools => scripts}/packager-linux/README.md (100%) rename {tools => scripts}/packager-linux/appimage.sh (99%) rename {tools => scripts}/packager-linux/ci/calculator-v0.sh (97%) rename {tools => scripts}/packager-linux/demo/Cargo.toml (100%) rename {tools => scripts}/packager-linux/demo/Dioxus.toml (100%) rename {tools => scripts}/packager-linux/demo/icon.png (100%) rename {tools => scripts}/packager-linux/demo/src/main.rs (100%) rename {tools => scripts}/packager-linux/flathub/dev.darkpyonix.Ember.toml (96%) rename {tools => scripts}/packager-linux/flathub/ember.sh (96%) rename {tools => scripts}/packager-linux/flatpak.sh (99%) rename {tools => scripts}/packager-linux/samples.toml (100%) rename {tools => scripts}/packager-linux/src/appimage.rs (100%) rename {tools => scripts}/packager-linux/src/appstream.rs (100%) rename {tools => scripts}/packager-linux/src/desktop.rs (100%) rename {tools => scripts}/packager-linux/src/flatpak.rs (100%) rename {tools => scripts}/packager-linux/src/icons.rs (100%) rename {tools => scripts}/packager-linux/src/lib.rs (100%) rename {tools => scripts}/packager-linux/src/main.rs (100%) rename {tools => scripts}/packager-linux/src/metadata.rs (100%) rename {tools => scripts}/packager-linux/tests/metadata.rs (100%) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 8ca6aa4d..67bcd2dc 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -24,7 +24,7 @@ name: Linux packaging on: push: paths: - - tools/packager-linux/** + - scripts/packager-linux/** - compose-rust/src/update.rs - compose-rust/tests/appimage_update.rs - .github/workflows/packager-linux.yml @@ -168,8 +168,8 @@ jobs: url=http://127.0.0.1:8080/latest/UpdateDemo-$ARCH.AppImage.zsync for v in 1.0.0 1.0.1 1.0.2; do key="$RELEASE_KEY"; [[ $v == 1.0.2 ]] && key="$IMPOSTOR_KEY" - tools/packager-linux/appimage.sh \ - --dioxus-toml tools/packager-linux/demo/Dioxus.toml \ + scripts/packager-linux/appimage.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml \ --payload "work/$v/update-demo" --version "$v" \ --zsync-url "$url" --sign-key "$key" --out-dir "work/dist" done @@ -292,11 +292,11 @@ jobs: echo "::notice title=No aarch64 calculator::no renderer was published for linux-arm64 at v0.0.0; the self-update steps above ran on aarch64" exit 0 fi - tools/packager-linux/ci/calculator-v0.sh work + scripts/packager-linux/ci/calculator-v0.sh work # Unsigned: these are test builds, and the signing key is not created yet. - tools/packager-linux/appimage.sh \ + scripts/packager-linux/appimage.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ - --overlay tools/packager-linux/samples.toml \ + --overlay scripts/packager-linux/samples.toml \ --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png \ --payload work/calculator/calculator --version 0.0.0 \ @@ -360,8 +360,8 @@ jobs: git archive --format=tar.gz --prefix=compose-rust/ -o work/flatpak-src/source.tar.gz HEAD # flatpak talks to its system helper over the session bus, which a runner has # none of until one is started. - dbus-run-session -- tools/packager-linux/flatpak.sh \ - --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.0 \ + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml --version 1.0.0 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ @@ -389,8 +389,8 @@ jobs: flatpak run "$id" --version | grep -qx "update-demo 1.0.0" # flatpak talks to its system helper over the session bus, which a runner has # none of until one is started. - dbus-run-session -- tools/packager-linux/flatpak.sh \ - --dioxus-toml tools/packager-linux/demo/Dioxus.toml --version 1.0.1 \ + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml --version 1.0.1 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ @@ -405,14 +405,14 @@ jobs: - name: Package a calculator as a prebuilt archive and start it in the sandbox run: | set -euo pipefail - tools/packager-linux/ci/calculator-v0.sh work + scripts/packager-linux/ci/calculator-v0.sh work mkdir -p work/flatpak-calculator cp work/calculator-linux-x64.tar.gz work/flatpak-calculator/ # flatpak talks to its system helper over the session bus, which a runner has # none of until one is started. - dbus-run-session -- tools/packager-linux/flatpak.sh \ + dbus-run-session -- scripts/packager-linux/flatpak.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ - --overlay tools/packager-linux/samples.toml \ + --overlay scripts/packager-linux/samples.toml \ --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png --version 0.0.0 \ --prebuilt-path calculator-linux-x64.tar.gz \ @@ -434,7 +434,7 @@ jobs: # release yet, so two inputs are stand-ins and the listing is not submitted: # the archives are the published calculator samples, and the screenshot is # Ember's large icon, both pinned. For a real release, - # tools/packager-linux/flathub/ember.sh takes the tag and fills in Ember's own + # scripts/packager-linux/flathub/ember.sh takes the tag and fills in Ember's own # archives, digests and the screenshot pinned to that tag. env: EMBER_COMMIT: faa430a4b75ec45928623130ea2f874b628a7224 @@ -444,8 +444,8 @@ jobs: mkdir -p work/ember-icons curl -fsSL -o work/ember-icons/128.png "$raw/src-tauri/icons/128x128.png" curl -fsSL -o work/ember-icons/256.png "$raw/src-tauri/icons/128x128@2x.png" - dbus-run-session -- tools/packager-linux/flatpak.sh \ - --dioxus-toml tools/packager-linux/flathub/dev.darkpyonix.Ember.toml \ + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml \ --app-id dev.darkpyonix.Ember --version 0.0.0 \ --icon work/ember-icons/128.png --icon work/ember-icons/256.png \ --screenshot "$raw/src-tauri/icons/128x128@2x.png" --screenshot-caption "Ember" \ diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 96b6a689..d28691e0 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -1,7 +1,7 @@ # Sample apps -- builds every sample for every desktop platform when a `sample-v*` tag is # pushed, and publishes the binaries as a release of DarkPyonix/compose-rust-samples, the # repository the samples' AppImages update from. Writing there needs the -# SAMPLES_RELEASE_TOKEN secret (tools/packager-linux/README.md). +# SAMPLES_RELEASE_TOKEN secret (scripts/packager-linux/README.md). # # A sample that does not build on a platform is skipped and the release goes ahead with # the rest. Eleven samples across three platforms is thirty-three builds, and holding all @@ -37,7 +37,7 @@ jobs: # The release goes to DarkPyonix/compose-rust-samples, the repository the samples' # AppImages look in for their updates, and the token this workflow gets cannot write # anywhere but here. Checked first, so that a missing token stops the run in seconds - # rather than after two hours of builds. tools/packager-linux/README.md says how to + # rather than after two hours of builds. scripts/packager-linux/README.md says how to # create it. name: Release token runs-on: ubuntu-latest @@ -49,11 +49,11 @@ jobs: run: | set -euo pipefail if [[ -z "$GH_TOKEN" ]]; then - echo "::error title=SAMPLES_RELEASE_TOKEN is missing::Create a fine-grained personal access token with Contents: read and write on $SAMPLES_REPO only, and save it as the repository secret SAMPLES_RELEASE_TOKEN (tools/packager-linux/README.md)." + echo "::error title=SAMPLES_RELEASE_TOKEN is missing::Create a fine-grained personal access token with Contents: read and write on $SAMPLES_REPO only, and save it as the repository secret SAMPLES_RELEASE_TOKEN (scripts/packager-linux/README.md)." exit 1 fi if ! gh api "repos/$SAMPLES_REPO" --jq .full_name; then - echo "::error title=SAMPLES_RELEASE_TOKEN cannot see $SAMPLES_REPO::The token exists but GitHub refused it for $SAMPLES_REPO. Give it access to that repository (tools/packager-linux/README.md)." + echo "::error title=SAMPLES_RELEASE_TOKEN cannot see $SAMPLES_REPO::The token exists but GitHub refused it for $SAMPLES_REPO. Give it access to that repository (scripts/packager-linux/README.md)." exit 1 fi @@ -296,9 +296,9 @@ jobs: # one the window wears; a sample that brings its own may have only the one. icons=(--icon "samples/$app/assets/icon.png") [[ -f "samples/$app/assets/icon-256.png" ]] && icons+=(--icon "samples/$app/assets/icon-256.png") - if ! tools/packager-linux/appimage.sh \ + if ! scripts/packager-linux/appimage.sh \ --dioxus-toml "samples/$app/Dioxus.toml" \ - --overlay tools/packager-linux/samples.toml \ + --overlay scripts/packager-linux/samples.toml \ --exec "sample-$app" "${icons[@]}" \ --payload "staging/$app" --version "$version" \ --github DarkPyonix/compose-rust-samples "${sign[@]}" \ @@ -550,7 +550,7 @@ jobs: TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail - [[ -n "$GH_TOKEN" ]] || { echo "::error::SAMPLES_RELEASE_TOKEN is missing (tools/packager-linux/README.md)"; exit 1; } + [[ -n "$GH_TOKEN" ]] || { echo "::error::SAMPLES_RELEASE_TOKEN is missing (scripts/packager-linux/README.md)"; exit 1; } # A release needs a commit to tag, and the repository starts empty. if ! gh api "repos/$SAMPLES_REPO/commits?per_page=1" >/dev/null 2>&1; then readme="$(printf '%s\n' \ diff --git a/Cargo.toml b/Cargo.toml index ce2a109e..370aa5bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ members = [ "samples/statistics", "samples/store", "samples/todo", - "tools/packager-linux", - "tools/packager-linux/demo", + "scripts/packager-linux", + "scripts/packager-linux/demo", ] resolver = "2" diff --git a/tools/packager-linux/Cargo.toml b/scripts/packager-linux/Cargo.toml similarity index 100% rename from tools/packager-linux/Cargo.toml rename to scripts/packager-linux/Cargo.toml diff --git a/tools/packager-linux/FLATHUB.md b/scripts/packager-linux/FLATHUB.md similarity index 95% rename from tools/packager-linux/FLATHUB.md rename to scripts/packager-linux/FLATHUB.md index 84ee5809..93551623 100644 --- a/tools/packager-linux/FLATHUB.md +++ b/scripts/packager-linux/FLATHUB.md @@ -3,7 +3,7 @@ The application ID is `dev.darkpyonix.Ember`. The owner controls the domain `darkpyonix.dev` and serves its verification file from a Cloudflare Worker. -Everything Flathub reviews is generated by `tools/packager-linux/flatpak.sh`. +Everything Flathub reviews is generated by `scripts/packager-linux/flatpak.sh`. `flathub/ember.sh` drives it for one Ember release. The `Linux packaging` workflow generates and lints the submission on every run and uploads it as the `flathub-submission-ember` artifact. Ember has no release yet, so two of the workflow's @@ -35,7 +35,7 @@ screenshot is Ember's large icon. Everything else is what will be submitted. ```sh cargo build --release -p packager-linux -PACKAGER_LINUX=target/release/packager-linux tools/packager-linux/flathub/ember.sh \ +PACKAGER_LINUX=target/release/packager-linux scripts/packager-linux/flathub/ember.sh \ --tag v1.0.0 --version 1.0.0 \ --screenshot docs/screenshot.png --screenshot-caption "The console" \ --out flathub-ember --build repo --lint diff --git a/tools/packager-linux/README.md b/scripts/packager-linux/README.md similarity index 100% rename from tools/packager-linux/README.md rename to scripts/packager-linux/README.md diff --git a/tools/packager-linux/appimage.sh b/scripts/packager-linux/appimage.sh similarity index 99% rename from tools/packager-linux/appimage.sh rename to scripts/packager-linux/appimage.sh index d2d62a07..d8a515ef 100755 --- a/tools/packager-linux/appimage.sh +++ b/scripts/packager-linux/appimage.sh @@ -2,7 +2,7 @@ # Wraps a built application as an AppImage that updates itself, with the .zsync file to # publish beside it. # -# tools/packager-linux/appimage.sh \ +# scripts/packager-linux/appimage.sh \ # --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ # --payload staging/calculator --version 1.2.3 \ # --github darkpyonix/compose-rust --out-dir dist diff --git a/tools/packager-linux/ci/calculator-v0.sh b/scripts/packager-linux/ci/calculator-v0.sh similarity index 97% rename from tools/packager-linux/ci/calculator-v0.sh rename to scripts/packager-linux/ci/calculator-v0.sh index 9f47a4fb..80fd4192 100755 --- a/tools/packager-linux/ci/calculator-v0.sh +++ b/scripts/packager-linux/ci/calculator-v0.sh @@ -2,7 +2,7 @@ # Stages a calculator whose executable and renderer belong together, for the packaging # workflow to wrap and start. x86_64 only. # -# tools/packager-linux/ci/calculator-v0.sh +# scripts/packager-linux/ci/calculator-v0.sh # # leaves /calculator/ (the executable, and the renderer in lib/ beside it) and # /calculator-linux-x64.tar.gz (the same directory as a release archive). diff --git a/tools/packager-linux/demo/Cargo.toml b/scripts/packager-linux/demo/Cargo.toml similarity index 100% rename from tools/packager-linux/demo/Cargo.toml rename to scripts/packager-linux/demo/Cargo.toml diff --git a/tools/packager-linux/demo/Dioxus.toml b/scripts/packager-linux/demo/Dioxus.toml similarity index 100% rename from tools/packager-linux/demo/Dioxus.toml rename to scripts/packager-linux/demo/Dioxus.toml diff --git a/tools/packager-linux/demo/icon.png b/scripts/packager-linux/demo/icon.png similarity index 100% rename from tools/packager-linux/demo/icon.png rename to scripts/packager-linux/demo/icon.png diff --git a/tools/packager-linux/demo/src/main.rs b/scripts/packager-linux/demo/src/main.rs similarity index 100% rename from tools/packager-linux/demo/src/main.rs rename to scripts/packager-linux/demo/src/main.rs diff --git a/tools/packager-linux/flathub/dev.darkpyonix.Ember.toml b/scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml similarity index 96% rename from tools/packager-linux/flathub/dev.darkpyonix.Ember.toml rename to scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml index db6bc54c..206d04fa 100644 --- a/tools/packager-linux/flathub/dev.darkpyonix.Ember.toml +++ b/scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml @@ -4,7 +4,7 @@ # pass that file with --dioxus-toml and this one with --overlay; until then this file is # both. What changes per release (the version, the archives and their digests, the # screenshot pinned to the release tag) is passed on the command line, as -# tools/packager-linux/flathub/ember.sh does. +# scripts/packager-linux/flathub/ember.sh does. # # The summary and description restate the repository's own one-line description. They # are the owner's to rewrite before the listing goes public. diff --git a/tools/packager-linux/flathub/ember.sh b/scripts/packager-linux/flathub/ember.sh similarity index 96% rename from tools/packager-linux/flathub/ember.sh rename to scripts/packager-linux/flathub/ember.sh index 7ad58683..e8c54c19 100755 --- a/tools/packager-linux/flathub/ember.sh +++ b/scripts/packager-linux/flathub/ember.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # Generates the Flathub submission for Ember (dev.darkpyonix.Ember) from one of its releases. # -# tools/packager-linux/flathub/ember.sh --tag v1.0.0 --version 1.0.0 \ +# scripts/packager-linux/flathub/ember.sh --tag v1.0.0 --version 1.0.0 \ # --screenshot docs/screenshot.png --out flathub-ember [--build repo] [--lint] # # writes into --out what the flathub/dev.darkpyonix.Ember repository holds: the manifest, # the desktop entry, the metainfo and the icons. Nothing is submitted; opening the -# pull request is a separate, deliberate step (tools/packager-linux/FLATHUB.md). +# pull request is a separate, deliberate step (scripts/packager-linux/FLATHUB.md). # # Options: # --tag the Ember release to package diff --git a/tools/packager-linux/flatpak.sh b/scripts/packager-linux/flatpak.sh similarity index 99% rename from tools/packager-linux/flatpak.sh rename to scripts/packager-linux/flatpak.sh index 111501a4..0f4c8929 100755 --- a/tools/packager-linux/flatpak.sh +++ b/scripts/packager-linux/flatpak.sh @@ -2,7 +2,7 @@ # Writes a Flatpak manifest for an application and, optionally, builds, installs and lints # it the way Flathub does. # -# tools/packager-linux/flatpak.sh \ +# scripts/packager-linux/flatpak.sh \ # --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ # --prebuilt-url https://.../calculator-linux-x64.tar.gz --prebuilt-sha256 \ # --icon icon-256.png --version 1.2.3 --out flatpak/ --build repo/ --lint diff --git a/tools/packager-linux/samples.toml b/scripts/packager-linux/samples.toml similarity index 100% rename from tools/packager-linux/samples.toml rename to scripts/packager-linux/samples.toml diff --git a/tools/packager-linux/src/appimage.rs b/scripts/packager-linux/src/appimage.rs similarity index 100% rename from tools/packager-linux/src/appimage.rs rename to scripts/packager-linux/src/appimage.rs diff --git a/tools/packager-linux/src/appstream.rs b/scripts/packager-linux/src/appstream.rs similarity index 100% rename from tools/packager-linux/src/appstream.rs rename to scripts/packager-linux/src/appstream.rs diff --git a/tools/packager-linux/src/desktop.rs b/scripts/packager-linux/src/desktop.rs similarity index 100% rename from tools/packager-linux/src/desktop.rs rename to scripts/packager-linux/src/desktop.rs diff --git a/tools/packager-linux/src/flatpak.rs b/scripts/packager-linux/src/flatpak.rs similarity index 100% rename from tools/packager-linux/src/flatpak.rs rename to scripts/packager-linux/src/flatpak.rs diff --git a/tools/packager-linux/src/icons.rs b/scripts/packager-linux/src/icons.rs similarity index 100% rename from tools/packager-linux/src/icons.rs rename to scripts/packager-linux/src/icons.rs diff --git a/tools/packager-linux/src/lib.rs b/scripts/packager-linux/src/lib.rs similarity index 100% rename from tools/packager-linux/src/lib.rs rename to scripts/packager-linux/src/lib.rs diff --git a/tools/packager-linux/src/main.rs b/scripts/packager-linux/src/main.rs similarity index 100% rename from tools/packager-linux/src/main.rs rename to scripts/packager-linux/src/main.rs diff --git a/tools/packager-linux/src/metadata.rs b/scripts/packager-linux/src/metadata.rs similarity index 100% rename from tools/packager-linux/src/metadata.rs rename to scripts/packager-linux/src/metadata.rs diff --git a/tools/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs similarity index 100% rename from tools/packager-linux/tests/metadata.rs rename to scripts/packager-linux/tests/metadata.rs From 663bf365e301558667ea047654ddc257ef1e1f8c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:52:30 +0900 Subject: [PATCH 047/224] Feat: Release the samples to samples-latest in this repository (#66) DarkPyonix/compose-rust-samples is gone, and the samples release from compose-rust itself. The Sample apps workflow keeps attaching each sample-v* build to its own release, now never marked latest, and also updates the samples-latest release in place: created if missing, a pre-release that is never latest so the library's v* releases keep that, its tag moved to the build, the new files uploaded before the ones the new set lacks are removed, and left alone when an older tag is rebuilt. Both are written with the workflow's GITHUB_TOKEN, so SAMPLES_RELEASE_TOKEN, its preflight job and its error messages go. The packager no longer assumes `latest`: --github takes --release , and the samples' AppImages carry gh-releases-zsync|DarkPyonix|compose-rust|samples-latest|-*-.AppImage.zsync. The packaging workflow checks that line on the calculator it wraps. --- .github/workflows/packager-linux.yml | 7 +- .github/workflows/samples.yml | 128 ++++++++++++----------- scripts/packager-linux/README.md | 30 ++---- scripts/packager-linux/appimage.sh | 19 +++- scripts/packager-linux/src/appimage.rs | 31 ++++-- scripts/packager-linux/src/main.rs | 23 +++- scripts/packager-linux/tests/metadata.rs | 16 ++- 7 files changed, 153 insertions(+), 101 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 67bcd2dc..8c943401 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -300,10 +300,13 @@ jobs: --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png \ --payload work/calculator/calculator --version 0.0.0 \ - --github DarkPyonix/compose-rust-samples \ + --github DarkPyonix/compose-rust --release samples-latest \ --out-dir work/dist app=work/dist/Calculator-0.0.0-$ARCH.AppImage - "$app" --appimage-updateinformation + # The address the Sample apps workflow publishes every sample's .zsync at. + "$app" --appimage-updateinformation \ + | tee /dev/stderr \ + | grep -qxF "gh-releases-zsync|DarkPyonix|compose-rust|samples-latest|Calculator-*-$ARCH.AppImage.zsync" xvfb-run -a -s "-screen 0 1280x800x24" bash -c " '$app' > work/calculator.log 2>&1 & pid=\$! diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index d28691e0..0b9a86a1 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -1,7 +1,7 @@ # Sample apps -- builds every sample for every desktop platform when a `sample-v*` tag is -# pushed, and publishes the binaries as a release of DarkPyonix/compose-rust-samples, the -# repository the samples' AppImages update from. Writing there needs the -# SAMPLES_RELEASE_TOKEN secret (scripts/packager-linux/README.md). +# pushed, attaches the binaries to that tag's release, and puts the same files in the +# `samples-latest` release, the fixed place every sample AppImage looks for its updates. +# Both releases are in this repository and are written with the workflow's own token. # # A sample that does not build on a platform is skipped and the release goes ahead with # the rest. Eleven samples across three platforms is thirty-three builds, and holding all @@ -33,32 +33,7 @@ permissions: contents: read jobs: - preflight: - # The release goes to DarkPyonix/compose-rust-samples, the repository the samples' - # AppImages look in for their updates, and the token this workflow gets cannot write - # anywhere but here. Checked first, so that a missing token stops the run in seconds - # rather than after two hours of builds. scripts/packager-linux/README.md says how to - # create it. - name: Release token - runs-on: ubuntu-latest - steps: - - name: SAMPLES_RELEASE_TOKEN can reach the samples repository - env: - GH_TOKEN: ${{ secrets.SAMPLES_RELEASE_TOKEN }} - SAMPLES_REPO: DarkPyonix/compose-rust-samples - run: | - set -euo pipefail - if [[ -z "$GH_TOKEN" ]]; then - echo "::error title=SAMPLES_RELEASE_TOKEN is missing::Create a fine-grained personal access token with Contents: read and write on $SAMPLES_REPO only, and save it as the repository secret SAMPLES_RELEASE_TOKEN (scripts/packager-linux/README.md)." - exit 1 - fi - if ! gh api "repos/$SAMPLES_REPO" --jq .full_name; then - echo "::error title=SAMPLES_RELEASE_TOKEN cannot see $SAMPLES_REPO::The token exists but GitHub refused it for $SAMPLES_REPO. Give it access to that repository (scripts/packager-linux/README.md)." - exit 1 - fi - build: - needs: preflight name: ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 @@ -265,9 +240,11 @@ jobs: - name: Wrap each Linux sample as an AppImage that updates itself # The same staged directory, as one file that carries the address of its newer - # versions: the newest release of DarkPyonix/compose-rust-samples whose assets - # include a matching .zsync. The samples are released there rather than here so - # that `latest` means the newest samples, not the newest library release. A sample that fails here keeps its archive and loses only the AppImage. + # versions: the `samples-latest` release of this repository, whose assets the + # release job below replaces with every sample release. A fixed tag rather than + # `latest`, because `latest` here is the newest library release, which carries no + # sample at all. A sample that fails here keeps its archive and loses only the + # AppImage. # # Signed when the repository has a signing key. An installed AppImage that was # signed refuses an update signed by any other key, so the first public AppImage @@ -301,7 +278,7 @@ jobs: --overlay scripts/packager-linux/samples.toml \ --exec "sample-$app" "${icons[@]}" \ --payload "staging/$app" --version "$version" \ - --github DarkPyonix/compose-rust-samples "${sign[@]}" \ + --github DarkPyonix/compose-rust --release samples-latest ${sign[@]+"${sign[@]}"} \ --out-dir artifacts; then echo "::warning title=No AppImage::sample-$app was not wrapped as an AppImage for ${{ matrix.target }}" fi @@ -325,7 +302,6 @@ jobs: android: name: android - needs: preflight runs-on: ubuntu-24.04 timeout-minutes: 120 steps: @@ -386,7 +362,6 @@ jobs: ios: name: ios-simulator - needs: preflight runs-on: macos-26 timeout-minutes: 120 steps: @@ -441,7 +416,6 @@ jobs: web: name: web - needs: preflight runs-on: ubuntu-24.04 timeout-minutes: 120 steps: @@ -507,6 +481,8 @@ jobs: - name: Say which sample and platform pairs are in this release id: notes + env: + TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail { @@ -521,12 +497,13 @@ jobs: echo echo 'Verify with `shasum -a 256 -c SHA256SUMS-`.' echo - echo "Built in this release:" - echo echo "Linux also ships each sample as an AppImage. Make it executable and run it;" - echo "it checks this repository's newest release for a newer version of itself." + echo "it checks the samples-latest release of this repository for a newer version" + echo "of itself." echo - echo "Built from DarkPyonix/compose-rust at $(git rev-parse --short HEAD)." + echo "Built from $GITHUB_REPOSITORY at $(git rev-parse --short HEAD) ($TAG)." + echo + echo "Built in this release:" echo for archive in artifacts/*.tar.gz artifacts/*.AppImage artifacts/*.apk; do [ -e "$archive" ] || continue @@ -539,34 +516,65 @@ jobs: } > notes.md cat notes.md - - name: Create or update the release in the samples repository + - name: Create or update the release for this tag # Published rather than drafted. A draft is invisible to everyone but the people - # with write access, so a release nobody can download is the same as no release - # at all. It goes to DarkPyonix/compose-rust-samples, whose newest release is what - # every installed sample AppImage checks for an update. + # with write access to this repository, so a release nobody can download is the + # same as no release at all. Never marked latest: that belongs to the library's + # v* releases, and a sample release that took it would make the newest library + # release look older than it is. env: - GH_TOKEN: ${{ secrets.SAMPLES_RELEASE_TOKEN }} - SAMPLES_REPO: DarkPyonix/compose-rust-samples + GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail - [[ -n "$GH_TOKEN" ]] || { echo "::error::SAMPLES_RELEASE_TOKEN is missing (scripts/packager-linux/README.md)"; exit 1; } - # A release needs a commit to tag, and the repository starts empty. - if ! gh api "repos/$SAMPLES_REPO/commits?per_page=1" >/dev/null 2>&1; then - readme="$(printf '%s\n' \ - '# compose-rust samples' '' \ - 'Release downloads and self-update feeds for the sample applications of' \ - '[compose-rust](https://github.com/DarkPyonix/compose-rust). The releases are' \ - 'published by its Sample apps workflow.' | base64 -w0)" - gh api -X PUT "repos/$SAMPLES_REPO/contents/README.md" \ - -f message="Describe the repository" -f content="$readme" >/dev/null - fi - if gh release view "$TAG" --repo "$SAMPLES_REPO" >/dev/null 2>&1; then - gh release upload "$TAG" artifacts/* --repo "$SAMPLES_REPO" --clobber - gh release edit "$TAG" --repo "$SAMPLES_REPO" --draft=false --latest --notes-file notes.md + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" artifacts/* --clobber + gh release edit "$TAG" --draft=false --latest=false --notes-file notes.md else - gh release create "$TAG" --repo "$SAMPLES_REPO" --latest \ + gh release create "$TAG" --latest=false \ --title "Sample apps $TAG" \ --notes-file notes.md \ artifacts/* fi + + - name: Replace the files of the samples-latest release + # Every sample AppImage looks in the release tagged samples-latest for a .zsync + # matching its name, so that release always holds the newest samples. It is a + # pre-release and never latest, so it does not compete with the library's v* + # releases. It is updated in place: the new files are uploaded over the old ones + # first, and only then are the files the new set lacks removed, so an AppImage + # checking in the middle still finds a .zsync. Rebuilding an older tag leaves it + # alone, so samples-latest never goes backwards. + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag || github.ref_name }} + FEED: samples-latest + run: | + set -euo pipefail + newest="$(git ls-remote --tags --refs origin 'refs/tags/sample-v*' \ + | sed 's#.*refs/tags/##' | sort -V | tail -n 1)" + if [[ -n "$newest" && "$newest" != "$TAG" ]]; then + echo "::notice title=samples-latest unchanged::$TAG is older than $newest, which is what samples-latest carries" + exit 0 + fi + sha="$(git rev-parse HEAD)" + title="Sample apps, newest ($TAG)" + if gh release view "$FEED" >/dev/null 2>&1; then + # The tag follows the build, so the release names the commit its files came + # from. + gh api -X PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$FEED" \ + -f sha="$sha" -F force=true >/dev/null \ + || echo "::warning title=samples-latest tag not moved::the files are $TAG's, but the tag still points at the previous build" + gh release edit "$FEED" --draft=false --prerelease --latest=false \ + --title "$title" --notes-file notes.md + else + gh release create "$FEED" --target "$sha" --prerelease --latest=false \ + --title "$title" --notes-file notes.md + fi + gh release upload "$FEED" artifacts/* --clobber + keep="$(cd artifacts && ls -1)" + gh release view "$FEED" --json assets --jq '.assets[].name' | while read -r asset; do + if ! grep -qxF "$asset" <<<"$keep"; then + gh release delete-asset "$FEED" "$asset" --yes + fi + done diff --git a/scripts/packager-linux/README.md b/scripts/packager-linux/README.md index 2406ee89..0f9e78d8 100644 --- a/scripts/packager-linux/README.md +++ b/scripts/packager-linux/README.md @@ -25,33 +25,23 @@ runs everything here on GitHub's Ubuntu runners. ## Where updates come from -Each application updates from the releases of its own repository. Its AppImage carries -`gh-releases-zsync|||latest|-*-.AppImage.zsync`, which matches the -newest release that is not a pre-release. Ember updates from `DarkPyonix/darkpyonix-ember`. +An AppImage carries `gh-releases-zsync||||-*-.AppImage.zsync`. +`appimage.sh --github --release ` writes it, and the release is always +named: a tag, or `latest` for the newest release that is not a pre-release. -The samples are released to `DarkPyonix/compose-rust-samples`, not to this repository. That -way `latest` means the newest samples rather than the newest library release. +The samples update from the `samples-latest` release of `DarkPyonix/compose-rust`. The +`Sample apps` workflow attaches each `sample-v*` build to its own release and also replaces +the files of `samples-latest` with it. That release is a pre-release and never marked +latest, so the library's `v*` releases keep `latest`, and a sample never looks in a library +release for its update. The workflow writes both with its own `GITHUB_TOKEN`. + +Ember updates from `DarkPyonix/darkpyonix-ember`. ## Secrets The owner creates these in this repository's settings (Settings, Secrets and variables, Actions). -### SAMPLES_RELEASE_TOKEN - -The `Sample apps` workflow publishes to `DarkPyonix/compose-rust-samples`. The token a -workflow is given can only write to its own repository, so it needs another one. - -1. On GitHub, open Settings, Developer settings, Personal access tokens, Fine-grained - tokens, and choose Generate new token. -2. Resource owner: DarkPyonix. Repository access: Only select repositories, then - `DarkPyonix/compose-rust-samples` only. -3. Repository permissions: Contents, Read and write. Leave everything else at No access. -4. Save the token as the Actions secret `SAMPLES_RELEASE_TOKEN` in `DarkPyonix/compose-rust`. - -Without it, the workflow's first job fails within seconds and names the missing secret, -before any build runs. Fine-grained tokens expire, so renew it before the expiry date. - ### APPIMAGE_SIGNING_KEY The armoured private key of the AppImage signing key (see below). While it is missing, the diff --git a/scripts/packager-linux/appimage.sh b/scripts/packager-linux/appimage.sh index d8a515ef..8dcb6200 100755 --- a/scripts/packager-linux/appimage.sh +++ b/scripts/packager-linux/appimage.sh @@ -5,16 +5,18 @@ # scripts/packager-linux/appimage.sh \ # --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ # --payload staging/calculator --version 1.2.3 \ -# --github darkpyonix/compose-rust --out-dir dist +# --github darkpyonix/compose-rust --release samples-latest --out-dir dist # # writes dist/Calculator-1.2.3-x86_64.AppImage and dist/Calculator-1.2.3-x86_64.AppImage.zsync. -# Publish both as assets of the same release; every later release's AppImage finds the -# newest .zsync through the update information embedded here. +# Publish both as assets of that release; an installed AppImage finds the newer .zsync +# there through the update information embedded here. # # Options: # --dioxus-toml, --overlay, --version, --date, --exec, --icon as packager-linux takes them # --payload the executable, or the directory holding it and its renderer -# --github update from that repository's newest GitHub release +# --github update from a GitHub release of that repository +# --release with --github: the release's tag, or `latest` for the newest +# release that is not a pre-release # --zsync-url update from one fixed .zsync URL instead # --sign-key sign with this gpg key; an installed AppImage that was signed # refuses an update signed by any other key @@ -59,6 +61,7 @@ declare -A DIGESTS=( common=() payload="" channel=() +release=() sign_key="" bundle_updater=1 out_dir="" @@ -71,6 +74,7 @@ while [[ $# -gt 0 ]]; do ;; --payload) payload="$2"; shift 2 ;; --github | --zsync-url) channel=("$1" "$2"); shift 2 ;; + --release) release=("$1" "$2"); shift 2 ;; --sign-key) sign_key="$2"; shift 2 ;; --no-updater) bundle_updater=0; shift ;; --out-dir) out_dir="$2"; shift 2 ;; @@ -81,6 +85,11 @@ done [[ -n "$out_dir" ]] || fail "--out-dir is required" [[ ${#channel[@]} -eq 2 ]] || fail "pass --github or --zsync-url " \ "An AppImage without update information cannot update itself." +if [[ "${channel[0]}" == --github && ${#release[@]} -ne 2 ]]; then + fail "--github needs --release " \ + "Name the release the AppImage looks in, or pass --release latest for the newest" \ + "release that is not a pre-release." +fi case "$(uname -m)" in x86_64) arch=x86_64 ;; @@ -138,7 +147,7 @@ if [[ $bundle_updater -eq 1 ]]; then updater_args=(--updater "$extracted") fi -update_information="$(packager update-information "${common[@]}" --arch "$arch" "${channel[@]}")" +update_information="$(packager update-information "${common[@]}" --arch "$arch" "${channel[@]}" ${release[@]+"${release[@]}"})" file_name="$(packager file-name "${common[@]}" --arch "$arch")" mkdir -p "$out_dir" diff --git a/scripts/packager-linux/src/appimage.rs b/scripts/packager-linux/src/appimage.rs index 65b0c660..5fe78cd6 100644 --- a/scripts/packager-linux/src/appimage.rs +++ b/scripts/packager-linux/src/appimage.rs @@ -65,22 +65,35 @@ pub fn appimage_file_name(meta: &AppMetadata, arch: &str) -> String { /// Where newer versions are published. #[derive(Debug, Clone, PartialEq, Eq)] pub enum UpdateChannel { - /// The newest non-prerelease GitHub release of `owner/repository` whose assets include - /// a `.zsync` for this application and architecture. - GitHubReleases { owner: String, repository: String }, + /// A GitHub release of `owner/repository` whose assets include a `.zsync` for this + /// application and architecture. `release` is a tag, or one of the words + /// `appimageupdatetool` reads as a search: `latest` (the newest release that is not a + /// pre-release), `latest-pre` or `latest-all`. A fixed tag keeps working when the + /// repository also publishes other releases that would otherwise win `latest`. + GitHubReleases { + owner: String, + repository: String, + release: String, + }, /// One fixed URL of a `.zsync` file, replaced by every release. Zsync { url: String }, } impl UpdateChannel { - /// Parses `owner/repository`. - pub fn github(slug: &str) -> Result { + /// Parses `owner/repository` and the release to look in. + pub fn github(slug: &str, release: &str) -> Result { + if !valid_field(release) { + return Err(AppImageError(format!( + "`{release}` is not a release tag; use letters, digits, `.`, `_` and `-`" + ))); + } let parts: Vec<&str> = slug.split('/').collect(); match parts.as_slice() { [owner, repository] if valid_field(owner) && valid_field(repository) => { Ok(UpdateChannel::GitHubReleases { owner: (*owner).to_owned(), repository: (*repository).to_owned(), + release: release.to_owned(), }) } _ => Err(AppImageError(format!("`{slug}` is not owner/repository"))), @@ -105,8 +118,12 @@ impl UpdateChannel { /// The update information line for this application on this architecture. pub fn update_information(&self, meta: &AppMetadata, arch: &str) -> String { match self { - UpdateChannel::GitHubReleases { owner, repository } => format!( - "gh-releases-zsync|{owner}|{repository}|latest|{}-*-{arch}.AppImage.zsync", + UpdateChannel::GitHubReleases { + owner, + repository, + release, + } => format!( + "gh-releases-zsync|{owner}|{repository}|{release}|{}-*-{arch}.AppImage.zsync", file_safe(&meta.name) ), UpdateChannel::Zsync { url } => format!("zsync|{url}"), diff --git a/scripts/packager-linux/src/main.rs b/scripts/packager-linux/src/main.rs index 1d1b5b14..2e12b6d9 100644 --- a/scripts/packager-linux/src/main.rs +++ b/scripts/packager-linux/src/main.rs @@ -47,7 +47,9 @@ appdir: --out update-information: - --github gh-releases-zsync, newest release + --github --release + gh-releases-zsync, the release with that tag (or `latest`, + `latest-pre`, `latest-all` to search) --zsync-url zsync, one fixed URL flatpak: @@ -156,7 +158,7 @@ fn run(args: &[String]) -> Result<(), String> { let extra: &[&str] = match command.as_str() { "metadata" => &["out"], "appdir" => &["payload", "updater", "out"], - "update-information" => &["github", "zsync-url"], + "update-information" => &["github", "release", "zsync-url"], "file-name" => &[], "flatpak" => &[ "out", @@ -228,9 +230,22 @@ fn run(args: &[String]) -> Result<(), String> { } } "update-information" => { + let release = options.one("release")?; let channel = match (options.one("github")?, options.one("zsync-url")?) { - (Some(slug), None) => UpdateChannel::github(&slug), - (None, Some(url)) => UpdateChannel::zsync(&url), + (Some(slug), None) => { + let release = release.ok_or( + "--github needs --release : the release the AppImage looks in \ + for its updates, or `latest` for the newest one that is not a \ + pre-release", + )?; + UpdateChannel::github(&slug, &release) + } + (None, Some(url)) => { + if release.is_some() { + return Err("--release goes with --github, not --zsync-url".into()); + } + UpdateChannel::zsync(&url) + } _ => return Err("pass exactly one of --github and --zsync-url".into()), } .map_err(|e| e.to_string())?; diff --git a/scripts/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs index 73a5d3b2..d19a88d0 100644 --- a/scripts/packager-linux/tests/metadata.rs +++ b/scripts/packager-linux/tests/metadata.rs @@ -398,10 +398,17 @@ fn fr35_appimage_update_information_points_at_zsync_files() { ); assert_eq!(file_safe("My App: 2"), "My_App__2"); - let github = UpdateChannel::github("darkpyonix/compose-rust").unwrap(); + let github = UpdateChannel::github("darkpyonix/compose-rust", "samples-latest").unwrap(); assert_eq!( github.update_information(&meta, "x86_64"), - "gh-releases-zsync|darkpyonix|compose-rust|latest|Calculator-*-x86_64.AppImage.zsync" + "gh-releases-zsync|darkpyonix|compose-rust|samples-latest|Calculator-*-x86_64.AppImage.zsync" + ); + // `latest` is still available, as a choice someone makes rather than a default. + assert_eq!( + UpdateChannel::github("darkpyonix/darkpyonix-ember", "latest") + .unwrap() + .update_information(&meta, "x86_64"), + "gh-releases-zsync|darkpyonix|darkpyonix-ember|latest|Calculator-*-x86_64.AppImage.zsync" ); // The pattern matches the file name of every later version. let pattern = "Calculator-*-aarch64.AppImage.zsync"; @@ -419,7 +426,10 @@ fn fr35_appimage_update_information_points_at_zsync_files() { ); for bad in ["darkpyonix", "a/b/c", "a|b/c", "/repo"] { - assert!(UpdateChannel::github(bad).is_err(), "{bad}"); + assert!(UpdateChannel::github(bad, "latest").is_err(), "{bad}"); + } + for bad in ["", "a|b", "a b", "tags/x"] { + assert!(UpdateChannel::github("a/b", bad).is_err(), "{bad:?}"); } for bad in [ "ftp://x/y.zsync", From f6fdef654990ff371e61e9bc62d74c56a7598fc8 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:52:45 +0900 Subject: [PATCH 048/224] Refactor: Format the packager test after the renderer rename --- scripts/packager-linux/tests/metadata.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs index d19a88d0..1b148b64 100644 --- a/scripts/packager-linux/tests/metadata.rs +++ b/scripts/packager-linux/tests/metadata.rs @@ -470,11 +470,7 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { std::fs::create_dir_all(&payload).unwrap(); std::fs::write(payload.join("sample-calculator"), b"#!/bin/sh\necho hi\n").unwrap(); std::fs::create_dir_all(payload.join("lib")).unwrap(); - std::fs::write( - payload.join("lib/libcompose_rust_renderer.so"), - b"\x7fELF", - ) - .unwrap(); + std::fs::write(payload.join("lib/libcompose_rust_renderer.so"), b"\x7fELF").unwrap(); let updater = dir.join("squashfs-root"); std::fs::create_dir_all(updater.join("usr/bin")).unwrap(); std::fs::write(updater.join("usr/bin/appimageupdatetool"), b"tool").unwrap(); From 56e7cf43074e2b133c67bfd2ca8953507f8ed503 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:56:17 +0900 Subject: [PATCH 049/224] Fix: Declare the update module in compose-rust The merge moved update.rs into compose-rust/src but never declared it, so compose_rust::update did not exist and the packager demo did not compile. --- compose-rust/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 058a99e7..49b943ed 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -37,6 +37,7 @@ pub mod schema; pub mod spans; pub mod theme; pub mod tokens; +pub mod update; pub mod window; pub use asset::asset; From 533dc1ba71ae8e9cdc0f3398d74b34eeb5ad3251 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:57:04 +0900 Subject: [PATCH 050/224] Fix: Let the launcher name CARGO_TARGET_DIR in the rule that forbids it agent-launchers.test.sh requires every launcher to say 'Never set or share CARGO_TARGET_DIR', and worktree-target.test.sh failed any launcher line naming it, so develop could not pass both. The second test now looks for what sets a build directory (an assignment, --target-dir, a target-dir key) rather than the word. --- scripts/tests/worktree-target.test.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/tests/worktree-target.test.sh b/scripts/tests/worktree-target.test.sh index ceff884c..c3864f17 100755 --- a/scripts/tests/worktree-target.test.sh +++ b/scripts/tests/worktree-target.test.sh @@ -50,10 +50,14 @@ if [[ ! -f "$launcher" ]]; then "this test can hold it to the same rule as scripts/setup-worktrees.sh." else launcher_body="$(grep -vE '^[[:space:]]*#' "$launcher")" - if grep -qE 'target[-_]dir|CARGO_TARGET_DIR' <<<"$launcher_body"; then + # What sets a build directory: an assignment, a cargo flag or a config key. The + # launcher's prompt text also names CARGO_TARGET_DIR, to forbid it, and saying so is + # not setting it. + sets_dir='CARGO_TARGET_DIR=|--target-dir|target[-_]dir[[:space:]]*=' + if grep -qE "$sets_dir" <<<"$launcher_body"; then fail "nfr12_the_launcher_leaves_the_build_directory_to_one_script" \ "$launcher decides the build directory itself:" \ - "$(grep -nE 'target[-_]dir|CARGO_TARGET_DIR' <<<"$launcher_body")" \ + "$(grep -nE "$sets_dir" <<<"$launcher_body")" \ "It has to call scripts/setup-worktrees.sh instead." elif ! grep -q 'setup-worktrees.sh' <<<"$launcher_body"; then fail "nfr12_the_launcher_leaves_the_build_directory_to_one_script" \ From 272c4a291b3ba2f53f91963aea27a38347462cf1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:57:20 +0900 Subject: [PATCH 051/224] Chore: Move the CI-only calculator helper to .github/scripts Scripts that only CI runs live in .github/scripts/. The packaging workflow is the only caller of the v0.0.0 calculator build. --- .../scripts/packager-linux-calculator-v0.sh | 4 ++-- .github/workflows/packager-linux.yml | 10 ++++++---- scripts/packager-linux/README.md | 1 - 3 files changed, 8 insertions(+), 7 deletions(-) rename scripts/packager-linux/ci/calculator-v0.sh => .github/scripts/packager-linux-calculator-v0.sh (95%) diff --git a/scripts/packager-linux/ci/calculator-v0.sh b/.github/scripts/packager-linux-calculator-v0.sh similarity index 95% rename from scripts/packager-linux/ci/calculator-v0.sh rename to .github/scripts/packager-linux-calculator-v0.sh index 80fd4192..9d1138e7 100755 --- a/scripts/packager-linux/ci/calculator-v0.sh +++ b/.github/scripts/packager-linux-calculator-v0.sh @@ -2,7 +2,7 @@ # Stages a calculator whose executable and renderer belong together, for the packaging # workflow to wrap and start. x86_64 only. # -# scripts/packager-linux/ci/calculator-v0.sh +# .github/scripts/packager-linux-calculator-v0.sh # # leaves /calculator/ (the executable, and the renderer in lib/ beside it) and # /calculator-linux-x64.tar.gz (the same directory as a release archive). @@ -19,7 +19,7 @@ set -euo pipefail work="$1" -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" renderer_url=https://github.com/DarkPyonix/dioxus-compose/releases/download/v0.0.0/dioxus-compose-renderer-v0.0.0-linux-x64.tar.gz renderer_sha256=5fe6ba31e359753a8e3c3a743f2f8ecadac409fda2634ba3b569e00f27ec19aa diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 8c943401..d596e364 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -27,6 +27,7 @@ on: - scripts/packager-linux/** - compose-rust/src/update.rs - compose-rust/tests/appimage_update.rs + - .github/scripts/packager-linux-calculator-v0.sh - .github/workflows/packager-linux.yml workflow_dispatch: @@ -283,8 +284,9 @@ jobs: - name: Wrap a calculator as an AppImage and start it # A calculator whose executable and renderer belong together: the v0.0.0 tag - # built against its own released renderer (ci/calculator-v0.sh says why the - # published sample archives cannot be used). That renderer exists for x86_64 + # built against its own released renderer + # (.github/scripts/packager-linux-calculator-v0.sh says why the published sample + # archives cannot be used). That renderer exists for x86_64 # only, so on aarch64 there is nothing that can start, and the step says so. run: | set -euo pipefail @@ -292,7 +294,7 @@ jobs: echo "::notice title=No aarch64 calculator::no renderer was published for linux-arm64 at v0.0.0; the self-update steps above ran on aarch64" exit 0 fi - scripts/packager-linux/ci/calculator-v0.sh work + .github/scripts/packager-linux-calculator-v0.sh work # Unsigned: these are test builds, and the signing key is not created yet. scripts/packager-linux/appimage.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ @@ -408,7 +410,7 @@ jobs: - name: Package a calculator as a prebuilt archive and start it in the sandbox run: | set -euo pipefail - scripts/packager-linux/ci/calculator-v0.sh work + .github/scripts/packager-linux-calculator-v0.sh work mkdir -p work/flatpak-calculator cp work/calculator-linux-x64.tar.gz work/flatpak-calculator/ # flatpak talks to its system helper over the session bus, which a runner has diff --git a/scripts/packager-linux/README.md b/scripts/packager-linux/README.md index 0f9e78d8..c9640280 100644 --- a/scripts/packager-linux/README.md +++ b/scripts/packager-linux/README.md @@ -18,7 +18,6 @@ application's `Dioxus.toml`: | `FLATHUB.md` | Checklist for submitting to Flathub | | `samples.toml` | Store facts shared by the sample applications, passed as an overlay | | `demo/` | The small program the packaging workflow updates from 1.0.0 to 1.0.1 | -| `ci/` | Helpers the packaging workflow uses | Run `packager-linux` with no arguments for its options. `.github/workflows/packager-linux.yml` runs everything here on GitHub's Ubuntu runners. From ed8faada48f5fd64973f8f981bb30815a125a880 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:00:27 +0900 Subject: [PATCH 052/224] Chore: Format the files the project rename left unformatted cargo fmt --check failed on develop in compose-rust/build.rs, src/bin/codegen.rs and src/codegen.rs, where the shorter compose_rust names now fit on one line. Formatting only. --- compose-rust/build.rs | 3 +-- compose-rust/src/bin/codegen.rs | 14 ++++---------- compose-rust/src/codegen.rs | 12 ++++-------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/compose-rust/build.rs b/compose-rust/build.rs index 9a8981ee..70363890 100644 --- a/compose-rust/build.rs +++ b/compose-rust/build.rs @@ -248,8 +248,7 @@ fn main() { // Only present in a checkout of this repository, and only once its renderer has been // built. A consumer of the published crate has neither, which is why everything below // it exists. - let workspace_lib_dir = - manifest_dir.join("../renderer/build/native-image/dist/lib"); + let workspace_lib_dir = manifest_dir.join("../renderer/build/native-image/dist/lib"); let crate_version = std::env::var("CARGO_PKG_VERSION").expect("Cargo sets CARGO_PKG_VERSION"); let target = artifact_target( &target_os, diff --git a/compose-rust/src/bin/codegen.rs b/compose-rust/src/bin/codegen.rs index 45c769b6..3cdbade1 100644 --- a/compose-rust/src/bin/codegen.rs +++ b/compose-rust/src/bin/codegen.rs @@ -138,21 +138,15 @@ mod tests { #[test] fn fr7_writes_the_checkout_it_was_compiled_in() { - let tree = output_tree( - Path::new("/one/compose-rust"), - invoked("/one/compose-rust"), - ) - .expect("the checkout that compiled it is the checkout it writes"); + let tree = output_tree(Path::new("/one/compose-rust"), invoked("/one/compose-rust")) + .expect("the checkout that compiled it is the checkout it writes"); assert_eq!(tree, PathBuf::from("/one/compose-rust")); } #[test] fn fr7_refuses_a_checkout_it_was_not_compiled_in() { - let message = output_tree( - Path::new("/one/compose-rust"), - invoked("/two/compose-rust"), - ) - .expect_err("a binary compiled elsewhere must not write here"); + let message = output_tree(Path::new("/one/compose-rust"), invoked("/two/compose-rust")) + .expect_err("a binary compiled elsewhere must not write here"); assert!( message.contains("/one/compose-rust") && message.contains("/two/compose-rust"), "the message has to name both checkouts, got: {message}" diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 2a6891cc..87ae4be0 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -20,8 +20,7 @@ use std::fmt::Write as _; /// The Kotlin Toolchain compiles the module's `src` tree by convention and offers no way to /// add another source root, so generated Kotlin lives inside `src`. -pub const GENERATED_RELATIVE_PATH: &str = - "../renderer/desktop/src/protocol/Protocol.gen.kt"; +pub const GENERATED_RELATIVE_PATH: &str = "../renderer/desktop/src/protocol/Protocol.gen.kt"; /// The schema's hash, as a line of text beside the crate. /// /// The renderer carries the same number, compiled into it, and the two are compared when @@ -2156,8 +2155,7 @@ fn lower_first(name: &str) -> String { /// Generated Rust, compiled into the cdylib only when the target is Android. pub const JNI_RUST_RELATIVE_PATH: &str = "src/boundary_jni.gen.rs"; /// Generated Kotlin. The Kotlin Toolchain compiles the module's `src` tree by convention. -pub const ANDROID_BRIDGE_RELATIVE_PATH: &str = - "../renderer/android/src/bridge/HostBridge.gen.kt"; +pub const ANDROID_BRIDGE_RELATIVE_PATH: &str = "../renderer/android/src/bridge/HostBridge.gen.kt"; pub const ANDROID_FAST_NATIVE_RELATIVE_PATH: &str = "../renderer/android/src/bridge/FastNative.gen.kt"; @@ -2666,11 +2664,9 @@ fn screaming_snake_case(name: &str) -> String { /// Generated Rust, compiled into the cdylib only when the target is wasm. pub const WASM_RUST_RELATIVE_PATH: &str = "src/boundary_wasm.gen.rs"; /// Generated Kotlin. The Kotlin Toolchain compiles the module's `src` tree by convention. -pub const WEB_BRIDGE_RELATIVE_PATH: &str = - "../renderer/web/src/bridge/HostBridge.gen.kt"; +pub const WEB_BRIDGE_RELATIVE_PATH: &str = "../renderer/web/src/bridge/HostBridge.gen.kt"; /// Generated JavaScript, in the module's resource tree, which is copied next to `web.mjs`. -pub const WEB_LOADER_RELATIVE_PATH: &str = - "../renderer/web/resources/compose-rust-host.gen.mjs"; +pub const WEB_LOADER_RELATIVE_PATH: &str = "../renderer/web/resources/compose-rust-host.gen.mjs"; const WEB_KOTLIN_PACKAGE: &str = "dev.darkpyonix.composerust.ui.platform"; From 7b087e0007d9f5e8cdcb81ccd6df4705ffb6cd03 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:23:41 +0900 Subject: [PATCH 053/224] Fix: Attach a node's key handler before its own modifiers (FR-12, #101) A focusable box with OnKeyDown only received keys from focused descendants because the handler sat after the focusable modifier in the chain. It now sits before it. Test: KeyEventTest.fr12_a_focused_box_receives_its_own_keys (not run here; the Kotlin gate runs at merge). --- renderer/desktop/src/renderer/RenderNode.kt | 16 +++++---- renderer/desktop/test/KeyEventTest.kt | 40 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 58dd01b9..f833035c 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -127,17 +127,21 @@ fun RenderNode( RenderNodeObserver.onCompose?.let { observer -> SideEffect { observer(nodeId) } } val theme = LocalDesignTheme.current val profiled = if (LayoutProfile.enabled) parentModifier.then(LayoutProfile.modifier(node.widget.name)) else parentModifier - val chain = profiled - .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme) { table.animations.animated(nodeId) }) - .testTag(nodeTestTag(nodeId)) + // The key handler goes ahead of the node's own modifiers. A modifier that makes the node + // focusable (a click, say) is a focus target, and a key event reaches only the handlers + // outside it on the way down. Placed after it, the handler would see keys from focused + // descendants only and never the keys pressed while the node itself has focus. // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) - val withKeys = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { - chain + val keyed = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { + profiled } else { - chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + profiled.hostKeyEvents(nodeId, keyDownHandler, dispatcher) } + val withKeys = keyed + .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme) { table.animations.animated(nodeId) }) + .testTag(nodeTestTag(nodeId)) // Only where the node said it is a place files may be dropped. Everywhere else this // adds nothing to the chain and the platform shows no drop cursor. val modifier = platformFileDrop(withKeys, node, dispatcher) diff --git a/renderer/desktop/test/KeyEventTest.kt b/renderer/desktop/test/KeyEventTest.kt index 26169464..922ae73b 100644 --- a/renderer/desktop/test/KeyEventTest.kt +++ b/renderer/desktop/test/KeyEventTest.kt @@ -14,6 +14,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Key as ProtocolKey +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.Mutation import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue @@ -140,4 +141,43 @@ class KeyEventTest { connection.nodeEvents, ) } + + /** + * A focusable box with `OnKeyDown` receives the keys pressed while the box itself has + * focus, not only keys that bubble up from a focused descendant (issue 101). The box has + * no children, so the only way a key can reach the handler is through the box's own + * focus target. + */ + @Test + fun fr12_a_focused_box_receives_its_own_keys() = runComposeUiTest { + val box = 2 + val connection = FakeHostConnection( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Size(40f, 40f)), + Mutation.SetModifier(box, 1, ProtocolModifier.Clickable(77L)), + Mutation.SetProp(box, PropertyKind.OnKeyDown, PropertyValue.Integer(KEY_HANDLER)), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(box)).requestFocus() + onNodeWithTag(nodeTestTag(box)).performKeyInput { pressKey(Key.Enter) } + waitForIdle() + + assertEquals( + listOf( + HostEvent.KeyDown( + nodeId = box, + handlerId = KEY_HANDLER, + key = ProtocolKey.Enter, + shiftKey = false, + ctrlKey = false, + altKey = false, + metaKey = false, + ), + ), + connection.nodeEvents.filterIsInstance(), + ) + } } From 0cc5888f84ce1e6a5a64328c761a5a2e61c832c1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:32:09 +0900 Subject: [PATCH 054/224] Fix: Key the Windows packaging workflow's concurrency by pull request --- .github/workflows/package-windows.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index 3675c97b..776fdbb8 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -77,8 +77,8 @@ permissions: actions: read concurrency: - group: package-windows-${{ github.ref }} - cancel-in-progress: true + group: package-windows-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always From 48f474d4c2ae9045e1de5829ea2cb671268e1a00 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:33:14 +0900 Subject: [PATCH 055/224] Chore: Stage the key handler change into the Android Kotlin copy --- compose-rust/android-kotlin/shared/RenderNode.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 58dd01b9..f833035c 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -127,17 +127,21 @@ fun RenderNode( RenderNodeObserver.onCompose?.let { observer -> SideEffect { observer(nodeId) } } val theme = LocalDesignTheme.current val profiled = if (LayoutProfile.enabled) parentModifier.then(LayoutProfile.modifier(node.widget.name)) else parentModifier - val chain = profiled - .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme) { table.animations.animated(nodeId) }) - .testTag(nodeTestTag(nodeId)) + // The key handler goes ahead of the node's own modifiers. A modifier that makes the node + // focusable (a click, say) is a focus target, and a key event reaches only the handlers + // outside it on the way down. Placed after it, the handler would see keys from focused + // descendants only and never the keys pressed while the node itself has focus. // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) - val withKeys = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { - chain + val keyed = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { + profiled } else { - chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + profiled.hostKeyEvents(nodeId, keyDownHandler, dispatcher) } + val withKeys = keyed + .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme) { table.animations.animated(nodeId) }) + .testTag(nodeTestTag(nodeId)) // Only where the node said it is a place files may be dropped. Everywhere else this // adds nothing to the chain and the platform shows no drop cursor. val modifier = platformFileDrop(withKeys, node, dispatcher) From 82ff41dd7983006361886aa626551ddfbc00a65b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:33:39 +0900 Subject: [PATCH 056/224] Fix: Keep the README crate-safe and the Sparkle rehearsal out of the workflow The rehearsal builds a Dioxus application, which only experiments/ may hold, and experiments/ does not reach main, so the macOS packaging workflow stops running it. The README says what the packager reads without naming Dioxus, which the crate README check forbids. --- .github/workflows/packager-macos.yml | 72 +++---------------------- README.md | 3 +- compose-rust/update/Cargo.toml | 2 +- docs/locales/README_ko.md | 2 +- experiments/self-update/app/Dioxus.toml | 2 +- experiments/self-update/rehearse.sh | 2 +- 6 files changed, 12 insertions(+), 71 deletions(-) diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml index 8dbd6f4b..52b84f10 100644 --- a/.github/workflows/packager-macos.yml +++ b/.github/workflows/packager-macos.yml @@ -1,15 +1,9 @@ -# macOS packaging: the packager's tests, the updater crate's tests, and the rehearsal that -# installs one version of an application and watches it update itself to the next. +# macOS packaging: the packager's tests and the updater crate's tests. # -# Everything is signed ad hoc, which is what the project ships without an Apple developer -# account, and Sparkle trusts an update by its EdDSA signature alone. The rehearsal is the -# only place the two halves meet a real renderer: it builds the demo against the published -# dioxus-compose 0.0.0 and the renderer released for it, so nothing here builds the renderer. -# -# Its evidence (logs, Info.plists, signatures, the appcast, screenshots) is uploaded as the -# self-update-evidence artifact whether or not it passed, and the bundles and served site -# as self-update-bundles, so the same update can be watched on a Mac that built nothing -# (DEMO_REUSE=1 experiments/self-update/rehearse.sh). +# The rehearsal that installs one version of an application and watches it update itself +# to the next is in experiments/self-update on develop. It builds a Dioxus application +# against the published dioxus-compose, which nothing outside experiments may do, and +# experiments/ is removed from main, so this workflow no longer runs it. name: macOS packaging @@ -19,21 +13,19 @@ on: paths: - 'scripts/packager-macos/**' - 'compose-rust/update/**' - - 'experiments/self-update/**' - 'scripts/bundle-renderer.sh' - '.github/workflows/packager-macos.yml' pull_request: paths: - 'scripts/packager-macos/**' - 'compose-rust/update/**' - - 'experiments/self-update/**' - 'scripts/bundle-renderer.sh' - '.github/workflows/packager-macos.yml' workflow_dispatch: concurrency: - group: packager-macos-${{ github.ref }} - cancel-in-progress: true + group: packager-macos-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -95,53 +87,3 @@ jobs: - name: Lint run: cargo clippy -p compose-rust-update --all-targets -- -D warnings - - rehearsal: - name: Self-update rehearsal (macOS) - needs: [packager, updater] - runs-on: macos-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install the Rust toolchain - run: | - rustup toolchain install stable --profile minimal --component clippy - rustup default stable - - - name: Rehearse the update - timeout-minutes: 25 - env: - DEMO_WORK: ${{ runner.temp }}/self-update - run: experiments/self-update/rehearse.sh - - - name: Lint the rehearsal application - # After the rehearsal, which fetched the released renderer its build script needs. - if: success() || failure() - env: - DIOXUS_COMPOSE_RENDERER_DIR: ${{ runner.temp }}/self-update/renderer - run: | - cargo clippy --manifest-path experiments/self-update/app/Cargo.toml -- -D warnings - cargo clippy --manifest-path experiments/self-update/app/Cargo.toml --no-default-features -- -D warnings - - - name: Keep the evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: self-update-evidence - path: ${{ runner.temp }}/self-update/evidence/ - if-no-files-found: warn - - - name: Keep the bundles - # Zipped by ditto in the script, so the framework's links survive the upload. - if: always() - uses: actions/upload-artifact@v4 - with: - name: self-update-bundles - path: ${{ runner.temp }}/self-update/bundles/ - if-no-files-found: warn - retention-days: 7 diff --git a/README.md b/README.md index 21053cd3..199dc653 100644 --- a/README.md +++ b/README.md @@ -244,8 +244,7 @@ window. ### Shipping a macOS application `scripts/packager-macos/package-macos` turns the executable `cargo build --release` made into an -application bundle, using the name, identifier and version your `Dioxus.toml` and -`Cargo.toml` already state. It needs Python 3.11 and Xcode's command line tools, and no Apple +application bundle, using the name, identifier and version your `dx` bundle configuration and`Cargo.toml` already state. It needs Python 3.11 and Xcode's command line tools, and no Apple developer account. ```sh diff --git a/compose-rust/update/Cargo.toml b/compose-rust/update/Cargo.toml index 21fab484..9059c4e8 100644 --- a/compose-rust/update/Cargo.toml +++ b/compose-rust/update/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true rust-version.workspace = true description = "Lets a compose-rust application handed out outside a store update itself (Sparkle 2 on macOS)" repository = "https://github.com/DarkPyonix/compose-rust" -keywords = ["update", "sparkle", "dioxus", "compose", "macos"] +keywords = ["update", "sparkle", "compose-rust", "compose", "macos"] categories = ["gui"] # Not published yet. The crate is new, and an application that ships through the Mac App # Store must not depend on it at all, which is easier to keep straight while it lives in diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index 72b575a1..21149d07 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -227,7 +227,7 @@ FreeType). 어디로 복사해도 실행됩니다. Windows는 정적 렌더러 ### macOS 애플리케이션 배포하기 `scripts/packager-macos/package-macos`는 `cargo build --release`가 만든 실행 파일을 애플리케이션 -번들로 만듭니다. 이름, 식별자, 버전은 `Dioxus.toml`과 `Cargo.toml`에 이미 적힌 것을 씁니다. +번들로 만듭니다. 이름, 식별자, 버전은 `dx` 번들 설정과 `Cargo.toml`에 이미 적힌 것을 씁니다. Python 3.11과 Xcode 명령줄 도구가 필요하고, Apple 개발자 계정은 필요하지 않습니다. ```sh diff --git a/experiments/self-update/app/Dioxus.toml b/experiments/self-update/app/Dioxus.toml index 22e6be63..ebc34295 100644 --- a/experiments/self-update/app/Dioxus.toml +++ b/experiments/self-update/app/Dioxus.toml @@ -6,7 +6,7 @@ name = "Self Update Demo" asset_dir = "assets" [bundle] -identifier = "dev.darkpyonix.dioxus.compose.experiments.selfupdate" +identifier = "dev.darkpyonix.composerust.experiments.selfupdate" publisher = "DarkPyonix" category = "DeveloperTool" short_description = "Installs at one version and updates itself to the next." diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh index a4784a4a..2d9c1379 100755 --- a/experiments/self-update/rehearse.sh +++ b/experiments/self-update/rehearse.sh @@ -42,7 +42,7 @@ package="$repo/scripts/packager-macos/package-macos" app_dir="$repo/experiments/self-update/app" evidence="$work/evidence" name="Self Update Demo" -identifier="dev.darkpyonix.dioxus.compose.experiments.selfupdate" +identifier="dev.darkpyonix.composerust.experiments.selfupdate" executable="self-update-demo" archive_name="SelfUpdateDemo-2.0.0.zip" From 46f47da0ae09d28817d640b36cfe6fd15fbc8f39 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:35:49 +0900 Subject: [PATCH 057/224] Fix: Run the Flatpak generator through uv and key the Linux packaging concurrency by pull request --- .github/workflows/packager-linux.yml | 10 ++++++---- scripts/packager-linux/flatpak.sh | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index d596e364..e8e65be1 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -35,8 +35,8 @@ permissions: contents: read concurrency: - group: packager-linux-${{ github.ref }} - cancel-in-progress: true + group: packager-linux-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always @@ -337,6 +337,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install flatpak and Flathub's builder run: | set -euo pipefail @@ -344,8 +347,7 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils dbus-daemon patchelf xvfb xauth imagemagick python3-pip - pip install --user 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1' + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils dbus-daemon patchelf xvfb xauth imagemagick flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true flatpak --user install --noninteractive flathub org.flatpak.Builder \ diff --git a/scripts/packager-linux/flatpak.sh b/scripts/packager-linux/flatpak.sh index 0f4c8929..bd258aff 100755 --- a/scripts/packager-linux/flatpak.sh +++ b/scripts/packager-linux/flatpak.sh @@ -89,11 +89,12 @@ if [[ -n "$cargo_lock" ]]; then "flatpak-cargo-generator has digest $got, pinned is $GENERATOR_SHA256" mv "$generator.part" "$generator" fi - python3 -c 'import aiohttp, tomlkit' 2>/dev/null || fail \ - "flatpak-cargo-generator needs the Python modules aiohttp and tomlkit" \ - "pip install 'aiohttp>=3.9.5,<4' 'tomlkit>=0.13.3,<1'" + command -v uv >/dev/null || fail \ + "flatpak-cargo-generator needs uv to run it with the Python modules aiohttp and tomlkit" \ + "install uv: https://docs.astral.sh/uv/" echo "== vendoring the crates in $cargo_lock" - python3 "$generator" "$cargo_lock" -o "$out/cargo-sources.json" + uv run --no-project --with 'aiohttp>=3.9.5,<4' --with 'tomlkit>=0.13.3,<1' \ + "$generator" "$cargo_lock" -o "$out/cargo-sources.json" passthrough+=(--cargo-sources cargo-sources.json) fi From d3fc6bad8fa813ba1309586eef33f58af4c47b6a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:44:08 +0900 Subject: [PATCH 058/224] Fix: Drop the old tools/msix workspace member and let cargo settle the lock file --- Cargo.lock | 9 +-------- Cargo.toml | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cac3648..fd0a1c12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,12 +14,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - [[package]] name = "bytes" version = "1.12.1" @@ -206,7 +200,7 @@ version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -342,7 +336,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", - "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3401bda8..3a8624d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ members = [ "samples/store", "samples/todo", "scripts/packager-windows", - "tools/msix", ] resolver = "2" From e66129416f8488d93940c586f0805a5fff06c554 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:51:47 +0900 Subject: [PATCH 059/224] Fix: Drop zsync update information and the update module from the Linux packager (FR-22.6) Direct distribution is packaged and updated with Velopack on all three OSes, so the AppImage carries no zsync update information and no appimageupdatetool, and compose_rust::update is gone. Removes the update module and its test, the update-information command, the self-update workflow steps, and the README section on update feeds. The AppImage, Flatpak, signing and samples-latest release stay. Renames the fr35_* tests to fr22_6_* (criteria 3, 5 and 9). --- .github/workflows/packager-linux.yml | 164 +----- .github/workflows/test-samples.yml | 43 +- compose-rust/src/lib.rs | 1 - compose-rust/src/update.rs | 655 ----------------------- compose-rust/tests/appimage_update.rs | 416 -------------- scripts/packager-linux/README.md | 33 +- scripts/packager-linux/appimage.sh | 73 +-- scripts/packager-linux/demo/Cargo.toml | 4 +- scripts/packager-linux/demo/Dioxus.toml | 13 +- scripts/packager-linux/demo/src/main.rs | 116 +--- scripts/packager-linux/src/appimage.rs | 104 +--- scripts/packager-linux/src/lib.rs | 4 +- scripts/packager-linux/src/main.rs | 37 +- scripts/packager-linux/tests/metadata.rs | 105 +--- 14 files changed, 104 insertions(+), 1664 deletions(-) delete mode 100644 compose-rust/src/update.rs delete mode 100644 compose-rust/tests/appimage_update.rs diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index e8e65be1..914ee9a3 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -1,14 +1,12 @@ -# Linux packaging: an AppImage that updates itself and a Flatpak built the way Flathub -# builds one. Each channel is shown updating an installed build to a newer one. +# Linux packaging: an AppImage and a Flatpak built the way Flathub builds one. Direct +# distribution is packaged and updated with Velopack, so nothing here carries update +# information. # -# - rust: the packager's tests, the update module's tests, lint, and the lock file. -# - appimage: builds a small program twice (1.0.0 and 1.0.1), wraps both as signed -# AppImages carrying zsync update information, serves the newer one's .zsync -# from nginx on the runner, and has the installed 1.0.0 update itself. Then a -# 1.0.2 signed by a different key is published and the update must be refused. -# Last, a real calculator with its renderer (built at the v0.0.0 tag against -# that tag's released renderer, x86_64) is wrapped and started under a -# virtual display. +# - rust: the packager's tests, lint, and the lock file. +# - appimage: builds a small program, wraps it as a signed AppImage, checks the contents +# and the metadata the desktop tools read. Then a real calculator with its +# renderer (built at the v0.0.0 tag against that tag's released renderer, +# x86_64) is wrapped and started under a virtual display. # - flatpak: builds the same program from source with flatpak-builder, offline, with # every crate vendored, installs it, then builds 1.0.1 into the same repository # and updates the installation with `flatpak update`. Then the same calculator @@ -25,8 +23,6 @@ on: push: paths: - scripts/packager-linux/** - - compose-rust/src/update.rs - - compose-rust/tests/appimage_update.rs - .github/scripts/packager-linux-calculator-v0.sh - .github/workflows/packager-linux.yml workflow_dispatch: @@ -89,13 +85,10 @@ jobs: - name: Clippy run: | cargo clippy -p packager-linux -p packager-linux-demo --all-targets -- -D warnings - cargo clippy -p compose-rust --no-default-features --lib --test appimage_update -- -D warnings - name: Tests run: | cargo test -p packager-linux - cargo test -p compose-rust --no-default-features --test appimage_update - cargo test -p compose-rust --no-default-features --doc update - name: Repository rules run: | @@ -107,7 +100,7 @@ jobs: run: exit 1 appimage: - name: AppImage updates itself (${{ matrix.arch }}) + name: AppImage (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: @@ -133,154 +126,56 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - zsync desktop-file-utils appstream gnupg nginx-light fuse3 libfuse2t64 patchelf \ + desktop-file-utils appstream gnupg fuse3 libfuse2t64 patchelf \ xvfb xauth imagemagick libgl1 libegl1 libgl1-mesa-dri fontconfig \ libxtst6 libxi6 libxrender1 libxext6 libxrandr2 fonts-dejavu-core rustup toolchain install stable --profile minimal && rustup default stable ls -l /dev/fuse || echo "no /dev/fuse" - - name: Build the packager and two versions of the program + - name: Build the packager and the program run: | set -euo pipefail cargo build --release -p packager-linux - mkdir -p work/1.0.0 work/1.0.1 work/1.0.2 - for v in 1.0.0 1.0.1 1.0.2; do + mkdir -p work/1.0.0 + for v in 1.0.0; do COMPOSE_RUST_APP_VERSION=$v cargo build --release -p packager-linux-demo cp target/release/update-demo "work/$v/update-demo" "work/$v/update-demo" --version done echo "PACKAGER_LINUX=$PWD/target/release/packager-linux" >> "$GITHUB_ENV" - - name: Make two signing keys - # The release key, and an impostor's, to show an update signed by anyone else is - # refused. + - name: Make a signing key run: | set -euo pipefail export GNUPGHOME="$PWD/work/gnupg" && mkdir -p -m 700 "$GNUPGHOME" echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" gpg --batch --passphrase '' --quick-gen-key "Release " ed25519 sign never - gpg --batch --passphrase '' --quick-gen-key "Impostor " ed25519 sign never echo "RELEASE_KEY=$(gpg --list-keys --with-colons release@example.invalid | awk -F: '/^fpr/ {print $10; exit}')" >> "$GITHUB_ENV" - echo "IMPOSTOR_KEY=$(gpg --list-keys --with-colons impostor@example.invalid | awk -F: '/^fpr/ {print $10; exit}')" >> "$GITHUB_ENV" - - name: Package all three versions + - name: Package the program run: | set -euo pipefail - url=http://127.0.0.1:8080/latest/UpdateDemo-$ARCH.AppImage.zsync - for v in 1.0.0 1.0.1 1.0.2; do - key="$RELEASE_KEY"; [[ $v == 1.0.2 ]] && key="$IMPOSTOR_KEY" - scripts/packager-linux/appimage.sh \ - --dioxus-toml scripts/packager-linux/demo/Dioxus.toml \ - --payload "work/$v/update-demo" --version "$v" \ - --zsync-url "$url" --sign-key "$key" --out-dir "work/dist" - done + scripts/packager-linux/appimage.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml \ + --payload "work/1.0.0/update-demo" --version 1.0.0 \ + --sign-key "$RELEASE_KEY" --out-dir "work/dist" ls -l work/dist - head -c 400 work/dist/UpdateDemo-1.0.1-$ARCH.AppImage.zsync | sed -n '1,/^$/p' - - name: The AppImage carries its update information and its updater + - name: The AppImage carries its payload and valid metadata run: | set -euo pipefail app=work/dist/UpdateDemo-1.0.0-$ARCH.AppImage - info="$("$app" --appimage-updateinformation)" - echo "$info" - [[ "$info" == "zsync|http://127.0.0.1:8080/latest/UpdateDemo-$ARCH.AppImage.zsync" ]] + # No update information: Velopack owns updates. + ! "$app" --appimage-updateinformation 2>/dev/null | grep -q zsync "$app" --appimage-signature | head -3 "$app" --appimage-extract >/dev/null - test -e squashfs-root/usr/lib/appimageupdatetool/AppRun test -x squashfs-root/usr/lib/update-demo/update-demo desktop-file-validate squashfs-root/dev.darkpyonix.compose.UpdateDemo.desktop appstreamcli validate --no-net --explain \ squashfs-root/usr/share/metainfo/dev.darkpyonix.compose.UpdateDemo.appdata.xml rm -rf squashfs-root - - - name: Serve releases from nginx on the runner - run: | - set -euo pipefail - srv="$PWD/work/server" && mkdir -p "$srv/latest" "$srv/tmp" - cat > "$srv/nginx.conf" <> "$GITHUB_ENV" - - - name: Install 1.0.0, publish 1.0.0, and it is up to date - run: | - set -euo pipefail - publish() { - rm -f "$SERVER/latest/"* - cp "work/dist/UpdateDemo-$1-$ARCH.AppImage" "$SERVER/latest/" - # One fixed name for the newest .zsync; it names the AppImage beside it. - cp "work/dist/UpdateDemo-$1-$ARCH.AppImage.zsync" "$SERVER/latest/UpdateDemo-$ARCH.AppImage.zsync" - } - declare -f publish > work/publish.sh - publish 1.0.0 - mkdir -p work/installed - cp work/dist/UpdateDemo-1.0.0-$ARCH.AppImage work/installed/UpdateDemo.AppImage - app=work/installed/UpdateDemo.AppImage - "$app" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" - "$app" --delivery - "$app" --check - - - name: Publish 1.0.1, and 1.0.0 updates itself to it - run: | - set -euo pipefail - source work/publish.sh - publish 1.0.1 - : > "$SERVER/access.log" - app="$PWD/work/installed/UpdateDemo.AppImage" - before="$(sha256sum "$app" | cut -d' ' -f1)" - set +e; "$app" --check; code=$?; set -e - [[ $code -eq 10 ]] || { echo "expected 10 (update available), got $code"; exit 1; } - # Check, ask (answered yes here), apply, and restart into the new version. - "$app" --update --yes 2>&1 | tee work/update.log - tail -1 work/update.log | grep -qx "update-demo 1.0.1" - "$app" --version | grep -qx "update-demo 1.0.1" - "$app" --check - after="$(sha256sum "$app" | cut -d' ' -f1)" - published="$(sha256sum "$SERVER/latest/UpdateDemo-1.0.1-$ARCH.AppImage" | cut -d' ' -f1)" - echo "before $before"; echo "after $after"; echo "served $published" - [[ "$after" == "$published" && "$before" != "$after" ]] - echo "--- requests" - cat "$SERVER/access.log" - size="$(stat -c %s "$app")" - fetched="$(awk '/AppImage HTTP/ {sum += $2} END {print sum + 0}' "$SERVER/access.log")" - echo "downloaded $fetched of $size bytes of the AppImage" - grep -q '^206 ' "$SERVER/access.log" || { echo "no ranged request: the update was not a delta"; exit 1; } - { - echo "### AppImage 1.0.0 updated itself to 1.0.1" - echo - echo "Downloaded $fetched of $size bytes (zsync ranged requests)." - } >> "$GITHUB_STEP_SUMMARY" - - - name: An update signed by another key is refused - run: | - set -euo pipefail - source work/publish.sh - publish 1.0.2 - app="$PWD/work/installed/UpdateDemo.AppImage" - before="$(sha256sum "$app" | cut -d' ' -f1)" - set +e; "$app" --update --yes > work/impostor.log 2>&1; code=$?; set -e - cat work/impostor.log - echo "exit status $code" - [[ $code -ne 0 ]] || { echo "the impostor's update was accepted"; exit 1; } - "$app" --version | grep -qx "update-demo 1.0.1" - [[ "$(sha256sum "$app" | cut -d' ' -f1)" == "$before" ]] - ls -la work/installed - echo "The impostor's 1.0.2 was refused and 1.0.1 still runs." >> "$GITHUB_STEP_SUMMARY" + cp "$app" work/UpdateDemo.AppImage + work/UpdateDemo.AppImage --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" - name: Wrap a calculator as an AppImage and start it # A calculator whose executable and renderer belong together: the v0.0.0 tag @@ -291,7 +186,7 @@ jobs: run: | set -euo pipefail if [[ "$ARCH" != x86_64 ]]; then - echo "::notice title=No aarch64 calculator::no renderer was published for linux-arm64 at v0.0.0; the self-update steps above ran on aarch64" + echo "::notice title=No aarch64 calculator::no renderer was published for linux-arm64 at v0.0.0" exit 0 fi .github/scripts/packager-linux-calculator-v0.sh work @@ -302,13 +197,8 @@ jobs: --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png \ --payload work/calculator/calculator --version 0.0.0 \ - --github DarkPyonix/compose-rust --release samples-latest \ --out-dir work/dist app=work/dist/Calculator-0.0.0-$ARCH.AppImage - # The address the Sample apps workflow publishes every sample's .zsync at. - "$app" --appimage-updateinformation \ - | tee /dev/stderr \ - | grep -qxF "gh-releases-zsync|DarkPyonix|compose-rust|samples-latest|Calculator-*-$ARCH.AppImage.zsync" xvfb-run -a -s "-screen 0 1280x800x24" bash -c " '$app' > work/calculator.log 2>&1 & pid=\$! @@ -325,10 +215,8 @@ jobs: name: appimage-${{ matrix.arch }} path: | work/dist/*.AppImage - work/dist/*.zsync work/*.log work/*.png - work/server/access.log flatpak: name: Flatpak builds and updates @@ -375,8 +263,6 @@ jobs: --out work/flatpak-src --build work/repo --lint id=dev.darkpyonix.compose.UpdateDemo flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" - flatpak run "$id" --delivery - flatpak run "$id" --update --yes | tee /dev/stderr | grep -q "store" # The renderer travelled beside the executable and the executable looks beside # itself for libraries. flatpak run --devel --command=sh "$id" -c 'ls /app/lib/update-demo; readelf -d /app/lib/update-demo/update-demo | grep -E "RUNPATH|RPATH|NEEDED"' diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 4587738d..f5e9ed19 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -8,9 +8,9 @@ # the ones a sample build reads beyond the crate itself; a change to compose-rust alone is # tested by the workspace's own tests, which include every sample's. # -# A release also puts the same files in the `samples-latest` release, the fixed place every -# sample AppImage looks for its updates. Both releases are in this repository and are -# written with the workflow's own token. +# A release also puts the same files in the `samples-latest` release, a fixed place that +# always holds the newest samples. Both releases are in this repository and are written +# with the workflow's own token. # # A sample that does not build on a platform is skipped and the release goes ahead with # the rest. Eleven samples across three platforms is thirty-three builds, and holding all @@ -168,7 +168,7 @@ jobs: sudo apt-get update sudo apt-get install -y build-essential pkg-config \ libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev \ - zsync desktop-file-utils appstream gnupg + desktop-file-utils appstream gnupg - name: Cache the Kotlin toolchain and Maven dependencies if: runner.os == 'Windows' @@ -295,17 +295,11 @@ jobs: tar -czf "artifacts/$app-${{ matrix.target }}.tar.gz" -C staging "$app" done < built.txt - - name: Wrap each Linux sample as an AppImage that updates itself - # The same staged directory, as one file that carries the address of its newer - # versions: the `samples-latest` release of this repository, whose assets the - # release job below replaces with every sample release. A fixed tag rather than - # `latest`, because `latest` here is the newest library release, which carries no - # sample at all. A sample that fails here keeps its archive and loses only the - # AppImage. + - name: Wrap each Linux sample as an AppImage + # The same staged directory, as one file. A sample that fails here keeps its + # archive and loses only the AppImage. # - # Signed when the repository has a signing key. An installed AppImage that was - # signed refuses an update signed by any other key, so the first public AppImage - # decides whether every later one has to be signed. + # Signed when the repository has a signing key. if: runner.os == 'Linux' shell: bash env: @@ -335,7 +329,7 @@ jobs: --overlay scripts/packager-linux/samples.toml \ --exec "sample-$app" "${icons[@]}" \ --payload "staging/$app" --version "$version" \ - --github DarkPyonix/compose-rust --release samples-latest ${sign[@]+"${sign[@]}"} \ + ${sign[@]+"${sign[@]}"} \ --out-dir artifacts; then echo "::warning title=No AppImage::sample-$app was not wrapped as an AppImage for ${{ matrix.target }}" fi @@ -347,8 +341,8 @@ jobs: set -euo pipefail cd artifacts shopt -s nullglob - if command -v sha256sum >/dev/null; then sha256sum *.tar.gz *.AppImage *.zsync > "SHA256SUMS-${{ matrix.target }}" - else shasum -a 256 *.tar.gz *.AppImage *.zsync > "SHA256SUMS-${{ matrix.target }}"; fi + if command -v sha256sum >/dev/null; then sha256sum *.tar.gz *.AppImage > "SHA256SUMS-${{ matrix.target }}" + else shasum -a 256 *.tar.gz *.AppImage > "SHA256SUMS-${{ matrix.target }}"; fi cat "SHA256SUMS-${{ matrix.target }}" - uses: actions/upload-artifact@v4 @@ -563,9 +557,7 @@ jobs: echo echo 'Verify with `shasum -a 256 -c SHA256SUMS-`.' echo - echo "Linux also ships each sample as an AppImage. Make it executable and run it;" - echo "it checks the samples-latest release of this repository for a newer version" - echo "of itself." + echo "Linux also ships each sample as an AppImage. Make it executable and run it." echo echo "Built from $GITHUB_REPOSITORY at $(git rev-parse --short HEAD) ($TAG)." echo @@ -604,12 +596,11 @@ jobs: fi - name: Replace the files of the samples-latest release - # Every sample AppImage looks in the release tagged samples-latest for a .zsync - # matching its name, so that release always holds the newest samples. It is a - # pre-release and never latest, so it does not compete with the library's v* - # releases. It is updated in place: the new files are uploaded over the old ones - # first, and only then are the files the new set lacks removed, so an AppImage - # checking in the middle still finds a .zsync. Rebuilding an older tag leaves it + # The release tagged samples-latest always holds the newest samples at one fixed + # address. It is a pre-release and never latest, so it does not compete with the + # library's v* releases. It is updated in place: the new files are uploaded over + # the old ones first, and only then are the files the new set lacks removed, so a + # download in the middle still finds a file. Rebuilding an older tag leaves it # alone, so samples-latest never goes backwards. env: GH_TOKEN: ${{ github.token }} diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index d1bdf299..53a719d0 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -47,7 +47,6 @@ pub mod spans; pub mod theme; pub mod tokens; pub mod ui; -pub mod update; pub mod window; mod writer; diff --git a/compose-rust/src/update.rs b/compose-rust/src/update.rs deleted file mode 100644 index d0ee2bab..00000000 --- a/compose-rust/src/update.rs +++ /dev/null @@ -1,655 +0,0 @@ -//! Updating an application that was delivered outside a store. -//! -//! Linux delivers an application one of two ways, and they update differently. -//! -//! - **A Flatpak is updated by whoever installed it**: Flathub through GNOME Software, -//! KDE Discover or `flatpak update`. The sandbox cannot write to its own installation, -//! and an application that tried would be fighting the store. [`delivery`] says -//! [`Delivery::Flatpak`] and nothing here offers to update it. -//! - **An AppImage is one file the person downloaded, and it updates itself.** The file -//! carries a line saying where newer versions are published (its *update information*, -//! in an ELF section named `.upd_info`). Each published version has a `.zsync` file -//! beside it, and `appimageupdatetool` reads the line, fetches the `.zsync`, and -//! downloads only the blocks of the new file that the old one does not already have. -//! -//! Nothing here runs on its own. The application decides when to check, asks the person, -//! and then applies. Checking reaches the network, so it belongs on a worker thread, never -//! on the thread that draws: -//! -//! ```no_run -//! use compose_rust::update::{UpdateCheck, delivery}; -//! -//! if let Ok(updater) = delivery().appimage_updater() { -//! std::thread::spawn(move || { -//! if let Ok(UpdateCheck::Available) = updater.check() { -//! // Tell the UI through a signal, ask the person, and only then: -//! updater.apply().expect("the update downloads and verifies"); -//! let error = updater.relaunch(std::env::args_os().skip(1)); -//! eprintln!("could not start the updated application: {error}"); -//! } -//! }); -//! } -//! ``` -//! -//! The work of updating belongs to `appimageupdatetool`, which the packager places inside -//! the AppImage. It verifies the result against the SHA-1 the `.zsync` file publishes, and -//! when the running AppImage is signed it refuses a new file that is not signed by the -//! same key. Reimplementing either here would only add a second place for them to be -//! wrong. - -use std::ffi::{OsStr, OsString}; -use std::fmt; -use std::fs::File; -use std::io::{self, Read, Seek, SeekFrom}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::str::FromStr; - -/// The ELF section an AppImage runtime reserves for the update information. -pub const UPDATE_INFORMATION_SECTION: &str = ".upd_info"; - -/// Names an `appimageupdatetool` to use instead of the one inside the AppImage. -pub const UPDATER_ENV: &str = "COMPOSE_RUST_APPIMAGEUPDATETOOL"; - -/// Where the packager puts `appimageupdatetool`, relative to the AppImage's mount point. -pub const BUNDLED_UPDATER: &str = "usr/lib/appimageupdatetool/AppRun"; - -/// How the running application reached this machine. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Delivery { - /// Started from an AppImage. `path` is the AppImage file itself, which is what gets - /// replaced; `mount` is where its contents are visible while it runs. - AppImage { - path: PathBuf, - mount: Option, - }, - /// Running inside a Flatpak sandbox. Updates are the store's job. - Flatpak { app_id: String }, - /// Neither: a binary someone built or unpacked themselves. - Unpackaged, -} - -/// Finds out how the running application was delivered. -/// -/// Reads only the environment and one file: an AppImage runtime sets `APPIMAGE` and -/// `APPDIR` for the program it starts, and Flatpak sets `FLATPAK_ID` and places -/// `/.flatpak-info` in every sandbox. -pub fn delivery() -> Delivery { - delivery_from( - |name| std::env::var_os(name), - Path::new("/.flatpak-info").exists(), - ) -} - -/// [`delivery`] with the environment and the sandbox marker given rather than read, so a -/// caller (and a test) can ask what a given environment means. -pub fn delivery_from(env: impl Fn(&str) -> Option, flatpak_info: bool) -> Delivery { - let present = |value: Option| value.filter(|value| !value.is_empty()); - if let Some(app_id) = present(env("FLATPAK_ID")) { - return Delivery::Flatpak { - app_id: app_id.to_string_lossy().into_owned(), - }; - } - if flatpak_info { - return Delivery::Flatpak { - app_id: String::new(), - }; - } - if let Some(path) = present(env("APPIMAGE")) { - return Delivery::AppImage { - path: PathBuf::from(path), - mount: present(env("APPDIR")).map(PathBuf::from), - }; - } - Delivery::Unpackaged -} - -impl Delivery { - /// An updater for the running AppImage, found the way [`AppImageUpdater::new`] finds - /// one. Anything other than an AppImage answers with the reason it cannot update - /// itself. - pub fn appimage_updater(&self) -> Result { - match self { - Delivery::AppImage { path, mount } => AppImageUpdater::new(path, mount.as_deref()), - Delivery::Flatpak { app_id } => Err(UpdateError::ManagedByStore { - app_id: app_id.clone(), - }), - Delivery::Unpackaged => Err(UpdateError::NotAnAppImage), - } - } -} - -/// Where an AppImage's newer versions are published. -/// -/// The three forms are the ones the AppImage tools define. Each one ends at a `.zsync` -/// file; the `.zsync` file then names the AppImage it describes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UpdateInformation { - /// `zsync|`. - Zsync { url: String }, - /// `gh-releases-zsync||||`. - /// - /// `release` is `latest` (the newest release that is not a pre-release) or a tag. The - /// pattern may use `*`, which is how one line keeps matching files whose names carry - /// a version. - GitHubReleasesZsync { - owner: String, - repository: String, - release: String, - file_name: String, - }, - /// `pling-v1-zsync||`, for the Pling store. - PlingV1Zsync { - product_id: String, - file_name: String, - }, -} - -impl FromStr for UpdateInformation { - type Err = UpdateError; - - fn from_str(line: &str) -> Result { - let invalid = |why: &str| UpdateError::InvalidUpdateInformation { - line: line.to_owned(), - reason: why.to_owned(), - }; - let fields: Vec<&str> = line.split('|').collect(); - let nonempty = |fields: &[&str]| fields.iter().all(|field| !field.trim().is_empty()); - match fields.as_slice() { - ["zsync", url] if nonempty(&[*url]) => Ok(UpdateInformation::Zsync { - url: (*url).to_owned(), - }), - ["zsync", ..] => Err(invalid("`zsync` takes exactly one field, the URL")), - ["gh-releases-zsync", owner, repository, release, file_name] - if nonempty(&[*owner, *repository, *release, *file_name]) => - { - Ok(UpdateInformation::GitHubReleasesZsync { - owner: (*owner).to_owned(), - repository: (*repository).to_owned(), - release: (*release).to_owned(), - file_name: (*file_name).to_owned(), - }) - } - ["gh-releases-zsync", ..] => Err(invalid( - "`gh-releases-zsync` takes four fields: owner, repository, release and file name", - )), - ["pling-v1-zsync", product_id, file_name] if nonempty(&[*product_id, *file_name]) => { - Ok(UpdateInformation::PlingV1Zsync { - product_id: (*product_id).to_owned(), - file_name: (*file_name).to_owned(), - }) - } - ["pling-v1-zsync", ..] => Err(invalid( - "`pling-v1-zsync` takes two fields: product id and file name", - )), - _ => Err(invalid( - "the first field must be `zsync`, `gh-releases-zsync` or `pling-v1-zsync`", - )), - } - } -} - -impl fmt::Display for UpdateInformation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - UpdateInformation::Zsync { url } => write!(f, "zsync|{url}"), - UpdateInformation::GitHubReleasesZsync { - owner, - repository, - release, - file_name, - } => write!( - f, - "gh-releases-zsync|{owner}|{repository}|{release}|{file_name}" - ), - UpdateInformation::PlingV1Zsync { - product_id, - file_name, - } => write!(f, "pling-v1-zsync|{product_id}|{file_name}"), - } - } -} - -/// Reads the update information an AppImage carries, without reading the whole file. -/// -/// `Ok(None)` is an AppImage built without any: the runtime always reserves the section, -/// and an empty one means nobody filled it in. -pub fn read_update_information( - file: &mut (impl Read + Seek), -) -> Result, UpdateError> { - let Some(bytes) = read_elf_section(file, UPDATE_INFORMATION_SECTION)? else { - return Ok(None); - }; - let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); - let text = std::str::from_utf8(&bytes[..end]).map_err(|_| { - UpdateError::NotElf("the update information section is not UTF-8 text".to_owned()) - })?; - let text = text.trim(); - Ok((!text.is_empty()).then(|| text.to_owned())) -} - -/// The update information of the AppImage at `path`, parsed. -pub fn update_information_of(path: &Path) -> Result, UpdateError> { - let mut file = File::open(path).map_err(|source| UpdateError::Io { - action: format!("open {}", path.display()), - source, - })?; - read_update_information(&mut file)? - .map(|line| line.parse()) - .transpose() -} - -/// Section headers are read whole, so a corrupt count must not turn into a huge read. -const MAX_SECTIONS: u64 = 1 << 16; -/// The runtime reserves 1024 bytes for update information; anything near this is not one. -const MAX_SECTION_BYTES: u64 = 1 << 20; - -fn read_elf_section( - file: &mut (impl Read + Seek), - wanted: &str, -) -> Result>, UpdateError> { - let not_elf = |why: &str| UpdateError::NotElf(why.to_owned()); - let mut ident = [0u8; 64]; - file.seek(SeekFrom::Start(0)).map_err(UpdateError::read)?; - let read = read_up_to(file, &mut ident).map_err(UpdateError::read)?; - if read < 52 || &ident[..4] != b"\x7fELF" { - return Err(not_elf("the file does not start with an ELF header")); - } - let wide = match ident[4] { - 1 => false, - 2 => true, - _ => return Err(not_elf("the ELF class is neither 32 nor 64 bit")), - }; - let big = match ident[5] { - 1 => false, - 2 => true, - _ => { - return Err(not_elf( - "the ELF byte order is neither little nor big endian", - )); - } - }; - let int = Endian { big }; - let (shoff, shentsize, shnum, shstrndx) = if wide { - if read < 64 { - return Err(not_elf("the 64-bit ELF header is cut short")); - } - ( - int.u64(&ident[0x28..0x30]), - int.u16(&ident[0x3a..0x3c]), - int.u16(&ident[0x3c..0x3e]), - int.u16(&ident[0x3e..0x40]), - ) - } else { - ( - int.u32(&ident[0x20..0x24]) as u64, - int.u16(&ident[0x2e..0x30]), - int.u16(&ident[0x30..0x32]), - int.u16(&ident[0x32..0x34]), - ) - }; - let minimum = if wide { 64 } else { 40 }; - if shoff == 0 || shnum == 0 { - return Ok(None); - } - if (shentsize as usize) < minimum || shnum as u64 > MAX_SECTIONS || shstrndx >= shnum { - return Err(not_elf("the section header table is malformed")); - } - let mut table = vec![0u8; shentsize as usize * shnum as usize]; - file.seek(SeekFrom::Start(shoff)) - .map_err(UpdateError::read)?; - file.read_exact(&mut table) - .map_err(|_| not_elf("the section header table runs past the end of the file"))?; - let section = |index: usize| { - let header = &table[index * shentsize as usize..][..shentsize as usize]; - if wide { - ( - int.u32(&header[0..4]), - int.u64(&header[24..32]), - int.u64(&header[32..40]), - ) - } else { - ( - int.u32(&header[0..4]), - int.u32(&header[16..20]) as u64, - int.u32(&header[20..24]) as u64, - ) - } - }; - let (_, names_offset, names_size) = section(shstrndx as usize); - let names = read_at(file, names_offset, names_size)?; - for index in 0..shnum as usize { - let (name, offset, size) = section(index); - let Some(rest) = names.get(name as usize..) else { - continue; - }; - let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len()); - if &rest[..end] == wanted.as_bytes() { - return read_at(file, offset, size).map(Some); - } - } - Ok(None) -} - -fn read_at(file: &mut (impl Read + Seek), offset: u64, size: u64) -> Result, UpdateError> { - if size > MAX_SECTION_BYTES { - return Err(UpdateError::NotElf( - "a section is larger than any this reader expects".to_owned(), - )); - } - let mut bytes = vec![0u8; size as usize]; - file.seek(SeekFrom::Start(offset)) - .map_err(UpdateError::read)?; - file.read_exact(&mut bytes) - .map_err(|_| UpdateError::NotElf("a section runs past the end of the file".to_owned()))?; - Ok(bytes) -} - -fn read_up_to(file: &mut impl Read, buffer: &mut [u8]) -> io::Result { - let mut filled = 0; - while filled < buffer.len() { - match file.read(&mut buffer[filled..]) { - Ok(0) => break, - Ok(n) => filled += n, - Err(error) if error.kind() == io::ErrorKind::Interrupted => {} - Err(error) => return Err(error), - } - } - Ok(filled) -} - -#[derive(Clone, Copy)] -struct Endian { - big: bool, -} - -impl Endian { - fn u16(self, bytes: &[u8]) -> u16 { - let bytes = [bytes[0], bytes[1]]; - if self.big { - u16::from_be_bytes(bytes) - } else { - u16::from_le_bytes(bytes) - } - } - - fn u32(self, bytes: &[u8]) -> u32 { - let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; - if self.big { - u32::from_be_bytes(bytes) - } else { - u32::from_le_bytes(bytes) - } - } - - fn u64(self, bytes: &[u8]) -> u64 { - let mut array = [0u8; 8]; - array.copy_from_slice(&bytes[..8]); - if self.big { - u64::from_be_bytes(array) - } else { - u64::from_le_bytes(array) - } - } -} - -/// What a check found. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateCheck { - /// The published file differs from the running one. - Available, - /// The published file is the one that is running. - UpToDate, -} - -/// Checks for and applies updates to one AppImage, through `appimageupdatetool`. -#[derive(Debug, Clone)] -pub struct AppImageUpdater { - appimage: PathBuf, - information: UpdateInformation, - tool: PathBuf, -} - -impl AppImageUpdater { - /// An updater for the AppImage at `appimage`, whose contents are mounted at `mount`. - /// - /// The tool is looked for in three places, first match wins: the path in - /// `COMPOSE_RUST_APPIMAGEUPDATETOOL`, the copy the packager places inside the - /// AppImage, and `appimageupdatetool` on `PATH`. - pub fn new(appimage: &Path, mount: Option<&Path>) -> Result { - let tool = find_tool( - std::env::var_os(UPDATER_ENV).as_deref(), - mount, - std::env::var_os("PATH").as_deref(), - ) - .ok_or(UpdateError::UpdaterMissing)?; - Self::with_tool(appimage, &tool) - } - - /// An updater that runs the given `appimageupdatetool`. - pub fn with_tool(appimage: &Path, tool: &Path) -> Result { - let information = - update_information_of(appimage)?.ok_or(UpdateError::NoUpdateInformation)?; - Ok(Self { - appimage: appimage.to_owned(), - information, - tool: tool.to_owned(), - }) - } - - /// The AppImage this updater replaces. - pub fn appimage(&self) -> &Path { - &self.appimage - } - - /// Where the AppImage says its newer versions are. - pub fn update_information(&self) -> &UpdateInformation { - &self.information - } - - /// Asks whether the published file differs from the one on disk. Reaches the - /// network, so call it from a worker thread. - pub fn check(&self) -> Result { - let output = self - .command() - .arg("--check-for-update") - .arg(&self.appimage) - .output(); - let output = output.map_err(|source| UpdateError::Io { - action: format!("run {}", self.tool.display()), - source, - })?; - // The tool's own contract: 1 when the published file differs, 0 when it does - // not, anything else when it could not tell. - match output.status.code() { - Some(0) => Ok(UpdateCheck::UpToDate), - Some(1) => Ok(UpdateCheck::Available), - code => Err(UpdateError::UpdaterFailed { - code, - output: describe_output(&output.stdout, &output.stderr), - }), - } - } - - /// Downloads the new version and puts it where the running AppImage is. - /// - /// Only the blocks that changed are downloaded. The file is replaced, not edited in - /// place, so the running process keeps reading the version it started from until it - /// exits or [`relaunch`](Self::relaunch)es. - pub fn apply(&self) -> Result<(), UpdateError> { - let output = self - .command() - .arg("--overwrite") - .arg(&self.appimage) - .output(); - let output = output.map_err(|source| UpdateError::Io { - action: format!("run {}", self.tool.display()), - source, - })?; - if output.status.success() { - Ok(()) - } else { - Err(UpdateError::UpdaterFailed { - code: output.status.code(), - output: describe_output(&output.stdout, &output.stderr), - }) - } - } - - /// Starts the AppImage on disk in place of this process. Returns only on failure. - pub fn relaunch(&self, args: I) -> io::Error - where - I: IntoIterator, - S: AsRef, - { - let mut command = Command::new(&self.appimage); - command.args(args); - // Each runtime sets these for the program it starts. Left in place, the new - // process would report the old mount point until its own runtime replaced them. - for name in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] { - command.env_remove(name); - } - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.exec() - } - #[cfg(not(unix))] - { - match command.spawn() { - Ok(_) => std::process::exit(0), - Err(error) => error, - } - } - } - - fn command(&self) -> Command { - let mut command = Command::new(&self.tool); - // The tool is itself an extracted AppImage. It must see its own directory, not - // the mount point of the application that started it. - for name in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] { - command.env_remove(name); - } - if self.tool.file_name() == Some(OsStr::new("AppRun")) { - if let Some(dir) = self.tool.parent() { - command.env("APPDIR", dir); - } - } - command - } -} - -/// Where `appimageupdatetool` is, given the override, the AppImage's mount point and -/// `PATH`, first match wins. -pub fn find_tool( - override_path: Option<&OsStr>, - mount: Option<&Path>, - path: Option<&OsStr>, -) -> Option { - if let Some(explicit) = override_path.filter(|value| !value.is_empty()) { - return Some(PathBuf::from(explicit)); - } - if let Some(bundled) = mount.map(|mount| mount.join(BUNDLED_UPDATER)) { - if bundled.is_file() { - return Some(bundled); - } - } - std::env::split_paths(path?) - .map(|dir| dir.join("appimageupdatetool")) - .find(|candidate| candidate.is_file()) -} - -fn describe_output(stdout: &[u8], stderr: &[u8]) -> String { - let mut text = String::from_utf8_lossy(stdout).trim().to_owned(); - let stderr = String::from_utf8_lossy(stderr); - if !stderr.trim().is_empty() { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(stderr.trim()); - } - text -} - -/// Why an update could not be checked for or applied. -#[derive(Debug)] -pub enum UpdateError { - /// The application was not started from an AppImage, so there is no file to replace. - NotAnAppImage, - /// A store installed this application and updates it; replacing it from inside would - /// fight the store. - ManagedByStore { app_id: String }, - /// The AppImage was built without update information, so it does not know where its - /// newer versions are. - NoUpdateInformation, - /// The update information is not in a form the AppImage tools define. - InvalidUpdateInformation { line: String, reason: String }, - /// The file is not an ELF executable, or its section table is damaged. - NotElf(String), - /// No `appimageupdatetool` inside the AppImage, in the override variable, or on `PATH`. - UpdaterMissing, - /// `appimageupdatetool` ran and reported a failure. - UpdaterFailed { code: Option, output: String }, - /// Reading the AppImage or starting the tool failed. - Io { action: String, source: io::Error }, -} - -impl UpdateError { - fn read(source: io::Error) -> Self { - UpdateError::Io { - action: "read the AppImage".to_owned(), - source, - } - } -} - -impl fmt::Display for UpdateError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - UpdateError::NotAnAppImage => write!( - f, - "this application was not started from an AppImage, so there is no file to update" - ), - UpdateError::ManagedByStore { app_id } if app_id.is_empty() => write!( - f, - "this application runs in a Flatpak sandbox; the store that installed it updates it" - ), - UpdateError::ManagedByStore { app_id } => write!( - f, - "this application is the Flatpak {app_id}; the store that installed it updates it" - ), - UpdateError::NoUpdateInformation => write!( - f, - "this AppImage was built without update information, so it does not know where newer versions are published" - ), - UpdateError::InvalidUpdateInformation { line, reason } => { - write!(f, "the update information `{line}` is not usable: {reason}") - } - UpdateError::NotElf(reason) => write!(f, "not a readable AppImage: {reason}"), - UpdateError::UpdaterMissing => write!( - f, - "appimageupdatetool was not found inside the AppImage ({BUNDLED_UPDATER}), in {UPDATER_ENV}, or on PATH" - ), - UpdateError::UpdaterFailed { code, output } => { - match code { - Some(code) => write!(f, "appimageupdatetool exited with status {code}")?, - None => write!(f, "appimageupdatetool was stopped by a signal")?, - } - if !output.is_empty() { - write!(f, ":\n{output}")?; - } - Ok(()) - } - UpdateError::Io { action, source } => write!(f, "could not {action}: {source}"), - } - } -} - -impl std::error::Error for UpdateError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - UpdateError::Io { source, .. } => Some(source), - _ => None, - } - } -} diff --git a/compose-rust/tests/appimage_update.rs b/compose-rust/tests/appimage_update.rs deleted file mode 100644 index 8f2e2d93..00000000 --- a/compose-rust/tests/appimage_update.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! An AppImage updates itself; a Flatpak leaves updating to its store. -//! -//! The AppImages here are synthetic ELF files with exactly the sections that matter, and -//! `appimageupdatetool` is a shell script that answers the way the real one does. The real -//! pair runs in `.github/workflows/packager-linux.yml`, which updates a built AppImage -//! against `.zsync` files served on the runner. - -use std::ffi::OsString; -use std::io::Cursor; -use std::path::{Path, PathBuf}; - -use compose_rust::update::{ - AppImageUpdater, BUNDLED_UPDATER, Delivery, UpdateCheck, UpdateError, UpdateInformation, - delivery_from, find_tool, read_update_information, update_information_of, -}; - -/// An ELF file with a null section, the given sections and a section name table. -fn elf(wide: bool, big: bool, sections: &[(&str, &[u8])]) -> Vec { - let put16 = |out: &mut Vec, v: u16| { - out.extend_from_slice(&if big { - v.to_be_bytes() - } else { - v.to_le_bytes() - }) - }; - let put32 = |out: &mut Vec, v: u32| { - out.extend_from_slice(&if big { - v.to_be_bytes() - } else { - v.to_le_bytes() - }) - }; - let put64 = |out: &mut Vec, v: u64| { - out.extend_from_slice(&if big { - v.to_be_bytes() - } else { - v.to_le_bytes() - }) - }; - let header_size = if wide { 64 } else { 52 }; - let entry_size: u16 = if wide { 64 } else { 40 }; - - let mut names = vec![0u8]; - let mut name_offsets = Vec::new(); - for (name, _) in sections { - name_offsets.push(names.len() as u32); - names.extend_from_slice(name.as_bytes()); - names.push(0); - } - let names_name = names.len() as u32; - names.extend_from_slice(b".shstrtab\0"); - - let mut body = Vec::new(); - let mut placed = Vec::new(); - for (_, data) in sections { - placed.push(((header_size + body.len()) as u64, data.len() as u64)); - body.extend_from_slice(data); - } - let names_at = (header_size + body.len()) as u64; - body.extend_from_slice(&names); - let table_at = (header_size + body.len()) as u64; - let count = sections.len() as u16 + 2; - - let mut out = Vec::new(); - out.extend_from_slice(b"\x7fELF"); - out.push(if wide { 2 } else { 1 }); - out.push(if big { 2 } else { 1 }); - out.push(1); - out.extend_from_slice(&[0; 9]); - put16(&mut out, 2); // executable - put16(&mut out, if wide { 62 } else { 3 }); - put32(&mut out, 1); - if wide { - put64(&mut out, 0); // entry - put64(&mut out, 0); // program headers - put64(&mut out, table_at); - } else { - put32(&mut out, 0); - put32(&mut out, 0); - put32(&mut out, table_at as u32); - } - put32(&mut out, 0); // flags - put16(&mut out, header_size as u16); - put16(&mut out, 0); - put16(&mut out, 0); - put16(&mut out, entry_size); - put16(&mut out, count); - put16(&mut out, count - 1); - assert_eq!(out.len(), header_size); - out.extend_from_slice(&body); - - let entry = |out: &mut Vec, name: u32, offset: u64, size: u64| { - let start = out.len(); - put32(out, name); - put32(out, if name == 0 && size == 0 { 0 } else { 1 }); - if wide { - put64(out, 0); // flags - put64(out, 0); // address - put64(out, offset); - put64(out, size); - } else { - put32(out, 0); - put32(out, 0); - put32(out, offset as u32); - put32(out, size as u32); - } - out.resize(start + entry_size as usize, 0); - }; - entry(&mut out, 0, 0, 0); - for (name, (offset, size)) in name_offsets.iter().zip(&placed) { - entry(&mut out, *name, *offset, *size); - } - entry(&mut out, names_name, names_at, names.len() as u64); - out -} - -/// The section as the AppImage runtime reserves it: 1024 bytes, filled from the start. -fn upd_info(line: &str) -> Vec { - let mut bytes = line.as_bytes().to_vec(); - bytes.resize(1024, 0); - bytes -} - -fn scratch(test: &str) -> PathBuf { - let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) - .join("appimage_update") - .join(test); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -const LOCAL: &str = "zsync|http://127.0.0.1:8080/Demo-x86_64.AppImage.zsync"; - -#[test] -fn fr35_reads_update_information_from_every_elf_layout() { - for wide in [true, false] { - for big in [false, true] { - let file = elf( - wide, - big, - &[ - (".text", b"\x90\x90"), - (".upd_info", &upd_info(LOCAL)), - (".sha256_sig", &[0; 1024]), - ], - ); - let found = read_update_information(&mut Cursor::new(file)).unwrap(); - assert_eq!( - found.as_deref(), - Some(LOCAL), - "wide={wide} big_endian={big}" - ); - } - } -} - -#[test] -fn fr35_an_empty_or_missing_section_means_no_update_information() { - let reserved = elf(true, false, &[(".upd_info", &[0; 1024])]); - assert_eq!( - read_update_information(&mut Cursor::new(reserved)).unwrap(), - None - ); - let absent = elf(true, false, &[(".text", b"\x90")]); - assert_eq!( - read_update_information(&mut Cursor::new(absent)).unwrap(), - None - ); -} - -#[test] -fn fr35_a_file_that_is_not_elf_is_an_error_not_a_panic() { - for bytes in [ - b"#!/bin/sh\necho hi\n".to_vec(), - Vec::new(), - b"\x7fELF".to_vec(), - ] { - let error = read_update_information(&mut Cursor::new(bytes)).unwrap_err(); - assert!(matches!(error, UpdateError::NotElf(_)), "{error}"); - } - // A section table that points past the end of the file. - let mut truncated = elf(true, false, &[(".upd_info", &upd_info(LOCAL))]); - truncated.truncate(truncated.len() - 10); - assert!(read_update_information(&mut Cursor::new(truncated)).is_err()); -} - -#[test] -fn fr35_update_information_parses_the_three_forms_and_prints_them_back() { - let lines = [ - LOCAL, - "gh-releases-zsync|darkpyonix|compose-rust|latest|Calculator-*-x86_64.AppImage.zsync", - "pling-v1-zsync|1234567|Calculator-*-x86_64.AppImage", - ]; - for line in lines { - let parsed: UpdateInformation = line.parse().unwrap(); - assert_eq!(parsed.to_string(), line); - } - let parsed: UpdateInformation = lines[1].parse().unwrap(); - assert_eq!( - parsed, - UpdateInformation::GitHubReleasesZsync { - owner: "darkpyonix".into(), - repository: "compose-rust".into(), - release: "latest".into(), - file_name: "Calculator-*-x86_64.AppImage.zsync".into(), - } - ); -} - -#[test] -fn fr35_malformed_update_information_says_what_is_wrong() { - for line in [ - "", - "zsync|", - "zsync|a|b", - "gh-releases-zsync|owner|repo|latest", - "gh-releases-zsync|owner||latest|file", - "bintray-zsync|a|b|c|d", - ] { - let error = line.parse::().unwrap_err(); - assert!( - matches!(error, UpdateError::InvalidUpdateInformation { .. }), - "{line}: {error}" - ); - assert!(error.to_string().contains("is not usable"), "{error}"); - } -} - -fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { - move |name| { - pairs - .iter() - .find(|(key, _)| *key == name) - .map(|(_, value)| OsString::from(value)) - } -} - -#[test] -fn fr35_delivery_tells_an_appimage_from_a_flatpak_from_neither() { - assert_eq!( - delivery_from( - env_of(&[ - ("APPIMAGE", "/home/a/Demo.AppImage"), - ("APPDIR", "/tmp/.mount_Demo") - ]), - false - ), - Delivery::AppImage { - path: "/home/a/Demo.AppImage".into(), - mount: Some("/tmp/.mount_Demo".into()), - } - ); - assert_eq!( - delivery_from(env_of(&[("FLATPAK_ID", "dev.example.Demo")]), true), - Delivery::Flatpak { - app_id: "dev.example.Demo".into() - } - ); - assert_eq!(delivery_from(env_of(&[]), false), Delivery::Unpackaged); - assert_eq!( - delivery_from(env_of(&[("APPIMAGE", "")]), false), - Delivery::Unpackaged - ); -} - -#[test] -fn fr35_a_flatpak_never_offers_to_update_itself() { - // Even an AppImage variable leaking into the sandbox does not make it one. - let inside = delivery_from( - env_of(&[ - ("FLATPAK_ID", "dev.example.Demo"), - ("APPIMAGE", "/x.AppImage"), - ]), - true, - ); - let error = inside.appimage_updater().unwrap_err(); - assert!( - matches!(error, UpdateError::ManagedByStore { .. }), - "{error}" - ); - assert!(error.to_string().contains("store"), "{error}"); - - let error = Delivery::Unpackaged.appimage_updater().unwrap_err(); - assert!(matches!(error, UpdateError::NotAnAppImage), "{error}"); -} - -#[test] -fn fr35_the_bundled_updater_wins_over_path_and_the_override_wins_over_both() { - let dir = scratch("find_tool"); - let mount = dir.join("mount"); - let bin = dir.join("bin"); - std::fs::create_dir_all(mount.join(BUNDLED_UPDATER).parent().unwrap()).unwrap(); - std::fs::write(mount.join(BUNDLED_UPDATER), b"").unwrap(); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("appimageupdatetool"), b"").unwrap(); - let path = std::env::join_paths([&bin]).unwrap(); - - assert_eq!( - find_tool(None, Some(&mount), Some(&path)), - Some(mount.join(BUNDLED_UPDATER)) - ); - assert_eq!( - find_tool(None, Some(&dir.join("elsewhere")), Some(&path)), - Some(bin.join("appimageupdatetool")) - ); - assert_eq!( - find_tool(Some("/opt/tool".as_ref()), Some(&mount), Some(&path)), - Some(PathBuf::from("/opt/tool")) - ); - assert_eq!(find_tool(None, None, None), None); -} - -#[test] -fn fr35_an_appimage_without_update_information_cannot_be_updated() { - let dir = scratch("no_information"); - let appimage = dir.join("Demo.AppImage"); - std::fs::write(&appimage, elf(true, false, &[(".upd_info", &[0; 1024])])).unwrap(); - let error = AppImageUpdater::with_tool(&appimage, Path::new("/bin/true")).unwrap_err(); - assert!(matches!(error, UpdateError::NoUpdateInformation), "{error}"); - assert_eq!(update_information_of(&appimage).unwrap(), None); -} - -#[cfg(unix)] -mod with_a_fake_tool { - use super::*; - use std::os::unix::fs::PermissionsExt; - - /// Answers like `appimageupdatetool`: 1 from a check while `new` differs from the - /// target, 0 once they match, and `--overwrite` copies `new` over the target. - const FAKE_TOOL: &str = r#"#!/bin/sh -here="$(dirname "$0")" -echo "APPDIR=${APPDIR:-} APPIMAGE=${APPIMAGE:-} $*" >> "$here/calls.log" -case "$1" in - --check-for-update) cmp -s "$here/new" "$2" && exit 0 || exit 1 ;; - --overwrite) cp "$here/new" "$2" && exit 0 ;; -esac -echo "unexpected arguments: $*" >&2 -exit 2 -"#; - - fn setup(test: &str) -> (PathBuf, PathBuf, PathBuf) { - let dir = scratch(test); - let tooldir = dir.join("appimageupdatetool"); - std::fs::create_dir_all(&tooldir).unwrap(); - let tool = tooldir.join("AppRun"); - std::fs::write(&tool, FAKE_TOOL).unwrap(); - std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); - let appimage = dir.join("Demo.AppImage"); - std::fs::write( - &appimage, - elf( - true, - false, - &[(".upd_info", &upd_info(LOCAL)), (".v", b"1")], - ), - ) - .unwrap(); - std::fs::write( - tooldir.join("new"), - elf( - true, - false, - &[(".upd_info", &upd_info(LOCAL)), (".v", b"2")], - ), - ) - .unwrap(); - (dir, appimage, tool) - } - - #[test] - fn fr35_check_then_apply_replaces_the_appimage_with_the_published_one() { - let (_dir, appimage, tool) = setup("check_apply"); - let updater = AppImageUpdater::with_tool(&appimage, &tool).unwrap(); - assert_eq!(updater.update_information().to_string(), LOCAL); - assert_eq!(updater.check().unwrap(), UpdateCheck::Available); - updater.apply().unwrap(); - assert_eq!( - std::fs::read(&appimage).unwrap(), - std::fs::read(tool.parent().unwrap().join("new")).unwrap() - ); - assert_eq!(updater.check().unwrap(), UpdateCheck::UpToDate); - - // The tool sees its own directory as APPDIR and never the application's AppImage. - let calls = std::fs::read_to_string(tool.parent().unwrap().join("calls.log")).unwrap(); - for line in calls.lines() { - assert!( - line.starts_with(&format!( - "APPDIR={} APPIMAGE= ", - tool.parent().unwrap().display() - )), - "{line}" - ); - } - } - - #[test] - fn fr35_a_failing_updater_is_reported_with_its_output() { - let (_dir, appimage, tool) = setup("failure"); - std::fs::write( - &tool, - "#!/bin/sh\necho 'signature does not match' >&2\nexit 3\n", - ) - .unwrap(); - let updater = AppImageUpdater::with_tool(&appimage, &tool).unwrap(); - for error in [updater.check().unwrap_err(), updater.apply().unwrap_err()] { - match &error { - UpdateError::UpdaterFailed { code, output } => { - assert_eq!(*code, Some(3)); - assert!(output.contains("signature does not match"), "{output}"); - } - other => panic!("expected UpdaterFailed, got {other}"), - } - } - } -} diff --git a/scripts/packager-linux/README.md b/scripts/packager-linux/README.md index c9640280..048e49ee 100644 --- a/scripts/packager-linux/README.md +++ b/scripts/packager-linux/README.md @@ -3,39 +3,24 @@ Packages a built compose-rust application for Linux in two ways, both described by the application's `Dioxus.toml`: -- an **AppImage** that updates itself. It carries zsync update information and - `appimageupdatetool`, and the application asks before it updates, through - `compose_rust::update`; +- an **AppImage**. It carries no update information: direct distribution is packaged and + updated with Velopack, not here; - a **Flatpak** manifest in the form Flathub reviews. A Flatpak is updated by the store that installed it. | File | What it does | |---|---| -| `src/` | The `packager-linux` command: desktop entry, AppStream metainfo, icons, AppDir, update information, Flatpak manifest | -| `appimage.sh` | Builds an AppImage and its `.zsync` with pinned AppImage tools | +| `src/` | The `packager-linux` command: desktop entry, AppStream metainfo, icons, AppDir, Flatpak manifest | +| `appimage.sh` | Builds an AppImage with pinned AppImage tools | | `flatpak.sh` | Writes the manifest, then builds, installs and lints it the way Flathub does | | `flathub/` | Ember's Flathub listing (`dev.darkpyonix.Ember`) and `ember.sh`, which generates the submission from an Ember release | | `FLATHUB.md` | Checklist for submitting to Flathub | | `samples.toml` | Store facts shared by the sample applications, passed as an overlay | -| `demo/` | The small program the packaging workflow updates from 1.0.0 to 1.0.1 | +| `demo/` | The small program the packaging workflow wraps and, as a Flatpak, updates from 1.0.0 to 1.0.1 | Run `packager-linux` with no arguments for its options. `.github/workflows/packager-linux.yml` runs everything here on GitHub's Ubuntu runners. -## Where updates come from - -An AppImage carries `gh-releases-zsync||||-*-.AppImage.zsync`. -`appimage.sh --github --release ` writes it, and the release is always -named: a tag, or `latest` for the newest release that is not a pre-release. - -The samples update from the `samples-latest` release of `DarkPyonix/compose-rust`. The -`Sample apps` workflow attaches each `sample-v*` build to its own release and also replaces -the files of `samples-latest` with it. That release is a pre-release and never marked -latest, so the library's `v*` releases keep `latest`, and a sample never looks in a library -release for its update. The workflow writes both with its own `GITHUB_TOKEN`. - -Ember updates from `DarkPyonix/darkpyonix-ember`. - ## Secrets The owner creates these in this repository's settings (Settings, Secrets and variables, @@ -48,13 +33,9 @@ The armoured private key of the AppImage signing key (see below). While it is mi ## AppImage signing key -An installed AppImage that was signed refuses any update that was not signed by the same key. -That makes the key a long-term commitment: +Sign from the first public AppImage onwards, so a downloaded file can be checked against +one key: -- Sign from the first public AppImage onwards. An AppImage that was installed unsigned - accepts any file. -- Changing the key breaks updates for everyone who installed an AppImage signed with the old - one; they have to download the new one by hand. - Make the key without an expiry date, keep an offline backup, and store only the armoured private key in `APPIMAGE_SIGNING_KEY`. diff --git a/scripts/packager-linux/appimage.sh b/scripts/packager-linux/appimage.sh index 8dcb6200..b66eb110 100755 --- a/scripts/packager-linux/appimage.sh +++ b/scripts/packager-linux/appimage.sh @@ -1,33 +1,22 @@ #!/usr/bin/env bash -# Wraps a built application as an AppImage that updates itself, with the .zsync file to -# publish beside it. +# Wraps a built application as an AppImage. # # scripts/packager-linux/appimage.sh \ # --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ -# --payload staging/calculator --version 1.2.3 \ -# --github darkpyonix/compose-rust --release samples-latest --out-dir dist +# --payload staging/calculator --version 1.2.3 --out-dir dist # -# writes dist/Calculator-1.2.3-x86_64.AppImage and dist/Calculator-1.2.3-x86_64.AppImage.zsync. -# Publish both as assets of that release; an installed AppImage finds the newer .zsync -# there through the update information embedded here. +# writes dist/Calculator-1.2.3-x86_64.AppImage. The AppImage carries no update +# information: direct distribution is packaged and updated with Velopack, not here. # # Options: # --dioxus-toml, --overlay, --version, --date, --exec, --icon as packager-linux takes them # --payload the executable, or the directory holding it and its renderer -# --github update from a GitHub release of that repository -# --release with --github: the release's tag, or `latest` for the newest -# release that is not a pre-release -# --zsync-url update from one fixed .zsync URL instead -# --sign-key sign with this gpg key; an installed AppImage that was signed -# refuses an update signed by any other key -# --no-updater do not carry appimageupdatetool inside +# --sign-key sign with this gpg key # --out-dir # -# Tools: appimagetool, the AppImage runtime and appimageupdatetool are downloaded at -# pinned versions and checked against pinned digests, into PACKAGER_LINUX_CACHE (default -# .scratch/packager-linux/tools in this repository). zsyncmake must be on PATH (Debian and -# Ubuntu: apt-get install zsync); appimagetool needs it to write the .zsync file and -# skips it silently otherwise, which would ship an AppImage that can never update. +# Tools: appimagetool and the AppImage runtime are downloaded at pinned versions and +# checked against pinned digests, into PACKAGER_LINUX_CACHE (default +# .scratch/packager-linux/tools in this repository). # desktop-file-validate and appstreamcli are used when present. # # packager-linux itself is run through `cargo run` unless PACKAGER_LINUX names a built one. @@ -48,22 +37,16 @@ fail() { # something nobody reviewed. APPIMAGETOOL_VERSION=1.9.1 RUNTIME_VERSION=20251108 -UPDATER_VERSION=2.0.0-alpha-1-20251018 declare -A DIGESTS=( [appimagetool-x86_64.AppImage]=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 [appimagetool-aarch64.AppImage]=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 [runtime-x86_64]=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d [runtime-aarch64]=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 - [appimageupdatetool-x86_64.AppImage]=d976cdac667b03dee8cb23fb95ef74b042c406c5cbab3ff294d2b16efeaff84f - [appimageupdatetool-aarch64.AppImage]=7aaf89dd4cf66ebd940d416c67e1c240c57a139cee38d9c0ed3bb9387bc435b0 ) common=() payload="" -channel=() -release=() sign_key="" -bundle_updater=1 out_dir="" while [[ $# -gt 0 ]]; do case "$1" in @@ -73,23 +56,13 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --payload) payload="$2"; shift 2 ;; - --github | --zsync-url) channel=("$1" "$2"); shift 2 ;; - --release) release=("$1" "$2"); shift 2 ;; --sign-key) sign_key="$2"; shift 2 ;; - --no-updater) bundle_updater=0; shift ;; --out-dir) out_dir="$2"; shift 2 ;; *) fail "unknown option $1" "See the comment at the top of $0." ;; esac done [[ -n "$payload" ]] || fail "--payload is required" [[ -n "$out_dir" ]] || fail "--out-dir is required" -[[ ${#channel[@]} -eq 2 ]] || fail "pass --github or --zsync-url " \ - "An AppImage without update information cannot update itself." -if [[ "${channel[0]}" == --github && ${#release[@]} -ne 2 ]]; then - fail "--github needs --release " \ - "Name the release the AppImage looks in, or pass --release latest for the newest" \ - "release that is not a pre-release." -fi case "$(uname -m)" in x86_64) arch=x86_64 ;; @@ -97,9 +70,6 @@ case "$(uname -m)" in *) fail "no pinned AppImage tools for $(uname -m)" ;; esac [[ "$(uname -s)" == Linux ]] || fail "an AppImage is assembled on Linux" -command -v zsyncmake >/dev/null || fail "zsyncmake is not on PATH" \ - "appimagetool writes the .zsync file with it and skips that step silently without it." \ - "Debian and Ubuntu: apt-get install zsync." packager() { if [[ -n "${PACKAGER_LINUX:-}" ]]; then @@ -133,29 +103,14 @@ fetch "appimagetool-$arch.AppImage" \ fetch "runtime-$arch" \ "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$arch" -updater_args=() -if [[ $bundle_updater -eq 1 ]]; then - fetch "appimageupdatetool-$arch.AppImage" \ - "https://github.com/AppImageCommunity/AppImageUpdate/releases/download/$UPDATER_VERSION/appimageupdatetool-$arch.AppImage" - extracted="$cache/appimageupdatetool-$arch-$UPDATER_VERSION" - if [[ ! -e "$extracted/AppRun" ]]; then - rm -rf "$extracted" "$cache/squashfs-root" - # Extracting reads the image without mounting it, so no FUSE is needed here. - (cd "$cache" && "./appimageupdatetool-$arch.AppImage" --appimage-extract >/dev/null) - mv "$cache/squashfs-root" "$extracted" - fi - updater_args=(--updater "$extracted") -fi - -update_information="$(packager update-information "${common[@]}" --arch "$arch" "${channel[@]}" ${release[@]+"${release[@]}"})" file_name="$(packager file-name "${common[@]}" --arch "$arch")" mkdir -p "$out_dir" out_dir="$(cd "$out_dir" && pwd)" appdir="$out_dir/${file_name%.AppImage}.AppDir" -rm -rf "$appdir" "$out_dir/$file_name" "$out_dir/$file_name.zsync" +rm -rf "$appdir" "$out_dir/$file_name" packager appdir "${common[@]}" --arch "$arch" --payload "$payload" \ - "${updater_args[@]}" --out "$appdir" >/dev/null + --out "$appdir" >/dev/null sign_args=() if [[ -n "$sign_key" ]]; then @@ -163,20 +118,14 @@ if [[ -n "$sign_key" ]]; then fi echo "== $file_name" -echo " update information: $update_information" # appimagetool is itself an AppImage. Extract-and-run spares the build machine FUSE. -# It runs from the output directory because zsyncmake writes the .zsync file into the -# directory it is started in. ( cd "$out_dir" APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$arch" "$cache/appimagetool-$arch.AppImage" \ --runtime-file "$cache/runtime-$arch" \ - --updateinformation "$update_information" \ "${sign_args[@]}" \ "$appdir" "$out_dir/$file_name" ) -[[ -f "$out_dir/$file_name.zsync" ]] || fail "appimagetool wrote no $file_name.zsync" \ - "Without it the update information points at nothing." rm -rf "$appdir" -(cd "$out_dir" && sha256sum "$file_name" "$file_name.zsync") +(cd "$out_dir" && sha256sum "$file_name") diff --git a/scripts/packager-linux/demo/Cargo.toml b/scripts/packager-linux/demo/Cargo.toml index a2a15559..138de74a 100644 --- a/scripts/packager-linux/demo/Cargo.toml +++ b/scripts/packager-linux/demo/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "packager-linux-demo" # The packaging workflow builds this twice with different versions in -# COMPOSE_RUST_APP_VERSION and shows the older one updating itself to the newer. +# COMPOSE_RUST_APP_VERSION and packages both. version = "0.0.0" edition.workspace = true license.workspace = true rust-version.workspace = true publish = false -description = "A small program that shows a packaged compose-rust application updating itself" +description = "A small program that the packaging workflow wraps as an AppImage and a Flatpak" [[bin]] name = "update-demo" diff --git a/scripts/packager-linux/demo/Dioxus.toml b/scripts/packager-linux/demo/Dioxus.toml index 831d0ed9..55e957a4 100644 --- a/scripts/packager-linux/demo/Dioxus.toml +++ b/scripts/packager-linux/demo/Dioxus.toml @@ -1,4 +1,4 @@ -# What the packaging workflow wraps: a program that updates itself, described the way an +# What the packaging workflow wraps: a small program, described the way an # application describes itself to dx, plus the [linux.store] facts a store listing needs. [application] @@ -8,17 +8,14 @@ name = "UpdateDemo" identifier = "dev.darkpyonix.compose.UpdateDemo" publisher = "DarkPyonix" icon = ["icon.png"] -short_description = "Shows a packaged application updating itself" +short_description = "A small packaged application" long_description = """ -A small program built with compose-rust that checks for a newer version of itself, asks, -downloads only the parts that changed, and restarts into it. - -Delivered as an AppImage it updates itself from the .zsync file published beside each -release. Delivered as a Flatpak it leaves updating to the store that installed it.""" +A small program built with compose-rust that prints its version, so a package can be +checked for the version it was built with.""" [linux] categories = ["Development"] -keywords = ["update", "appimage", "flatpak"] +keywords = ["appimage", "flatpak"] [linux.store] exec = "update-demo" diff --git a/scripts/packager-linux/demo/src/main.rs b/scripts/packager-linux/demo/src/main.rs index c03bb1b6..a1766abf 100644 --- a/scripts/packager-linux/demo/src/main.rs +++ b/scripts/packager-linux/demo/src/main.rs @@ -1,136 +1,30 @@ -//! A packaged application that checks for, asks about, applies and restarts into an -//! update, the way a real one would, with standard input in place of a dialog. -//! -//! The packaging workflow builds it twice with different versions, wraps both as -//! AppImages, serves the newer one's `.zsync` on the runner, and runs the older one with -//! `--update`. The same program inside a Flatpak says the store updates it and changes -//! nothing. +//! A small packaged application. The packaging workflow builds it twice with different +//! versions, wraps each as an AppImage and as a Flatpak, and checks that the package +//! carries the version it was built with. A Flatpak is also updated from the first +//! version to the second with `flatpak update`. //! //! ```text //! update-demo --version print the version -//! update-demo --delivery say how this copy was delivered -//! update-demo --check exit 0 when up to date, 10 when an update is available -//! update-demo --update check, ask, apply, and restart into the new version -//! [--yes] answer yes without asking //! ``` -use std::io::{BufRead, Write}; use std::process::ExitCode; -use compose_rust::update::{Delivery, UpdateCheck, delivery}; - /// Set by the build, so two builds of the same source differ the way two releases do. const VERSION: &str = match option_env!("COMPOSE_RUST_APP_VERSION") { Some(version) => version, None => env!("CARGO_PKG_VERSION"), }; -const UPDATE_AVAILABLE: u8 = 10; - fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); - let yes = args.iter().any(|a| a == "--yes"); match args.first().map(String::as_str) { Some("--version") | None => { println!("update-demo {VERSION}"); ExitCode::SUCCESS } - Some("--delivery") => { - match delivery() { - Delivery::AppImage { path, mount } => println!( - "AppImage {} mounted at {}", - path.display(), - mount.map_or("(unknown)".into(), |m| m.display().to_string()) - ), - Delivery::Flatpak { app_id } => println!("Flatpak {app_id}"), - Delivery::Unpackaged => println!("unpackaged"), - } - ExitCode::SUCCESS - } - Some("--check") => check(), - Some("--update") => update(yes), Some(other) => { - eprintln!("unknown argument {other}; see the comment at the top of main.rs"); + eprintln!("unknown argument {other}; the only one is --version"); ExitCode::from(2) } } } - -fn check() -> ExitCode { - let updater = match delivery().appimage_updater() { - Ok(updater) => updater, - Err(error) => { - println!("no self-update: {error}"); - return ExitCode::SUCCESS; - } - }; - println!("update information: {}", updater.update_information()); - match updater.check() { - Ok(UpdateCheck::UpToDate) => { - println!("update-demo {VERSION} is up to date"); - ExitCode::SUCCESS - } - Ok(UpdateCheck::Available) => { - println!("update-demo {VERSION}: an update is available"); - ExitCode::from(UPDATE_AVAILABLE) - } - Err(error) => { - eprintln!("error: {error}"); - ExitCode::FAILURE - } - } -} - -fn update(yes: bool) -> ExitCode { - let updater = match delivery().appimage_updater() { - Ok(updater) => updater, - Err(error) => { - println!("no self-update: {error}"); - return ExitCode::SUCCESS; - } - }; - println!("update-demo {VERSION} at {}", updater.appimage().display()); - println!("update information: {}", updater.update_information()); - - // A real application does this on a worker thread and tells the UI through a signal. - let checked = std::thread::spawn({ - let updater = updater.clone(); - move || updater.check() - }) - .join() - .expect("the check thread does not panic"); - match checked { - Ok(UpdateCheck::UpToDate) => { - println!("already up to date"); - return ExitCode::SUCCESS; - } - Ok(UpdateCheck::Available) => println!("an update is available"), - Err(error) => { - eprintln!("error: {error}"); - return ExitCode::FAILURE; - } - } - - if !yes && !ask("Install it and restart? [y/N] ") { - println!("not updating"); - return ExitCode::SUCCESS; - } - if let Err(error) = updater.apply() { - eprintln!("error: {error}"); - return ExitCode::FAILURE; - } - println!("updated; restarting"); - let error = updater.relaunch(["--version"]); - eprintln!("error: could not restart: {error}"); - ExitCode::FAILURE -} - -fn ask(question: &str) -> bool { - print!("{question}"); - let _ = std::io::stdout().flush(); - let mut answer = String::new(); - if std::io::stdin().lock().read_line(&mut answer).is_err() { - return false; - } - matches!(answer.trim(), "y" | "Y" | "yes") -} diff --git a/scripts/packager-linux/src/appimage.rs b/scripts/packager-linux/src/appimage.rs index 5fe78cd6..feda18c9 100644 --- a/scripts/packager-linux/src/appimage.rs +++ b/scripts/packager-linux/src/appimage.rs @@ -1,10 +1,4 @@ -//! The AppImage channel: an AppDir laid out for `appimagetool`, and the update -//! information line that lets the finished file update itself. -//! -//! The line is written into the AppImage by `appimagetool -u`, which also asks -//! `zsyncmake` for the `.zsync` file that is published beside it. An installed AppImage -//! reads the line back, finds the newest `.zsync`, and downloads only the blocks that -//! changed. +//! The AppImage channel: an AppDir laid out for `appimagetool`. use std::fmt; use std::fs; @@ -16,10 +10,6 @@ use crate::desktop::{desktop_entry, desktop_file_name}; use crate::icons::{Icon, best}; use crate::metadata::AppMetadata; -/// Where the packager puts an extracted `appimageupdatetool`, relative to the AppDir. The -/// application looks for it at the same place under the mount point. -pub const UPDATER_DIR: &str = "usr/lib/appimageupdatetool"; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppImageError(pub String); @@ -56,88 +46,11 @@ pub fn file_safe(name: &str) -> String { .collect() } -/// `--.AppImage`, the shape the AppImage tools expect and the one -/// the update information's pattern matches. +/// `--.AppImage`, the shape the AppImage tools expect. pub fn appimage_file_name(meta: &AppMetadata, arch: &str) -> String { format!("{}-{}-{arch}.AppImage", file_safe(&meta.name), meta.version) } -/// Where newer versions are published. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UpdateChannel { - /// A GitHub release of `owner/repository` whose assets include a `.zsync` for this - /// application and architecture. `release` is a tag, or one of the words - /// `appimageupdatetool` reads as a search: `latest` (the newest release that is not a - /// pre-release), `latest-pre` or `latest-all`. A fixed tag keeps working when the - /// repository also publishes other releases that would otherwise win `latest`. - GitHubReleases { - owner: String, - repository: String, - release: String, - }, - /// One fixed URL of a `.zsync` file, replaced by every release. - Zsync { url: String }, -} - -impl UpdateChannel { - /// Parses `owner/repository` and the release to look in. - pub fn github(slug: &str, release: &str) -> Result { - if !valid_field(release) { - return Err(AppImageError(format!( - "`{release}` is not a release tag; use letters, digits, `.`, `_` and `-`" - ))); - } - let parts: Vec<&str> = slug.split('/').collect(); - match parts.as_slice() { - [owner, repository] if valid_field(owner) && valid_field(repository) => { - Ok(UpdateChannel::GitHubReleases { - owner: (*owner).to_owned(), - repository: (*repository).to_owned(), - release: release.to_owned(), - }) - } - _ => Err(AppImageError(format!("`{slug}` is not owner/repository"))), - } - } - - pub fn zsync(url: &str) -> Result { - if !(url.starts_with("https://") || url.starts_with("http://")) - || url.contains('|') - || url.contains(' ') - || !url.ends_with(".zsync") - { - return Err(AppImageError(format!( - "`{url}` is not an http(s) URL of a .zsync file" - ))); - } - Ok(UpdateChannel::Zsync { - url: url.to_owned(), - }) - } - - /// The update information line for this application on this architecture. - pub fn update_information(&self, meta: &AppMetadata, arch: &str) -> String { - match self { - UpdateChannel::GitHubReleases { - owner, - repository, - release, - } => format!( - "gh-releases-zsync|{owner}|{repository}|{release}|{}-*-{arch}.AppImage.zsync", - file_safe(&meta.name) - ), - UpdateChannel::Zsync { url } => format!("zsync|{url}"), - } - } -} - -fn valid_field(field: &str) -> bool { - !field.is_empty() - && field - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) -} - /// The script the runtime starts. It runs the application from the directory it was /// copied into, so the renderer and its companions sit beside it the way they did when /// it was built. @@ -167,8 +80,6 @@ pub struct AppDirInputs<'a> { /// and its companions), or the executable alone. pub payload: &'a Path, pub icons: &'a [Icon], - /// An extracted `appimageupdatetool` (the `squashfs-root` of its AppImage). - pub updater: Option<&'a Path>, } /// Lays out an AppDir at `out`, which must not exist yet. Returns the files it wrote, @@ -245,17 +156,6 @@ pub fn assemble_appdir(inputs: &AppDirInputs, out: &Path) -> Result set_executable(&executable).map_err(|e| io_error(&executable.display().to_string(), e))?; written.push(PathBuf::from(format!("usr/lib/{0}/{0}", meta.exec))); - if let Some(updater) = inputs.updater { - if !updater.join("AppRun").exists() { - return Err(AppImageError(format!( - "{} has no AppRun; pass the directory `appimageupdatetool --appimage-extract` made", - updater.display() - ))); - } - let target = out.join(UPDATER_DIR); - copy_tree(updater, &target).map_err(|e| io_error(&updater.display().to_string(), e))?; - written.push(PathBuf::from(format!("{UPDATER_DIR}/AppRun"))); - } Ok(written) } diff --git a/scripts/packager-linux/src/lib.rs b/scripts/packager-linux/src/lib.rs index 19d604b0..9d16610d 100644 --- a/scripts/packager-linux/src/lib.rs +++ b/scripts/packager-linux/src/lib.rs @@ -1,5 +1,5 @@ -//! Packages a built compose-rust application for Linux: an AppImage that updates itself -//! and a Flatpak manifest for Flathub, both described from the application's +//! Packages a built compose-rust application for Linux: an AppImage and a Flatpak +//! manifest for Flathub, both described from the application's //! `Dioxus.toml`. //! //! The pieces are plain functions from metadata to file contents, so they are tested diff --git a/scripts/packager-linux/src/main.rs b/scripts/packager-linux/src/main.rs index 2e12b6d9..b4bb09bc 100644 --- a/scripts/packager-linux/src/main.rs +++ b/scripts/packager-linux/src/main.rs @@ -7,7 +7,7 @@ use std::process::ExitCode; use std::time::{SystemTime, UNIX_EPOCH}; use packager_linux::appimage::{ - AppDirInputs, UpdateChannel, appimage_arch, appimage_file_name, assemble_appdir, + AppDirInputs, appimage_arch, appimage_file_name, assemble_appdir, }; use packager_linux::appstream::{metainfo, metainfo_file_name}; use packager_linux::desktop::{desktop_entry, desktop_file_name}; @@ -23,7 +23,6 @@ usage: packager-linux --dioxus-toml [common options] [command o commands: metadata write .desktop, .metainfo.xml and the icons into --out appdir lay out an AppDir for appimagetool at --out - update-information print the AppImage update information line file-name print the AppImage file name, --.AppImage flatpak write a flatpak-builder manifest and its packaging files into --out @@ -43,15 +42,8 @@ common options: appdir: --payload the executable, or a directory holding it and its renderer - --updater an extracted appimageupdatetool to carry inside --out -update-information: - --github --release - gh-releases-zsync, the release with that tag (or `latest`, - `latest-pre`, `latest-all` to search) - --zsync-url zsync, one fixed URL - flatpak: --out --prebuilt-url --prebuilt-sha256 [--prebuilt-arch ] (repeatable, @@ -157,8 +149,7 @@ fn run(args: &[String]) -> Result<(), String> { let options = Options::parse(rest)?; let extra: &[&str] = match command.as_str() { "metadata" => &["out"], - "appdir" => &["payload", "updater", "out"], - "update-information" => &["github", "release", "zsync-url"], + "appdir" => &["payload", "out"], "file-name" => &[], "flatpak" => &[ "out", @@ -214,13 +205,11 @@ fn run(args: &[String]) -> Result<(), String> { "appdir" => { let out = PathBuf::from(options.required("out")?); let payload = PathBuf::from(options.required("payload")?); - let updater = options.one("updater")?.map(PathBuf::from); let written = assemble_appdir( &AppDirInputs { meta: &meta, payload: &payload, icons: &icons, - updater: updater.as_deref(), }, &out, ) @@ -229,28 +218,6 @@ fn run(args: &[String]) -> Result<(), String> { println!("{}", path.display()); } } - "update-information" => { - let release = options.one("release")?; - let channel = match (options.one("github")?, options.one("zsync-url")?) { - (Some(slug), None) => { - let release = release.ok_or( - "--github needs --release : the release the AppImage looks in \ - for its updates, or `latest` for the newest one that is not a \ - pre-release", - )?; - UpdateChannel::github(&slug, &release) - } - (None, Some(url)) => { - if release.is_some() { - return Err("--release goes with --github, not --zsync-url".into()); - } - UpdateChannel::zsync(&url) - } - _ => return Err("pass exactly one of --github and --zsync-url".into()), - } - .map_err(|e| e.to_string())?; - println!("{}", channel.update_information(&meta, &arch)); - } "file-name" => println!("{}", appimage_file_name(&meta, &arch)), "flatpak" => { check_for_flathub(&icons).map_err(|e| e.to_string())?; diff --git a/scripts/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs index 1b148b64..03a6d4dc 100644 --- a/scripts/packager-linux/tests/metadata.rs +++ b/scripts/packager-linux/tests/metadata.rs @@ -1,5 +1,5 @@ //! Metadata generation for the Linux packages: from `Dioxus.toml` to a desktop entry, an -//! AppStream file, icons, an AppDir, AppImage update information and a Flatpak manifest. +//! AppStream file, icons, an AppDir and a Flatpak manifest. //! //! Whether the generated files are accepted by the tools that consume them //! (`desktop-file-validate`, `appstreamcli validate`, `appimagetool`, `flatpak-builder` @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use packager_linux::appimage::{ - AppDirInputs, UPDATER_DIR, UpdateChannel, appimage_arch, appimage_file_name, apprun, + AppDirInputs, appimage_arch, appimage_file_name, apprun, assemble_appdir, file_safe, }; use packager_linux::appstream::{appdata_file_name, metainfo, metainfo_file_name}; @@ -75,7 +75,7 @@ fn resolve_error(text: &str, overlays: &[&str]) -> String { } #[test] -fn fr35_metadata_comes_from_dioxus_toml() { +fn fr22_6_3_metadata_comes_from_dioxus_toml() { let meta = meta(); assert_eq!(meta.id, "dev.example.Calculator"); assert_eq!(meta.name, "Calculator"); @@ -99,7 +99,7 @@ fn fr35_metadata_comes_from_dioxus_toml() { } #[test] -fn fr35_dx_linux_keys_override_the_shared_bundle_table() { +fn fr22_6_3_dx_linux_keys_override_the_shared_bundle_table() { let overlay = r#" [linux] identifier = "dev.example.CalculatorLinux" @@ -113,7 +113,7 @@ publisher = "Linux Desk" } #[test] -fn fr35_an_overlay_adds_what_the_application_file_lacks() { +fn fr22_6_3_an_overlay_adds_what_the_application_file_lacks() { // The shape of a sample's own file: nothing Linux-specific at all. let bare = r#" [application] @@ -140,7 +140,7 @@ release_notes = ["Now updates itself."] } #[test] -fn fr35_every_sample_resolves_with_an_overlay() { +fn fr22_6_3_every_sample_resolves_with_an_overlay() { let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); let overlay = r#" [linux] @@ -165,7 +165,7 @@ homepage = "https://github.com/darkpyonix/compose-rust" } #[test] -fn fr35_missing_store_facts_name_the_key_to_set() { +fn fr22_6_3_missing_store_facts_name_the_key_to_set() { let without = |key: &str| { DIOXUS_TOML .lines() @@ -192,7 +192,7 @@ fn fr35_missing_store_facts_name_the_key_to_set() { } #[test] -fn fr35_bad_values_are_refused_before_any_file_is_written() { +fn fr22_6_3_bad_values_are_refused_before_any_file_is_written() { for (overlay, expected) in [ ("[linux.store]\nhomepage = \"example.dev\"", "http(s) URL"), ("[linux.store]\nbrand_light = \"blue\"", "#rrggbb"), @@ -221,7 +221,7 @@ fn fr35_bad_values_are_refused_before_any_file_is_written() { } #[test] -fn fr35_application_ids_follow_flatpak_and_dbus_rules() { +fn fr22_6_3_application_ids_follow_flatpak_and_dbus_rules() { for good in [ "dev.darkpyonix.composerust.samples.calculator", "org.example.My_App", @@ -242,7 +242,7 @@ fn fr35_application_ids_follow_flatpak_and_dbus_rules() { } #[test] -fn fr35_desktop_entry_names_the_icon_and_executable() { +fn fr22_6_3_desktop_entry_names_the_icon_and_executable() { let meta = meta(); assert_eq!(desktop_file_name(&meta), "dev.example.Calculator.desktop"); assert_eq!( @@ -261,7 +261,7 @@ fn fr35_desktop_entry_names_the_icon_and_executable() { } #[test] -fn fr35_desktop_entry_escapes_what_the_format_reserves() { +fn fr22_6_3_desktop_entry_escapes_what_the_format_reserves() { let mut meta = meta(); meta.name = "Back\\slash\nNew".into(); meta.keywords = vec!["semi;colon".into()]; @@ -276,7 +276,7 @@ fn fr35_desktop_entry_escapes_what_the_format_reserves() { } #[test] -fn fr35_metainfo_carries_what_flathub_requires() { +fn fr22_6_3_metainfo_carries_what_flathub_requires() { let meta = meta(); assert_eq!( metainfo_file_name(&meta), @@ -315,7 +315,7 @@ fn fr35_metainfo_carries_what_flathub_requires() { } #[test] -fn fr35_metainfo_writes_release_notes_and_content_rating() { +fn fr22_6_3_metainfo_writes_release_notes_and_content_rating() { let overlay = r#" [linux.store] release_notes = ["Updates itself & more."] @@ -344,7 +344,7 @@ fn png(size: u32) -> Vec { } #[test] -fn fr35_icons_go_where_their_real_size_says() { +fn fr22_6_3_icons_go_where_their_real_size_says() { assert_eq!(png_size(&png(256)), Some((256, 256))); let icon = Icon::classify(Path::new("whatever.png"), &png(256)).unwrap(); assert_eq!(icon.kind, IconKind::Png(256)); @@ -372,7 +372,7 @@ fn fr35_icons_go_where_their_real_size_says() { } #[test] -fn fr35_flathub_needs_a_large_enough_icon() { +fn fr22_6_3_flathub_needs_a_large_enough_icon() { let small = Icon::classify(Path::new("64.png"), &png(64)).unwrap(); let large = Icon::classify(Path::new("128.png"), &png(128)).unwrap(); assert!( @@ -387,7 +387,7 @@ fn fr35_flathub_needs_a_large_enough_icon() { } #[test] -fn fr35_appimage_update_information_points_at_zsync_files() { +fn fr22_6_3_appimage_names_follow_the_tools() { let meta = meta(); assert_eq!(appimage_arch("x86_64"), Some("x86_64")); assert_eq!(appimage_arch("aarch64"), Some("aarch64")); @@ -397,51 +397,10 @@ fn fr35_appimage_update_information_points_at_zsync_files() { "Calculator-1.2.3-x86_64.AppImage" ); assert_eq!(file_safe("My App: 2"), "My_App__2"); - - let github = UpdateChannel::github("darkpyonix/compose-rust", "samples-latest").unwrap(); - assert_eq!( - github.update_information(&meta, "x86_64"), - "gh-releases-zsync|darkpyonix|compose-rust|samples-latest|Calculator-*-x86_64.AppImage.zsync" - ); - // `latest` is still available, as a choice someone makes rather than a default. - assert_eq!( - UpdateChannel::github("darkpyonix/darkpyonix-ember", "latest") - .unwrap() - .update_information(&meta, "x86_64"), - "gh-releases-zsync|darkpyonix|darkpyonix-ember|latest|Calculator-*-x86_64.AppImage.zsync" - ); - // The pattern matches the file name of every later version. - let pattern = "Calculator-*-aarch64.AppImage.zsync"; - assert!( - github - .update_information(&meta, "aarch64") - .ends_with(pattern) - ); - - let local = - UpdateChannel::zsync("http://127.0.0.1:8080/Calculator-x86_64.AppImage.zsync").unwrap(); - assert_eq!( - local.update_information(&meta, "x86_64"), - "zsync|http://127.0.0.1:8080/Calculator-x86_64.AppImage.zsync" - ); - - for bad in ["darkpyonix", "a/b/c", "a|b/c", "/repo"] { - assert!(UpdateChannel::github(bad, "latest").is_err(), "{bad}"); - } - for bad in ["", "a|b", "a b", "tags/x"] { - assert!(UpdateChannel::github("a/b", bad).is_err(), "{bad:?}"); - } - for bad in [ - "ftp://x/y.zsync", - "https://x/y.AppImage", - "https://x/a|b.zsync", - ] { - assert!(UpdateChannel::zsync(bad).is_err(), "{bad}"); - } } #[test] -fn fr35_apprun_starts_the_payload_from_the_mount_point() { +fn fr22_6_3_apprun_starts_the_payload_from_the_mount_point() { let script = apprun(&meta()); assert!(script.starts_with("#!/bin/sh\n")); assert!(script.contains("APPDIR=\"${APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}\"\n")); @@ -463,7 +422,7 @@ fn scratch(test: &str) -> PathBuf { } #[test] -fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { +fn fr22_6_3_appdir_holds_entry_icon_metainfo_and_payload() { let dir = scratch("appdir"); let meta = meta(); let payload = dir.join("payload"); @@ -471,13 +430,6 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { std::fs::write(payload.join("sample-calculator"), b"#!/bin/sh\necho hi\n").unwrap(); std::fs::create_dir_all(payload.join("lib")).unwrap(); std::fs::write(payload.join("lib/libcompose_rust_renderer.so"), b"\x7fELF").unwrap(); - let updater = dir.join("squashfs-root"); - std::fs::create_dir_all(updater.join("usr/bin")).unwrap(); - std::fs::write(updater.join("usr/bin/appimageupdatetool"), b"tool").unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink("usr/bin/appimageupdatetool", updater.join("AppRun")).unwrap(); - #[cfg(not(unix))] - std::fs::write(updater.join("AppRun"), b"tool").unwrap(); let icon_path = dir.join("icon.png"); std::fs::write(&icon_path, png(256)).unwrap(); let icons = vec![Icon::classify(&icon_path, &png(256)).unwrap()]; @@ -488,7 +440,6 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { meta: &meta, payload: &payload, icons: &icons, - updater: Some(&updater), }, &out, ) @@ -505,12 +456,10 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { ] { assert!(out.join(path).is_file(), "missing {path}"); } - assert!(written.contains(&PathBuf::from(format!("{UPDATER_DIR}/AppRun")))); + assert!(written.contains(&PathBuf::from("usr/lib/sample-calculator/sample-calculator"))); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let link = std::fs::read_link(out.join(UPDATER_DIR).join("AppRun")).unwrap(); - assert_eq!(link, Path::new("usr/bin/appimageupdatetool")); for executable in ["AppRun", "usr/lib/sample-calculator/sample-calculator"] { let mode = std::fs::metadata(out.join(executable)) .unwrap() @@ -530,7 +479,6 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { meta: &meta, payload: &payload, icons: &icons, - updater: None, }, &out, ); @@ -538,7 +486,7 @@ fn fr35_appdir_holds_entry_icon_metainfo_payload_and_updater() { } #[test] -fn fr35_appdir_refuses_a_payload_without_the_executable() { +fn fr22_6_3_appdir_refuses_a_payload_without_the_executable() { let dir = scratch("appdir_missing"); let payload = dir.join("payload"); std::fs::create_dir_all(&payload).unwrap(); @@ -551,7 +499,6 @@ fn fr35_appdir_refuses_a_payload_without_the_executable() { meta: &meta(), payload: &payload, icons: &icons, - updater: None, }, &dir.join("out"), ) @@ -560,7 +507,7 @@ fn fr35_appdir_refuses_a_payload_without_the_executable() { } #[test] -fn fr35_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { +fn fr22_6_3_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { let meta = meta(); assert_eq!( finish_args(&meta, false), @@ -594,7 +541,7 @@ fn icons_256() -> Vec { } #[test] -fn fr35_flatpak_manifest_for_a_prebuilt_archive() { +fn fr22_6_3_flatpak_manifest_for_a_prebuilt_archive() { let meta = meta(); let icons = icons_256(); let text = manifest( @@ -652,7 +599,7 @@ fn fr35_flatpak_manifest_for_a_prebuilt_archive() { } #[test] -fn fr35_flatpak_manifest_builds_from_source_offline() { +fn fr22_6_3_flatpak_manifest_builds_from_source_offline() { let mut meta = meta(); meta.runtime_version = Some("24.08".into()); let icons = icons_256(); @@ -710,7 +657,7 @@ fn fr35_flatpak_manifest_builds_from_source_offline() { } #[test] -fn fr35_json_and_shell_quoting_survive_awkward_text() { +fn fr22_6_3_json_and_shell_quoting_survive_awkward_text() { assert_eq!( Json::Object(vec![( "k\"ey".into(), @@ -725,7 +672,7 @@ fn fr35_json_and_shell_quoting_survive_awkward_text() { } #[test] -fn fr35_release_dates_are_reproducible() { +fn fr22_6_3_release_dates_are_reproducible() { assert_eq!(release_date(None, 0), "1970-01-01"); assert_eq!(release_date(None, 1_790_985_600), "2026-10-03"); assert_eq!(release_date(Some("951782400"), 1_790_985_600), "2000-02-29"); @@ -734,7 +681,7 @@ fn fr35_release_dates_are_reproducible() { } #[test] -fn fr35_screenshots_are_pinned_to_a_tag_or_commit() { +fn fr22_6_3_screenshots_are_pinned_to_a_tag_or_commit() { for good in [ "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/v1.0.0/docs/shot.png", "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/refs/tags/v1.0.0/shot.png", From 344b20025f2d8b96d4f003f99b21190b0c800c45 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:55:10 +0900 Subject: [PATCH 060/224] Fix: Drop Sparkle, the appcast and the update crate from the macOS packager (FR-22.6) Direct distribution is packaged and updated with Velopack on all three OSes, so the bundle carries no Sparkle settings or framework. Removes the compose-rust-update crate and its workspace entry (Cargo.lock restored to the develop version by hand, since nothing else in the branch changed it), the EdDSA signing and appcast modules, the keygen, archive and appcast commands, the self-update experiment, the updater CI job and the README sections on Sparkle. Signing, the .app bundle and the .dmg stay. Renames the fr35_* tests to fr22_6_* (criteria 2, 3 and 5). --- .github/workflows/packager-macos.yml | 47 +-- Cargo.lock | 49 --- Cargo.toml | 1 - README.md | 20 +- compose-rust/update/Cargo.toml | 25 -- compose-rust/update/src/lib.rs | 227 ------------ compose-rust/update/src/sparkle.rs | 209 ----------- docs/locales/README_ko.md | 20 +- experiments/self-update/app/Cargo.toml | 37 -- experiments/self-update/app/Dioxus.toml | 15 - experiments/self-update/app/assets/icon.png | Bin 482 -> 0 bytes experiments/self-update/app/build.rs | 27 -- experiments/self-update/app/src/main.rs | 149 -------- experiments/self-update/rehearse.sh | 348 ------------------ .../packager-macos/dxc_packager/__init__.py | 2 +- .../packager-macos/dxc_packager/appcast.py | 141 ------- scripts/packager-macos/dxc_packager/bundle.py | 78 +--- scripts/packager-macos/dxc_packager/cli.py | 147 +------- .../packager-macos/dxc_packager/ed25519.py | 185 ---------- .../dxc_packager/install-notes.txt | 5 +- .../packager-macos/dxc_packager/metadata.py | 4 +- scripts/packager-macos/dxc_packager/plist.py | 72 +--- .../packager-macos/dxc_packager/signing.py | 78 ---- scripts/packager-macos/tests/support.py | 5 - scripts/packager-macos/tests/test_appcast.py | 112 ------ scripts/packager-macos/tests/test_bundle.py | 87 +---- scripts/packager-macos/tests/test_ed25519.py | 86 ----- scripts/packager-macos/tests/test_metadata.py | 18 +- scripts/packager-macos/tests/test_plist.py | 62 +--- scripts/packager-macos/tests/test_signing.py | 61 --- 30 files changed, 69 insertions(+), 2248 deletions(-) delete mode 100644 compose-rust/update/Cargo.toml delete mode 100644 compose-rust/update/src/lib.rs delete mode 100644 compose-rust/update/src/sparkle.rs delete mode 100644 experiments/self-update/app/Cargo.toml delete mode 100644 experiments/self-update/app/Dioxus.toml delete mode 100644 experiments/self-update/app/assets/icon.png delete mode 100644 experiments/self-update/app/build.rs delete mode 100644 experiments/self-update/app/src/main.rs delete mode 100755 experiments/self-update/rehearse.sh delete mode 100644 scripts/packager-macos/dxc_packager/appcast.py delete mode 100644 scripts/packager-macos/dxc_packager/ed25519.py delete mode 100644 scripts/packager-macos/dxc_packager/signing.py delete mode 100644 scripts/packager-macos/tests/test_appcast.py delete mode 100644 scripts/packager-macos/tests/test_ed25519.py delete mode 100644 scripts/packager-macos/tests/test_signing.py diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml index 52b84f10..3c87a5ab 100644 --- a/.github/workflows/packager-macos.yml +++ b/.github/workflows/packager-macos.yml @@ -1,9 +1,7 @@ -# macOS packaging: the packager's tests and the updater crate's tests. +# macOS packaging: the packager's tests. # -# The rehearsal that installs one version of an application and watches it update itself -# to the next is in experiments/self-update on develop. It builds a Dioxus application -# against the published dioxus-compose, which nothing outside experiments may do, and -# experiments/ is removed from main, so this workflow no longer runs it. +# Direct distribution is packaged and updated with Velopack, so nothing here builds an +# update feed. name: macOS packaging @@ -12,13 +10,11 @@ on: branches: [develop, main, 'feature/packager-*'] paths: - 'scripts/packager-macos/**' - - 'compose-rust/update/**' - 'scripts/bundle-renderer.sh' - '.github/workflows/packager-macos.yml' pull_request: paths: - 'scripts/packager-macos/**' - - 'compose-rust/update/**' - 'scripts/bundle-renderer.sh' - '.github/workflows/packager-macos.yml' workflow_dispatch: @@ -32,8 +28,6 @@ permissions: env: CARGO_TERM_COLOR: always - SPARKLE_VERSION: '2.10.0' - SPARKLE_SHA256: c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c jobs: packager: @@ -50,40 +44,5 @@ jobs: with: python-version: '3.12' - - name: Unpack Sparkle - # On macOS the tests also compare against Sparkle's own tools and sign a real - # Sparkle.framework. Linux has neither codesign nor Sparkle, and runs the rest. - if: runner.os == 'macOS' - run: | - set -euo pipefail - dir="$RUNNER_TEMP/sparkle" - mkdir -p "$dir" - curl -fsSL -o "$dir/Sparkle.tar.xz" \ - "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" - echo "$SPARKLE_SHA256 $dir/Sparkle.tar.xz" | shasum -a 256 -c - - tar -xf "$dir/Sparkle.tar.xz" -C "$dir" - echo "SPARKLE_DIR=$dir" >> "$GITHUB_ENV" - - name: Run the packager's tests run: python -m unittest discover -s scripts/packager-macos/tests -v - - updater: - name: Updater crate (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - - name: Install the Rust toolchain - run: | - rustup toolchain install stable --profile minimal --component clippy - rustup default stable - - - name: Test - run: cargo test -p compose-rust-update - - - name: Lint - run: cargo clippy -p compose-rust-update --all-targets -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 7b531f98..1fac7451 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "bytes" version = "1.12.1" @@ -62,15 +47,6 @@ dependencies = [ "syn", ] -[[package]] -name = "compose-rust-update" -version = "0.0.0" -dependencies = [ - "block2", - "objc2", - "objc2-foundation", -] - [[package]] name = "jni" version = "0.21.1" @@ -127,31 +103,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "objc2", -] - [[package]] name = "proc-macro2" version = "1.0.107" diff --git a/Cargo.toml b/Cargo.toml index 2149ec36..47c32068 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ members = [ "compose-rust", "compose-rust/macros", - "compose-rust/update", # The sample applications, written on the compose-rust authoring API, and the library # their recording tests draw their screens through. "samples/academic", diff --git a/README.md b/README.md index 199dc653..ad8842b3 100644 --- a/README.md +++ b/README.md @@ -248,22 +248,13 @@ application bundle, using the name, identifier and version your `dx` bundle conf developer account. ```sh -scripts/packager-macos/package-macos keygen --out ~/keys/sparkle.key # once; keep it safe -scripts/packager-macos/package-macos app --updater sparkle --manifest-dir . \ - --executable target/release/my-app --out dist \ - --feed-url https://example.com/appcast.xml --ed-key-file ~/keys/sparkle.key \ - --sparkle-framework Sparkle.framework +scripts/packager-macos/package-macos app --manifest-dir . \ + --executable target/release/my-app --out dist scripts/packager-macos/package-macos dmg --app "dist/My App.app" --out dist/MyApp-1.0.0.dmg -scripts/packager-macos/package-macos archive --app "dist/My App.app" --out site/MyApp-1.0.0.zip -scripts/packager-macos/package-macos appcast --appcast site/appcast.xml --app "dist/My App.app" \ - --archive site/MyApp-1.0.0.zip --url https://example.com/MyApp-1.0.0.zip \ - --ed-key-file ~/keys/sparkle.key ``` -The application then updates itself with [Sparkle 2](https://sparkle-project.org): add the -`compose-rust-update` crate, call `compose_rust_update::start(Default::default())` before -`launch`, and `check_for_updates()` from a menu item. Every update is checked against the -EdDSA key the bundle carries; `--updater none` makes a bundle that does not update. +The bundle carries no update settings: direct distribution is packaged and updated with +Velopack. Every bundle is signed ad hoc. Apple silicon needs a signature to run anything, and an ad hoc one needs no account, but it does not name a developer Apple has verified, so **macOS asks @@ -278,8 +269,7 @@ before it opens a downloaded copy for the first time**: download mark, and macOS opens it without asking. Move the app into Applications before the first launch: run from the disk image or Downloads, -macOS starts a hidden temporary copy that cannot update itself. Updates Sparkle installs open -without asking again. The disk image carries these steps as `If macOS will not open the +macOS starts a hidden temporary copy. The disk image carries these steps as `If macOS will not open the app.txt`. diff --git a/compose-rust/update/Cargo.toml b/compose-rust/update/Cargo.toml deleted file mode 100644 index 9059c4e8..00000000 --- a/compose-rust/update/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "compose-rust-update" -version = "0.0.0" -edition.workspace = true -license.workspace = true -rust-version.workspace = true -description = "Lets a compose-rust application handed out outside a store update itself (Sparkle 2 on macOS)" -repository = "https://github.com/DarkPyonix/compose-rust" -keywords = ["update", "sparkle", "compose-rust", "compose", "macos"] -categories = ["gui"] -# Not published yet. The crate is new, and an application that ships through the Mac App -# Store must not depend on it at all, which is easier to keep straight while it lives in -# this repository beside the packager that checks for it. -publish = false - -[lib] -bench = false - -# Sparkle is not linked. The framework is loaded at run time from the application's own -# Contents/Frameworks, so this crate builds on a machine that has never seen Sparkle and a -# binary built with it starts, and says why, when the framework is missing. -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] } -block2 = "0.6" diff --git a/compose-rust/update/src/lib.rs b/compose-rust/update/src/lib.rs deleted file mode 100644 index 5ca1a456..00000000 --- a/compose-rust/update/src/lib.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Updates for a compose-rust application that is handed out directly rather than -//! through a store. -//! -//! On macOS this drives [Sparkle 2](https://sparkle-project.org): the application checks -//! an appcast, shows Sparkle's own update window (or installs silently when the person -//! allowed that), verifies the archive's EdDSA signature against the key in its -//! Info.plist, and replaces itself. Everything about where to look and which key to trust -//! is in the bundle, written by `scripts/packager-macos/package-macos app --updater sparkle`; -//! this crate only starts Sparkle and passes on the person's requests. -//! -//! ```no_run -//! // In main, before launch: the request waits on the main thread's queue and runs -//! // once the renderer has AppKit running there. -//! if let Err(error) = compose_rust_update::start(Default::default()) { -//! eprintln!("updates are off: {error}"); -//! } -//! // compose_rust::launch(app); -//! -//! // From a menu item or a button: -//! // on_click: move |_| compose_rust_update::check_for_updates(), -//! ``` -//! -//! Sparkle checks every update against the EdDSA key in the bundle's Info.plist, not -//! against who signed the code, so this works for an application signed ad hoc with no -//! Apple developer account behind it. An application that does not update itself (one a -//! store or package manager updates) leaves this crate out, and is packaged with -//! `--updater none`. -//! -//! Elsewhere (Windows, Linux, iOS, Android, the browser) [`start`] answers -//! [`UpdaterError::Unsupported`] and the other functions do nothing, so calling code -//! needs no `cfg` of its own. - -use std::fmt; -use std::path::{Path, PathBuf}; - -#[cfg(target_os = "macos")] -mod sparkle; - -/// When an update that was downloaded without asking gets installed. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Install { - /// When the application quits, which is Sparkle's default: nothing interrupts the - /// person, and the next launch is the new version. - #[default] - OnQuit, - /// As soon as it is ready, relaunching the application. For applications that run for - /// weeks without quitting, and for rehearsing an update unattended. - Immediately, -} - -/// How the updater starts. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct UpdaterOptions { - /// Check once at launch, in the background, besides Sparkle's own schedule. - pub check_on_start: bool, - /// What happens to an update Sparkle downloaded on its own. - pub install: Install, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum UpdaterError { - /// This platform has no updater here; its store or package manager updates it. - Unsupported, - /// The executable is not inside an application bundle, so there is no Info.plist to - /// read the feed and key from and nowhere Sparkle could install to. - NotInBundle(PathBuf), - /// The bundle has no Sparkle.framework where the packager puts it. - FrameworkMissing(PathBuf), - /// [`start`] was already called in this process. - AlreadyStarted, - /// The executable's own path could not be read. - ExecutablePath(String), -} - -impl fmt::Display for UpdaterError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unsupported => write!(f, "this platform has no self-updater"), - Self::NotInBundle(path) => write!( - f, - "{} is not inside an application bundle (Name.app/Contents/MacOS), so there \ - is no Info.plist to read the update feed from; run the packaged application", - path.display() - ), - Self::FrameworkMissing(path) => write!( - f, - "there is no Sparkle framework at {}; package the application with \ - `package-macos app --updater sparkle --sparkle-framework ...`", - path.display() - ), - Self::AlreadyStarted => write!(f, "the updater was already started"), - Self::ExecutablePath(error) => { - write!(f, "could not read this executable's path: {error}") - } - } - } -} - -impl std::error::Error for UpdaterError {} - -/// Where the packager puts Sparkle for an executable at `executable`. -/// -/// The executable of a bundle is `Name.app/Contents/MacOS/`, and frameworks are in -/// `Name.app/Contents/Frameworks`. Anything else is not a bundle. -pub fn framework_path(executable: &Path) -> Result { - let not_in_bundle = || UpdaterError::NotInBundle(executable.to_path_buf()); - let macos = executable.parent().ok_or_else(not_in_bundle)?; - if macos.file_name().and_then(|name| name.to_str()) != Some("MacOS") { - return Err(not_in_bundle()); - } - let contents = macos.parent().ok_or_else(not_in_bundle)?; - if contents.file_name().and_then(|name| name.to_str()) != Some("Contents") { - return Err(not_in_bundle()); - } - let app = contents.parent().ok_or_else(not_in_bundle)?; - if app.extension().and_then(|extension| extension.to_str()) != Some("app") { - return Err(not_in_bundle()); - } - Ok(contents.join("Frameworks").join("Sparkle.framework")) -} - -/// Whether `framework` looks like a Sparkle 2 framework, judged by its binary. -pub fn is_sparkle_framework(framework: &Path) -> bool { - framework.join("Versions").join("B").join("Sparkle").is_file() -} - -/// Start the updater. Call it once, before `compose_rust::launch`. -/// -/// The framework is checked for here, on the calling thread, so a missing one is an error -/// the caller sees. Loading it and creating Sparkle's controller happen on the main thread -/// once AppKit runs there, which is after `launch` has started the renderer; a failure at -/// that point is written to standard error. -pub fn start(options: UpdaterOptions) -> Result<(), UpdaterError> { - #[cfg(target_os = "macos")] - { - let executable = std::env::current_exe() - .map_err(|error| UpdaterError::ExecutablePath(error.to_string()))?; - let framework = framework_path(&executable)?; - if !is_sparkle_framework(&framework) { - return Err(UpdaterError::FrameworkMissing(framework)); - } - sparkle::start(framework, options) - } - #[cfg(not(target_os = "macos"))] - { - let _ = options; - Err(UpdaterError::Unsupported) - } -} - -/// Check now, showing Sparkle's window: the "Check for Updates..." menu item. -/// -/// Does nothing if [`start`] was not called or failed. -pub fn check_for_updates() { - #[cfg(target_os = "macos")] - sparkle::check_for_updates(); -} - -/// Check now without a window unless an update is found. -pub fn check_in_background() { - #[cfg(target_os = "macos")] - sparkle::check_in_background(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fr35_6_framework_is_found_beside_the_executable_in_its_bundle() { - assert_eq!( - framework_path(Path::new("/Applications/Demo.app/Contents/MacOS/demo")), - Ok(PathBuf::from( - "/Applications/Demo.app/Contents/Frameworks/Sparkle.framework" - )) - ); - } - - #[test] - fn fr35_6_an_executable_outside_a_bundle_is_named_in_the_error() { - for path in [ - "/usr/local/bin/demo", - "/Applications/Demo.app/Contents/demo", - "/Applications/Demo/Contents/MacOS/demo", - "demo", - ] { - let error = framework_path(Path::new(path)).unwrap_err(); - assert_eq!(error, UpdaterError::NotInBundle(PathBuf::from(path))); - assert!(error.to_string().contains(path), "{error}"); - } - } - - #[test] - fn fr35_6_a_directory_without_the_binary_is_not_a_framework() { - let scratch = std::env::temp_dir().join(format!( - "compose-rust-update-test-{}", - std::process::id() - )); - let framework = scratch.join("Sparkle.framework"); - std::fs::create_dir_all(framework.join("Versions").join("B")).unwrap(); - assert!(!is_sparkle_framework(&framework)); - std::fs::write(framework.join("Versions").join("B").join("Sparkle"), b"").unwrap(); - assert!(is_sparkle_framework(&framework)); - std::fs::remove_dir_all(&scratch).unwrap(); - } - - #[test] - fn fr35_6_an_executable_outside_a_bundle_cannot_start_the_updater() { - // The test binary lives in target/, not in a bundle, which is exactly the case of - // running an application with `cargo run`. That must be an error the caller can - // print, not a crash and not a silent success. - let error = start(UpdaterOptions::default()).unwrap_err(); - if cfg!(target_os = "macos") { - assert!(matches!(error, UpdaterError::NotInBundle(_)), "{error:?}"); - } else { - assert_eq!(error, UpdaterError::Unsupported); - } - } - - #[test] - fn fr35_6_checks_before_start_do_nothing() { - // Neither may panic or block: on macOS they queue work for the main thread, which - // in a test binary never runs, and the queued work finds no controller. - check_for_updates(); - check_in_background(); - } -} diff --git a/compose-rust/update/src/sparkle.rs b/compose-rust/update/src/sparkle.rs deleted file mode 100644 index 2788d277..00000000 --- a/compose-rust/update/src/sparkle.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! Sparkle 2, loaded from the bundle at run time and driven through the Objective-C -//! runtime. -//! -//! Why the Host and not the Renderer: Sparkle is an Objective-C framework, and the Rust -//! side can call it directly through objc2. The Renderer is a GraalVM native image, from -//! which the same calls would need hand-written JNI or C glue, which this project does not -//! write. Nothing crosses the Host/Renderer boundary for this either: Sparkle draws its -//! own AppKit windows, and the only thing the application's UI does is ask for a check, -//! which is a plain Rust function call from an event handler. -//! -//! Threads: everything Sparkle is told happens on the main thread, where the renderer's -//! C entry runs `[NSApp run]`. Work is put on the main dispatch queue, which that run loop -//! drains, so it does not matter which thread asked or whether AppKit is running yet. - -use std::ffi::c_void; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; - -use block2::{Block, RcBlock}; -use objc2::rc::{Allocated, Retained}; -use objc2::runtime::{AnyClass, AnyObject, Bool, NSObject}; -use objc2::{AnyThread, define_class, msg_send}; -use objc2_foundation::NSString; - -use crate::{Install, UpdaterError, UpdaterOptions}; - -/// The SPUStandardUpdaterController, retained for the life of the process. -static CONTROLLER: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); -static STARTED: AtomicBool = AtomicBool::new(false); - -#[repr(C)] -struct DispatchQueue { - _opaque: [u8; 0], -} - -// libdispatch, which every macOS process links through libSystem. -// `dispatch_get_main_queue()` is a macro for the address of `_dispatch_main_q`. -unsafe extern "C" { - static _dispatch_main_q: DispatchQueue; - fn dispatch_async_f( - queue: *const DispatchQueue, - context: *mut c_void, - work: extern "C" fn(*mut c_void), - ); -} - -/// Run `work` on the main thread, after whatever is already queued there. -fn on_main_thread(work: F) { - extern "C" fn trampoline(context: *mut c_void) { - // SAFETY: `context` is the box leaked below, and the queue calls this once. - let work = unsafe { Box::from_raw(context.cast::()) }; - // An unwind may not cross into libdispatch. - if catch_unwind(AssertUnwindSafe(work)).is_err() { - eprintln!("compose-rust-update: an update request panicked on the main thread"); - } - } - let context = Box::into_raw(Box::new(work)).cast::(); - // SAFETY: the main queue lives for the whole process and the trampoline matches the - // context's type. - unsafe { dispatch_async_f(&raw const _dispatch_main_q, context, trampoline::) }; -} - -/// A value that is only touched on the main thread, carried there from the main thread. -struct OnMain(T); - -// SAFETY: only constructed on the main thread and only consumed by work queued to the -// main thread, so the value never runs anywhere else. -unsafe impl Send for OnMain {} - -impl OnMain> { - fn call(self) { - self.0.call(()); - } -} - -define_class!( - // SAFETY: NSObject has no subclassing requirements, and this class has no Drop. - #[unsafe(super(NSObject))] - struct InstallNowDelegate; - - impl InstallNowDelegate { - // SPUUpdaterDelegate. Sparkle asks this when an update it downloaded on its own is - // ready and would otherwise wait for the application to quit. Answering YES and - // calling the block installs it now and relaunches. - #[unsafe(method(updater:willInstallUpdateOnQuit:immediateInstallationBlock:))] - fn will_install_update_on_quit( - &self, - _updater: *mut AnyObject, - _item: *mut AnyObject, - install: &Block, - ) -> Bool { - eprintln!("compose-rust-update: update ready, installing and relaunching"); - // Not from inside the delegate call: Sparkle is still in the middle of the - // step that asked. - let install = OnMain(install.copy()); - on_main_thread(move || install.call()); - Bool::YES - } - } -); - -impl InstallNowDelegate { - fn new() -> Retained { - let this = Self::alloc().set_ivars(()); - // SAFETY: NSObject's init on a freshly allocated object. - unsafe { msg_send![super(this), init] } - } -} - -pub(crate) fn start(framework: PathBuf, options: UpdaterOptions) -> Result<(), UpdaterError> { - if STARTED.swap(true, Ordering::AcqRel) { - return Err(UpdaterError::AlreadyStarted); - } - on_main_thread(move || { - // SAFETY: on the main thread, which is where AppKit and Sparkle must be used. - match unsafe { start_on_main(&framework, options.install) } { - Ok(()) => eprintln!("compose-rust-update: Sparkle started"), - Err(message) => eprintln!("compose-rust-update: {message}"), - } - }); - if options.check_on_start { - check_in_background(); - } - Ok(()) -} - -unsafe fn start_on_main(framework: &Path, install: Install) -> Result<(), String> { - let path = NSString::from_str(&framework.to_string_lossy()); - let bundle_class = - AnyClass::get(c"NSBundle").ok_or("the Objective-C runtime has no NSBundle")?; - // SAFETY: +[NSBundle bundleWithPath:] takes a string and returns a bundle or nil. - let bundle: Option> = unsafe { msg_send![bundle_class, bundleWithPath: &*path] }; - let bundle = bundle.ok_or_else(|| format!("{} is not a bundle", framework.display()))?; - // SAFETY: -[NSBundle load] takes nothing and returns whether the code is loaded. - let loaded: Bool = unsafe { msg_send![&*bundle, load] }; - if !loaded.as_bool() { - return Err(format!( - "{} did not load. A framework signed by a different team than the application \ - is refused by library validation; sign the whole bundle with one identity.", - framework.display() - )); - } - let controller_class = AnyClass::get(c"SPUStandardUpdaterController").ok_or_else(|| { - format!( - "{} loaded but has no SPUStandardUpdaterController; it is not Sparkle 2", - framework.display() - ) - })?; - - let delegate = match install { - Install::Immediately => Some(InstallNowDelegate::new()), - Install::OnQuit => None, - }; - // SAFETY: +alloc on a class that exists. - let allocated: Allocated = unsafe { msg_send![controller_class, alloc] }; - // SAFETY: SPUStandardUpdaterController's designated initializer: whether to start the - // updater now, an updater delegate or nil, and a user driver delegate or nil. - let controller: Option> = unsafe { - msg_send![ - allocated, - initWithStartingUpdater: Bool::YES, - updaterDelegate: delegate.as_deref(), - userDriverDelegate: None::<&AnyObject> - ] - }; - let controller = controller - .ok_or_else(|| "SPUStandardUpdaterController refused to initialise".to_owned())?; - - // Sparkle keeps its delegate weakly, so the delegate is kept here, for the life of the - // process, as is the controller. - if let Some(delegate) = delegate { - let _ = Retained::into_raw(delegate); - } - CONTROLLER.store(Retained::into_raw(controller), Ordering::Release); - Ok(()) -} - -pub(crate) fn check_for_updates() { - on_main_thread(|| { - let controller = CONTROLLER.load(Ordering::Acquire); - if controller.is_null() { - eprintln!("compose-rust-update: no updater is running, so there is nothing to check with"); - return; - } - // SAFETY: the controller is retained for the life of the process; checkForUpdates: - // is its menu action and takes the sender, which may be nil. - unsafe { - let () = msg_send![controller, checkForUpdates: None::<&AnyObject>]; - } - }); -} - -pub(crate) fn check_in_background() { - on_main_thread(|| { - let controller = CONTROLLER.load(Ordering::Acquire); - if controller.is_null() { - eprintln!("compose-rust-update: no updater is running, so there is nothing to check with"); - return; - } - // SAFETY: as above; -updater returns the controller's SPUUpdater. - unsafe { - let updater: Option> = msg_send![controller, updater]; - if let Some(updater) = updater { - let () = msg_send![&*updater, checkForUpdatesInBackground]; - } - } - }); -} diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index 21149d07..e64a3f11 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -231,23 +231,12 @@ FreeType). 어디로 복사해도 실행됩니다. Windows는 정적 렌더러 Python 3.11과 Xcode 명령줄 도구가 필요하고, Apple 개발자 계정은 필요하지 않습니다. ```sh -scripts/packager-macos/package-macos keygen --out ~/keys/sparkle.key # 한 번만, 잘 보관 -scripts/packager-macos/package-macos app --updater sparkle --manifest-dir . \ - --executable target/release/my-app --out dist \ - --feed-url https://example.com/appcast.xml --ed-key-file ~/keys/sparkle.key \ - --sparkle-framework Sparkle.framework +scripts/packager-macos/package-macos app --manifest-dir . \ + --executable target/release/my-app --out dist scripts/packager-macos/package-macos dmg --app "dist/My App.app" --out dist/MyApp-1.0.0.dmg -scripts/packager-macos/package-macos archive --app "dist/My App.app" --out site/MyApp-1.0.0.zip -scripts/packager-macos/package-macos appcast --appcast site/appcast.xml --app "dist/My App.app" \ - --archive site/MyApp-1.0.0.zip --url https://example.com/MyApp-1.0.0.zip \ - --ed-key-file ~/keys/sparkle.key ``` -이렇게 만든 애플리케이션은 [Sparkle 2](https://sparkle-project.org)로 스스로 갱신합니다. -`compose-rust-update` 크레이트를 추가하고, `launch` 전에 -`compose_rust_update::start(Default::default())`를, 메뉴 항목에서 `check_for_updates()`를 -부르면 됩니다. 모든 갱신은 번들이 가진 EdDSA 키로 검증됩니다. `--updater none`은 갱신하지 -않는 번들을 만듭니다. +번들에는 갱신 설정이 없습니다. 직접 배포는 Velopack으로 패키징하고 갱신합니다. 모든 번들은 임시(ad hoc) 서명됩니다. Apple 실리콘은 서명 없는 코드를 실행하지 않고, 임시 서명에는 계정이 필요 없습니다. 다만 Apple이 확인한 개발자를 가리키지 않으므로, **내려받은 @@ -261,8 +250,7 @@ scripts/packager-macos/package-macos appcast --appcast site/appcast.xml --app "d 표시를 지우고, 그러면 macOS가 묻지 않고 엽니다. 처음 실행하기 전에 앱을 응용 프로그램 폴더로 옮기십시오. 디스크 이미지나 다운로드 폴더에서 -실행하면 macOS가 숨겨진 임시 사본을 실행하고, 그 사본은 스스로 갱신할 수 없습니다. Sparkle이 -설치한 갱신은 다시 묻지 않고 열립니다. 디스크 이미지에는 이 절차가 +실행하면 macOS가 숨겨진 임시 사본을 실행합니다. 디스크 이미지에는 이 절차가 `If macOS will not open the app.txt`로 들어 있습니다. ### `develop`의 작성 API 쓰기 diff --git a/experiments/self-update/app/Cargo.toml b/experiments/self-update/app/Cargo.toml deleted file mode 100644 index f6c7acb6..00000000 --- a/experiments/self-update/app/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -# The application the self-update rehearsal installs at one version and watches replace -# itself with the next. -# -# It depends on the published dioxus-compose, not the one in this checkout, and -# rehearse.sh fetches the renderer released for that version: building this needs no -# renderer build, and the two halves are guaranteed to be from the same schema. 0.0.0 does -# not fetch its renderer by itself, so the script does and names it with -# DIOXUS_COMPOSE_RENDERER_DIR. -[package] -name = "self-update-demo" -version = "1.0.0" -edition = "2024" -license = "Apache-2.0" -publish = false - -# Its own workspace: this directory is inside the repository's but is not one of its -# members, and the repository's lock file should not carry a crate it only rehearses with. -[workspace] - -[features] -default = ["sparkle"] -# The updater. Without it (default features off) the application does not update itself, -# and the packager makes it a bundle with no Sparkle in it. -sparkle = ["dep:compose-rust-update", "dep:objc2"] - -[dependencies] -dioxus-compose = { version = "=0.0.0", features = ["native-renderer"] } -# The published 0.0.0's rsx! expands to paths in these two crates, so an application of -# that version names them itself. Later versions re-export what the macro needs. -dioxus-core = "0.7.10" -dioxus-signals = "0.7" -compose-rust-update = { path = "../../../compose-rust/update", optional = true } - -# Only for the rehearsal's "quit after N seconds", which stands in for the person choosing -# Quit so that installing on quit can be watched without anyone at the keyboard. -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = { version = "0.6", optional = true } diff --git a/experiments/self-update/app/Dioxus.toml b/experiments/self-update/app/Dioxus.toml deleted file mode 100644 index ebc34295..00000000 --- a/experiments/self-update/app/Dioxus.toml +++ /dev/null @@ -1,15 +0,0 @@ -# dx project configuration for the self-update rehearsal. The packager reads the bundle -# name, identifier and category from here. - -[application] -name = "Self Update Demo" -asset_dir = "assets" - -[bundle] -identifier = "dev.darkpyonix.composerust.experiments.selfupdate" -publisher = "DarkPyonix" -category = "DeveloperTool" -short_description = "Installs at one version and updates itself to the next." - -[macos] -bundle_name = "Self Update Demo" diff --git a/experiments/self-update/app/assets/icon.png b/experiments/self-update/app/assets/icon.png deleted file mode 100644 index 39063eec7c6db3590279c9b11ae40132f0fcfcec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 482 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU|i|x;uunK>+M~`yu$_pE`j&{ z&&!HgRj7HtftNRH=jX2bQjO0K>P^ZxUR&Q@^jVGJfiHst0|x`60s~6}g8%~)PL6c_ z3&VL=)7IC=&QDim=<2h}F8vbh=+e*U6v&XzGr#NLhlc_@@_h$>z$lKMc#(s89LM_xPout>mN@U zLYgmB{NN-wwz PbQ*)FtDnm{r-UW|bsm&5 diff --git a/experiments/self-update/app/build.rs b/experiments/self-update/app/build.rs deleted file mode 100644 index 86940482..00000000 --- a/experiments/self-update/app/build.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Makes the Host's entry points visible to the renderer, for the published 0.0.0 only. -//! -//! The renderer finds `dioxus_compose_host_*` in the executable by name when it is loaded. -//! dioxus-compose 0.0.0 asked for that with link arguments in its own build script, and -//! Cargo does not pass a dependency's link arguments on to the application, so an -//! application of that version starts with "symbol not found in flat namespace -//! '_dioxus_compose_host_dispatch_event'". Later versions keep the symbols from inside the -//! crate and need none of this. - -const HOST_ENTRY_POINTS: [&str; 5] = [ - "dioxus_compose_host_init", - "dioxus_compose_host_dispatch_event", - "dioxus_compose_host_render_frame", - "dioxus_compose_host_release_batch", - "dioxus_compose_host_shutdown", -]; - -fn main() { - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { - return; - } - println!("cargo:rustc-link-arg-bins=-Wl,-export_dynamic"); - for symbol in HOST_ENTRY_POINTS { - // Kept even though nothing in the program calls them: the renderer does. - println!("cargo:rustc-link-arg-bins=-Wl,-u,_{symbol}"); - } -} diff --git a/experiments/self-update/app/src/main.rs b/experiments/self-update/app/src/main.rs deleted file mode 100644 index 334a1080..00000000 --- a/experiments/self-update/app/src/main.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! The application the self-update rehearsal installs and watches update itself. -//! -//! It shows its own version, offers "Check for Updates...", and appends one line per -//! launch to `self-update-demo.log` beside its bundle, which is how the rehearsal tells -//! the version that was installed from the one that relaunched in its place. -//! -//! Environment, read at launch (a relaunch by Sparkle has none of it, and gets the -//! defaults): -//! -//! - `DEMO_INSTALL=on-quit` waits for the application to quit before installing, which is -//! Sparkle's default; otherwise an update found in the background is installed at once. -//! - `DEMO_CHECK_AFTER_SECS=n` checks in the background after n seconds rather than at -//! launch, so the old version can be seen first. -//! - `DEMO_QUIT_AFTER_SECS=n` quits after n seconds the way the Quit menu item does. - -#![windows_subsystem = "windows"] - -use dioxus_compose::prelude::*; - -const VERSION: &str = match option_env!("DEMO_VERSION") { - Some(version) => version, - None => env!("CARGO_PKG_VERSION"), -}; - -fn app() -> Element { - let title = format!("Self Update Demo {VERSION}"); - let channel = if cfg!(feature = "sparkle") { - "Updates itself with Sparkle." - } else { - "Does not update itself." - }; - rsx! { - Column { - fill_max_width: true, - Text { text: title } - Text { text: channel } - Button { - text: "Check for Updates...", - on_click: move |_| check_for_updates(), - } - } - } -} - -fn check_for_updates() { - #[cfg(feature = "sparkle")] - compose_rust_update::check_for_updates(); -} - -#[cfg(feature = "sparkle")] -fn seconds_from(name: &str) -> Option { - std::env::var(name).ok()?.parse().ok() -} - -/// One line per launch, beside the bundle, so a relaunch can be told from the original. -fn record_launch() { - let Ok(executable) = std::env::current_exe() else { - return; - }; - // Name.app/Contents/MacOS/: four levels up is the directory holding the app. - let Some(beside) = executable.ancestors().nth(4) else { - return; - }; - let line = format!( - "version {VERSION} started, pid {}, from {}\n", - std::process::id(), - executable.display() - ); - let _ = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(beside.join("self-update-demo.log")) - .and_then(|mut file| std::io::Write::write_all(&mut file, line.as_bytes())); - eprint!("self-update-demo: {line}"); -} - -#[cfg(feature = "sparkle")] -fn start_updates() { - use compose_rust_update::{Install, UpdaterOptions, check_in_background, start}; - - let install = match std::env::var("DEMO_INSTALL").as_deref() { - Ok("on-quit") => Install::OnQuit, - _ => Install::Immediately, - }; - let delay = seconds_from("DEMO_CHECK_AFTER_SECS"); - let options = UpdaterOptions { - check_on_start: delay.is_none(), - install, - }; - if let Err(error) = start(options) { - eprintln!("self-update-demo: updates are off: {error}"); - return; - } - if let Some(seconds) = delay { - // A Host worker thread: the request itself is queued to the main thread. - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(seconds)); - check_in_background(); - }); - } -} - -/// `[NSApp terminate:nil]` on the main thread, which is what the Quit menu item sends. -#[cfg(all(feature = "sparkle", target_os = "macos"))] -fn quit_after(seconds: u64) { - use objc2::runtime::{AnyClass, AnyObject}; - use objc2::msg_send; - use std::ffi::c_void; - - #[repr(C)] - struct DispatchQueue { - _opaque: [u8; 0], - } - unsafe extern "C" { - static _dispatch_main_q: DispatchQueue; - fn dispatch_async_f( - queue: *const DispatchQueue, - context: *mut c_void, - work: extern "C" fn(*mut c_void), - ); - } - extern "C" fn terminate(_: *mut c_void) { - let Some(class) = AnyClass::get(c"NSApplication") else { - return; - }; - // SAFETY: on the main thread; sharedApplication exists once the renderer is up. - unsafe { - let app: *mut AnyObject = msg_send![class, sharedApplication]; - let () = msg_send![app, terminate: std::ptr::null_mut::()]; - } - } - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(seconds)); - eprintln!("self-update-demo: quitting as the Quit menu item would"); - // SAFETY: the main queue lives for the process, and `terminate` takes no context. - unsafe { dispatch_async_f(&raw const _dispatch_main_q, std::ptr::null_mut(), terminate) }; - }); -} - -fn main() { - record_launch(); - #[cfg(feature = "sparkle")] - start_updates(); - #[cfg(all(feature = "sparkle", target_os = "macos"))] - if let Some(seconds) = seconds_from("DEMO_QUIT_AFTER_SECS") { - quit_after(seconds); - } - dioxus_compose::launch(app); -} diff --git a/experiments/self-update/rehearse.sh b/experiments/self-update/rehearse.sh deleted file mode 100755 index 2d9c1379..00000000 --- a/experiments/self-update/rehearse.sh +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env bash -# Usage: experiments/self-update/rehearse.sh -# -# Rehearses a Sparkle self-update end to end on one machine with no Apple developer -# account: every bundle is signed ad hoc, and the only thing that vouches for an update is -# its EdDSA signature. -# -# 1. Builds the demo application at 1.0.0 and 2.0.0 and packages both (ad hoc). -# 2. Publishes 2.0.0: a zip, and an appcast signed with a key made here, checked against -# Sparkle's own sign_update and generate_appcast. -# 3. Gatekeeper: a copy is marked as downloaded (com.apple.quarantine, as a browser -# leaves it), and after `xattr -dr com.apple.quarantine`, the step the install notes -# give, it launches. DEMO_GATEKEEPER_HELD=1 also shows the marked copy being held at -# launch first, which leaves a dialog on the screen for someone to close. -# 4. That copy, 1.0.0, finds 2.0.0 in the appcast served from 127.0.0.1, installs it at -# once and relaunches as 2.0.0. The new bundle carries no quarantine mark, so -# Gatekeeper lets the relaunch through, and 2.0.0 checks the feed and downloads -# nothing more. -# 5. A second 1.0.0 installs the update when it quits, which is Sparkle's default. -# -# Everything goes under $DEMO_WORK (default .scratch/self-update in this repository): -# builds, bundles, the served site, the private key, and evidence/ with logs, plists, -# signatures and screenshots. evidence/result.txt says what passed. -# -# DEMO_REUSE=1 skips steps 1 and 2 and uses the dist-1, dist-2 and site directories already -# in $DEMO_WORK, for example ones unpacked from the CI artifact, so the update can be -# watched on a Mac that has not built anything. -# -# Needs Xcode's command line tools, Python 3.11 or newer, and (unless reusing) Rust. -# Sparkle's release archive and the released renderer are downloaded into $DEMO_WORK and -# checked against pinned SHA-256 sums. Running the application writes what any macOS -# application writes: Sparkle's preferences and download cache under ~/Library for the -# demo's bundle identifier. The last step lists them. - -set -euo pipefail - -repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -work="${DEMO_WORK:-$repo/.scratch/self-update}" -port="${DEMO_PORT:-8765}" -reuse="${DEMO_REUSE:-0}" -package="$repo/scripts/packager-macos/package-macos" -app_dir="$repo/experiments/self-update/app" -evidence="$work/evidence" -name="Self Update Demo" -identifier="dev.darkpyonix.composerust.experiments.selfupdate" -executable="self-update-demo" -archive_name="SelfUpdateDemo-2.0.0.zip" - -renderer_version="0.0.0" -sparkle_version="2.10.0" -sparkle_sha256="c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c" - -rm -rf "$work"/install-* "$evidence" -if [[ "$reuse" != 1 ]]; then - rm -rf "$work/build" "$work"/dist-* "$work/site" "$work/generated" "$work/bundles" -fi -mkdir -p "$work/build" "$work/site" "$evidence" -result="$evidence/result.txt" -: >"$result" - -step() { printf '\n== %s\n' "$*"; } -pass() { echo "PASS $*" | tee -a "$result"; } -fail() { - echo "FAIL $*" | tee -a "$result" - diagnose - exit 1 -} -# What a failure leaves to read: which demo processes are alive, and any crash reports. -diagnose() { - { - echo "== processes" - ps -axo pid,ppid,stat,etime,command | grep -i "self.update\|Autoupdate\|Updater" | grep -v grep || true - echo "== crash reports" - ls -la "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null | grep -i "self-update\|Autoupdate\|Updater" || true - } >"$evidence/diagnosis.txt" 2>&1 - for report in "$HOME"/Library/Logs/DiagnosticReports/*self-update*; do - [[ -f "$report" ]] && cp "$report" "$evidence/" - done - return 0 -} -shot() { - # The whole screen. A machine where this shell may not record the screen gets no - # picture, which is noted rather than fatal: the logs are the evidence and the picture - # is for a person. - sleep "${2:-0}" - screencapture -x "$evidence/$1.png" 2>>"$evidence/screencapture.log" || - echo "screencapture failed for $1" >>"$evidence/screencapture.log" -} - -pids=() -cleanup() { - for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done - pkill -f "$work/install-" 2>/dev/null || true - pkill -f "AppTranslocation/.*/$name.app" 2>/dev/null || true -} -trap cleanup EXIT - -# Info.plist's CFBundleVersion of an installed app, or nothing while it is being replaced. -installed_build() { - /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$1/Contents/Info.plist" 2>/dev/null || true -} - -wait_for() { - local seconds="$1" - shift - local waited=0 - until "$@"; do - ((waited >= seconds)) && return 1 - sleep 1 - waited=$((waited + 1)) - done -} - -# Mark a bundle the way a browser and Archive Utility leave a download. -quarantine() { - local value - value="0083;$(printf %x "$(date +%s)");Safari;$(uuidgen)" - xattr -w -r com.apple.quarantine "$value" "$1" -} - -# Launch through LaunchServices, as a double click does, so that Gatekeeper is consulted. -# The application's own launch log (beside its bundle) is the record. open's --stdout fails -# with -10810 on some machines and its --env did not reach the application on the runner, -# so neither is used: a launch that needs settings runs the executable directly. -launch() { - # In the background: open waits for the launch to finish, and a launch Gatekeeper holds - # for the person never finishes on a machine with nobody at it. - open -n "$1" & -} - -step "Sparkle $sparkle_version" -sparkle="${SPARKLE_DIR:-$work/sparkle}" -if [[ ! -x "$sparkle/bin/sign_update" ]]; then - mkdir -p "$sparkle" - curl -fsSL -o "$sparkle/Sparkle.tar.xz" \ - "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/Sparkle-$sparkle_version.tar.xz" - echo "$sparkle_sha256 $sparkle/Sparkle.tar.xz" | shasum -a 256 -c - - tar -xf "$sparkle/Sparkle.tar.xz" -C "$sparkle" -fi - -if [[ "$reuse" != 1 ]]; then - step "Renderer $renderer_version, as released for the dioxus-compose the demo depends on" - renderer="$work/renderer" - if [[ ! -f "$renderer/lib/libdioxus_compose_renderer.dylib" ]]; then - artifact="dioxus-compose-renderer-v$renderer_version-macos-aarch64.tar.gz" - release="https://github.com/DarkPyonix/dioxus-compose/releases/download/v$renderer_version" - mkdir -p "$work/downloads" "$renderer" - curl -fsSL -o "$work/downloads/$artifact" "$release/$artifact" - curl -fsSL -o "$work/downloads/$artifact.sha256" "$release/$artifact.sha256" - (cd "$work/downloads" && shasum -a 256 -c "$artifact.sha256") - tar -xzf "$work/downloads/$artifact" -C "$renderer" - fi - export DIOXUS_COMPOSE_RENDERER_DIR="$renderer" - - step "EdDSA key (kept in $work/keys, never in the repository)" - key="$work/keys/sparkle.key" - [[ -f "$key" ]] || "$package" keygen --out "$key" - echo "SUPublicEDKey $("$package" public-key --key "$key")" - - # Plain http to 127.0.0.1 is how an update is rehearsed on one machine. App Transport - # Security refuses it unless the application allows local networking. - cat >"$work/ats.plist" <<'PLIST' - - - - NSAppTransportSecurityNSAllowsLocalNetworking - -PLIST - - step "Build and package 1.0.0 and 2.0.0, signed ad hoc" - mkdir -p "$work/bundles" - for major in 1 2; do - DEMO_VERSION="$major.0.0" cargo build --release --manifest-path "$app_dir/Cargo.toml" - cp "$app_dir/target/release/$executable" "$work/build/$executable-$major" - "$package" app --updater sparkle --manifest-dir "$app_dir" \ - --executable "$work/build/$executable-$major" --renderer-dir "$renderer/lib" \ - --version "$major.0.0" --build "$major" --out "$work/dist-$major" \ - --feed-url "http://127.0.0.1:$port/appcast.xml" --ed-key-file "$key" \ - --sparkle-framework "$sparkle/Sparkle.framework" --automatically-update \ - --plist-file "$work/ats.plist" | tee "$evidence/package-$major.txt" - # Kept whole (ditto keeps the framework's links, which an artifact upload would - # not), so the update can be watched again on another Mac without building. - ditto -c -k --keepParent "$work/dist-$major/$name.app" "$work/bundles/$name-$major.0.0.zip" - done - "$package" dmg --app "$work/dist-1/$name.app" --out "$work/bundles/$name-1.0.0.dmg" - pass "1.0.0 and 2.0.0 packaged; every piece of code in them is signed ad hoc" - - step "Publish 2.0.0" - "$package" archive --app "$work/dist-2/$name.app" --out "$work/site/$archive_name" - "$package" appcast --appcast "$work/site/appcast.xml" --app "$work/dist-2/$name.app" \ - --archive "$work/site/$archive_name" --url "http://127.0.0.1:$port/$archive_name" \ - --ed-key-file "$key" --sign-update "$sparkle/bin/sign_update" - signature="$(python3 -c ' -import sys, xml.etree.ElementTree as ET -S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" -print(ET.parse(sys.argv[1]).find("channel/item/enclosure").get(S + "edSignature")) -' "$work/site/appcast.xml")" - "$sparkle/bin/sign_update" --ed-key-file "$key" --verify "$work/site/$archive_name" "$signature" - pass "the appcast's signature verifies with Sparkle's sign_update" - - # Sparkle's own generator, given the same archive and key, must say the same thing. - mkdir -p "$work/generated" - cp "$work/site/$archive_name" "$work/generated/" - "$sparkle/bin/generate_appcast" --ed-key-file "$key" "$work/generated" >"$evidence/generate_appcast.txt" 2>&1 - cp "$work/generated/appcast.xml" "$evidence/appcast-from-generate_appcast.xml" - python3 - "$work/site/appcast.xml" "$work/generated/appcast.xml" <<'PY' | tee "$evidence/appcast-comparison.txt" -import sys, xml.etree.ElementTree as ET -S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" -def read(path): - item = ET.parse(path).find("channel/item") - enclosure = item.find("enclosure") - return { - "version": item.findtext(S + "version"), - "shortVersionString": item.findtext(S + "shortVersionString"), - "minimumSystemVersion": item.findtext(S + "minimumSystemVersion"), - "hardwareRequirements": item.findtext(S + "hardwareRequirements"), - "length": enclosure.get("length"), - "edSignature": enclosure.get(S + "edSignature"), - } -ours, theirs = read(sys.argv[1]), read(sys.argv[2]) -for key in ours: - print(f"{key}: ours={ours[key]} generate_appcast={theirs[key]}") -sys.exit(0 if ours == theirs else 1) -PY - pass "the appcast item matches what Sparkle's generate_appcast writes for the same archive" - cp -R "$work/site" "$work/bundles/site" -fi - -for major in 1 2; do - plutil -p "$work/dist-$major/$name.app/Contents/Info.plist" >"$evidence/Info-$major.plist.txt" - codesign -dvv "$work/dist-$major/$name.app" >"$evidence/codesign-$major.txt" 2>&1 - codesign --verify --strict --deep --verbose=2 "$work/dist-$major/$name.app" \ - >>"$evidence/codesign-$major.txt" 2>&1 - find "$work/dist-$major/$name.app" -type f \( -perm +111 -o -name "*.dylib" \) | while read -r code; do - printf '%s: ' "${code#"$work/dist-$major/"}" - codesign -dv "$code" 2>&1 | grep -E "^Signature=" || echo "not signed" - done >"$evidence/signatures-$major.txt" - "$package" verify --app "$work/dist-$major/$name.app" --updater sparkle >>"$evidence/codesign-$major.txt" -done -cp "$work/site/appcast.xml" "$evidence/appcast.xml" - -step "Serve the site on 127.0.0.1:$port" -# http.server's own server_bind looks the address up by name, which on macOS 15 and newer -# asks the person whether Python may use the local network. Nothing here needs a name. -python3 - "$work/site" "$port" >"$evidence/http.log" 2>&1 <<'PY' & -import functools, http.server, socketserver, sys -handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=sys.argv[1]) -class Server(http.server.ThreadingHTTPServer): - def server_bind(self): - socketserver.TCPServer.server_bind(self) - self.server_name, self.server_port = self.server_address[:2] -Server(("127.0.0.1", int(sys.argv[2])), handler).serve_forever() -PY -pids+=($!) -wait_for 10 curl -fsS -o /dev/null "http://127.0.0.1:$port/appcast.xml" || fail "the server did not start" - -/usr/bin/log stream --style compact --level debug --predicate \ - 'subsystem == "org.sparkle-project.Sparkle" OR process == "Autoupdate" OR process == "Updater" OR process == "'"$executable"'" OR (process == "syspolicyd" AND eventMessage CONTAINS "selfupdate")' \ - >"$evidence/unified.log" 2>&1 & -pids+=($!) - -downloads() { grep -c "GET /$archive_name" "$evidence/http.log" || true; } -running_from() { pgrep -f "$1/Contents/MacOS/$executable" >/dev/null; } - -step "Gatekeeper and a downloaded copy" -install="$work/install-immediately" -mkdir -p "$install" -ditto "$work/dist-1/$name.app" "$install/$name.app" -quarantine "$install/$name.app" -xattr -p com.apple.quarantine "$install/$name.app" >"$evidence/quarantine-before.txt" -spctl --assess --type execute -vv "$install/$name.app" >>"$evidence/quarantine-before.txt" 2>&1 || true -if [[ "${DEMO_GATEKEEPER_HELD:-0}" == 1 ]]; then - # Opt in: this leaves Gatekeeper's dialog on the screen, which on an unattended runner - # nobody answers and which has stalled a whole run. Run it where someone can close it. - # A held copy has a process, parked until the person answers, but it never reaches - # main, so it never writes its launch line. - launch "$install/$name.app" - shot gatekeeper-1-quarantined-copy-held 20 - if grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; then - fail "a quarantined ad hoc signed copy started without being approved" - fi - pass "a copy marked as downloaded does not start until the person approves it" - pkill -f "$name.app/Contents/MacOS/$executable" || true - sleep 2 -fi - -xattr -dr com.apple.quarantine "$install/$name.app" -xattr -l "$install/$name.app" >"$evidence/quarantine-after-xattr.txt" - -step "1.0.0 installs 2.0.0 as soon as it is ready and relaunches" -# Through LaunchServices, so Gatekeeper decides; the update follows within seconds. -launch "$install/$name.app" -started() { grep -q "version 1.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } -wait_for 30 started || fail "1.0.0 did not start after its quarantine mark was removed" -pass "after xattr -dr com.apple.quarantine, the same copy starts" -shot immediately-1-launched-1.0.0 1 -relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } -wait_for 180 relaunched || fail "1.0.0 did not relaunch as 2.0.0 within three minutes" -[[ "$(installed_build "$install/$name.app")" == 2 ]] || fail "the installed bundle is not 2.0.0" -shot immediately-2-relaunched-2.0.0 8 -cp "$install/self-update-demo.log" "$evidence/immediately-launches.log" -ps -axo pid,stat,etime,command | grep "$install" | grep -v grep >"$evidence/immediately-processes-2.0.0.txt" || true -running_from "$install/$name.app" || fail "2.0.0 is not running from where 1.0.0 was installed" -pass "1.0.0 updated itself to 2.0.0 and relaunched in place (see immediately-launches.log)" -plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/immediately-installed-Info.plist.txt" -codesign --verify --strict --deep --verbose=2 "$install/$name.app" >"$evidence/immediately-installed-codesign.txt" 2>&1 -xattr -lr "$install/$name.app" >"$evidence/immediately-installed-xattr.txt" 2>&1 || true -if grep -q "com.apple.quarantine" "$evidence/immediately-installed-xattr.txt"; then - fail "the update Sparkle installed carries a quarantine mark" -fi -pass "the installed 2.0.0 carries no quarantine mark, so Gatekeeper let its relaunch through" -# The new version checks too, on its own launch. It must find nothing newer. -sleep 15 -[[ "$(downloads)" == 1 ]] || fail "the archive was downloaded $(downloads) times; 2.0.0 offered itself again" -pass "2.0.0 checked the same feed and downloaded nothing" -pkill -f "$install/" || true - -step "1.0.0 downloads 2.0.0 and installs it when it quits" -install="$work/install-on-quit" -mkdir -p "$install" -ditto "$work/dist-1/$name.app" "$install/$name.app" -DEMO_INSTALL=on-quit DEMO_CHECK_AFTER_SECS=3 DEMO_QUIT_AFTER_SECS=40 \ - "$install/$name.app/Contents/MacOS/$executable" >"$evidence/on-quit-1.0.0.log" 2>&1 & -shot on-quit-1-running-1.0.0 8 -quit() { ! running_from "$install/$name.app"; } -wait_for 90 quit || fail "1.0.0 did not quit" -updated() { [[ "$(installed_build "$install/$name.app")" == 2 ]]; } -wait_for 120 updated || fail "1.0.0 quit and 2.0.0 was not installed within two minutes" -plutil -p "$install/$name.app/Contents/Info.plist" >"$evidence/on-quit-installed-Info.plist.txt" -pass "1.0.0 quit and Sparkle installed 2.0.0 in its place" -launch "$install/$name.app" -on_quit_relaunched() { grep -q "version 2.0.0 started" "$install/self-update-demo.log" 2>/dev/null; } -wait_for 30 on_quit_relaunched || fail "the 2.0.0 installed on quit does not start" -cp "$install/self-update-demo.log" "$evidence/on-quit-launches.log" -pass "the 2.0.0 installed on quit starts" -pkill -f "$install/" || true - -step "What running the demo left outside $work" -{ - ls -d "$HOME/Library/Caches/$identifier" 2>/dev/null || true - ls "$HOME/Library/Preferences/$identifier.plist" 2>/dev/null || true - ls -d "$HOME/Library/HTTPStorages/$identifier" 2>/dev/null || true - ls -d "$HOME/Library/Saved Application State/$identifier.savedState" 2>/dev/null || true -} | tee "$evidence/left-outside.txt" -echo "remove with: rm -rf ; defaults delete $identifier" - -echo -cat "$result" diff --git a/scripts/packager-macos/dxc_packager/__init__.py b/scripts/packager-macos/dxc_packager/__init__.py index 0f2d4d7c..dbe16263 100644 --- a/scripts/packager-macos/dxc_packager/__init__.py +++ b/scripts/packager-macos/dxc_packager/__init__.py @@ -1 +1 @@ -"""Packaging compose-rust applications for macOS: bundles, signing, store packages, Sparkle feeds.""" +"""Packaging compose-rust applications for macOS: bundles, signing, disk images.""" diff --git a/scripts/packager-macos/dxc_packager/appcast.py b/scripts/packager-macos/dxc_packager/appcast.py deleted file mode 100644 index 8f79965a..00000000 --- a/scripts/packager-macos/dxc_packager/appcast.py +++ /dev/null @@ -1,141 +0,0 @@ -"""The Sparkle appcast: an RSS feed whose items are the versions an application can update to. - -Sparkle 2 reads, per item: `sparkle:version` (compared with the installed CFBundleVersion), -`sparkle:shortVersionString` (what the person is shown), `sparkle:minimumSystemVersion`, -an optional release notes link, and one `enclosure` whose `url`, `length` and -`sparkle:edSignature` say where the archive is and how to know it is the one that was -published. - -Adding a version keeps every item already in the feed, including anything this module does -not itself write, so a feed that grew by hand or by Sparkle's generate_appcast can be -extended without losing what it says. -""" - -import re -import xml.etree.ElementTree as ET -from dataclasses import dataclass -from datetime import datetime, timezone -from email.utils import format_datetime - -from .metadata import PackagingError - -SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" -DC_NS = "http://purl.org/dc/elements/1.1/" -ET.register_namespace("sparkle", SPARKLE_NS) -ET.register_namespace("dc", DC_NS) - - -def _s(name): - return f"{{{SPARKLE_NS}}}{name}" - - -@dataclass(frozen=True) -class Item: - version: str - short_version: str - url: str - length: int - ed_signature: str - minimum_system_version: str - pub_date: datetime - title: str | None = None - release_notes_url: str | None = None - critical: bool = False - # "arm64" for an application that has no Intel code, so an Intel Mac is not offered an - # update it cannot run. Sparkle's generate_appcast writes the same from the binary. - hardware_requirements: str | None = None - - -def version_key(version): - """Order versions the way Sparkle's default comparator does for numeric versions.""" - return tuple(int(part) for part in re.findall(r"\d+", version)) - - -def _item_element(item): - element = ET.Element("item") - ET.SubElement(element, "title").text = item.title or f"Version {item.short_version}" - ET.SubElement(element, "pubDate").text = format_datetime( - item.pub_date.astimezone(timezone.utc) - ) - ET.SubElement(element, _s("version")).text = item.version - ET.SubElement(element, _s("shortVersionString")).text = item.short_version - ET.SubElement(element, _s("minimumSystemVersion")).text = item.minimum_system_version - if item.hardware_requirements: - ET.SubElement(element, _s("hardwareRequirements")).text = item.hardware_requirements - if item.release_notes_url: - ET.SubElement(element, _s("releaseNotesLink")).text = item.release_notes_url - if item.critical: - ET.SubElement(element, _s("criticalUpdate")) - ET.SubElement( - element, - "enclosure", - { - "url": item.url, - "length": str(item.length), - "type": "application/octet-stream", - _s("edSignature"): item.ed_signature, - }, - ) - return element - - -def element_version(element): - """The version an existing item offers, wherever its writer put it.""" - version = element.findtext(_s("version")) - if version: - return version.strip() - # Sparkle 1 feeds put it on the enclosure. - enclosure = element.find("enclosure") - if enclosure is not None and enclosure.get(_s("version")): - return enclosure.get(_s("version")).strip() - raise PackagingError("an existing appcast item has no sparkle:version") - - -def new_feed(title, link=None, description=None): - rss = ET.Element("rss", {"version": "2.0"}) - channel = ET.SubElement(rss, "channel") - ET.SubElement(channel, "title").text = title - if link: - ET.SubElement(channel, "link").text = link - if description: - ET.SubElement(channel, "description").text = description - ET.SubElement(channel, "language").text = "en" - return rss - - -def parse(text): - try: - rss = ET.fromstring(text) - except ET.ParseError as error: - raise PackagingError(f"the existing appcast is not XML: {error}") from error - if rss.tag != "rss" or rss.find("channel") is None: - raise PackagingError("the existing appcast is not an RSS feed with a channel") - return rss - - -def add(rss, item): - """Add `item` to the feed, replacing an item for the same version, newest first.""" - channel = rss.find("channel") - items = [element for element in channel.findall("item")] - for element in items: - channel.remove(element) - kept = [element for element in items if element_version(element) != item.version] - kept.append(_item_element(item)) - kept.sort(key=lambda element: version_key(element_version(element)), reverse=True) - for element in kept: - channel.append(element) - return rss - - -def versions(rss): - return [element_version(element) for element in rss.find("channel").findall("item")] - - -def dumps(rss): - ET.indent(rss, space=" ") - body = ET.tostring(rss, encoding="unicode") - return '\n' + body + "\n" - - -def now(): - return datetime.now(timezone.utc).replace(microsecond=0) diff --git a/scripts/packager-macos/dxc_packager/bundle.py b/scripts/packager-macos/dxc_packager/bundle.py index 82d73e21..fd68a727 100644 --- a/scripts/packager-macos/dxc_packager/bundle.py +++ b/scripts/packager-macos/dxc_packager/bundle.py @@ -7,7 +7,6 @@ MacOS/ Frameworks/lib/ the renderer's own lib directory, whole, when the executable loads the renderer as a library - Frameworks/Sparkle.framework when the bundle updates itself Resources/AppIcon.icns The renderer directory goes to Frameworks/lib and not straight into Frameworks because AWT @@ -20,12 +19,10 @@ runs no arm64 code without a signature, and copying a library or rewriting its load commands invalidates the one the linker left. An ad hoc signature names no developer, so Gatekeeper does not let a downloaded copy open without the person's say-so (see -install-notes.txt), but once it is open nothing else differs, and Sparkle's updates are -verified by their EdDSA signature rather than by who signed the code. +install-notes.txt), but once it is open nothing else differs. """ import os -import plistlib import shutil import subprocess import tempfile @@ -39,17 +36,6 @@ BUNDLE_RENDERER = REPOSITORY / "scripts" / "bundle-renderer.sh" INSTALL_NOTES = Path(__file__).resolve().parent / "install-notes.txt" -# What the Sparkle framework contains that is code, innermost first. Signing goes from the -# inside out, each piece before whatever contains it, so that every seal covers signatures -# that are already final. `codesign --deep` would reach the same pieces but in an order -# it chooses and without keeping the Downloader service's own entitlements. -SPARKLE_NESTED = ( - ("Versions/B/XPCServices/Installer.xpc", False), - ("Versions/B/XPCServices/Downloader.xpc", True), - ("Versions/B/Autoupdate", False), - ("Versions/B/Updater.app", False), -) - def run(command, **kwargs): try: @@ -139,16 +125,6 @@ def is_mach_o(path): ) -def hardware_requirements(app): - """What Sparkle should require of a Mac before offering this app: "arm64" or nothing.""" - info = plistlib.loads((Path(app) / "Contents" / "Info.plist").read_bytes()) - executable = Path(app) / "Contents" / "MacOS" / info["CFBundleExecutable"] - architectures = set(run(["lipo", "-archs", str(executable)]).stdout.split()) - if architectures and architectures <= {"arm64", "arm64e"}: - return "arm64" - return None - - def make_icns(source, destination): source = Path(source) if source.suffix.lower() == ".icns": @@ -181,34 +157,14 @@ def assemble( executable, out_dir, *, - sparkle=None, - sparkle_framework=None, extra_plist=None, renderer_dir=None, ): - """Build Name.app in `out_dir` and return its path. Replaces one already there. - - With `sparkle` settings the bundle updates itself, and `sparkle_framework` (from a - Sparkle 2 release archive) goes inside it. Without them it is a plain application. - """ + """Build Name.app in `out_dir` and return its path. Replaces one already there.""" executable = Path(executable) if not executable.is_file(): raise PackagingError(f"no executable at {executable}") - info = plists.info_plist(metadata, sparkle=sparkle, extra=extra_plist) - if sparkle is not None: - if sparkle_framework is None: - raise PackagingError( - "a bundle that updates itself needs --sparkle-framework, the " - "Sparkle.framework from a Sparkle 2 release archive" - ) - sparkle_framework = Path(sparkle_framework) - if not (sparkle_framework / "Versions" / "B" / "Sparkle").exists(): - raise PackagingError(f"{sparkle_framework} is not a Sparkle 2 framework") - elif sparkle_framework is not None: - raise PackagingError( - "Sparkle.framework was given for a bundle with no feed to update from; pass " - "--updater sparkle with its settings, or leave the framework out" - ) + info = plists.info_plist(metadata, extra=extra_plist) app = Path(out_dir) / f"{metadata.name}.app" if app.exists(): @@ -247,13 +203,6 @@ def assemble( shutil.copytree(renderer, contents / "Frameworks" / "lib", symlinks=True) run(["bash", str(BUNDLE_RENDERER), str(staged), "../Frameworks/lib"]) - if sparkle_framework is not None: - # symlinks=True: a framework is Versions/Current plus links into it, and copying - # the links as files makes a framework codesign rejects as ambiguous. - shutil.copytree( - sparkle_framework, contents / "Frameworks" / "Sparkle.framework", symlinks=True - ) - if metadata.icon is not None: make_icns(metadata.icon, contents / "Resources" / "AppIcon.icns") @@ -281,28 +230,11 @@ def sign(app): if path.is_file() and not path.is_symlink() and is_mach_o(path): _codesign(path) - framework = contents / "Frameworks" / "Sparkle.framework" - if framework.exists(): - for relative, keep_entitlements in SPARKLE_NESTED: - nested = framework / relative - if nested.exists(): - _codesign(nested, preserve_entitlements=keep_entitlements) - _codesign(framework) - _codesign(app) run(["codesign", "--verify", "--strict", "--deep", "--verbose=2", str(app)]) return app -def zip_for_sparkle(app, out): - """The archive Sparkle downloads: a zip made by ditto, which keeps links and metadata.""" - out = Path(out) - if out.exists(): - out.unlink() - run(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", str(app), str(out)]) - return out - - def dmg(app, out, volume_name): """A disk image for the first install: the app, a link to /Applications, and the notes that say how to open an application macOS cannot attribute to a developer.""" @@ -324,10 +256,6 @@ def dmg(app, out, volume_name): return out -def sparkle_in(app): - return (Path(app) / "Contents" / "Frameworks" / "Sparkle.framework").exists() - - def absolute_references(app): """Libraries the bundle's code loads from outside the bundle and outside the system.""" app = Path(app) diff --git a/scripts/packager-macos/dxc_packager/cli.py b/scripts/packager-macos/dxc_packager/cli.py index 562dca51..306f4fca 100644 --- a/scripts/packager-macos/dxc_packager/cli.py +++ b/scripts/packager-macos/dxc_packager/cli.py @@ -2,21 +2,13 @@ Every bundle is signed ad hoc, which needs no Apple developer account: Apple silicon runs it, Gatekeeper asks the person once before the first launch of a downloaded copy (the -disk image carries the notes that say how), and Sparkle verifies updates by their EdDSA -signature. - -A bundle that updates itself: - package-macos keygen --out keys/sparkle.key # once, ever; keep the key - package-macos app --updater sparkle --manifest-dir . --executable target/release/app \\ - --feed-url https://example.com/appcast.xml --ed-key-file keys/sparkle.key \\ - --sparkle-framework Sparkle.framework --out dist - package-macos dmg --app dist/App.app --out dist/App-1.2.0.dmg # first install - package-macos archive --app dist/App.app --out site/App-1.2.0.zip # what Sparkle fetches - package-macos appcast --appcast site/appcast.xml --app dist/App.app \\ - --archive site/App-1.2.0.zip --url https://example.com/App-1.2.0.zip \\ - --ed-key-file keys/sparkle.key --sign-update Sparkle/bin/sign_update - -A plain application (no updates): --updater none, and no Sparkle options. +disk image carries the notes that say how). + + package-macos app --manifest-dir . --executable target/release/app --out dist + package-macos dmg --app dist/App.app --out dist/App-1.2.0.dmg + +The bundle carries no update settings: direct distribution is packaged and updated with +Velopack. """ import argparse @@ -24,7 +16,7 @@ import sys from pathlib import Path -from . import appcast, bundle, metadata, plist, signing +from . import bundle, metadata, plist from .metadata import PackagingError @@ -44,8 +36,6 @@ def _add_app_arguments(parser): parser.add_argument("--renderer-dir", type=Path, help="the renderer's lib directory, when the executable names it " "through @rpath rather than by its path") - parser.add_argument("--updater", required=True, choices=plist.UPDATERS, - help="sparkle: the app updates itself from an appcast; none: it does not") parser.add_argument("--out", required=True, type=Path, help="directory to put Name.app in") parser.add_argument("--version", help="CFBundleShortVersionString; default from Cargo.toml") parser.add_argument("--build", help="CFBundleVersion; default the version") @@ -56,18 +46,6 @@ def _add_app_arguments(parser): parser.add_argument("--plist-file", type=Path, help="a plist whose entries are added to Info.plist") - sparkle = parser.add_argument_group("--updater sparkle") - sparkle.add_argument("--feed-url") - key = sparkle.add_mutually_exclusive_group() - key.add_argument("--public-ed-key", help="SUPublicEDKey, base64") - key.add_argument("--ed-key-file", type=Path, - help="the private key file; SUPublicEDKey is derived from it") - sparkle.add_argument("--sparkle-framework", type=Path) - sparkle.add_argument("--check-interval", type=int, default=plist.DEFAULT_CHECK_INTERVAL) - sparkle.add_argument("--no-automatic-checks", action="store_true") - sparkle.add_argument("--automatically-update", action="store_true", - help="download and install without asking (SUAutomaticallyUpdate)") - def command_app(args): meta = metadata.load(args.manifest_dir, version=args.version, build=args.build) @@ -78,55 +56,27 @@ def command_app(args): if args.plist_file: extra.update(plistlib.loads(args.plist_file.read_bytes())) - sparkle = None - if args.updater == plist.SPARKLE: - public_key = args.public_ed_key - if args.ed_key_file: - public_key = signing.public_key_of(args.ed_key_file) - if not args.feed_url or not public_key: - raise PackagingError( - "--updater sparkle needs --feed-url and a key (--ed-key-file or " - "--public-ed-key): without them the application has nowhere to look for " - "updates and nothing to check them against" - ) - sparkle = plist.SparkleSettings( - feed_url=args.feed_url, - public_ed_key=public_key, - automatic_checks=not args.no_automatic_checks, - check_interval=args.check_interval, - automatically_update=args.automatically_update, - ) - elif args.feed_url or args.public_ed_key or args.ed_key_file or args.sparkle_framework: - raise PackagingError("Sparkle options were given with --updater none") - args.out.mkdir(parents=True, exist_ok=True) app = bundle.assemble( meta, args.executable, args.out, - sparkle=sparkle, - sparkle_framework=args.sparkle_framework, extra_plist=extra, renderer_dir=args.renderer_dir, ) bundle.sign(app) - problems = _verify(app, args.updater) + problems = _verify(app) if problems: raise PackagingError("the bundle is not shippable:\n " + "\n ".join(problems)) print(app) -def _verify(app, updater): - problems = bundle.unsigned_code(app) + bundle.absolute_references(app) - if updater == plist.SPARKLE and not bundle.sparkle_in(app): - problems.append("the bundle updates itself but has no Sparkle.framework") - if updater == plist.NONE and bundle.sparkle_in(app): - problems.append("the bundle does not update itself but carries Sparkle.framework") - return problems +def _verify(app): + return bundle.unsigned_code(app) + bundle.absolute_references(app) def command_verify(args): - problems = _verify(args.app, args.updater) + problems = _verify(args.app) for problem in problems: print(f"error: {problem}", file=sys.stderr) if problems: @@ -135,55 +85,10 @@ def command_verify(args): return 0 -def command_archive(args): - print(bundle.zip_for_sparkle(args.app, args.out)) - - def command_dmg(args): print(bundle.dmg(args.app, args.out, args.volume_name or args.app.stem)) -def command_keygen(args): - public = signing.keygen(args.out) - print(f"private key written to {args.out}; keep it out of the repository") - print(f"SUPublicEDKey: {public}") - - -def command_public_key(args): - print(signing.public_key_of(args.key)) - - -def command_appcast(args): - info = plistlib.loads((args.app / "Contents" / "Info.plist").read_bytes()) - public_key = info.get("SUPublicEDKey") - if not public_key: - raise PackagingError(f"{args.app} has no SUPublicEDKey; it is not a sparkle build") - signature = signing.sign_archive( - args.archive, args.ed_key_file, sign_update=args.sign_update, public_key=public_key - ) - item = appcast.Item( - version=info["CFBundleVersion"], - short_version=info["CFBundleShortVersionString"], - url=args.url, - length=args.archive.stat().st_size, - ed_signature=signature, - minimum_system_version=info.get("LSMinimumSystemVersion", "13.0"), - pub_date=appcast.now(), - title=args.title, - release_notes_url=args.release_notes_url, - critical=args.critical, - hardware_requirements=bundle.hardware_requirements(args.app), - ) - if args.appcast.exists(): - feed = appcast.parse(args.appcast.read_text()) - else: - feed = appcast.new_feed(args.feed_title or info.get("CFBundleName", "Updates")) - appcast.add(feed, item) - args.appcast.parent.mkdir(parents=True, exist_ok=True) - args.appcast.write_text(appcast.dumps(feed)) - print(f"{args.appcast}: {', '.join(appcast.versions(feed))}") - - def parser(): root = argparse.ArgumentParser( prog="package-macos", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter @@ -196,41 +101,13 @@ def parser(): verify = commands.add_parser("verify", help="check a bundle is signed and self-contained") verify.add_argument("--app", required=True, type=Path) - verify.add_argument("--updater", required=True, choices=plist.UPDATERS) verify.set_defaults(run=command_verify) - archive = commands.add_parser("archive", help="the zip Sparkle downloads") - archive.add_argument("--app", required=True, type=Path) - archive.add_argument("--out", required=True, type=Path) - archive.set_defaults(run=command_archive) - dmg = commands.add_parser("dmg", help="a disk image for the first install, with install notes") dmg.add_argument("--app", required=True, type=Path) dmg.add_argument("--out", required=True, type=Path) dmg.add_argument("--volume-name") dmg.set_defaults(run=command_dmg) - - keygen = commands.add_parser("keygen", help="a new EdDSA key pair for Sparkle") - keygen.add_argument("--out", required=True, type=Path) - keygen.set_defaults(run=command_keygen) - - public_key = commands.add_parser("public-key", help="SUPublicEDKey for a private key file") - public_key.add_argument("--key", required=True, type=Path) - public_key.set_defaults(run=command_public_key) - - feed = commands.add_parser("appcast", help="add a signed version to an appcast") - feed.add_argument("--appcast", required=True, type=Path) - feed.add_argument("--app", required=True, type=Path, - help="the bundle inside the archive; versions and key are read from it") - feed.add_argument("--archive", required=True, type=Path) - feed.add_argument("--url", required=True, help="where the archive will be downloaded from") - feed.add_argument("--ed-key-file", required=True, type=Path) - feed.add_argument("--sign-update", type=Path, help="Sparkle's sign_update, preferred when given") - feed.add_argument("--title") - feed.add_argument("--feed-title") - feed.add_argument("--release-notes-url") - feed.add_argument("--critical", action="store_true") - feed.set_defaults(run=command_appcast) return root diff --git a/scripts/packager-macos/dxc_packager/ed25519.py b/scripts/packager-macos/dxc_packager/ed25519.py deleted file mode 100644 index 56ecee3a..00000000 --- a/scripts/packager-macos/dxc_packager/ed25519.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Ed25519 as RFC 8032 writes it, for the keys Sparkle uses to sign updates. - -Sparkle keeps its private key as the base64 of a 32 byte seed, and puts the base64 of the -32 byte public key in the application's Info.plist as SUPublicEDKey. Those two numbers are -the whole of the trust an installed application places in an update, so the packager has -to be able to answer three questions without a keychain: which public key belongs to this -private key, does this signature verify under the key the application carries, and, on a -machine without Sparkle's tools (Linux CI), what is the signature. - -This is the reference algorithm and not a hardened one: its arithmetic takes time that -depends on the secret. Signing on a shared machine should go through Sparkle's -sign_update, which the packager prefers whenever it is given one; this module then only -checks that what sign_update produced verifies under the application's public key. -""" - -import base64 -import hashlib - -_P = 2**255 - 19 -_L = 2**252 + 27742317777372353535851937790883648493 -_D = -121665 * pow(121666, _P - 2, _P) % _P -_SQRT_M1 = pow(2, (_P - 1) // 4, _P) - - -def _sha512(data): - return hashlib.sha512(data).digest() - - -def _sha512_mod_l(data): - return int.from_bytes(_sha512(data), "little") % _L - - -def _add(a, b): - x1, y1, z1, t1 = a - x2, y2, z2, t2 = b - pa = (y1 - x1) * (y2 - x2) % _P - pb = (y1 + x1) * (y2 + x2) % _P - pc = 2 * t1 * t2 * _D % _P - pd = 2 * z1 * z2 % _P - e, f, g, h = pb - pa, pd - pc, pd + pc, pb + pa - return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) - - -def _mul(scalar, point): - result = (0, 1, 1, 0) - while scalar > 0: - if scalar & 1: - result = _add(result, point) - point = _add(point, point) - scalar >>= 1 - return result - - -def _equal(a, b): - return (a[0] * b[2] - b[0] * a[2]) % _P == 0 and (a[1] * b[2] - b[1] * a[2]) % _P == 0 - - -def _recover_x(y, sign): - if y >= _P: - return None - x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) - if x2 == 0: - return None if sign else 0 - x = pow(x2, (_P + 3) // 8, _P) - if (x * x - x2) % _P != 0: - x = x * _SQRT_M1 % _P - if (x * x - x2) % _P != 0: - return None - if (x & 1) != sign: - x = _P - x - return x - - -_GY = 4 * pow(5, _P - 2, _P) % _P -_GX = _recover_x(_GY, 0) -_G = (_GX, _GY, 1, _GX * _GY % _P) - - -def _compress(point): - zinv = pow(point[2], _P - 2, _P) - x = point[0] * zinv % _P - y = point[1] * zinv % _P - return int.to_bytes(y | ((x & 1) << 255), 32, "little") - - -def _decompress(data): - if len(data) != 32: - return None - y = int.from_bytes(data, "little") - sign = y >> 255 - y &= (1 << 255) - 1 - x = _recover_x(y, sign) - if x is None: - return None - return (x, y, 1, x * y % _P) - - -def _expand_seed(seed): - digest = _sha512(seed) - return _clamp(digest[:32]), digest[32:] - - -def _clamp(scalar_bytes): - a = int.from_bytes(scalar_bytes, "little") - a &= (1 << 254) - 8 - a |= 1 << 254 - return a - - -class InvalidKey(ValueError): - """A private key that is not one Sparkle could have written.""" - - -def _expanded_from_private(private): - """The secret scalar and nonce prefix for a Sparkle 2 private key. - - Sparkle 2's generate_keys exports a 32 byte seed, and that is the form sign_update was - checked against. Keys from Sparkle 1 are longer and carry the public key beside the - secret; generate_keys can import one (`-f`) and export it again as a seed (`-x`). - """ - if len(private) == 32: - return _expand_seed(private) - raise InvalidKey( - f"a Sparkle 2 private key is a 32 byte seed, and this one is {len(private)} bytes. " - f"A key from Sparkle 1 can be converted by importing it with `generate_keys -f` " - f"and exporting it with `generate_keys -x`." - ) - - -def public_key(private): - scalar, _ = _expanded_from_private(private) - return _compress(_mul(scalar, _G)) - - -def sign(private, message): - scalar, prefix = _expanded_from_private(private) - public = _compress(_mul(scalar, _G)) - r = _sha512_mod_l(prefix + message) - encoded_r = _compress(_mul(r, _G)) - h = _sha512_mod_l(encoded_r + public + message) - s = (r + h * scalar) % _L - return encoded_r + int.to_bytes(s, 32, "little") - - -def verify(public, message, signature): - if len(public) != 32 or len(signature) != 64: - return False - point_a = _decompress(public) - if point_a is None: - return False - encoded_r = signature[:32] - point_r = _decompress(encoded_r) - if point_r is None: - return False - s = int.from_bytes(signature[32:], "little") - if s >= _L: - return False - h = _sha512_mod_l(encoded_r + public + message) - return _equal(_mul(s, _G), _add(point_r, _mul(h, point_a))) - - -def read_private_key_file(path): - """The key bytes from a file holding the base64 Sparkle's tools write.""" - with open(path, "rb") as handle: - text = handle.read().strip() - try: - private = base64.b64decode(text, validate=True) - except ValueError as error: - raise InvalidKey(f"{path} is not base64: {error}") from error - _expanded_from_private(private) - return private - - -def decode_public_key(text): - """The 32 bytes behind an SUPublicEDKey value, or a ValueError saying what is wrong.""" - try: - raw = base64.b64decode(text, validate=True) - except ValueError as error: - raise ValueError(f"SUPublicEDKey {text!r} is not base64: {error}") from error - if len(raw) != 32: - raise ValueError( - f"SUPublicEDKey decodes to {len(raw)} bytes; an Ed25519 public key is 32. " - f"It is the line generate_keys prints, or `package-macos keygen` writes." - ) - return raw diff --git a/scripts/packager-macos/dxc_packager/install-notes.txt b/scripts/packager-macos/dxc_packager/install-notes.txt index fea17acd..d74000d9 100644 --- a/scripts/packager-macos/dxc_packager/install-notes.txt +++ b/scripts/packager-macos/dxc_packager/install-notes.txt @@ -3,11 +3,10 @@ If macOS will not open the app This app is signed, but not by a developer Apple has verified, so the first time you open a copy you downloaded, macOS asks you to confirm. You do this once. After that the app -opens normally, and the updates it installs itself open without asking again. +opens normally. First drag the app into Applications. Opening it straight from the disk image or the -Downloads folder works too, but macOS then runs it from a hidden temporary copy, where it -cannot update itself. +Downloads folder works too, but macOS then runs it from a hidden temporary copy. macOS 15 (Sequoia) and newer ---------------------------- diff --git a/scripts/packager-macos/dxc_packager/metadata.py b/scripts/packager-macos/dxc_packager/metadata.py index cf374610..a359f52f 100644 --- a/scripts/packager-macos/dxc_packager/metadata.py +++ b/scripts/packager-macos/dxc_packager/metadata.py @@ -54,7 +54,7 @@ class PackagingError(Exception): "Weather": "public.app-category.weather", } -# The App Store and Sparkle both compare CFBundleVersion as dot separated integers, and App +# The App Store compares CFBundleVersion as dot separated integers, and App # Store Connect refuses anything else, so a version is checked here rather than at upload. _BUNDLE_VERSION = re.compile(r"^\d+(\.\d+){0,2}$") _IDENTIFIER = re.compile(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$") @@ -141,7 +141,7 @@ def load(manifest_dir, *, executable=None, version=None, build=None): if not identifier: raise PackagingError( f"{manifest_dir}/Dioxus.toml has no [bundle] identifier. The bundle identifier " - f"is what macOS, Sparkle and App Store Connect know the application by, so it " + f"is what macOS and App Store Connect know the application by, so it " f"has to be chosen rather than made up here." ) if not _IDENTIFIER.match(identifier): diff --git a/scripts/packager-macos/dxc_packager/plist.py b/scripts/packager-macos/dxc_packager/plist.py index 19c6d0b8..897d6289 100644 --- a/scripts/packager-macos/dxc_packager/plist.py +++ b/scripts/packager-macos/dxc_packager/plist.py @@ -1,8 +1,7 @@ """Info.plist for a compose-rust application bundle. -A bundle either updates itself with Sparkle or does not update at all (the plain .app a -sample release hands out). Everything that differs between the two is decided here, from -whether Sparkle settings were given, so a bundle cannot carry half of Sparkle. +The bundle carries no update settings: direct distribution is packaged and updated with +Velopack, which needs nothing in the Info.plist. Nothing here asks for an entitlement. The bundle is signed ad hoc without the hardened runtime and without a sandbox, which is what runs on a Mac without an Apple developer @@ -11,53 +10,12 @@ """ import plistlib -from dataclasses import dataclass -from urllib.parse import urlparse -from . import ed25519 from .metadata import PackagingError -SPARKLE = "sparkle" -NONE = "none" -UPDATERS = (SPARKLE, NONE) -# Sparkle checks once a day unless told otherwise, and will not check more often than -# once an hour however it is told. -DEFAULT_CHECK_INTERVAL = 86400 -MINIMUM_CHECK_INTERVAL = 3600 - -_LOOPBACK = {"localhost", "127.0.0.1", "::1"} - - -@dataclass(frozen=True) -class SparkleSettings: - feed_url: str - public_ed_key: str - automatic_checks: bool = True - check_interval: int = DEFAULT_CHECK_INTERVAL - automatically_update: bool = False - - -def check_feed_url(url): - parsed = urlparse(url) - if parsed.scheme == "https" and parsed.netloc: - return url - if parsed.scheme == "http" and parsed.hostname in _LOOPBACK: - # Loopback is how an update is rehearsed on one machine. Nothing else gets http: - # the EdDSA signature protects the archive, but not the appcast that says which - # archive is newest or what its release notes say. - return url - if parsed.scheme == "file" and parsed.path: - # A feed on this disk: the same rehearsal with no server at all. - return url - raise PackagingError( - f"SUFeedURL {url!r} must be https. Plain http to localhost and file:// are " - f"accepted for rehearsing an update on one machine." - ) - - -def info_plist(metadata, *, sparkle=None, extra=None): - """The Info.plist dictionary for `metadata`, updating itself when `sparkle` is given.""" +def info_plist(metadata, *, extra=None): + """The Info.plist dictionary for `metadata`.""" plist = { "CFBundleDevelopmentRegion": "en", "CFBundleDisplayName": metadata.display_name, @@ -84,34 +42,12 @@ def info_plist(metadata, *, sparkle=None, extra=None): if metadata.category: plist["LSApplicationCategoryType"] = metadata.category - if sparkle is not None: - ed25519.decode_public_key(sparkle.public_ed_key) - if sparkle.check_interval < MINIMUM_CHECK_INTERVAL: - raise PackagingError( - f"Sparkle does not check more often than every {MINIMUM_CHECK_INTERVAL} " - f"seconds; {sparkle.check_interval} was asked for" - ) - plist.update( - { - "SUFeedURL": check_feed_url(sparkle.feed_url), - "SUPublicEDKey": sparkle.public_ed_key, - "SUEnableAutomaticChecks": sparkle.automatic_checks, - "SUScheduledCheckInterval": sparkle.check_interval, - "SUAutomaticallyUpdate": sparkle.automatically_update, - } - ) - for key, value in (extra or {}).items(): if key in plist and plist[key] != value: raise PackagingError( f"the extra Info.plist entries set {key}, which the packager already " f"derives ({plist[key]!r}); change it where it comes from instead" ) - if sparkle is None and key.startswith("SU"): - raise PackagingError( - f"{key} is a Sparkle setting, and this bundle has no Sparkle; package it " - f"with --updater sparkle" - ) plist[key] = value return plist diff --git a/scripts/packager-macos/dxc_packager/signing.py b/scripts/packager-macos/dxc_packager/signing.py deleted file mode 100644 index f24ecc2f..00000000 --- a/scripts/packager-macos/dxc_packager/signing.py +++ /dev/null @@ -1,78 +0,0 @@ -"""EdDSA signatures over update archives, and the key pair they are made with. - -The private key is a file holding the base64 of a 32 byte seed, which is the form Sparkle's -own `generate_keys -x` exports and `sign_update --ed-key-file` reads. It is kept out of -any keychain on purpose: a CI job gets it from a secret, writes it to a file only it can -read, and nothing about the key outlives the job. -""" - -import base64 -import os -import subprocess -from pathlib import Path - -from . import ed25519 -from .metadata import PackagingError - - -def keygen(private_key_path): - """Write a new private key to `private_key_path` and return the public key (base64).""" - path = Path(private_key_path) - if path.exists(): - raise PackagingError( - f"{path} already exists. A new key would orphan every installed copy that " - f"trusts the old one, so this never overwrites a key." - ) - seed = os.urandom(32) - path.parent.mkdir(parents=True, exist_ok=True) - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, "w") as handle: - handle.write(base64.b64encode(seed).decode() + "\n") - return base64.b64encode(ed25519.public_key(seed)).decode() - - -def public_key_of(private_key_path): - private = ed25519.read_private_key_file(private_key_path) - return base64.b64encode(ed25519.public_key(private)).decode() - - -def _sign_with_sparkle(archive, private_key_path, sign_update): - try: - result = subprocess.run( - [str(sign_update), "--ed-key-file", str(private_key_path), "-p", str(archive)], - check=True, - text=True, - capture_output=True, - ) - except subprocess.CalledProcessError as error: - raise PackagingError(f"sign_update failed: {error.stdout}{error.stderr}") from error - return result.stdout.strip() - - -def sign_archive(archive, private_key_path, *, sign_update=None, public_key=None): - """The base64 EdDSA signature of `archive`, checked against `public_key` if given. - - With `sign_update` (the path of Sparkle's tool) Sparkle signs, and this module only - checks the result. Without it the built-in implementation signs, for machines that - have no Sparkle, such as a Linux runner publishing the feed. - - Checking against the public key the application carries is the point of the - argument: a feed signed with a different key from the one in the installed - application's Info.plist is a feed every installed copy refuses, and it is better - found here than by every user at once. - """ - data = Path(archive).read_bytes() - if sign_update is not None: - signature = _sign_with_sparkle(archive, private_key_path, sign_update) - else: - private = ed25519.read_private_key_file(private_key_path) - signature = base64.b64encode(ed25519.sign(private, data)).decode() - if public_key is not None: - raw_public = ed25519.decode_public_key(public_key) - if not ed25519.verify(raw_public, data, base64.b64decode(signature)): - raise PackagingError( - f"the signature of {archive} does not verify under SUPublicEDKey " - f"{public_key}. The private key is not the one the application was built " - f"to trust, and every installed copy would refuse this update." - ) - return signature diff --git a/scripts/packager-macos/tests/support.py b/scripts/packager-macos/tests/support.py index 18f5506b..a773f770 100644 --- a/scripts/packager-macos/tests/support.py +++ b/scripts/packager-macos/tests/support.py @@ -1,6 +1,5 @@ """Shared helpers for the packager tests.""" -import os import sys import tempfile from pathlib import Path @@ -9,10 +8,6 @@ REPOSITORY = PACKAGER.parents[1] sys.path.insert(0, str(PACKAGER)) -# Where Sparkle's release archive was unpacked, when the run has one. Tests that compare -# against Sparkle's own tools are skipped without it rather than downloading it. -SPARKLE_DIR = Path(os.environ["SPARKLE_DIR"]) if os.environ.get("SPARKLE_DIR") else None - def project(cargo, dioxus=None, files=()): """A throwaway project directory with the given Cargo.toml and Dioxus.toml text.""" diff --git a/scripts/packager-macos/tests/test_appcast.py b/scripts/packager-macos/tests/test_appcast.py deleted file mode 100644 index 67586569..00000000 --- a/scripts/packager-macos/tests/test_appcast.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Writing and extending the Sparkle appcast.""" - -import unittest -import xml.etree.ElementTree as ET -from datetime import datetime, timezone - -from support import SPARKLE_DIR # noqa: F401 (puts the packager on the path) - -from dxc_packager import appcast -from dxc_packager.metadata import PackagingError - -S = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" - - -def item(version, **changes): - fields = dict( - version=version, - short_version=f"{version}.0", - url=f"https://example.com/Demo-{version}.zip", - length=1234, - ed_signature="c2lnbmF0dXJl", - minimum_system_version="13.0", - pub_date=datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc), - ) - fields.update(changes) - return appcast.Item(**fields) - - -def feed_with(*items): - rss = appcast.new_feed("Demo") - for entry in items: - appcast.add(rss, entry) - return ET.fromstring(appcast.dumps(rss)) - - -class Appcast(unittest.TestCase): - def test_fr35_5_item_carries_what_sparkle_2_reads(self): - rss = feed_with(item("2", release_notes_url="https://example.com/2.html")) - element = rss.find("channel/item") - self.assertEqual(element.findtext(f"{S}version"), "2") - self.assertEqual(element.findtext(f"{S}shortVersionString"), "2.0") - self.assertEqual(element.findtext(f"{S}minimumSystemVersion"), "13.0") - self.assertEqual(element.findtext(f"{S}releaseNotesLink"), "https://example.com/2.html") - self.assertEqual(element.findtext("pubDate"), "Sat, 03 Oct 2026 12:00:00 +0000") - enclosure = element.find("enclosure") - self.assertEqual(enclosure.get("url"), "https://example.com/Demo-2.zip") - self.assertEqual(enclosure.get("length"), "1234") - self.assertEqual(enclosure.get(f"{S}edSignature"), "c2lnbmF0dXJl") - self.assertIsNone(element.find(f"{S}criticalUpdate")) - - def test_fr35_5_output_is_a_feed_declaring_the_sparkle_namespace(self): - text = appcast.dumps(appcast.add(appcast.new_feed("Demo"), item("1"))) - self.assertTrue(text.startswith('')) - self.assertIn('xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"', text) - self.assertEqual(ET.fromstring(text).get("version"), "2.0") - - def test_fr35_5_newest_first_comparing_numbers_not_text(self): - rss = feed_with(item("9"), item("10"), item("1.2")) - self.assertEqual(appcast.versions(rss), ["10", "9", "1.2"]) - - def test_fr35_5_publishing_a_version_again_replaces_it(self): - rss = feed_with(item("1"), item("2"), item("2", length=99)) - self.assertEqual(appcast.versions(rss), ["2", "1"]) - self.assertEqual(rss.find("channel/item/enclosure").get("length"), "99") - - def test_fr35_5_existing_items_and_their_unknown_elements_survive(self): - existing = """ - - - Demo - - Version 1.0 - 1 - beta - - - - Sparkle 1 style - - - -""" - rss = appcast.parse(existing) - appcast.add(rss, item("2")) - rss = ET.fromstring(appcast.dumps(rss)) - self.assertEqual(appcast.versions(rss), ["2", "1", "0.9"]) - old = rss.findall("channel/item")[1] - self.assertEqual(old.findtext(f"{S}channel"), "beta") - self.assertEqual(rss.findtext("channel/title"), "Demo") - - def test_fr35_5_an_apple_silicon_only_update_says_so(self): - rss = feed_with(item("4", hardware_requirements="arm64")) - self.assertEqual(rss.findtext(f"channel/item/{S}hardwareRequirements"), "arm64") - self.assertIsNone(feed_with(item("4")).find(f"channel/item/{S}hardwareRequirements")) - - def test_fr35_5_critical_updates_are_marked(self): - rss = feed_with(item("3", critical=True)) - self.assertIsNotNone(rss.find(f"channel/item/{S}criticalUpdate")) - - def test_fr35_5_something_that_is_not_a_feed_is_refused(self): - with self.assertRaisesRegex(PackagingError, "not XML"): - appcast.parse("") - with self.assertRaisesRegex(PackagingError, "RSS"): - appcast.parse("") - with self.assertRaisesRegex(PackagingError, "sparkle:version"): - appcast.add(appcast.parse(""), item("1")) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/packager-macos/tests/test_bundle.py b/scripts/packager-macos/tests/test_bundle.py index 2fe6a9fb..5a92ee80 100644 --- a/scripts/packager-macos/tests/test_bundle.py +++ b/scripts/packager-macos/tests/test_bundle.py @@ -12,11 +12,10 @@ import unittest from pathlib import Path -from support import CARGO, DIOXUS, REPOSITORY, SPARKLE_DIR, project +from support import CARGO, DIOXUS, REPOSITORY, project -from dxc_packager import bundle, cli, metadata, plist +from dxc_packager import bundle, cli, metadata -PUBLIC_KEY = "2kja3FFzveTqSefH+8D0npKfvngmu2zAMqubhIgBuh8=" ICON = REPOSITORY / "samples" / "calculator" / "assets" / "icon.png" @@ -54,34 +53,27 @@ def renderer_and_executable(scratch, install_name=None): return renderer, executable -def settings(): - return plist.SparkleSettings( - feed_url="https://example.com/appcast.xml", public_ed_key=PUBLIC_KEY - ) - - @unittest.skipUnless(sys.platform == "darwin", "codesign and hdiutil are macOS tools") -class PlainBundle(unittest.TestCase): +class Bundle(unittest.TestCase): def setUp(self): - self.scratch = Path(tempfile.mkdtemp(prefix="packager-plain-")) + self.scratch = Path(tempfile.mkdtemp(prefix="packager-bundle-")) self.renderer, self.executable = renderer_and_executable(self.scratch) meta = metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), icon=ICON) self.app = bundle.sign(bundle.assemble(meta, self.executable, self.scratch / "out")) - def test_fr35_1_layout_and_info_plist(self): + def test_fr22_6_3_layout_and_info_plist(self): contents = self.app / "Contents" self.assertEqual(self.app.name, "Demo App.app") self.assertTrue((contents / "MacOS" / "sample-demo").is_file()) self.assertTrue((contents / "Resources" / "AppIcon.icns").is_file()) self.assertEqual((contents / "PkgInfo").read_text(), "APPL????") - self.assertFalse((contents / "Frameworks" / "Sparkle.framework").exists()) info = plistlib.loads((contents / "Info.plist").read_bytes()) self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") self.assertEqual(info["CFBundleIconFile"], "AppIcon") self.assertIs(info["NSHighResolutionCapable"], True) self.assertFalse([key for key in info if key.startswith("SU")]) - def test_fr35_3_renderer_directory_travels_whole_and_is_loaded_relatively(self): + def test_fr22_6_2_renderer_directory_travels_whole_and_is_loaded_relatively(self): lib = self.app / "Contents" / "Frameworks" / "lib" self.assertTrue((lib / bundle.RENDERER_LIBRARY).is_file()) self.assertTrue((lib / "libskiko-stand-in.dylib").is_file()) @@ -91,7 +83,7 @@ def test_fr35_3_renderer_directory_travels_whole_and_is_loaded_relatively(self): ) self.assertEqual(bundle.absolute_references(self.app), []) - def test_fr35_3_every_piece_of_code_is_signed_ad_hoc(self): + def test_fr22_6_2_every_piece_of_code_is_signed_ad_hoc(self): subprocess.run(["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True) self.assertEqual(bundle.unsigned_code(self.app), []) for path in ( @@ -107,22 +99,22 @@ def test_fr35_3_every_piece_of_code_is_signed_ad_hoc(self): ["codesign", "-dv", str(self.app)], capture_output=True, text=True ).stderr self.assertIn("Identifier=dev.example.demo", details) - self.assertEqual(cli._verify(self.app, plist.NONE), []) + self.assertEqual(cli._verify(self.app), []) - def test_fr35_3_the_signed_application_runs(self): + def test_fr22_6_2_the_signed_application_runs(self): # The point of signing at all: Apple silicon kills unsigned arm64 code on sight, # and rewriting the load command left the linker's signature invalid. result = subprocess.run([str(self.app / "Contents" / "MacOS" / "sample-demo")]) self.assertEqual(result.returncode, 0) - def test_fr35_3_code_whose_signature_broke_is_reported(self): + def test_fr22_6_2_code_whose_signature_broke_is_reported(self): library = self.app / "Contents/Frameworks/lib/libskiko-stand-in.dylib" with open(library, "ab") as handle: handle.write(b"\0changed after signing") problems = bundle.unsigned_code(self.app) self.assertTrue(any("libskiko-stand-in.dylib" in problem for problem in problems)) - def test_fr35_3_a_renderer_named_through_rpath_needs_its_directory_given(self): + def test_fr22_6_2_a_renderer_named_through_rpath_needs_its_directory_given(self): scratch = self.scratch / "rpath" renderer, executable = renderer_and_executable( scratch, install_name="@rpath/" + bundle.RENDERER_LIBRARY @@ -136,13 +128,13 @@ def test_fr35_3_a_renderer_named_through_rpath_needs_its_directory_given(self): bundle.load_commands(app / "Contents/MacOS/sample-demo"), ) - def test_fr35_3_a_renderer_directory_holding_data_is_refused_by_name(self): + def test_fr22_6_2_a_renderer_directory_holding_data_is_refused_by_name(self): (self.renderer / "fonts.conf").write_text("data") meta = metadata.load(project(CARGO, DIOXUS)) with self.assertRaisesRegex(metadata.PackagingError, "fonts.conf"): bundle.assemble(meta, self.executable, self.scratch / "again") - def test_fr35_4_disk_image_carries_the_app_applications_and_install_notes(self): + def test_fr22_6_5_disk_image_carries_the_app_applications_and_install_notes(self): image = bundle.dmg(self.app, self.scratch / "Demo.dmg", "Demo App") mount = self.scratch / "mount" attached = subprocess.run( @@ -165,59 +157,6 @@ def test_fr35_4_disk_image_carries_the_app_applications_and_install_notes(self): finally: subprocess.run(["hdiutil", "detach", str(mount)], capture_output=True) - def test_fr35_1_sparkle_options_without_a_feed_are_refused(self): - meta = metadata.load(project(CARGO, DIOXUS)) - with self.assertRaisesRegex(metadata.PackagingError, "--updater sparkle"): - bundle.assemble( - meta, self.executable, self.scratch / "x", sparkle_framework=Path("Sparkle.framework") - ) - - -@unittest.skipUnless(sys.platform == "darwin", "codesign is a macOS tool") -@unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") -class SparkleBundle(unittest.TestCase): - def setUp(self): - self.scratch = Path(tempfile.mkdtemp(prefix="packager-sparkle-")) - _, self.executable = renderer_and_executable(self.scratch) - meta = metadata.load(project(CARGO, DIOXUS)) - self.app = bundle.sign( - bundle.assemble( - meta, self.executable, self.scratch / "out", sparkle=settings(), - sparkle_framework=SPARKLE_DIR / "Sparkle.framework", - ) - ) - - def test_fr35_3_framework_and_everything_in_it_is_signed_ad_hoc(self): - framework = self.app / "Contents" / "Frameworks" / "Sparkle.framework" - self.assertTrue((framework / "Versions" / "Current").is_symlink()) - for nested in ("Versions/B/Sparkle", "Versions/B/Autoupdate", "Versions/B/Updater.app", - "Versions/B/XPCServices/Downloader.xpc", - "Versions/B/XPCServices/Installer.xpc"): - details = subprocess.run( - ["codesign", "-dv", str(framework / nested)], capture_output=True, text=True - ).stderr - self.assertIn("Signature=adhoc", details, nested) - subprocess.run(["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True) - self.assertEqual(cli._verify(self.app, plist.SPARKLE), []) - - def test_fr35_3_a_sparkle_bundle_is_not_a_plain_one(self): - self.assertTrue(cli._verify(self.app, plist.NONE)) - - def test_fr35_5_hardware_requirement_follows_the_executable(self): - machine = subprocess.run(["uname", "-m"], capture_output=True, text=True).stdout.strip() - expected = "arm64" if machine == "arm64" else None - self.assertEqual(bundle.hardware_requirements(self.app), expected) - - def test_fr35_4_archive_round_trips_with_links_and_signatures_intact(self): - archive = bundle.zip_for_sparkle(self.app, self.scratch / "Demo-1.4.2.zip") - unpacked = self.scratch / "unpacked" - subprocess.run(["ditto", "-x", "-k", str(archive), str(unpacked)], check=True) - copy = unpacked / self.app.name - self.assertTrue( - (copy / "Contents/Frameworks/Sparkle.framework/Versions/Current").is_symlink() - ) - subprocess.run(["codesign", "--verify", "--strict", "--deep", str(copy)], check=True) - if __name__ == "__main__": unittest.main() diff --git a/scripts/packager-macos/tests/test_ed25519.py b/scripts/packager-macos/tests/test_ed25519.py deleted file mode 100644 index be82a25e..00000000 --- a/scripts/packager-macos/tests/test_ed25519.py +++ /dev/null @@ -1,86 +0,0 @@ -"""The signature arithmetic, against RFC 8032 and against Sparkle's own sign_update.""" - -import base64 -import subprocess -import tempfile -import unittest -from pathlib import Path - -from support import SPARKLE_DIR # noqa: F401 (puts the packager on the path) - -from dxc_packager import ed25519, signing - -# RFC 8032, section 7.1, tests 1 and 2. -VECTORS = [ - ( - "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", - "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", - "", - "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bac" - "c61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", - ), - ( - "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", - "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", - "72", - "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e" - "458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", - ), -] - - -class Rfc8032(unittest.TestCase): - def test_fr35_5_public_key_and_signature_match_the_rfc_vectors(self): - for secret, public, message, signature in VECTORS: - secret = bytes.fromhex(secret) - message = bytes.fromhex(message) - self.assertEqual(ed25519.public_key(secret).hex(), public) - self.assertEqual(ed25519.sign(secret, message).hex(), signature) - self.assertTrue( - ed25519.verify(bytes.fromhex(public), message, bytes.fromhex(signature)) - ) - - def test_fr35_5_a_changed_byte_does_not_verify(self): - secret, public, _, _ = VECTORS[1] - signature = ed25519.sign(bytes.fromhex(secret), b"archive") - self.assertFalse(ed25519.verify(bytes.fromhex(public), b"archivf", signature)) - tampered = bytes([signature[0] ^ 1]) + signature[1:] - self.assertFalse(ed25519.verify(bytes.fromhex(public), b"archive", tampered)) - - def test_fr35_5_a_key_that_is_not_a_seed_is_refused_with_the_way_out(self): - with tempfile.TemporaryDirectory() as scratch: - path = Path(scratch) / "key" - path.write_text(base64.b64encode(bytes(64)).decode()) - with self.assertRaisesRegex(ed25519.InvalidKey, "generate_keys -x"): - ed25519.read_private_key_file(path) - - def test_fr35_5_public_key_must_be_32_bytes(self): - with self.assertRaisesRegex(ValueError, "32"): - ed25519.decode_public_key(base64.b64encode(bytes(31)).decode()) - - -@unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") -class AgreesWithSparkle(unittest.TestCase): - def test_fr35_5_signature_is_the_one_sign_update_makes(self): - with tempfile.TemporaryDirectory() as scratch: - key = Path(scratch) / "sparkle.key" - signing.keygen(key) - archive = Path(scratch) / "update.zip" - archive.write_bytes(bytes(range(256)) * 1000) - ours = signing.sign_archive(archive, key) - theirs = subprocess.run( - [str(SPARKLE_DIR / "bin" / "sign_update"), "--ed-key-file", str(key), "-p", - str(archive)], - check=True, text=True, capture_output=True, - ).stdout.strip() - self.assertEqual(ours, theirs) - checked = subprocess.run( - [str(SPARKLE_DIR / "bin" / "sign_update"), "--ed-key-file", str(key), - "--verify", str(archive), ours], - capture_output=True, - ) - self.assertEqual(checked.returncode, 0, checked.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/packager-macos/tests/test_metadata.py b/scripts/packager-macos/tests/test_metadata.py index 895e7e4f..b5fddfb4 100644 --- a/scripts/packager-macos/tests/test_metadata.py +++ b/scripts/packager-macos/tests/test_metadata.py @@ -9,7 +9,7 @@ class Load(unittest.TestCase): - def test_fr35_1_metadata_comes_from_the_project_files(self): + def test_fr22_6_3_metadata_comes_from_the_project_files(self): meta = metadata.load(project(CARGO, DIOXUS)) self.assertEqual(meta.identifier, "dev.example.demo") self.assertEqual(meta.name, "Demo App") @@ -20,7 +20,7 @@ def test_fr35_1_metadata_comes_from_the_project_files(self): self.assertEqual(meta.copyright, "Copyright Example") self.assertEqual(meta.minimum_system_version, "13.0") - def test_fr35_1_every_sample_in_the_repository_loads(self): + def test_fr22_6_3_every_sample_in_the_repository_loads(self): samples = sorted(path for path in (REPOSITORY / "samples").iterdir() if (path / "Dioxus.toml").exists()) self.assertTrue(samples) @@ -29,7 +29,7 @@ def test_fr35_1_every_sample_in_the_repository_loads(self): self.assertTrue(meta.identifier.startswith("dev.darkpyonix."), sample) self.assertTrue(meta.executable.startswith("sample-"), sample) - def test_fr35_1_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(self): + def test_fr22_6_3_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(self): dioxus = DIOXUS.replace("[macos]\nbundle_name = \"Demo App\"", "") + ( "\n[bundle.macos]\nbundle_name = \"Other\"\nminimum_system_version = \"14.0\"\n" ) @@ -37,33 +37,33 @@ def test_fr35_1_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(se self.assertEqual(meta.name, "Other") self.assertEqual(meta.minimum_system_version, "14.0") - def test_fr35_1_overrides_win_and_build_follows_version(self): + def test_fr22_6_3_overrides_win_and_build_follows_version(self): meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0") self.assertEqual((meta.version, meta.build), ("2.0.0", "2.0.0")) meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0", build="57") self.assertEqual(meta.build, "57") - def test_fr35_1_icon_falls_back_to_the_asset_directory(self): + def test_fr22_6_3_icon_falls_back_to_the_asset_directory(self): directory = project(CARGO, DIOXUS, files=[("assets/icon.png", b"png")]) self.assertEqual(metadata.load(directory).icon, directory / "assets" / "icon.png") - def test_fr35_1_missing_identifier_is_an_error_not_a_guess(self): + def test_fr22_6_3_missing_identifier_is_an_error_not_a_guess(self): with self.assertRaisesRegex(PackagingError, "identifier"): metadata.load(project(CARGO, "[application]\nname = \"X\"\n")) - def test_fr35_1_prerelease_version_needs_explicit_numbers(self): + def test_fr22_6_3_prerelease_version_needs_explicit_numbers(self): cargo = CARGO.replace("1.4.2", "1.4.2-beta.1") with self.assertRaisesRegex(PackagingError, "one to three dot separated integers"): metadata.load(project(cargo, DIOXUS)) meta = metadata.load(project(cargo, DIOXUS), version="1.4.2", build="142") self.assertEqual(meta.version, "1.4.2") - def test_fr35_1_workspace_version_has_to_be_given(self): + def test_fr22_6_3_workspace_version_has_to_be_given(self): cargo = CARGO.replace('version = "1.4.2"', "version.workspace = true") with self.assertRaisesRegex(PackagingError, "--version"): metadata.load(project(cargo, DIOXUS)) - def test_fr35_1_categories_accept_both_spellings_and_refuse_others(self): + def test_fr22_6_3_categories_accept_both_spellings_and_refuse_others(self): self.assertEqual(metadata.category_type("Utility"), "public.app-category.utilities") self.assertEqual( metadata.category_type("public.app-category.music"), "public.app-category.music" diff --git a/scripts/packager-macos/tests/test_plist.py b/scripts/packager-macos/tests/test_plist.py index a2b4a047..2620d5d3 100644 --- a/scripts/packager-macos/tests/test_plist.py +++ b/scripts/packager-macos/tests/test_plist.py @@ -1,4 +1,4 @@ -"""Info.plist for bundles that update themselves with Sparkle and bundles that do not.""" +"""Info.plist for an application bundle.""" import unittest @@ -7,22 +7,14 @@ from dxc_packager import metadata, plist from dxc_packager.metadata import PackagingError -PUBLIC_KEY = "2kja3FFzveTqSefH+8D0npKfvngmu2zAMqubhIgBuh8=" - def meta(**changes): return metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), **changes) -def sparkle(**changes): - settings = {"feed_url": "https://example.com/appcast.xml", "public_ed_key": PUBLIC_KEY} - settings.update(changes) - return plist.SparkleSettings(**settings) - - class InfoPlist(unittest.TestCase): - def test_fr35_1_common_keys_come_from_the_metadata(self): - info = plist.info_plist(meta(), sparkle=sparkle()) + def test_fr22_6_3_common_keys_come_from_the_metadata(self): + info = plist.info_plist(meta()) self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") self.assertEqual(info["CFBundleExecutable"], "sample-demo") self.assertEqual(info["CFBundleName"], "Demo App") @@ -33,62 +25,30 @@ def test_fr35_1_common_keys_come_from_the_metadata(self): self.assertEqual(info["LSApplicationCategoryType"], "public.app-category.developer-tools") self.assertIs(info["NSHighResolutionCapable"], True) - def test_fr35_1_a_bundle_without_updates_has_no_sparkle_keys(self): + def test_fr22_6_3_a_bundle_has_no_update_settings(self): info = plist.info_plist(meta()) self.assertFalse([key for key in info if key.startswith("SU")]) - with self.assertRaisesRegex(PackagingError, "--updater sparkle"): - plist.info_plist(meta(), extra={"SUFeedURL": "https://example.com/appcast.xml"}) - - def test_fr35_2_sparkle_bundle_carries_feed_key_and_schedule(self): - info = plist.info_plist( - meta(), sparkle=sparkle(check_interval=7200, automatically_update=True) - ) - self.assertEqual(info["SUFeedURL"], "https://example.com/appcast.xml") - self.assertEqual(info["SUPublicEDKey"], PUBLIC_KEY) - self.assertIs(info["SUEnableAutomaticChecks"], True) - self.assertEqual(info["SUScheduledCheckInterval"], 7200) - self.assertIs(info["SUAutomaticallyUpdate"], True) - - def test_fr35_2_feed_must_be_https_except_on_this_machine(self): - for url in ("http://example.com/appcast.xml", "ftp://example.com/a", "appcast.xml"): - with self.assertRaisesRegex(PackagingError, "https"): - plist.info_plist(meta(), sparkle=sparkle(feed_url=url)) - for url in ( - "http://127.0.0.1:8000/appcast.xml", - "http://localhost/appcast.xml", - "file:///Users/someone/site/appcast.xml", - ): - info = plist.info_plist(meta(), sparkle=sparkle(feed_url=url)) - self.assertEqual(info["SUFeedURL"], url) - - def test_fr35_2_public_key_that_is_not_an_ed25519_key_is_refused(self): - with self.assertRaisesRegex(ValueError, "32"): - plist.info_plist(meta(), sparkle=sparkle(public_ed_key="AAAA")) - - def test_fr35_2_check_interval_below_sparkles_floor_is_refused(self): - with self.assertRaisesRegex(PackagingError, "3600"): - plist.info_plist(meta(), sparkle=sparkle(check_interval=60)) - def test_fr35_1_extra_entries_are_added_but_cannot_contradict_derived_ones(self): + def test_fr22_6_3_extra_entries_are_added_but_cannot_contradict_derived_ones(self): info = plist.info_plist( - meta(), sparkle=sparkle(), + meta(), extra={"NSAppTransportSecurity": {"NSAllowsLocalNetworking": True}}, ) self.assertEqual(info["NSAppTransportSecurity"], {"NSAllowsLocalNetworking": True}) with self.assertRaisesRegex(PackagingError, "CFBundleIdentifier"): - plist.info_plist(meta(), sparkle=sparkle(), extra={"CFBundleIdentifier": "other.id"}) + plist.info_plist(meta(), extra={"CFBundleIdentifier": "other.id"}) - def test_fr35_1_command_line_entries_are_typed(self): + def test_fr22_6_3_command_line_entries_are_typed(self): self.assertEqual( plist.parse_extra(["A=true", "B=12", "C=text"]), {"A": True, "B": 12, "C": "text"} ) with self.assertRaisesRegex(PackagingError, "KEY=VALUE"): plist.parse_extra(["nothing"]) - def test_fr35_1_plist_serialises_as_xml(self): - data = plist.dumps(plist.info_plist(meta(), sparkle=sparkle())) + def test_fr22_6_3_plist_serialises_as_xml(self): + data = plist.dumps(plist.info_plist(meta())) self.assertTrue(data.startswith(b"SUPublicEDKey", data) + self.assertIn(b"CFBundleIdentifier", data) if __name__ == "__main__": diff --git a/scripts/packager-macos/tests/test_signing.py b/scripts/packager-macos/tests/test_signing.py deleted file mode 100644 index 8be72d66..00000000 --- a/scripts/packager-macos/tests/test_signing.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Keys and archive signatures for the Sparkle channel.""" - -import base64 -import os -import stat -import tempfile -import unittest -from pathlib import Path - -from support import SPARKLE_DIR - -from dxc_packager import ed25519, signing -from dxc_packager.metadata import PackagingError - - -class Signing(unittest.TestCase): - def setUp(self): - self.scratch = Path(tempfile.mkdtemp(prefix="packager-signing-")) - self.key = self.scratch / "keys" / "sparkle.key" - self.public = signing.keygen(self.key) - self.archive = self.scratch / "Demo-2.zip" - self.archive.write_bytes(os.urandom(4096)) - - def test_fr35_5_keygen_writes_a_private_file_and_prints_the_matching_public_key(self): - self.assertEqual(stat.S_IMODE(self.key.stat().st_mode), 0o600) - self.assertEqual(len(base64.b64decode(self.key.read_text())), 32) - self.assertEqual(signing.public_key_of(self.key), self.public) - self.assertEqual(len(ed25519.decode_public_key(self.public)), 32) - - def test_fr35_5_keygen_never_overwrites_a_key(self): - before = self.key.read_bytes() - with self.assertRaisesRegex(PackagingError, "never overwrites"): - signing.keygen(self.key) - self.assertEqual(self.key.read_bytes(), before) - - def test_fr35_5_signature_verifies_under_the_public_key(self): - signature = signing.sign_archive(self.archive, self.key, public_key=self.public) - self.assertTrue( - ed25519.verify( - base64.b64decode(self.public), - self.archive.read_bytes(), - base64.b64decode(signature), - ) - ) - - def test_fr35_5_signing_with_a_key_the_app_does_not_trust_is_refused(self): - other = signing.keygen(self.scratch / "other.key") - with self.assertRaisesRegex(PackagingError, "every installed copy would refuse"): - signing.sign_archive(self.archive, self.key, public_key=other) - - @unittest.skipUnless(SPARKLE_DIR, "SPARKLE_DIR names no unpacked Sparkle release") - def test_fr35_5_sign_update_is_used_when_given_and_checked(self): - signature = signing.sign_archive( - self.archive, self.key, sign_update=SPARKLE_DIR / "bin" / "sign_update", - public_key=self.public, - ) - self.assertEqual(signature, signing.sign_archive(self.archive, self.key)) - - -if __name__ == "__main__": - unittest.main() From 737abf4ad5661abb5e50f09f26addbaa59488bb5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:55:11 +0900 Subject: [PATCH 061/224] Test: Rename fr35_* tests to fr22_6_* (FR-22.6) The Windows packager work belongs under FR-22.6, and FR-35 does not exist. Test names now point at the criterion they defend: 3 for metadata and icons, 4 for the MSIX manifest, version and staging. Tests that match no criterion are named fr22_6_ plus a description. --- compose-rust/tests/windows_manifest.rs | 6 +++--- scripts/packager-windows/src/appinstaller.rs | 6 +++--- scripts/packager-windows/src/assets.rs | 12 ++++++------ scripts/packager-windows/src/lib.rs | 4 ++-- scripts/packager-windows/src/manifest.rs | 14 +++++++------- scripts/packager-windows/src/metadata.rs | 16 ++++++++-------- scripts/packager-windows/src/sdk.rs | 2 +- scripts/packager-windows/src/version.rs | 18 +++++++++--------- scripts/packager-windows/tests/stage.rs | 12 ++++++------ 9 files changed, 45 insertions(+), 45 deletions(-) diff --git a/compose-rust/tests/windows_manifest.rs b/compose-rust/tests/windows_manifest.rs index f27530a1..d87b54bb 100644 --- a/compose-rust/tests/windows_manifest.rs +++ b/compose-rust/tests/windows_manifest.rs @@ -19,7 +19,7 @@ fn u32_at(bytes: &[u8], at: usize) -> u32 { } #[test] -fn fr35_the_manifest_declares_per_monitor_v2_awareness() { +fn fr22_6_the_manifest_declares_per_monitor_v2_awareness() { // Both elements: `dpiAwareness` is what Windows 10 1607 and later read, `dpiAware` // with `/pm` is the fallback for anything older, and the certification kit accepts // either. @@ -35,7 +35,7 @@ fn fr35_the_manifest_declares_per_monitor_v2_awareness() { } #[test] -fn fr35_the_resource_file_starts_with_the_empty_entry() { +fn fr22_6_the_resource_file_starts_with_the_empty_entry() { let res = manifest_resource(b""); // DataSize 0, HeaderSize 32, type ordinal 0, name ordinal 0, and zeros after. The // linker recognises a resource file by exactly these 32 bytes. @@ -47,7 +47,7 @@ fn fr35_the_resource_file_starts_with_the_empty_entry() { } #[test] -fn fr35_the_resource_file_holds_the_manifest_as_resource_one() { +fn fr22_6_the_resource_file_holds_the_manifest_as_resource_one() { let manifest = APPLICATION_MANIFEST.as_bytes(); let res = manifest_resource(manifest); let e = 32; diff --git a/scripts/packager-windows/src/appinstaller.rs b/scripts/packager-windows/src/appinstaller.rs index eb4ebbf7..9a29a8e7 100644 --- a/scripts/packager-windows/src/appinstaller.rs +++ b/scripts/packager-windows/src/appinstaller.rs @@ -93,7 +93,7 @@ mod tests { } #[test] - fn fr35_feed_points_at_the_bundle_and_at_itself() { + fn fr22_6_feed_points_at_the_bundle_and_at_itself() { let v = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(12)).unwrap(); let feed = Feed::beside( "https://example.com/ember/", @@ -114,14 +114,14 @@ mod tests { } #[test] - fn fr35_feed_on_a_file_share_uses_backslashes() { + fn fr22_6_feed_on_a_file_share_uses_backslashes() { let feed = Feed::beside("\\\\host\\share\\", "a.appinstaller", "b.msixbundle").unwrap(); assert_eq!(feed.feed_uri, "\\\\host\\share\\a.appinstaller"); assert_eq!(feed.bundle_uri, "\\\\host\\share\\b.msixbundle"); } #[test] - fn fr35_feed_refuses_a_relative_address() { + fn fr22_6_feed_refuses_a_relative_address() { assert!(Feed::beside("ember", "a.appinstaller", "b.msixbundle").is_err()); } } diff --git a/scripts/packager-windows/src/assets.rs b/scripts/packager-windows/src/assets.rs index 4efdd348..72849274 100644 --- a/scripts/packager-windows/src/assets.rs +++ b/scripts/packager-windows/src/assets.rs @@ -295,7 +295,7 @@ mod tests { } #[test] - fn fr35_every_asset_has_its_exact_size() { + fn fr22_6_3_every_asset_has_its_exact_size() { let icon = solid(64, 64, [200, 80, 40, 255]); for spec in ALL { let img = render(&icon, spec); @@ -311,7 +311,7 @@ mod tests { } #[test] - fn fr35_resizing_keeps_a_solid_colour_solid() { + fn fr22_6_3_resizing_keeps_a_solid_colour_solid() { let icon = solid(64, 64, [10, 120, 230, 255]); for (w, h) in [(44, 44), (150, 150), (50, 50), (3, 3), (256, 256)] { let r = icon.resize(w, h); @@ -324,7 +324,7 @@ mod tests { } #[test] - fn fr35_transparent_surround_does_not_darken_the_edge() { + fn fr22_6_3_transparent_surround_does_not_darken_the_edge() { // Left half opaque red, right half fully transparent black. let mut icon = solid(8, 8, [0, 0, 0, 0]); for y in 0..8 { @@ -340,7 +340,7 @@ mod tests { } #[test] - fn fr35_wide_tile_centres_the_icon() { + fn fr22_6_3_wide_tile_centres_the_icon() { let icon = solid(16, 16, [0, 255, 0, 255]); let wide = render(&icon, WIDE_310); assert_eq!(wide.pixel(0, 75)[3], 0); @@ -349,7 +349,7 @@ mod tests { } #[test] - fn fr35_small_icons_are_reported() { + fn fr22_6_3_small_icons_are_reported() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples/notepad/assets/icon.png"); let bytes = std::fs::read(dir).unwrap(); @@ -359,7 +359,7 @@ mod tests { } #[test] - fn fr35_gray_and_rgb_pngs_decode() { + fn fr22_6_3_gray_and_rgb_pngs_decode() { for (color, channels) in [ (png::ColorType::Rgb, 3usize), (png::ColorType::Grayscale, 1), diff --git a/scripts/packager-windows/src/lib.rs b/scripts/packager-windows/src/lib.rs index 3358e4ae..39d4339f 100644 --- a/scripts/packager-windows/src/lib.rs +++ b/scripts/packager-windows/src/lib.rs @@ -304,7 +304,7 @@ mod tests { use super::*; #[test] - fn fr35_executable_paths_use_backslashes() { + fn fr22_6_4_executable_paths_use_backslashes() { let p = Payload::Directory { root: "/x".into(), executable: Path::new("bin").join("app.exe"), @@ -315,7 +315,7 @@ mod tests { } #[test] - fn fr35_executable_must_stay_inside_the_payload() { + fn fr22_6_4_executable_must_stay_inside_the_payload() { let p = Payload::Directory { root: "/x".into(), executable: Path::new("..").join("app.exe"), diff --git a/scripts/packager-windows/src/manifest.rs b/scripts/packager-windows/src/manifest.rs index 927704b7..4b806f95 100644 --- a/scripts/packager-windows/src/manifest.rs +++ b/scripts/packager-windows/src/manifest.rs @@ -217,7 +217,7 @@ mod tests { } #[test] - fn fr35_manifest_carries_identity_and_version() { + fn fr22_6_4_manifest_carries_identity_and_version() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); assert!(xml.contains( "" @@ -227,7 +227,7 @@ mod tests { } #[test] - fn fr35_manifest_declares_a_full_trust_desktop_application() { + fn fr22_6_4_manifest_declares_a_full_trust_desktop_application() { let xml = render(&meta(), version(), Arch::Arm64, "bin\\ember.exe"); assert!(xml.contains("EntryPoint=\"Windows.FullTrustApplication\"")); assert!(xml.contains("Executable=\"bin\\ember.exe\"")); @@ -238,14 +238,14 @@ mod tests { } #[test] - fn fr35_manifest_escapes_text() { + fn fr22_6_4_manifest_escapes_text() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); assert!(xml.contains("Description=\"Notes & <things>\"")); assert!(!xml.contains("Notes & <")); } #[test] - fn fr35_manifest_names_every_asset_it_ships() { + fn fr22_6_4_manifest_names_every_asset_it_ships() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); for spec in assets::ALL { assert!( @@ -257,7 +257,7 @@ mod tests { } #[test] - fn fr35_capabilities_are_ordered_and_namespaced() { + fn fr22_6_4_capabilities_are_ordered_and_namespaced() { let xml = render(&meta(), version(), Arch::X64, "ember.exe"); let foundation = xml.find("")); assert!(xml.contains("")); } #[test] - fn fr35_application_id_is_alphanumeric() { + fn fr22_6_4_application_id_is_alphanumeric() { assert_eq!(application_id("Ember"), "Ember"); assert_eq!(application_id("My App 2"), "MyApp2"); assert_eq!(application_id("2048"), "App"); diff --git a/scripts/packager-windows/src/metadata.rs b/scripts/packager-windows/src/metadata.rs index 270d75ba..c845daaa 100644 --- a/scripts/packager-windows/src/metadata.rs +++ b/scripts/packager-windows/src/metadata.rs @@ -384,7 +384,7 @@ path = "src/main.rs" "#; #[test] - fn fr35_metadata_comes_from_dioxus_toml_and_cargo_toml() { + fn fr22_6_3_metadata_comes_from_dioxus_toml_and_cargo_toml() { let (d, c) = tables(NOTEPAD, CARGO); let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "0.1.0".into()).unwrap(); assert_eq!(m.display_name, "Notepad"); @@ -405,7 +405,7 @@ path = "src/main.rs" } #[test] - fn fr35_store_identity_overrides_the_derived_one() { + fn fr22_6_3_store_identity_overrides_the_derived_one() { let dioxus = format!( "{NOTEPAD}\n[windows.msix]\nidentity_name = \"DarkPyonix.Ember\"\npublisher = \"CN=1F2E3D4C-0000-1111-2222-333344445555\"\ndisplay_name = \"Ember\"\nlanguages = [\"en-us\", \"ko-kr\"]\n" ); @@ -419,7 +419,7 @@ path = "src/main.rs" } #[test] - fn fr35_windows_section_overrides_bundle() { + fn fr22_6_3_windows_section_overrides_bundle() { let dioxus = format!( "{NOTEPAD}\n[windows]\npublisher = \"Dark Pyonix, Inc.\"\nshort_description = \"Windows text\"\ncapabilities = [\"internetClient\"]\n" ); @@ -432,7 +432,7 @@ path = "src/main.rs" } #[test] - fn fr35_missing_publisher_is_an_error() { + fn fr22_6_3_missing_publisher_is_an_error() { let (d, c) = tables( "[application]\nname = \"X\"\n[bundle]\nidentifier = \"a.b.c\"\n", CARGO, @@ -443,7 +443,7 @@ path = "src/main.rs" } #[test] - fn fr35_identity_names_are_made_valid() { + fn fr22_6_3_identity_names_are_made_valid() { assert_eq!( identity_from_identifier("com.example.my_app").unwrap(), "com.example.my-app" @@ -460,14 +460,14 @@ path = "src/main.rs" } #[test] - fn fr35_publisher_must_be_a_distinguished_name() { + fn fr22_6_3_publisher_must_be_a_distinguished_name() { assert!(validate_publisher("CN=DarkPyonix").is_ok()); assert!(validate_publisher("O=Org, CN=Name").is_ok()); assert!(validate_publisher("DarkPyonix").is_err()); } #[test] - fn fr35_executable_follows_the_explicit_override() { + fn fr22_6_3_executable_follows_the_explicit_override() { let dioxus = format!("{NOTEPAD}\n[windows.msix]\nexecutable = \"ember\"\n"); let (d, c) = tables(&dioxus, CARGO); let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); @@ -475,7 +475,7 @@ path = "src/main.rs" } #[test] - fn fr35_the_real_samples_produce_valid_metadata() { + fn fr22_6_3_the_real_samples_produce_valid_metadata() { let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); let mut seen = 0; for entry in std::fs::read_dir(&samples).unwrap() { diff --git a/scripts/packager-windows/src/sdk.rs b/scripts/packager-windows/src/sdk.rs index 0b84aa7c..18fc2024 100644 --- a/scripts/packager-windows/src/sdk.rs +++ b/scripts/packager-windows/src/sdk.rs @@ -91,7 +91,7 @@ mod tests { use super::*; #[test] - fn fr35_sdk_versions_order_numerically() { + fn fr22_6_sdk_versions_order_numerically() { let mut v = [ sdk_version("10.0.9999.0").unwrap(), sdk_version("10.0.22621.0").unwrap(), diff --git a/scripts/packager-windows/src/version.rs b/scripts/packager-windows/src/version.rs index acac8356..4a361350 100644 --- a/scripts/packager-windows/src/version.rs +++ b/scripts/packager-windows/src/version.rs @@ -137,44 +137,44 @@ mod tests { use super::*; #[test] - fn fr35_store_version_is_the_crate_version_with_revision_zero() { + fn fr22_6_4_store_version_is_the_crate_version_with_revision_zero() { let v = PackageVersion::from_semver("1.4.2", Channel::Store, None).unwrap(); assert_eq!(v.to_string(), "1.4.2.0"); } #[test] - fn fr35_store_refuses_a_nonzero_revision() { + fn fr22_6_4_store_refuses_a_nonzero_revision() { assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(7)).is_err()); assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(0)).is_ok()); } #[test] - fn fr35_store_refuses_a_prerelease() { + fn fr22_6_4_store_refuses_a_prerelease() { let err = PackageVersion::from_semver("1.4.2-beta.1", Channel::Store, None).unwrap_err(); assert!(err.to_string().contains("pre-release"), "{err}"); } #[test] - fn fr35_sideload_revision_is_the_build_number() { + fn fr22_6_4_sideload_revision_is_the_build_number() { let v = PackageVersion::from_semver("1.4.2-beta.1+abc", Channel::Sideload, Some(31)).unwrap(); assert_eq!(v.to_string(), "1.4.2.31"); } #[test] - fn fr35_build_metadata_is_ignored() { + fn fr22_6_4_build_metadata_is_ignored() { let v = PackageVersion::from_semver("2.0.1+git.abc", Channel::Store, None).unwrap(); assert_eq!(v.to_string(), "2.0.1.0"); } #[test] - fn fr35_parts_above_sixteen_bits_are_refused() { + fn fr22_6_4_parts_above_sixteen_bits_are_refused() { assert!(PackageVersion::from_semver("1.70000.0", Channel::Store, None).is_err()); assert!(PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(70_000)).is_err()); } #[test] - fn fr35_malformed_versions_are_refused() { + fn fr22_6_4_malformed_versions_are_refused() { for bad in ["1.0", "1.0.0.0", "a.b.c", "", "1..0"] { assert!( PackageVersion::from_semver(bad, Channel::Store, None).is_err(), @@ -184,7 +184,7 @@ mod tests { } #[test] - fn fr35_later_releases_map_to_greater_versions() { + fn fr22_6_4_later_releases_map_to_greater_versions() { let order = ["0.9.9", "1.0.0", "1.0.1", "1.1.0", "2.0.0"]; let mapped: Vec<_> = order .iter() @@ -198,7 +198,7 @@ mod tests { } #[test] - fn fr35_channel_names_parse() { + fn fr22_6_4_channel_names_parse() { assert_eq!(Channel::parse("store").unwrap(), Channel::Store); assert_eq!(Channel::parse("sideload").unwrap(), Channel::Sideload); assert!(Channel::parse("beta").is_err()); diff --git a/scripts/packager-windows/tests/stage.rs b/scripts/packager-windows/tests/stage.rs index 888f7913..59f314ea 100644 --- a/scripts/packager-windows/tests/stage.rs +++ b/scripts/packager-windows/tests/stage.rs @@ -34,7 +34,7 @@ fn plan(meta: AppMetadata, payload: Payload) -> Plan { } #[test] -fn fr35_a_single_executable_is_staged_with_manifest_and_images() { +fn fr22_6_4_a_single_executable_is_staged_with_manifest_and_images() { let dir = scratch("single"); let exe = dir.join("sample-notepad.exe"); std::fs::write(&exe, b"MZ not really").unwrap(); @@ -75,7 +75,7 @@ fn fr35_a_single_executable_is_staged_with_manifest_and_images() { } #[test] -fn fr35_a_payload_directory_is_copied_whole() { +fn fr22_6_4_a_payload_directory_is_copied_whole() { let dir = scratch("directory"); let payload = dir.join("payload"); std::fs::create_dir_all(payload.join("bin")).unwrap(); @@ -117,7 +117,7 @@ fn fr35_a_payload_directory_is_copied_whole() { } #[test] -fn fr35_a_payload_that_already_has_a_manifest_is_refused() { +fn fr22_6_4_a_payload_that_already_has_a_manifest_is_refused() { let dir = scratch("reserved"); let payload = dir.join("payload"); std::fs::create_dir_all(&payload).unwrap(); @@ -136,7 +136,7 @@ fn fr35_a_payload_that_already_has_a_manifest_is_refused() { } #[test] -fn fr35_a_missing_executable_is_refused() { +fn fr22_6_4_a_missing_executable_is_refused() { let dir = scratch("missing"); let meta = AppMetadata::load(¬epad()).unwrap(); let p = plan(meta, Payload::Executable(dir.join("nothing.exe"))); @@ -144,7 +144,7 @@ fn fr35_a_missing_executable_is_refused() { } #[test] -fn fr35_workspace_version_is_followed() { +fn fr22_6_3_workspace_version_is_followed() { let root = scratch("workspace"); let project = root.join("app"); std::fs::create_dir_all(&project).unwrap(); @@ -170,7 +170,7 @@ fn fr35_workspace_version_is_followed() { } #[test] -fn fr35_every_sample_stages() { +fn fr22_6_3_every_sample_stages() { let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); let dir = scratch("every"); let mut staged = 0; From 60302ddbac0e556c577a7f6f85478af1e111ae75 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:00:39 +0900 Subject: [PATCH 062/224] Chore: Remove the MSIX App Installer update feed (FR-22.6) Updates on all three OSes come only from Velopack. The packager keeps MSIX packaging, the DPI manifest, staging and WACK. Drops appinstaller.rs, the --feed-base option, the update CI job, test-update.ps1, serve_feed.py and the three fr22_6_feed_* tests. --- .github/workflows/package-windows.yml | 63 +-------- scripts/packager-windows/Cargo.toml | 3 +- .../packager-windows/scripts/serve_feed.py | 85 ------------ .../packager-windows/scripts/test-update.ps1 | 122 ----------------- scripts/packager-windows/src/appinstaller.rs | 127 ------------------ scripts/packager-windows/src/lib.rs | 25 +--- scripts/packager-windows/src/main.rs | 13 +- scripts/packager-windows/src/version.rs | 4 +- 8 files changed, 8 insertions(+), 434 deletions(-) delete mode 100644 scripts/packager-windows/scripts/serve_feed.py delete mode 100644 scripts/packager-windows/scripts/test-update.ps1 delete mode 100644 scripts/packager-windows/src/appinstaller.rs diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index 776fdbb8..d086c88d 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -1,6 +1,5 @@ # Windows package -- builds an MSIX and an .msixbundle from one executable, signs them -# with a throwaway certificate, runs the Windows App Certification Kit against them, and -# proves that a newer bundle published to an App Installer feed replaces an installed one. +# with a throwaway certificate, and runs the Windows App Certification Kit against them. # # The kit is what Partner Center runs on a submission, so a package that passes here is # not refused there for the same reasons. The Store signs what it takes in itself; the @@ -324,63 +323,3 @@ jobs: name: wack-reports-host path: ${{ runner.temp }}/wack/ if-no-files-found: warn - - update: - name: App Installer update - needs: test - runs-on: windows-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - - name: Install the Rust toolchain - run: | - rustup toolchain install stable --profile minimal - rustup default stable - - - name: Build the packager and the stand-in program - run: cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix --example fixture-app - - - name: Package two builds of one release for the sideload channel - id: pack - run: | - $ErrorActionPreference = 'Stop' - $tool = 'target\release\compose-rust-msix.exe' - foreach ($revision in 1, 2) { - $out = Join-Path $env:RUNNER_TEMP "sideload-$revision" - $summary = Join-Path $env:RUNNER_TEMP "sideload-$revision.txt" - & $tool --project $env:PROJECT --out $out --summary $summary ` - --channel sideload --revision $revision ` - --feed-base 'http://localhost:8642' ` - --exe 'target\release\examples\fixture-app.exe' - if ($LASTEXITCODE -ne 0) { throw "packaging revision $revision failed" } - Get-Content (Join-Path $out '*.appinstaller') - } - Get-Content (Join-Path $env:RUNNER_TEMP 'sideload-1.txt') | - Where-Object { $_ -match '^(identity|publisher|bundle)=' } | - Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 - $bundle2 = (Get-Content (Join-Path $env:RUNNER_TEMP 'sideload-2.txt') | Where-Object { $_ -match '^bundle=' }) -replace '^bundle=', '' - Add-Content -Path $env:GITHUB_OUTPUT -Value "bundle2=$bundle2" -Encoding utf8 - - - name: Create and trust a test certificate - id: cert - run: > - ./scripts/packager-windows/scripts/new-test-certificate.ps1 - -Publisher '${{ steps.pack.outputs.publisher }}' - -OutDir "$env:RUNNER_TEMP\cert" -Trust - - - name: Sign both bundles - run: > - ./scripts/packager-windows/scripts/sign.ps1 - -Path '${{ steps.pack.outputs.bundle }}','${{ steps.pack.outputs.bundle2 }}' - -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' - - - name: Install from the feed, publish a newer build, update - shell: powershell - run: > - ./scripts/packager-windows/scripts/test-update.ps1 - -Older "$env:RUNNER_TEMP\sideload-1" - -Newer "$env:RUNNER_TEMP\sideload-2" - -Serve "$env:RUNNER_TEMP\feed" - -Port 8642 - -Name '${{ steps.pack.outputs.identity }}' diff --git a/scripts/packager-windows/Cargo.toml b/scripts/packager-windows/Cargo.toml index 4aecc1b1..ec0fbaeb 100644 --- a/scripts/packager-windows/Cargo.toml +++ b/scripts/packager-windows/Cargo.toml @@ -8,8 +8,7 @@ rust-version.workspace = true publish = false # The packager runs on the machine that ships the application, not inside it, so it is a -# plain command line tool. Everything it decides (identity, version, manifest, icons, -# update feed) is ordinary Rust and is tested on any host; only the last step, handing +# plain command line tool. Everything it decides (identity, version, manifest, icons) is ordinary Rust and is tested on any host; only the last step, handing # the staged directory to the Windows SDK's MakeAppx, needs Windows. [dependencies] png = "0.17" diff --git a/scripts/packager-windows/scripts/serve_feed.py b/scripts/packager-windows/scripts/serve_feed.py deleted file mode 100644 index 4508ccfe..00000000 --- a/scripts/packager-windows/scripts/serve_feed.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""Serves a directory over HTTP with byte ranges, for testing an App Installer feed. - -Windows downloads a bundle named by an App Installer feed in pieces and refuses a server -that cannot answer a Range request (0x80D05011), which Python's own http.server cannot. -This adds single-range support and the media types App Installer expects, and nothing -else. It is for a test on one machine, not for publishing. - -Usage: serve_feed.py -""" - -import http.server -import os -import re -import sys - - -class RangeHandler(http.server.SimpleHTTPRequestHandler): - protocol_version = "HTTP/1.1" - extensions_map = { - **http.server.SimpleHTTPRequestHandler.extensions_map, - ".appinstaller": "application/appinstaller", - ".msix": "application/msix", - ".msixbundle": "application/msixbundle", - } - - def send_head(self): - header = self.headers.get("Range") - path = self.translate_path(self.path) - if not header or not os.path.isfile(path): - return super().send_head() - match = re.fullmatch(r"bytes=(\d*)-(\d*)", header.strip()) - size = os.path.getsize(path) - if not match or (match.group(1) == "" and match.group(2) == ""): - self.send_error(416) - return None - if match.group(1) == "": - start = max(0, size - int(match.group(2))) - end = size - 1 - else: - start = int(match.group(1)) - end = int(match.group(2)) if match.group(2) else size - 1 - end = min(end, size - 1) - if start > end: - self.send_response(416) - self.send_header("Content-Range", f"bytes */{size}") - self.end_headers() - return None - f = open(path, "rb") - f.seek(start) - self._remaining = end - start + 1 - self.send_response(206) - self.send_header("Content-Type", self.guess_type(path)) - self.send_header("Content-Range", f"bytes {start}-{end}/{size}") - self.send_header("Content-Length", str(self._remaining)) - self.send_header("Accept-Ranges", "bytes") - self.end_headers() - return f - - def copyfile(self, source, outputfile): - remaining = getattr(self, "_remaining", None) - if remaining is None: - return super().copyfile(source, outputfile) - self._remaining = None - while remaining > 0: - chunk = source.read(min(65536, remaining)) - if not chunk: - break - outputfile.write(chunk) - remaining -= len(chunk) - - def end_headers(self): - if not self.headers.get("Range"): - self.send_header("Accept-Ranges", "bytes") - super().end_headers() - - -def main(): - directory, port = sys.argv[1], int(sys.argv[2]) - handler = lambda *a, **kw: RangeHandler(*a, directory=directory, **kw) # noqa: E731 - http.server.ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever() - - -if __name__ == "__main__": - main() diff --git a/scripts/packager-windows/scripts/test-update.ps1 b/scripts/packager-windows/scripts/test-update.ps1 deleted file mode 100644 index e7ee4bd4..00000000 --- a/scripts/packager-windows/scripts/test-update.ps1 +++ /dev/null @@ -1,122 +0,0 @@ -<# -.SYNOPSIS -Installs a package from an App Installer feed, publishes a newer one to the same feed, -and checks that Windows sees the update and replaces the installed package with it. - -.DESCRIPTION -This is the update path a downloaded application takes, exercised end to end on one -machine. The feed is served over HTTP (by serve_feed.py, because Windows fetches the -bundle in byte ranges) from a directory this script fills, standing in for the web -server a release would publish to: - - 1. the older bundle and its feed go on the share, and the feed is installed; - 2. the newer bundle goes on the share and its feed replaces the older one; - 3. the installed package is asked whether an update is available, the question - Windows asks on every launch, and has to answer yes; - 4. the feed is installed again, which is what Windows does with that answer, and the - installed package has to be the newer version, with the same identity, alone. - -The Store channel cannot be exercised here: only the Store delivers its updates. The -property it relies on is the one step 4 checks, that a package with the same name and -publisher and a greater version replaces the installed one. - -Must run in Windows PowerShell 5.1, which can call the Windows Runtime directly. - -.PARAMETER Older -Directory with the older .msixbundle and .appinstaller. - -.PARAMETER Newer -Directory with the newer .msixbundle and .appinstaller. - -.PARAMETER Serve -Directory the feed and bundles are served from. - -.PARAMETER Port -The local port the feeds were written for (http://localhost:). - -.PARAMETER Name -The package identity name. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)][string]$Older, - [Parameter(Mandatory)][string]$Newer, - [Parameter(Mandatory)][string]$Serve, - [Parameter(Mandatory)][int]$Port, - [Parameter(Mandatory)][string]$Name -) -$ErrorActionPreference = 'Stop' - -Add-Type -AssemblyName System.Runtime.WindowsRuntime -$null = [Windows.Management.Deployment.PackageManager, Windows.Management.Deployment, ContentType = WindowsRuntime] -$null = [Windows.ApplicationModel.Package, Windows.ApplicationModel, ContentType = WindowsRuntime] -$asTaskGeneric = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { - $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and - $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' -} | Select-Object -First 1 - -function Wait-WinRt($operation, [Type]$resultType) { - $task = $asTaskGeneric.MakeGenericMethod($resultType).Invoke($null, @($operation)) - $task.Wait() | Out-Null - $task.Result -} - -function Get-Installed { - @(Get-AppxPackage -Name $Name) -} - -function Publish-Feed([string]$from) { - Copy-Item -Path (Join-Path $from '*.msixbundle') -Destination $Serve -Force - Copy-Item -Path (Join-Path $from '*.appinstaller') -Destination $Serve -Force -} - -New-Item -ItemType Directory -Force -Path $Serve | Out-Null -$python = (Get-Command python -ErrorAction Stop).Source -$server = Start-Process -FilePath $python -PassThru -WindowStyle Hidden ` - -ArgumentList (Join-Path $PSScriptRoot 'serve_feed.py'), $Serve, "$Port" -try { - Start-Sleep -Seconds 2 - Get-Installed | Remove-AppxPackage - - Publish-Feed $Older - $feedFile = Get-ChildItem -Path $Serve -Filter '*.appinstaller' | Select-Object -First 1 - $feed = "http://localhost:$Port/$($feedFile.Name)" - Write-Host "installing from $feed" - Add-AppxPackage -AppInstallerFile $feed - $first = Get-Installed - if ($first.Count -ne 1) { throw "expected one installed $Name, found $($first.Count)" } - $firstVersion = [version]$first[0].Version - Write-Host "installed $Name $firstVersion" - - Publish-Feed $Newer - [xml]$newFeed = Get-Content -Raw -Path (Join-Path $Serve $feedFile.Name) - $expected = [version]$newFeed.AppInstaller.MainBundle.Version - if ($expected -le $firstVersion) { throw "the newer feed names $expected, not above $firstVersion" } - - $manager = New-Object Windows.Management.Deployment.PackageManager - $package = $manager.FindPackagesForUser('') | Where-Object { $_.Id.Name -eq $Name } | Select-Object -First 1 - if (-not $package) { throw "the Windows Runtime does not see $Name" } - $info = $package.GetAppInstallerInfo() - if (-not $info) { throw "$Name was installed without its feed, so Windows would never look for an update" } - Write-Host "the installed package watches $($info.Uri)" - $availability = Wait-WinRt ($package.CheckUpdateAvailabilityAsync()) ([Windows.ApplicationModel.PackageUpdateAvailabilityResult]) - Write-Host "update availability: $($availability.Availability)" - if ($availability.Availability -notin @('Available', 'Required')) { - throw "Windows reports $($availability.Availability) after a newer bundle was published" - } - - Add-AppxPackage -AppInstallerFile $feed - $after = Get-Installed - if ($after.Count -ne 1) { throw "expected the update to replace $Name, found $($after.Count) installed" } - $afterVersion = [version]$after[0].Version - if ($afterVersion -ne $expected) { throw "after the update $Name is $afterVersion, expected $expected" } - if ($after[0].Publisher -ne $first[0].Publisher) { throw "the update changed the publisher" } - Write-Host "updated $Name $firstVersion -> $afterVersion" - if ($env:GITHUB_STEP_SUMMARY) { - Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "App Installer update: $Name $firstVersion -> **$afterVersion**" -Encoding utf8 - } - - Get-Installed | Remove-AppxPackage -} finally { - Stop-Process -Id $server.Id -ErrorAction SilentlyContinue -} diff --git a/scripts/packager-windows/src/appinstaller.rs b/scripts/packager-windows/src/appinstaller.rs deleted file mode 100644 index 9a29a8e7..00000000 --- a/scripts/packager-windows/src/appinstaller.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! The App Installer update feed. -//! -//! A package installed from the Store is updated by the Store. A package installed from -//! a download has no Store behind it, so it carries the address of a small XML file -//! instead. Windows reads that file when the application starts and in the background, -//! and when it names a greater version, downloads and installs it over the running -//! copy. Publishing an update is replacing two files on a web server: the bundle and -//! this one. -//! -//! The feed is written next to the bundle and names its own address, which is how -//! Windows knows where to look the next time. - -use crate::Error; -use crate::manifest::xml_escape; -use crate::metadata::AppMetadata; -use crate::version::PackageVersion; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Feed { - /// Where the feed itself is served, ending in `.appinstaller`. - pub feed_uri: String, - /// Where the bundle it points at is served. - pub bundle_uri: String, - /// How often, in hours, Windows checks the feed when the application starts. Zero - /// means every start. - pub hours_between_checks: u32, -} - -fn check_uri(what: &str, uri: &str) -> Result<(), Error> { - let ok = uri.starts_with("https://") - || uri.starts_with("http://") - || uri.starts_with("\\\\") - || uri.starts_with("file://"); - if !ok { - return Err(Error::new(format!( - "{what} `{uri}` has to be an https:// address (or a file share for testing)" - ))); - } - Ok(()) -} - -impl Feed { - /// A feed served from `base`, a directory URL, with the bundle beside it. - pub fn beside(base: &str, feed_file: &str, bundle_file: &str) -> Result { - // A file share is joined the way Windows writes paths, everything else as a URL. - let sep = if base.starts_with("\\\\") { '\\' } else { '/' }; - let base = base.trim_end_matches(['/', '\\']); - let feed = Feed { - feed_uri: format!("{base}{sep}{feed_file}"), - bundle_uri: format!("{base}{sep}{bundle_file}"), - hours_between_checks: 0, - }; - check_uri("feed address", &feed.feed_uri)?; - Ok(feed) - } - - pub fn render(&self, meta: &AppMetadata, version: PackageVersion) -> Result { - check_uri("feed address", &self.feed_uri)?; - check_uri("bundle address", &self.bundle_uri)?; - let e = xml_escape; - Ok(format!( - "\n\ - \n \ - \n \ - \n \ - \n \ - \n \ - \n\ - \n", - feed = e(&self.feed_uri), - bundle = e(&self.bundle_uri), - name = e(&meta.identity_name), - publisher = e(&meta.publisher), - hours = self.hours_between_checks, - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::version::Channel; - - fn meta() -> AppMetadata { - let d: toml::Table = "[application]\nname = \"Ember\"\n[bundle]\nidentifier = \"dev.darkpyonix.ember\"\npublisher = \"DarkPyonix\"\n" - .parse() - .unwrap(); - let c: toml::Table = "[package]\nname = \"ember\"\nversion = \"1.0.0\"\n" - .parse() - .unwrap(); - AppMetadata::from_tables(std::path::Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap() - } - - #[test] - fn fr22_6_feed_points_at_the_bundle_and_at_itself() { - let v = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(12)).unwrap(); - let feed = Feed::beside( - "https://example.com/ember/", - "Ember.appinstaller", - "Ember_1.0.0.12.msixbundle", - ) - .unwrap(); - let xml = feed.render(&meta(), v).unwrap(); - assert!( - xml.contains("Uri=\"https://example.com/ember/Ember.appinstaller\""), - "{xml}" - ); - assert!(xml.contains( - "" - ), "{xml}"); - assert!(xml.contains("")); - assert!(xml.contains("")); - } - - #[test] - fn fr22_6_feed_on_a_file_share_uses_backslashes() { - let feed = Feed::beside("\\\\host\\share\\", "a.appinstaller", "b.msixbundle").unwrap(); - assert_eq!(feed.feed_uri, "\\\\host\\share\\a.appinstaller"); - assert_eq!(feed.bundle_uri, "\\\\host\\share\\b.msixbundle"); - } - - #[test] - fn fr22_6_feed_refuses_a_relative_address() { - assert!(Feed::beside("ember", "a.appinstaller", "b.msixbundle").is_err()); - } -} diff --git a/scripts/packager-windows/src/lib.rs b/scripts/packager-windows/src/lib.rs index 39d4339f..5c0362c6 100644 --- a/scripts/packager-windows/src/lib.rs +++ b/scripts/packager-windows/src/lib.rs @@ -2,15 +2,13 @@ //! //! The input is what a release build already produces: one executable, or a directory //! holding the executable with the renderer beside it. The output is a package the -//! Microsoft Store accepts for submission, a bundle wrapping it, and optionally an App -//! Installer feed that keeps a downloaded copy current. Name, publisher, description, +//! Microsoft Store accepts for submission and a bundle wrapping it. Name, publisher, description, //! icon and version come from the application's own `Dioxus.toml` and `Cargo.toml`. //! //! The steps are split so that every decision can be checked anywhere: [`stage`] builds //! the package directory (payload, manifest, images) on any host, and [`pack`] hands that //! directory to the Windows SDK's MakeAppx, which only exists on Windows. -pub mod appinstaller; pub mod assets; pub mod manifest; pub mod metadata; @@ -111,10 +109,6 @@ impl Plan { pub fn bundle_file_name(&self) -> String { format!("{}_{}.msixbundle", self.meta.identity_name, self.version) } - - pub fn feed_file_name(&self) -> String { - format!("{}.appinstaller", self.meta.identity_name) - } } /// Names a package directory must keep for itself. @@ -226,16 +220,13 @@ pub fn stage(plan: &Plan, layout: &Path) -> Result, Error> { pub struct Outputs { pub package: PathBuf, pub bundle: PathBuf, - pub feed: Option, } -/// Turns a staged directory into `.msix` and `.msixbundle` with MakeAppx, and writes the -/// App Installer feed when `feed_base` is given. Windows only, because MakeAppx is. +/// Turns a staged directory into `.msix` and `.msixbundle` with MakeAppx, Windows only, because MakeAppx is. pub fn pack( plan: &Plan, layout: &Path, out_dir: &Path, - feed_base: Option<&str>, ) -> Result { let makeappx = sdk::find_tool("makeappx.exe")?; std::fs::create_dir_all(out_dir) @@ -281,21 +272,9 @@ pub fn pack( )?; std::fs::remove_dir_all(&bundle_src).ok(); - let feed = match feed_base { - Some(base) => { - let feed = - appinstaller::Feed::beside(base, &plan.feed_file_name(), &plan.bundle_file_name())?; - let path = out_dir.join(plan.feed_file_name()); - std::fs::write(&path, feed.render(&plan.meta, plan.version)?) - .map_err(|e| Error::new(format!("cannot write {}: {e}", path.display())))?; - Some(path) - } - None => None, - }; Ok(Outputs { package, bundle, - feed, }) } diff --git a/scripts/packager-windows/src/main.rs b/scripts/packager-windows/src/main.rs index b5391de9..91d55c25 100644 --- a/scripts/packager-windows/src/main.rs +++ b/scripts/packager-windows/src/main.rs @@ -23,7 +23,7 @@ USAGE: REQUIRED: --project The application's directory, holding Dioxus.toml and Cargo.toml --exe The executable. With --payload-dir, a path inside that directory - --out Where the layout, .msix, .msixbundle and feed are written + --out Where the layout, .msix and .msixbundle are written OPTIONS: --payload-dir Package this whole directory (renderer beside the program) @@ -32,7 +32,6 @@ OPTIONS: --version Use this instead of the version in Cargo.toml --arch x64 (default) or arm64 --icon Use this icon instead of the one Dioxus.toml names - --feed-base Write an App Installer feed for a bundle served from this URL --test-identity Allow the Store channel with a derived identity (for certification runs) --layout-only Stage the package directory and stop; runs on any host --summary Append key=value lines describing the outputs (for CI) @@ -49,7 +48,6 @@ struct Args { version: Option, arch: Arch, icon: Option, - feed_base: Option, test_identity: bool, layout_only: bool, summary: Option, @@ -65,7 +63,6 @@ fn parse_args() -> Result, Error> { let mut version = None; let mut arch = Arch::X64; let mut icon = None; - let mut feed_base = None; let mut test_identity = false; let mut layout_only = false; let mut summary = None; @@ -97,7 +94,6 @@ fn parse_args() -> Result, Error> { .ok_or_else(|| Error::new(format!("--arch `{v}`: use x64 or arm64")))?; } "--icon" => icon = Some(PathBuf::from(value()?)), - "--feed-base" => feed_base = Some(value()?), "--test-identity" => test_identity = true, "--layout-only" => layout_only = true, "--summary" => summary = Some(PathBuf::from(value()?)), @@ -117,7 +113,6 @@ fn parse_args() -> Result, Error> { version, arch, icon, - feed_base, test_identity, layout_only, summary, @@ -176,15 +171,11 @@ fn run() -> Result<(), Error> { format!("layout={}", layout.display()), ]; if !args.layout_only { - let outputs = pack(&plan, &layout, &args.out, args.feed_base.as_deref())?; + let outputs = pack(&plan, &layout, &args.out)?; println!("package {}", outputs.package.display()); println!("bundle {}", outputs.bundle.display()); lines.push(format!("package={}", outputs.package.display())); lines.push(format!("bundle={}", outputs.bundle.display())); - if let Some(feed) = &outputs.feed { - println!("feed {}", feed.display()); - lines.push(format!("feed={}", feed.display())); - } } if let Some(path) = &args.summary { let mut file = std::fs::OpenOptions::new() diff --git a/scripts/packager-windows/src/version.rs b/scripts/packager-windows/src/version.rs index 4a361350..fd70c356 100644 --- a/scripts/packager-windows/src/version.rs +++ b/scripts/packager-windows/src/version.rs @@ -16,7 +16,7 @@ pub enum Channel { /// Partner Center. The Store reserves the revision for its own use and rejects a /// package whose revision is not zero, so a Store release is `Major.Minor.Patch.0`. Store, - /// Direct download kept current by App Installer. The revision is a build number + /// Direct download outside the Store. The revision is a build number /// that only grows, so successive builds of one release (or of a pre-release) each /// install over the last. Sideload, @@ -28,7 +28,7 @@ impl Channel { "store" => Ok(Channel::Store), "sideload" => Ok(Channel::Sideload), other => Err(Error::new(format!( - "unknown channel `{other}`: use `store` for Partner Center or `sideload` for App Installer" + "unknown channel `{other}`: use `store` for Partner Center or `sideload` for a direct download" ))), } } From 7745eaa990d5059d4eb41bd7d20b42d40d0a35a2 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:15:31 +0900 Subject: [PATCH 063/224] Refactor: Share one span style function and a side-table copy helper Preparation for per-span text metrics. Measuring and drawing build span styles through one function, and the SpanFonts copy in measure.rs is a generic side-table helper. Same bytes out, no behaviour change (PR-2). --- .../android-kotlin/shared/TextResolution.kt | 61 +++++++++------ compose-rust/src/measure.rs | 74 ++++++++++++++++++- .../desktop/src/renderer/TextResolution.kt | 61 +++++++++------ renderer/desktop/test/MeasureTest.kt | 34 +++++++++ 4 files changed, 184 insertions(+), 46 deletions(-) diff --git a/compose-rust/android-kotlin/shared/TextResolution.kt b/compose-rust/android-kotlin/shared/TextResolution.kt index 90615059..ead97ef5 100644 --- a/compose-rust/android-kotlin/shared/TextResolution.kt +++ b/compose-rust/android-kotlin/shared/TextResolution.kt @@ -354,34 +354,51 @@ private fun annotate( runs.forEachIndexed { index, run -> val from = broken.position(utf8.decodeToString(0, run.start).length) val to = broken.position(utf8.decodeToString(0, run.start + run.length).length) - // A run of text with no role may name its own font. Where the text has a - // role, or the run does, the font is the theme's. - val family = input.runFonts[index] - ?.takeIf { input.noRole && run.role == null } - ?.let { resolveFontFamily(it, assets, problems) } - val style = SpanStyle( - color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, - // Behind this run's letters only, line by line where it wraps. The line's - // own background is the node's, and this is the part of it that changed. - background = runBackground(run, theme), - fontWeight = if (run.bold) FontWeight.Bold else null, - fontStyle = if (run.italic) FontStyle.Italic else null, - fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, - fontFamily = family, - textDecoration = when { - run.underline && run.strikethrough -> - TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) - run.underline -> TextDecoration.Underline - run.strikethrough -> TextDecoration.LineThrough - else -> null - }, - ) + val style = spanStyle(run, input.runFonts[index], input.noRole, theme, assets, sized, problems) addStyle(style, from, to) if (link != null && run.handlerId != 0L) addLink(link(run), from, to) } } } +/** + * The Compose style of one span record. Measuring and drawing both build their spans here, + * so what a span changes about its letters is decided in one place and the two cannot + * drift apart. + */ +private fun spanStyle( + run: TextRun, + fonts: List?, + noRole: Boolean, + theme: ResolvedTheme, + assets: AssetCache, + sized: (Float) -> TextUnit, + problems: MutableList, +): SpanStyle { + // A run of text with no role may name its own font. Where the text has a + // role, or the run does, the font is the theme's. + val family = fonts + ?.takeIf { noRole && run.role == null } + ?.let { resolveFontFamily(it, assets, problems) } + return SpanStyle( + color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, + // Behind this run's letters only, line by line where it wraps. The line's + // own background is the node's, and this is the part of it that changed. + background = runBackground(run, theme), + fontWeight = if (run.bold) FontWeight.Bold else null, + fontStyle = if (run.italic) FontStyle.Italic else null, + fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, + fontFamily = family, + textDecoration = when { + run.underline && run.strikethrough -> + TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) + run.underline -> TextDecoration.Underline + run.strikethrough -> TextDecoration.LineThrough + else -> null + }, + ) +} + private fun tabId(index: Int): String = "compose-rust-tab-$index" /** diff --git a/compose-rust/src/measure.rs b/compose-rust/src/measure.rs index 582d453c..516a7ec8 100644 --- a/compose-rust/src/measure.rs +++ b/compose-rust/src/measure.rs @@ -290,6 +290,14 @@ struct PendingRecord { /// Reused across frames: clearing keeps every buffer's capacity, so once warm, asking the /// same number of questions allocates nothing. #[derive(Debug, Default)] +/// Where an entry of a side table keeps the list it points at. +#[derive(Clone, Copy)] +struct SideTable { + entry_len: usize, + list_offset_at: usize, + list_count_at: usize, +} + pub struct MeasureRequests { records: Vec, /// The strings and lists the records point at, counted from the start of this area @@ -447,6 +455,22 @@ impl MeasureRequests { /// Copies a `SpanFonts` table without its count, moving every offset inside it, and /// answers where the entries start and how many there are. fn copy_span_font_table(&mut self, table: &[u8]) -> (u32, u32) { + self.copy_side_table( + table, + SideTable { + entry_len: SPAN_FONT_ENTRY_LEN, + list_offset_at: SPAN_FONT_OFFSET_AT, + list_count_at: 8, + }, + ) + } + + /// Copies a side table of fixed-size entries without its count. Each entry points at a + /// list of font records elsewhere in the table: the entry's list offset is moved to + /// where the list now sits, the names the list points at move with it, and every moved + /// field is marked to be counted from the start of the buffer. Answers where the + /// entries start and how many there are. + fn copy_side_table(&mut self, table: &[u8], layout: SideTable) -> (u32, u32) { let at = self.payload.len(); if table.len() < 4 { return (at as u32, 0); @@ -455,14 +479,15 @@ impl MeasureRequests { self.payload.extend_from_slice(&table[4..]); let moved = (at as u32).wrapping_sub(4); for entry in 0..entries as usize { - let field = at + entry * SPAN_FONT_ENTRY_LEN + SPAN_FONT_OFFSET_AT; + let start = at + entry * layout.entry_len; + let field = start + layout.list_offset_at; if field + 4 > self.payload.len() { break; } let list = read_u32(&self.payload, field).wrapping_add(moved); self.payload[field..field + 4].copy_from_slice(&list.to_le_bytes()); self.payload_patches.push(field as u32); - let count = read_u32(&self.payload, at + entry * SPAN_FONT_ENTRY_LEN + 8); + let count = read_u32(&self.payload, start + layout.list_count_at); self.move_font_records(list as usize, count, moved); } (at as u32, entries) @@ -786,3 +811,48 @@ fn fake_lines(text: &str, width: f32, advance: f32) -> Vec { } lines } + +#[cfg(test)] +mod side_table_tests { + use super::*; + + /// One entry (index, list offset, list count) followed by one system font record + /// and its name, laid out as the table is on the wire. + fn table() -> Vec { + let mut t = Vec::new(); + t.extend_from_slice(&1u32.to_le_bytes()); + t.extend_from_slice(&7u32.to_le_bytes()); + t.extend_from_slice(&(4 + SPAN_FONT_ENTRY_LEN as u32).to_le_bytes()); + t.extend_from_slice(&1u32.to_le_bytes()); + let name_at = 4 + SPAN_FONT_ENTRY_LEN as u32 + FONT_REF_LEN as u32; + t.extend_from_slice(&FONT_REF_SYSTEM.to_le_bytes()); + t.extend_from_slice(&name_at.to_le_bytes()); + t.resize(4 + SPAN_FONT_ENTRY_LEN + FONT_REF_LEN, 0); + t.extend_from_slice(b"name"); + t + } + + #[test] + fn pr2_side_table_copy_moves_offsets_and_keeps_the_bytes() { + let mut requests = MeasureRequests::new(); + requests.payload.extend_from_slice(&[0xAA; 10]); + let table = table(); + let (at, entries) = requests.copy_span_font_table(&table); + assert_eq!((at, entries), (10, 1)); + let moved = 10u32 - 4; + let copied = &requests.payload[10..]; + assert_eq!(copied.len(), table.len() - 4); + assert_eq!(read_u32(copied, 0), 7, "fields that are not offsets are untouched"); + assert_eq!(read_u32(copied, SPAN_FONT_OFFSET_AT), table_u32(&table, 4 + SPAN_FONT_OFFSET_AT) + moved); + assert_eq!(read_u32(copied, 8), 1); + let record = SPAN_FONT_ENTRY_LEN; + assert_eq!(read_u32(copied, record + 4), table_u32(&table, 4 + record + 4) + moved); + assert_eq!(&copied[copied.len() - 4..], b"name"); + assert_eq!(requests.payload_patches.len(), 2); + assert_eq!(requests.copy_span_font_table(&[]), (requests.payload.len() as u32, 0)); + } + + fn table_u32(table: &[u8], at: usize) -> u32 { + read_u32(table, at) + } +} diff --git a/renderer/desktop/src/renderer/TextResolution.kt b/renderer/desktop/src/renderer/TextResolution.kt index 90615059..ead97ef5 100644 --- a/renderer/desktop/src/renderer/TextResolution.kt +++ b/renderer/desktop/src/renderer/TextResolution.kt @@ -354,34 +354,51 @@ private fun annotate( runs.forEachIndexed { index, run -> val from = broken.position(utf8.decodeToString(0, run.start).length) val to = broken.position(utf8.decodeToString(0, run.start + run.length).length) - // A run of text with no role may name its own font. Where the text has a - // role, or the run does, the font is the theme's. - val family = input.runFonts[index] - ?.takeIf { input.noRole && run.role == null } - ?.let { resolveFontFamily(it, assets, problems) } - val style = SpanStyle( - color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, - // Behind this run's letters only, line by line where it wraps. The line's - // own background is the node's, and this is the part of it that changed. - background = runBackground(run, theme), - fontWeight = if (run.bold) FontWeight.Bold else null, - fontStyle = if (run.italic) FontStyle.Italic else null, - fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, - fontFamily = family, - textDecoration = when { - run.underline && run.strikethrough -> - TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) - run.underline -> TextDecoration.Underline - run.strikethrough -> TextDecoration.LineThrough - else -> null - }, - ) + val style = spanStyle(run, input.runFonts[index], input.noRole, theme, assets, sized, problems) addStyle(style, from, to) if (link != null && run.handlerId != 0L) addLink(link(run), from, to) } } } +/** + * The Compose style of one span record. Measuring and drawing both build their spans here, + * so what a span changes about its letters is decided in one place and the two cannot + * drift apart. + */ +private fun spanStyle( + run: TextRun, + fonts: List?, + noRole: Boolean, + theme: ResolvedTheme, + assets: AssetCache, + sized: (Float) -> TextUnit, + problems: MutableList, +): SpanStyle { + // A run of text with no role may name its own font. Where the text has a + // role, or the run does, the font is the theme's. + val family = fonts + ?.takeIf { noRole && run.role == null } + ?.let { resolveFontFamily(it, assets, problems) } + return SpanStyle( + color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, + // Behind this run's letters only, line by line where it wraps. The line's + // own background is the node's, and this is the part of it that changed. + background = runBackground(run, theme), + fontWeight = if (run.bold) FontWeight.Bold else null, + fontStyle = if (run.italic) FontStyle.Italic else null, + fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, + fontFamily = family, + textDecoration = when { + run.underline && run.strikethrough -> + TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) + run.underline -> TextDecoration.Underline + run.strikethrough -> TextDecoration.LineThrough + else -> null + }, + ) +} + private fun tabId(index: Int): String = "compose-rust-tab-$index" /** diff --git a/renderer/desktop/test/MeasureTest.kt b/renderer/desktop/test/MeasureTest.kt index 212ad49e..4e7b6bba 100644 --- a/renderer/desktop/test/MeasureTest.kt +++ b/renderer/desktop/test/MeasureTest.kt @@ -156,6 +156,24 @@ class MeasureTest { ) { text, width -> text(text, width = width, spans = spanRecord(6, 4, flags = 1) + spanRecord(15, 4, role = 9)) }, + Case( + "mixed role spans", + "Heading words then body words then a small caption that wraps", + 110f, + listOf( + PropertyKind.Spans to PropertyValue.Bytes( + spanRecord(0, 7, role = 3) + spanRecord(19, 4, flags = 1) + spanRecord(37, 7, role = 9) + + spanRecord(45, 7, role = 3, flags = 1), + ), + ), + ) { text, width -> + text( + text, + width = width, + spans = spanRecord(0, 7, role = 3) + spanRecord(19, 4, flags = 1) + spanRecord(37, 7, role = 9) + + spanRecord(45, 7, role = 3, flags = 1), + ) + }, Case( "truncated", "One line, then another line, then a third that is cut off", @@ -190,6 +208,22 @@ class MeasureTest { } } + @Test + fun pr2_span_fonts_are_ignored_where_the_text_has_a_role() = runComposeUiTest { + startHost(FakeHostConnection(emptyList())) + val text = "Words set in a role keep the theme font" + val spans = spanRecord(0, 5) + spanRecord(10, 4, flags = 1) + val mono = mapOf(0 to listOf(FontRef.Generic(GenericFamily.Monospace)), 1 to listOf(FontRef.Generic(GenericFamily.Monospace))) + val (status, results) = measure( + MeasureRequests() + .text(text, role = 5, width = 400f, spans = spans) + .text(text, role = 5, width = 400f, spans = spans, spanFonts = mono), + ) + assertEquals(MeasureRecords.CALL_OK, status) + assertEquals(results[0].width, results[1].width) + assertEquals(results[0].height, results[1].height) + } + @Test fun pr2_intrinsic_widths_match_compose() = runComposeUiTest { lateinit var drawDensity: Density From 9c83c889d7d9213236dc4689d91ff146b904a821 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:17:12 +0900 Subject: [PATCH 064/224] Chore: Format the Windows packager --- scripts/packager-windows/src/lib.rs | 11 ++--------- scripts/packager-windows/tests/stage.rs | 4 +--- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/scripts/packager-windows/src/lib.rs b/scripts/packager-windows/src/lib.rs index 5c0362c6..8740512c 100644 --- a/scripts/packager-windows/src/lib.rs +++ b/scripts/packager-windows/src/lib.rs @@ -223,11 +223,7 @@ pub struct Outputs { } /// Turns a staged directory into `.msix` and `.msixbundle` with MakeAppx, Windows only, because MakeAppx is. -pub fn pack( - plan: &Plan, - layout: &Path, - out_dir: &Path, -) -> Result { +pub fn pack(plan: &Plan, layout: &Path, out_dir: &Path) -> Result { let makeappx = sdk::find_tool("makeappx.exe")?; std::fs::create_dir_all(out_dir) .map_err(|e| Error::new(format!("cannot create {}: {e}", out_dir.display())))?; @@ -272,10 +268,7 @@ pub fn pack( )?; std::fs::remove_dir_all(&bundle_src).ok(); - Ok(Outputs { - package, - bundle, - }) + Ok(Outputs { package, bundle }) } #[cfg(test)] diff --git a/scripts/packager-windows/tests/stage.rs b/scripts/packager-windows/tests/stage.rs index 59f314ea..41f924cb 100644 --- a/scripts/packager-windows/tests/stage.rs +++ b/scripts/packager-windows/tests/stage.rs @@ -2,9 +2,7 @@ use std::path::{Path, PathBuf}; -use compose_rust_msix::{ - AppMetadata, Arch, Channel, PackageVersion, Payload, Plan, assets, stage, -}; +use compose_rust_msix::{AppMetadata, Arch, Channel, PackageVersion, Payload, Plan, assets, stage}; fn scratch(name: &str) -> PathBuf { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) From 03146e648b966ce50c51038bd7860bcdd769f8c5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:21:12 +0900 Subject: [PATCH 065/224] Chore: Format the Linux packager --- scripts/packager-linux/src/main.rs | 4 +--- scripts/packager-linux/tests/metadata.rs | 7 ++++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/scripts/packager-linux/src/main.rs b/scripts/packager-linux/src/main.rs index b4bb09bc..ebbca871 100644 --- a/scripts/packager-linux/src/main.rs +++ b/scripts/packager-linux/src/main.rs @@ -6,9 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{SystemTime, UNIX_EPOCH}; -use packager_linux::appimage::{ - AppDirInputs, appimage_arch, appimage_file_name, assemble_appdir, -}; +use packager_linux::appimage::{AppDirInputs, appimage_arch, appimage_file_name, assemble_appdir}; use packager_linux::appstream::{metainfo, metainfo_file_name}; use packager_linux::desktop::{desktop_entry, desktop_file_name}; use packager_linux::flatpak::{FlatpakOptions, Payload, SourceRef, icon_source_path, manifest}; diff --git a/scripts/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs index 03a6d4dc..99b285aa 100644 --- a/scripts/packager-linux/tests/metadata.rs +++ b/scripts/packager-linux/tests/metadata.rs @@ -9,8 +9,7 @@ use std::path::{Path, PathBuf}; use packager_linux::appimage::{ - AppDirInputs, appimage_arch, appimage_file_name, apprun, - assemble_appdir, file_safe, + AppDirInputs, appimage_arch, appimage_file_name, apprun, assemble_appdir, file_safe, }; use packager_linux::appstream::{appdata_file_name, metainfo, metainfo_file_name}; use packager_linux::desktop::{desktop_entry, desktop_file_name}; @@ -456,7 +455,9 @@ fn fr22_6_3_appdir_holds_entry_icon_metainfo_and_payload() { ] { assert!(out.join(path).is_file(), "missing {path}"); } - assert!(written.contains(&PathBuf::from("usr/lib/sample-calculator/sample-calculator"))); + assert!(written.contains(&PathBuf::from( + "usr/lib/sample-calculator/sample-calculator" + ))); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; From 1bb2b84f86d3524eb71a4bc95b441732714f7f42 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:32:38 +0900 Subject: [PATCH 066/224] Test: Focus the clickable box with Tab in the own-keys test (FR-12, #101) A clickable box is a focus target but exposes no RequestFocus semantics action, so the test failed before reaching the handler. It now focuses the box by Tab and asserts focus. --- renderer/desktop/test/KeyEventTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/renderer/desktop/test/KeyEventTest.kt b/renderer/desktop/test/KeyEventTest.kt index 922ae73b..52c74343 100644 --- a/renderer/desktop/test/KeyEventTest.kt +++ b/renderer/desktop/test/KeyEventTest.kt @@ -2,6 +2,7 @@ package dev.darkpyonix.composerust.test import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performKeyInput @@ -161,7 +162,11 @@ class KeyEventTest { ) setContent { ComposeRustContent(rememberComposeRustHost(connection)) } - onNodeWithTag(nodeTestTag(box)).requestFocus() + // A clickable box is a focus target but exposes no RequestFocus semantics action + // (only Modifier.focusable does), so it is focused the way a keyboard user does it. + onNodeWithTag(nodeTestTag(box)).performKeyInput { pressKey(Key.Tab) } + waitForIdle() + onNodeWithTag(nodeTestTag(box)).assertIsFocused() onNodeWithTag(nodeTestTag(box)).performKeyInput { pressKey(Key.Enter) } waitForIdle() From 67b5dbc6d64f401b9f76e27a23bad31a08ea79b0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:37:35 +0900 Subject: [PATCH 067/224] Fix: Build the packager's host sample against the published Windows renderer Leave COMPOSE_RUST_RENDERER_DIR unset so the build script fetches the renderer for the crate version, package the distribution it copies beside the executable, and drop the old-repository sample tarball payload and its inputs. --- .github/workflows/package-windows.yml | 108 +++++--------------------- 1 file changed, 19 insertions(+), 89 deletions(-) diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index d086c88d..5b4e06e7 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -10,15 +10,11 @@ # stand-in a tiny windowed program built here, described by the notepad sample's # metadata. It proves the pipeline in minutes and owes nothing to the # renderer. -# host a sample built here from this checkout, against the renderer from that -# same release, so that what the crate now puts into every executable (its -# application manifest) is in the package the kit inspects. This one has -# to pass the kit's DPI awareness check outright. -# sample a real build: the program with the renderer and its companion files -# beside it, the shape a compose-rust application ships in. By default the -# notepad archive from a published sample release; dispatched with a run id, -# an artifact from that run instead (a samples-windows-x64 artifact, or any -# build packed the same way). +# host a sample built here from this checkout, against the published renderer +# for this crate version (fetched by the crate's build script), so that what +# the crate now puts into every executable (its application manifest) is in +# the package the kit inspects. This one has to pass the kit's DPI awareness +# check outright. # # Building the renderer is not part of this workflow. It takes most of an hour and a # released build is exactly the input the packager is meant to take. @@ -46,30 +42,6 @@ on: description: 'Application directory holding Dioxus.toml and Cargo.toml' default: 'samples/notepad' type: string - payload_url: - description: 'URL of a .tar.gz holding a real build (used when no run id is given)' - default: 'https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/notepad-windows-x64.tar.gz' - type: string - payload_run_id: - description: 'Run id whose artifact holds the build to package instead of the URL' - default: '' - type: string - payload_artifact: - description: 'Artifact name in that run' - default: 'samples-windows-x64' - type: string - payload_archive: - description: 'Archive inside the artifact' - default: 'notepad-windows-x64.tar.gz' - type: string - payload_root: - description: 'Directory inside the archive that becomes the package root' - default: 'notepad' - type: string - payload_exe: - description: 'The program, relative to that directory' - default: 'bin/sample-notepad.exe' - type: string permissions: contents: read @@ -82,10 +54,6 @@ concurrency: env: CARGO_TERM_COLOR: always PROJECT: ${{ inputs.project || 'samples/notepad' }} - PAYLOAD_URL: ${{ inputs.payload_url || 'https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/notepad-windows-x64.tar.gz' }} - PAYLOAD_ARCHIVE: ${{ inputs.payload_archive || 'notepad-windows-x64.tar.gz' }} - PAYLOAD_ROOT: ${{ inputs.payload_root || 'notepad' }} - PAYLOAD_EXE: ${{ inputs.payload_exe || 'bin/sample-notepad.exe' }} jobs: test: @@ -119,14 +87,10 @@ jobs: run: cargo test --locked -p compose-rust --no-default-features --test windows_manifest package: - name: Package, sign and certify (${{ matrix.payload }}) + name: Package, sign and certify (stand-in) needs: test runs-on: windows-latest timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - payload: [stand-in, sample] steps: - uses: actions/checkout@v4 @@ -138,22 +102,6 @@ jobs: - name: Build the packager and the stand-in program run: cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix --example fixture-app - - name: Fetch the build from a run - if: matrix.payload == 'sample' && inputs.payload_run_id != '' - uses: actions/download-artifact@v4 - with: - run-id: ${{ inputs.payload_run_id }} - name: ${{ inputs.payload_artifact }} - path: payload-artifact - github-token: ${{ github.token }} - - - name: Fetch the build from a release - if: matrix.payload == 'sample' && inputs.payload_run_id == '' - run: | - $ErrorActionPreference = 'Stop' - New-Item -ItemType Directory -Force -Path payload-artifact | Out-Null - Invoke-WebRequest -Uri $env:PAYLOAD_URL -OutFile "payload-artifact\$env:PAYLOAD_ARCHIVE" - - name: Package id: pack run: | @@ -161,18 +109,8 @@ jobs: $tool = 'target\release\compose-rust-msix.exe' $out = Join-Path $env:RUNNER_TEMP 'msix' $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' - if ('${{ matrix.payload }}' -eq 'sample') { - $unpacked = Join-Path $env:RUNNER_TEMP 'payload' - New-Item -ItemType Directory -Force -Path $unpacked | Out-Null - tar -xzf "payload-artifact\$env:PAYLOAD_ARCHIVE" -C $unpacked - if ($LASTEXITCODE -ne 0) { throw "could not unpack $env:PAYLOAD_ARCHIVE" } - Get-ChildItem -Recurse (Join-Path $unpacked $env:PAYLOAD_ROOT) | Select-Object -ExpandProperty FullName - & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` - --payload-dir (Join-Path $unpacked $env:PAYLOAD_ROOT) --exe $env:PAYLOAD_EXE - } else { - & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` - --exe 'target\release\examples\fixture-app.exe' - } + & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` + --exe 'target\release\examples\fixture-app.exe' if ($LASTEXITCODE -ne 0) { throw "packaging failed" } Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 Get-Content (Join-Path $out 'layout\AppxManifest.xml') @@ -216,7 +154,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: wack-reports-${{ matrix.payload }} + name: wack-reports-stand-in path: ${{ runner.temp }}/wack/ if-no-files-found: warn @@ -224,7 +162,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: msix-packages-${{ matrix.payload }} + name: msix-packages-stand-in path: | ${{ runner.temp }}/msix/*.msix ${{ runner.temp }}/msix/*.msixbundle @@ -244,23 +182,11 @@ jobs: rustup toolchain install stable --profile minimal rustup default stable - - name: Fetch a released renderer - # The renderer is not built here. The released notepad archive carries the - # renderer DLL, its import library and the files AWT reads, which is everything - # the crate needs to link a host against. It predates schema hashes, so the build - # warns that it cannot tell whether the two agree; the drawing is not what this - # job checks, the executable and its manifest are. - run: | - $ErrorActionPreference = 'Stop' - New-Item -ItemType Directory -Force -Path payload-artifact | Out-Null - Invoke-WebRequest -Uri $env:PAYLOAD_URL -OutFile "payload-artifact\$env:PAYLOAD_ARCHIVE" - $unpacked = Join-Path $env:RUNNER_TEMP 'renderer' - New-Item -ItemType Directory -Force -Path $unpacked | Out-Null - tar -xzf "payload-artifact\$env:PAYLOAD_ARCHIVE" -C $unpacked - if ($LASTEXITCODE -ne 0) { throw "could not unpack $env:PAYLOAD_ARCHIVE" } - Add-Content -Path $env:GITHUB_ENV -Value "COMPOSE_RUST_RENDERER_DIR=$(Join-Path $unpacked $env:PAYLOAD_ROOT)" -Encoding utf8 - - name: Build the packager and the notepad sample + # COMPOSE_RUST_RENDERER_DIR is left unset on purpose. The crate's build script then + # downloads the published Windows renderer for this crate version, the way any + # consumer's build does, unpacks it into the user cache, and copies its `bin` and + # `lib` beside the executables it builds. The renderer is not built here. run: | cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix if ($LASTEXITCODE -ne 0) { throw "the packager did not build" } @@ -285,8 +211,12 @@ jobs: $ErrorActionPreference = 'Stop' $payload = Join-Path $env:RUNNER_TEMP 'host-payload' New-Item -ItemType Directory -Force -Path $payload | Out-Null + # The build script left the renderer's distribution (`bin` with the DLLs, `lib` with + # the files AWT reads) in the profile directory beside the executable. foreach ($child in 'bin', 'lib') { - Copy-Item -Recurse -Path (Join-Path $env:COMPOSE_RUST_RENDERER_DIR $child) -Destination $payload + $source = Join-Path 'target\release' $child + if (-not (Test-Path $source)) { throw "the build left no $child beside the sample, so no renderer was linked" } + Copy-Item -Recurse -Path $source -Destination $payload } Copy-Item -Force 'target\release\sample-notepad.exe' (Join-Path $payload 'bin\sample-notepad.exe') $out = Join-Path $env:RUNNER_TEMP 'msix' From 81cdf22971a76ad7979f7ffe928c2fc3e5c160f1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:38:38 +0900 Subject: [PATCH 068/224] Fix: Package the host sample against a renderer built from this tree The published v0.0.2 renderer predates the current schema, so the build script refuses it. The host job moves to package-windows-host.yml, a heavy workflow that calls windows-static.yml, takes the renderer kit it now uploads, and links it via DXC_WINDOWS_NATIVE_LIB. The stand-in packaging stays fast in package-windows.yml. --- .github/scripts/heavy-checks.py | 1 + .github/workflows/package-windows-host.yml | 137 +++++++++++++++++++++ .github/workflows/package-windows.yml | 96 +-------------- .github/workflows/windows-static.yml | 7 ++ 4 files changed, 148 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/package-windows-host.yml diff --git a/.github/scripts/heavy-checks.py b/.github/scripts/heavy-checks.py index 98686f1f..ffdeed0a 100644 --- a/.github/scripts/heavy-checks.py +++ b/.github/scripts/heavy-checks.py @@ -57,6 +57,7 @@ "test-samples.yml", "test-windows-resize-metrics.yml", "test-windows-resize-probe.yml", + "package-windows-host.yml", ] REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/.github/workflows/package-windows-host.yml b/.github/workflows/package-windows-host.yml new file mode 100644 index 00000000..e1758068 --- /dev/null +++ b/.github/workflows/package-windows-host.yml @@ -0,0 +1,137 @@ +# Windows package, host sample -- builds the notepad sample from this checkout against the +# Kotlin/Native renderer built from this same tree, packages it, signs it with a throwaway +# certificate, and runs the Windows App Certification Kit, which must pass the DPI awareness +# check outright. That proves what the crate puts into every executable (its application +# manifest) reaches the package the kit inspects. +# +# Heavy: the renderer is built here (windows-static.yml, two to three hours, cached), so it +# runs on request like the other heavy workflows. package-windows.yml keeps the fast checks. + +name: Windows package host + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - 'scripts/packager-windows/**' + - 'compose-rust/build.rs' + - 'compose-rust/build/**' + - '.github/workflows/package-windows-host.yml' + workflow_dispatch: + inputs: + project: + description: 'Application directory holding Dioxus.toml and Cargo.toml' + default: 'samples/notepad' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: package-windows-host-${{ github.event.pull_request.number || github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-heavy-ci' && format('-other-label-{0}', github.run_id) || '' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + PROJECT: ${{ inputs.project || 'samples/notepad' }} + +jobs: + requested: + name: Heavy checks requested + if: >- + github.event_name != 'pull_request' || + (github.event.action == 'labeled' && github.event.label.name == 'run-heavy-ci') || + (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-heavy-ci')) + runs-on: ubuntu-latest + steps: + - run: echo "Heavy checks requested by ${{ github.event_name }} ${{ github.event.action }}" + + windows-static: + name: renderer + needs: requested + uses: ./.github/workflows/windows-static.yml + + host: + name: Build a sample here, package and certify it + needs: [requested, windows-static] + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Take the renderer built from this tree + uses: actions/download-artifact@v4 + with: + name: windows-static-renderer-kit + path: ${{ runner.temp }}/renderer-kit + + - name: Build the packager and the notepad sample + # The Kotlin/Native static renderer from windows-static.yml, linked into the + # executable, so the sample is one file and its schema is this tree's. + env: + DXC_WINDOWS_NATIVE_LIB: ${{ runner.temp }}/renderer-kit + run: | + cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix + if ($LASTEXITCODE -ne 0) { throw "the packager did not build" } + cargo build --locked --release -p sample-notepad --bin sample-notepad + if ($LASTEXITCODE -ne 0) { throw "the sample did not build" } + + - name: Show the manifest embedded in the sample + run: | + $ErrorActionPreference = 'Stop' + . ./scripts/packager-windows/scripts/common.ps1 + $mt = Find-SdkTool 'mt.exe' + $exe = 'target\release\sample-notepad.exe' + & $mt -nologo "-inputresource:$exe;#1" "-out:$env:RUNNER_TEMP\embedded.manifest" + if ($LASTEXITCODE -ne 0) { throw "sample-notepad.exe has no embedded manifest" } + $text = Get-Content -Raw "$env:RUNNER_TEMP\embedded.manifest" + Write-Host $text + if ($text -notmatch 'PerMonitorV2') { throw "the embedded manifest does not declare PerMonitorV2" } + + - name: Lay the sample out beside the renderer and package it + id: pack + run: | + $ErrorActionPreference = 'Stop' + $payload = Join-Path $env:RUNNER_TEMP 'host-payload' + New-Item -ItemType Directory -Force -Path (Join-Path $payload 'bin') | Out-Null + Copy-Item -Force 'target\release\sample-notepad.exe' (Join-Path $payload 'bin\sample-notepad.exe') + $out = Join-Path $env:RUNNER_TEMP 'msix' + $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' + & 'target\release\compose-rust-msix.exe' --project $env:PROJECT --out $out --summary $summary ` + --test-identity --payload-dir $payload --exe 'bin\sample-notepad.exe' + if ($LASTEXITCODE -ne 0) { throw "packaging failed" } + Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + + - name: Create and trust a test certificate + id: cert + run: > + ./scripts/packager-windows/scripts/new-test-certificate.ps1 + -Publisher '${{ steps.pack.outputs.publisher }}' + -OutDir "$env:RUNNER_TEMP\cert" -Trust + + - name: Sign + run: > + ./scripts/packager-windows/scripts/sign.ps1 + -Path '${{ steps.pack.outputs.package }}','${{ steps.pack.outputs.bundle }}' + -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' + + - name: Windows App Certification Kit (DPI awareness must pass) + run: > + ./scripts/packager-windows/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.bundle }}' + -Report "$env:RUNNER_TEMP\wack\host-bundle-report.xml" + -RequirePass DPIAwarenessValidation + + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: wack-reports-host + path: ${{ runner.temp }}/wack/ + if-no-files-found: warn diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index 5b4e06e7..a62c00ed 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -5,19 +5,14 @@ # not refused there for the same reasons. The Store signs what it takes in itself; the # test certificate exists only so that this machine will install the package. # -# Two things are packaged, side by side: +# What is packaged here: # # stand-in a tiny windowed program built here, described by the notepad sample's # metadata. It proves the pipeline in minutes and owes nothing to the # renderer. -# host a sample built here from this checkout, against the published renderer -# for this crate version (fetched by the crate's build script), so that what -# the crate now puts into every executable (its application manifest) is in -# the package the kit inspects. This one has to pass the kit's DPI awareness -# check outright. # -# Building the renderer is not part of this workflow. It takes most of an hour and a -# released build is exactly the input the packager is meant to take. +# A sample built against a renderer from this tree is package-windows-host.yml, which is +# heavy and runs on request. name: Windows package @@ -168,88 +163,3 @@ jobs: ${{ runner.temp }}/msix/*.msixbundle ${{ runner.temp }}/cert/test-signing.cer if-no-files-found: warn - - host: - name: Build a sample here, package and certify it - needs: test - runs-on: windows-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - - - name: Install the Rust toolchain - run: | - rustup toolchain install stable --profile minimal - rustup default stable - - - name: Build the packager and the notepad sample - # COMPOSE_RUST_RENDERER_DIR is left unset on purpose. The crate's build script then - # downloads the published Windows renderer for this crate version, the way any - # consumer's build does, unpacks it into the user cache, and copies its `bin` and - # `lib` beside the executables it builds. The renderer is not built here. - run: | - cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix - if ($LASTEXITCODE -ne 0) { throw "the packager did not build" } - cargo build --locked --release -p sample-notepad --bin sample-notepad - if ($LASTEXITCODE -ne 0) { throw "the sample did not build" } - - - name: Show the manifest embedded in the sample - run: | - $ErrorActionPreference = 'Stop' - . ./scripts/packager-windows/scripts/common.ps1 - $mt = Find-SdkTool 'mt.exe' - $exe = 'target\release\sample-notepad.exe' - & $mt -nologo "-inputresource:$exe;#1" "-out:$env:RUNNER_TEMP\embedded.manifest" - if ($LASTEXITCODE -ne 0) { throw "sample-notepad.exe has no embedded manifest" } - $text = Get-Content -Raw "$env:RUNNER_TEMP\embedded.manifest" - Write-Host $text - if ($text -notmatch 'PerMonitorV2') { throw "the embedded manifest does not declare PerMonitorV2" } - - - name: Lay the sample out beside the renderer and package it - id: pack - run: | - $ErrorActionPreference = 'Stop' - $payload = Join-Path $env:RUNNER_TEMP 'host-payload' - New-Item -ItemType Directory -Force -Path $payload | Out-Null - # The build script left the renderer's distribution (`bin` with the DLLs, `lib` with - # the files AWT reads) in the profile directory beside the executable. - foreach ($child in 'bin', 'lib') { - $source = Join-Path 'target\release' $child - if (-not (Test-Path $source)) { throw "the build left no $child beside the sample, so no renderer was linked" } - Copy-Item -Recurse -Path $source -Destination $payload - } - Copy-Item -Force 'target\release\sample-notepad.exe' (Join-Path $payload 'bin\sample-notepad.exe') - $out = Join-Path $env:RUNNER_TEMP 'msix' - $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' - & 'target\release\compose-rust-msix.exe' --project $env:PROJECT --out $out --summary $summary ` - --test-identity --payload-dir $payload --exe 'bin\sample-notepad.exe' - if ($LASTEXITCODE -ne 0) { throw "packaging failed" } - Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 - - - name: Create and trust a test certificate - id: cert - run: > - ./scripts/packager-windows/scripts/new-test-certificate.ps1 - -Publisher '${{ steps.pack.outputs.publisher }}' - -OutDir "$env:RUNNER_TEMP\cert" -Trust - - - name: Sign - run: > - ./scripts/packager-windows/scripts/sign.ps1 - -Path '${{ steps.pack.outputs.package }}','${{ steps.pack.outputs.bundle }}' - -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' - - - name: Windows App Certification Kit (DPI awareness must pass) - run: > - ./scripts/packager-windows/scripts/run-wack.ps1 - -Package '${{ steps.pack.outputs.bundle }}' - -Report "$env:RUNNER_TEMP\wack\host-bundle-report.xml" - -RequirePass DPIAwarenessValidation - - - name: Upload the report - if: always() - uses: actions/upload-artifact@v4 - with: - name: wack-reports-host - path: ${{ runner.temp }}/wack/ - if-no-files-found: warn diff --git a/.github/workflows/windows-static.yml b/.github/workflows/windows-static.yml index ce92646a..66714ab2 100644 --- a/.github/workflows/windows-static.yml +++ b/.github/workflows/windows-static.yml @@ -274,6 +274,13 @@ jobs: ls -la renderer/build/windows renderer/build/windows/*/ du -sh renderer/build/windows + - name: Hand the renderer kit to workflows that call this one + uses: actions/upload-artifact@v4 + with: + name: windows-static-renderer-kit + path: renderer/build/windows + retention-days: 3 + - name: Kotlin exceptions and startup in an MSVC executable, with and without the rewrite if: always() && steps.renderer.outcome == 'success' run: ./scripts/check-windows-mingw-link.sh renderer/build/windows "$(cygpath -u "$RUNNER_TEMP")/mingw-link-probe" From 08ac3f51230e98d8410be98ea624b11241fc2f90 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:43:11 +0900 Subject: [PATCH 069/224] Chore: Format the side table test --- compose-rust/src/measure.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/compose-rust/src/measure.rs b/compose-rust/src/measure.rs index 516a7ec8..bdb437b3 100644 --- a/compose-rust/src/measure.rs +++ b/compose-rust/src/measure.rs @@ -842,14 +842,27 @@ mod side_table_tests { let moved = 10u32 - 4; let copied = &requests.payload[10..]; assert_eq!(copied.len(), table.len() - 4); - assert_eq!(read_u32(copied, 0), 7, "fields that are not offsets are untouched"); - assert_eq!(read_u32(copied, SPAN_FONT_OFFSET_AT), table_u32(&table, 4 + SPAN_FONT_OFFSET_AT) + moved); + assert_eq!( + read_u32(copied, 0), + 7, + "fields that are not offsets are untouched" + ); + assert_eq!( + read_u32(copied, SPAN_FONT_OFFSET_AT), + table_u32(&table, 4 + SPAN_FONT_OFFSET_AT) + moved + ); assert_eq!(read_u32(copied, 8), 1); let record = SPAN_FONT_ENTRY_LEN; - assert_eq!(read_u32(copied, record + 4), table_u32(&table, 4 + record + 4) + moved); + assert_eq!( + read_u32(copied, record + 4), + table_u32(&table, 4 + record + 4) + moved + ); assert_eq!(&copied[copied.len() - 4..], b"name"); assert_eq!(requests.payload_patches.len(), 2); - assert_eq!(requests.copy_span_font_table(&[]), (requests.payload.len() as u32, 0)); + assert_eq!( + requests.copy_span_font_table(&[]), + (requests.payload.len() as u32, 0) + ); } fn table_u32(table: &[u8], at: usize) -> u32 { From 8291999e8146a3da72a606a3971f636ca086de3e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:46:17 +0900 Subject: [PATCH 070/224] Fix: Give the Flatpak test listings a pinned screenshot and mirror it into the repository (FR-22.6) --- .github/workflows/packager-linux.yml | 8 +++++++- scripts/packager-linux/flatpak.sh | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 914ee9a3..7c16c96c 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -44,6 +44,9 @@ env: CALCULATOR_ARM64_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-arm64.tar.gz CALCULATOR_ARM64_SHA256: b264dbc9efa2d7dc7e1ff9559743840d63c2aeca01f69a0e6d1ea1da9d97306f FLATPAK_RUNTIME: "26.08" + # A screenshot for the test listings, pinned to a commit the way a store listing must be + # (Ember's large icon; the images are stand-ins, the pinning and mirroring are real). + SCREENSHOT_URL: https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/faa430a4b75ec45928623130ea2f874b628a7224/src-tauri/icons/128x128@2x.png # The renderer release archive, built into the from-source Flatpak so that the step # which points an executable at the renderer beside it runs for real. RENDERER_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/v0.0.0/dioxus-compose-renderer-v0.0.0-linux-x64.tar.gz @@ -260,6 +263,7 @@ jobs: --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ + --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ --out work/flatpak-src --build work/repo --lint id=dev.darkpyonix.compose.UpdateDemo flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" @@ -287,9 +291,10 @@ jobs: --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ + --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ --out work/flatpak-src (cd work/flatpak-src && flatpak-builder \ - --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") + --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") flatpak --user update --noninteractive "$id" flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" flatpak --user info "$id" @@ -309,6 +314,7 @@ jobs: --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png --version 0.0.0 \ --prebuilt-path calculator-linux-x64.tar.gz \ + --screenshot "$SCREENSHOT_URL" --screenshot-caption "The calculator" \ --out work/flatpak-calculator --build work/repo-calculator --lint id=dev.darkpyonix.composerust.samples.calculator cat "work/flatpak-calculator/$id.json" diff --git a/scripts/packager-linux/flatpak.sh b/scripts/packager-linux/flatpak.sh index bd258aff..b92aa2fc 100755 --- a/scripts/packager-linux/flatpak.sh +++ b/scripts/packager-linux/flatpak.sh @@ -129,6 +129,7 @@ if [[ -n "$build_repo" ]]; then ( cd "$out" builder --user --force-clean --install-deps-from=flathub \ + --mirror-screenshots-url=https://dl.flathub.org/media \ --repo="$build_repo" --install --state-dir="$out/.flatpak-builder" \ "$out/build" "$manifest" ) From ca0e6e9d16dbcb35f184dbbe5e5786c93d0f78b0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:00:50 +0900 Subject: [PATCH 071/224] Fix: Put the measure requests' derive back on the measure requests --- compose-rust/src/measure.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/compose-rust/src/measure.rs b/compose-rust/src/measure.rs index bdb437b3..dde41d69 100644 --- a/compose-rust/src/measure.rs +++ b/compose-rust/src/measure.rs @@ -285,11 +285,6 @@ struct PendingRecord { bytes: [u8; MEASURE_RECORD_LEN], } -/// A set of questions for one call, built up and then asked at once. -/// -/// Reused across frames: clearing keeps every buffer's capacity, so once warm, asking the -/// same number of questions allocates nothing. -#[derive(Debug, Default)] /// Where an entry of a side table keeps the list it points at. #[derive(Clone, Copy)] struct SideTable { @@ -298,6 +293,11 @@ struct SideTable { list_count_at: usize, } +/// A set of questions for one call, built up and then asked at once. +/// +/// Reused across frames: clearing keeps every buffer's capacity, so once warm, asking the +/// same number of questions allocates nothing. +#[derive(Debug, Default)] pub struct MeasureRequests { records: Vec, /// The strings and lists the records point at, counted from the start of this area From 9be2879836a1480b084e576a17e6fae1070d4c37 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:03:09 +0900 Subject: [PATCH 072/224] Fix: Give each caller of the Windows static renderer its own concurrency group --- .github/workflows/windows-static.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-static.yml b/.github/workflows/windows-static.yml index 66714ab2..2dcf82ab 100644 --- a/.github/workflows/windows-static.yml +++ b/.github/workflows/windows-static.yml @@ -26,8 +26,10 @@ on: # that calls it. workflow_dispatch: + # A called workflow takes its caller's name as github.workflow, so each caller gets its own + # group: the native renderer test and the Windows package host no longer cancel each other. concurrency: - group: windows-static-${{ github.event.pull_request.number || github.ref }} + group: windows-static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: From 43763f70a0c5b7f65cb9e3d36cfab22b094330af Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:05:24 +0900 Subject: [PATCH 073/224] Fix: Pass --compose-url-policy=full with the screenshot mirror so remote media is rewritten (FR-22.6) --- .github/workflows/packager-linux.yml | 2 +- scripts/packager-linux/flatpak.sh | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 7c16c96c..309c7629 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -294,7 +294,7 @@ jobs: --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ --out work/flatpak-src (cd work/flatpak-src && flatpak-builder \ - --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") + --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") flatpak --user update --noninteractive "$id" flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" flatpak --user info "$id" diff --git a/scripts/packager-linux/flatpak.sh b/scripts/packager-linux/flatpak.sh index b92aa2fc..3955df9c 100755 --- a/scripts/packager-linux/flatpak.sh +++ b/scripts/packager-linux/flatpak.sh @@ -128,8 +128,10 @@ if [[ -n "$build_repo" ]]; then echo "== building into $build_repo" ( cd "$out" + # Flathub's linter requires both flags: they mirror screenshots and remote icons + # into the media repository and the screenshots ref (docs.flathub.org/linter). builder --user --force-clean --install-deps-from=flathub \ - --mirror-screenshots-url=https://dl.flathub.org/media \ + --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media \ --repo="$build_repo" --install --state-dir="$out/.flatpak-builder" \ "$out/build" "$manifest" ) From d99100d938a8768882b96631ded99ddf3691972c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:42:49 +0900 Subject: [PATCH 074/224] Fix: Make a Box, Column or Row with OnKeyDown a focus target (FR-12, #101) --- compose-rust/android-kotlin/shared/RenderNode.kt | 10 ++++++++++ renderer/desktop/src/renderer/RenderNode.kt | 10 ++++++++++ renderer/desktop/test/KeyEventTest.kt | 4 ++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index f833035c..16cd7c29 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -1,5 +1,6 @@ package dev.darkpyonix.composerust.ui.node +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -137,7 +138,16 @@ fun RenderNode( val keyed = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { profiled } else { + // Plain containers and text are not focus targets on their own, so a node that + // listens for keys is made one, ahead of its modifiers like the handler. Without it + // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. + // Widgets that are controls bring their own focus handling and are left alone. + val focusTarget = when (node.widget) { + WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget -> true + else -> false + } profiled.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + .let { if (focusTarget) it.focusable() else it } } val withKeys = keyed .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme) { table.animations.animated(nodeId) }) diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index f833035c..16cd7c29 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -1,5 +1,6 @@ package dev.darkpyonix.composerust.ui.node +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -137,7 +138,16 @@ fun RenderNode( val keyed = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { profiled } else { + // Plain containers and text are not focus targets on their own, so a node that + // listens for keys is made one, ahead of its modifiers like the handler. Without it + // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. + // Widgets that are controls bring their own focus handling and are left alone. + val focusTarget = when (node.widget) { + WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget -> true + else -> false + } profiled.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + .let { if (focusTarget) it.focusable() else it } } val withKeys = keyed .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme) { table.animations.animated(nodeId) }) diff --git a/renderer/desktop/test/KeyEventTest.kt b/renderer/desktop/test/KeyEventTest.kt index 52c74343..f6c72c10 100644 --- a/renderer/desktop/test/KeyEventTest.kt +++ b/renderer/desktop/test/KeyEventTest.kt @@ -162,8 +162,8 @@ class KeyEventTest { ) setContent { ComposeRustContent(rememberComposeRustHost(connection)) } - // A clickable box is a focus target but exposes no RequestFocus semantics action - // (only Modifier.focusable does), so it is focused the way a keyboard user does it. + // A box with a key handler is made focusable but may expose no RequestFocus action, + // so it is focused the way a keyboard user does it. onNodeWithTag(nodeTestTag(box)).performKeyInput { pressKey(Key.Tab) } waitForIdle() onNodeWithTag(nodeTestTag(box)).assertIsFocused() From 33d0cf548071cd2ad9adca2fc7e11299c90163ea Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:39:53 +0900 Subject: [PATCH 075/224] Docs: Agree FR-41.3 Transform3D and add FR-41.5 preserve-3d (Draft) Closes the spec half of #105. Modifier 27, k = 8, 92 bytes, backface flag in the record. --- docs/SPEC.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index b4986fdf..5d3ab33e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2713,12 +2713,69 @@ dioxus-compose FR-34.2 수용 기준 5(`prefers-reduced-motion`)는 Host가 미 --- -### FR-41.3 3D 변환 (`Draft`, 다음 단계) +### FR-41.3 3D 변환 (`Agreed`) -`rotateX`, `rotateY`, `perspective`, `matrix3d`처럼 2D 아핀 레코드로 나타낼 수 없는 변환입니다. 다음 단계에서 구현합니다. 지금 정해 둔 것은 다음과 같습니다. -- 값은 4x4 행렬 수정자(64바이트, 넓은 `SetModifier` 규칙으로 `k = 6`)입니다. -- `graphicsLayer`는 `rotationX`/`rotationY`와 `cameraDistance`까지 안정 API로 그립니다. 임의의 4x4 원근 행렬은 안정 API 밖이므로, 그 부분은 어댑터 한 파일에 가두고 버전을 고정합니다(NFR-6). -- 수용 기준은 이 항목을 구현할 때 적습니다. 그전까지 Host는 3D 변환을 2D로 근사해 보내지 않습니다. dioxus-compose는 그 변환을 적용하지 않고 개발자 경고를 냅니다. 틀린 근사보다 경고가 낫기 때문입니다. +`rotateX`, `rotateY`, `perspective`, `matrix3d`처럼 2D 아핀 레코드(FR-41.1)로 나타낼 수 없는 변환입니다. 노드와 그 안의 내용 전부를 4x4 행렬로 그립니다. 레이아웃에는 영향을 주지 않고, 적중 판정은 변환된 모양을 따릅니다. 오너가 2026-10-25에 승인했습니다(이슈 #105). + +#### 값 + +- `Modifier::Transform3D { m: [f32; 16], origin_x, origin_y, backface_hidden }`. 수정자 태그 27. +- `m`은 열 우선(column-major)입니다. CSS `matrix3d(a1, b1, c1, d1, a2, ...)`의 인자 순서와 같고, `m[4 * column + row]`입니다. 열 벡터 기준으로 `p' = M · (x, y, z, 1)`, 화면 좌표는 `(x'/w', y'/w')`입니다. 이동 성분(`m[12]`, `m[13]`, `m[14]`)과 `perspective` 항(`m[11] = -1/d`)은 CSS px와 같은 단위로, Host가 `Offset`과 같은 변환을 합니다. +- `origin_x`, `origin_y`는 FR-41.1과 같은 뜻입니다. 노드가 측정된 크기(border box)에 대한 비율이고 `(0.5, 0.5)`가 가운데입니다. 원점의 z는 0입니다. 길이로 쓴 원점은 Host가 `T(o)·M·T(-o)`로 행렬에 접어 넣고 원점을 `(0, 0)`으로 보냅니다. +- **Host는 CSS의 3D 함수를 모두 행렬 하나로 접습니다.** `rotateX`, `rotateY`, `rotateZ`, `rotate3d`, `translateZ`, `translate3d`, `scaleZ`, `scale3d`, `perspective()`, `matrix3d`와 2D 함수들을 `transform` 속성에 적힌 순서대로 곱합니다. `perspective()`는 곱의 그 자리에서 행렬 안에 들어갑니다. 요소의 `perspective` 속성(부모가 자식에게 주는 원근)은 Host가 자식의 행렬 앞에 `perspective(d)`를 `T(po)·P·T(-po)`로 접어 보냅니다. 그래서 렌더러는 원근을 따로 받지 않습니다. +- `backface_hidden`은 `backface-visibility: hidden`입니다. 레코드 안의 플래그로 보냅니다. 따로 태그를 두지 않는 이유: 이 플래그는 행렬과 함께 바뀌고(회전하는 카드), 별도 수정자로 나누면 같은 노드의 갱신이 레코드 둘이 됩니다(FR-41.1이 두 수정자 안을 버린 이유와 같습니다). 와이어에서는 8바이트 한 칸이고 값은 `0` 또는 `1`입니다. 그 밖의 값은 `ProtocolError`입니다(`Clip`의 플래그와 같습니다). +- **2D만 있는 변환은 이 수정자로 보내지 않습니다.** 3D 성분이 없는 `transform`은 계속 FR-41.1의 `Transform`입니다. 한 노드에는 `Transform`과 `Transform3D` 가운데 하나만 둡니다. 둘이 한 목록에 있으면 바깥 것부터 차례로 곱해지지만 Host는 하나로 합쳐 보냅니다. + +#### 와이어: 넓은 `SetModifier` + +`SetModifier`의 길이는 `28 + 8k`이고(FR-41.1), 머리 12바이트 뒤에 `f32`가 열여덟 개(행렬 16, 원점 2, 72바이트)와 플래그 한 칸(8바이트)이 오므로 `12 + 72 + 8 = 92`바이트이고 `k = (92 - 28) / 8 = 8`입니다. 예전에 행렬만으로 적은 `k = 6`은 원점과 플래그를 더하면 모자랍니다. + +``` +SetModifier, Transform3D (92바이트) +offset 0 tag: u16 = 3 offset 2 len: u16 = 92 +offset 4 node_id: u32 +offset 8 index: u16 offset 10 modifier: u16 = 27 +offset 12 m[0..2] (f32 x 2) offset 20 m[2..4] +offset 28 m[4..6] offset 36 m[6..8] +offset 44 m[8..10] offset 52 m[10..12] +offset 60 m[12..14] offset 68 m[14..16] +offset 76 origin_x: f32 offset 80 origin_y: f32 +offset 84 backface_hidden: u64 (0 또는 1) +``` + +- 두 `f32`가 `u64` 하나에 들어가는 방식은 `Transform`과 같습니다(첫 값이 하위 32비트). 슬롯은 `First`부터 `Tenth`까지 열 개이고, 행렬이 앞의 여덟 개, 원점이 아홉째, 플래그가 열째입니다. +- 태그가 요구하는 길이와 레코드의 `len`이 다르면 `ProtocolError`입니다. +- 태그 27을 더하면 스키마 해시가 바뀝니다. 핸드셰이크가 낡은 쪽을 거절합니다. + +#### Compose 대응 + +- **납작한 노드는 호모그래피 하나입니다.** 내용이 z = 0 평면에 있으므로 그려지는 것은 행렬 열여섯 개 가운데 `x' = (m0 x + m4 y + m12) / w`, `y' = (m1 x + m5 y + m13) / w`, `w = m3 x + m7 y + m15`에 나오는 아홉 개뿐입니다. 렌더러는 이 호모그래피를 `graphicsLayer`로 정확히 그립니다. 안정 API만 쓰고 근사는 없습니다. + - 원근이 없으면(`m3 = m7 = 0`) 2D 아핀이고 FR-41.1의 두 겹으로 그립니다. + - 원근이 있으면 먼저 `graphicsLayer` 한 겹을 찾습니다. 한 겹의 행렬은 이동, `rotationX/Y/Z`, 확대, 원근(`cameraDistance`)으로 정해지고, 행렬의 위쪽 두 행이 서로 직교한다는 조건에서 확대와 카메라 거리가 풀립니다. `perspective(d) rotateY(a)`처럼 CSS에서 온 행렬은 이 한 겹이 되고 `rotationY = a`, 카메라 거리는 `d`로 돌아옵니다(CSS `perspective(d)`는 `cameraDistance = d`를 dp에서 px로 바꾼 값을 72로 나눈 것). + - 한 겹으로 안 되는 행렬(기울임과 원근의 곱, 원근 안쪽의 이동)은 어댑터 경로입니다. 같은 원근 행을 만드는 `rotationX/Y` 한 겹 뒤에 나머지 2x2 변환을 FR-41.1의 아핀 겹으로 쌓습니다. 겹은 정확히 합성되므로 한 겹과 같은 그림이고, 적중 판정도 겹마다 역행렬을 거쳐 같은 모양을 따릅니다. 세 겹까지 쌓입니다. + - 이 분해와 Compose 레이어의 속성 적용 순서(`prepareTransformationMatrix`)에 기대는 코드는 어댑터 한 파일에 둡니다(NFR-6). 그 파일은 자신이 읽은 Compose 버전을 상수로 기록하고, 모듈이 의존하는 버전과 다르면 빌드가 아니라 테스트가 실패합니다. +- **`backface_hidden`.** 변환된 노드의 뒷면이 보이면(위 호모그래피에서 원근 항을 뗀 2x2 부분의 행렬식이 음수, 곧 그림의 방향이 뒤집힘) 그 노드와 내용을 그리지 않고 적중 판정에서도 뺍니다. `scaleX(-1)`처럼 거울상이 되는 변환도 뒷면입니다. CSS와 같습니다. +- **행렬식이 0이거나 원점에서 `w`(`m15`)가 0 이하인 노드**는 그리지 않고 적중 판정에서도 빠집니다(FR-41.1의 행렬식 0 규칙과 같습니다). +- 값에 NaN이나 무한대가 있으면 `ProtocolError`입니다(NFR-7). +- **레이아웃은 바뀌지 않고**, 적중 판정은 Compose가 변환된 모양으로 합니다. 순서 규칙(`Offset`, `RequiredSize`, `Transform3D`, `Alpha`, ...)은 FR-41.1과 같고 `Transform3D`는 `Transform` 자리에 놓입니다. +- 분해 결과는 레코드 값에서 유도한 캐시로, `remember`의 키가 레코드 값입니다. 값이 바뀌면 레이어 속성만 갱신되고 그 노드와 자식은 다시 측정되지 않습니다. + +수용 기준: +1. `Transform3D` 수정자가 92바이트 `SetModifier`로 Rust와 Kotlin에서 같은 바이트로 인코딩되고 디코딩됩니다(체크인된 프로토콜 벡터). 기존 수정자 0~26의 벡터는 바이트 하나도 바뀌지 않습니다(`fr41_3_transform3d_round_trips`). +2. 값에 NaN이나 무한대가 든 행렬, 0과 1이 아닌 `backface_hidden`, 길이가 92가 아닌 `Transform3D`는 `ProtocolError` 이벤트가 되고 프로세스가 중단되지 않습니다(`fr41_3_non_finite_is_a_protocol_error`). +3. `perspective(400px) rotateY(45deg)`를 가운데 원점으로 단 100x40dp 상자의 네 꼭짓점이 CSS 계산(동차 좌표 나눗셈)과 1px 안에서 맞습니다. 같은 검사를 `rotateX(60deg)`와 둘의 조합에도 합니다(`fr41_3_rotate_perspective_corners_match_css`). +4. `graphicsLayer`로 분해되지 않는 행렬(기울임과 원근의 곱)은 어댑터 경로로 그려져 기준 3과 같은 오차로 맞고, 분해되는 행렬은 어댑터 경로를 타지 않습니다. 어댑터 파일이 묶인 Compose 버전이 빌드에 쓰인 버전과 같은지 검사합니다(`fr41_3_undecomposable_matrix_uses_the_adapter_path`). +5. 변환된 모양 안의 점을 누르면 `Clicked`가 오고, 원래 사각형 안이지만 변환된 모양 밖인 점을 누르면 오지 않습니다(`fr41_3_hit_test_follows_the_shape`). +6. `backface_hidden`이 켜진 노드를 `rotateY(180deg)`로 돌리면 그려지지 않고 눌러도 `Clicked`가 오지 않으며, 켜지 않은 같은 노드는 그려집니다. `rotateY(90deg)`보다 작은 각에서는 켜져 있어도 그려집니다(`fr41_3_backface_hidden`). +7. 변환 값만 바뀐 갱신은 그 노드의 `SetModifier` 하나만 싣고 그 노드와 자식은 다시 측정되지 않습니다(`fr41_3_update_sends_one_record_and_does_not_remeasure`). + +### FR-41.5 `transform-style: preserve-3d` (`Draft`) + +`transform-style: preserve-3d`는 자식들이 부모와 같은 3D 공간을 공유하게 합니다. 자식의 깊이가 서로 비교되어 앞뒤가 정해지고(교차하는 면도 깊이로 자릅니다), 부모의 변환이 자식에게 합쳐서 적용됩니다. + +배경: FR-41.3은 변환마다 노드 하나를 평면으로 합성합니다. 요청한 쪽은 dioxus-compose FR-34.1이고, 카드 뒤집기와 입방체 같은 예가 `preserve-3d`를 씁니다. Host는 `preserve-3d`를 만나면 개발자 경고를 내고 `flat`처럼 그립니다. 틀린 깊이 정렬보다 경고가 낫기 때문입니다. + +안정 Compose로 할 수 없는 이유: `graphicsLayer`는 노드마다 자기만의 오프스크린 레이어로 합성하고, 레이어 안의 `cameraDistance`는 그 레이어 하나의 깊이만 다룹니다. 형제 레이어 사이에는 깊이 버퍼가 없고 그리는 순서(체인과 `zIndex`)가 앞뒤를 정합니다. 그래서 두 면이 서로 파고드는 장면이나 자식이 부모의 3D 변환을 이어받는 장면을 안정 API로 그릴 수 없습니다. 정해야 할 것은 (a) 깊이 정렬을 Host가 면 단위로 풀어 `AbsoluteBox` 순서로 보낼지(교차 면은 못 그림), (b) 렌더러가 깊이를 가진 그리기 경로를 가질지(어댑터 파일의 범위를 넘음)입니다. 어느 쪽이든 오너의 결정이 필요하고, 수용 기준은 그때 적습니다. ### FR-41.4 compose-rust 작성 API (`Agreed`) From 283f560fe58cd743cb072c4fc321891f406f1a62 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:53:03 +0900 Subject: [PATCH 076/224] Feat: Transform3D modifier and the layers that draw it (FR-41.3) Modifier tag 27, a 92 byte SetModifier (k = 8): a column-major 4x4 matrix, the origin as fractions of the node, and a backface-visibility flag. Non-finite values, a flag other than 0 or 1 and a wrong length are protocol errors. The schema hash changes. The Renderer draws the matrix exactly: no perspective by the two affine layers, a matrix one layer can make by a single graphicsLayer, anything else by stacked layers in one adapter file bound to the Compose release it was read against. Covers acceptance criteria 1 to 7. Nothing was compiled or run when this was written. --- .../android-kotlin/shared/ModifierChain.kt | 12 + .../android-kotlin/shared/Protocol.gen.kt | 5 +- .../shared/Transform3DLayers.kt | 304 +++++++++++++ compose-rust/schema-hash.txt | 2 +- compose-rust/src/bin/codegen.rs | 9 +- compose-rust/src/codegen.rs | 34 ++ compose-rust/src/protocol.rs | 87 +++- compose-rust/src/schema.rs | 160 ++++++- compose-rust/tests/generated_protocol.rs | 7 +- compose-rust/tests/html_elements.rs | 14 +- compose-rust/tests/transform3d.rs | 124 ++++++ compose-rust/tests/vectors/transform3d.bin | Bin 0 -> 104 bytes compose-rust/tests/vectors/vectors.json | 12 +- .../android/src/shared/Transform3DLayers.kt | 1 + renderer/desktop/src/protocol/Protocol.gen.kt | 5 +- .../desktop/src/renderer/ModifierChain.kt | 12 + .../desktop/src/renderer/Transform3DLayers.kt | 304 +++++++++++++ .../desktop/test/Transform3DProtocolTest.kt | 80 ++++ renderer/desktop/test/Transform3DTest.kt | 401 ++++++++++++++++++ renderer/ios/src/shared/Transform3DLayers.kt | 1 + .../linux/src/shared/Transform3DLayers.kt | 1 + .../macos/src/shared/Transform3DLayers.kt | 1 + renderer/web/src/shared/Transform3DLayers.kt | 1 + .../windows/src/shared/Transform3DLayers.kt | 1 + 24 files changed, 1566 insertions(+), 12 deletions(-) create mode 100644 compose-rust/android-kotlin/shared/Transform3DLayers.kt create mode 100644 compose-rust/tests/transform3d.rs create mode 100644 compose-rust/tests/vectors/transform3d.bin create mode 120000 renderer/android/src/shared/Transform3DLayers.kt create mode 100644 renderer/desktop/src/renderer/Transform3DLayers.kt create mode 100644 renderer/desktop/test/Transform3DProtocolTest.kt create mode 100644 renderer/desktop/test/Transform3DTest.kt create mode 120000 renderer/ios/src/shared/Transform3DLayers.kt create mode 120000 renderer/linux/src/shared/Transform3DLayers.kt create mode 120000 renderer/macos/src/shared/Transform3DLayers.kt create mode 120000 renderer/web/src/shared/Transform3DLayers.kt create mode 120000 renderer/windows/src/shared/Transform3DLayers.kt diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index ad19aa7e..db30d66a 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -225,6 +225,18 @@ internal fun List.toComposeModifier( } } + // CSS 3D `transform`: one matrix, drawn through the layers that reproduce it + // exactly, so the layout is untouched and a pointer is tested against the shape + // that is drawn. The plan is worked out here, once for each value of the record. + is ProtocolModifier.Transform3D -> { + val plan = Transform3DPlan() + plan.solve( + floatArrayOf(value.m0, value.m1, value.m2, value.m3, value.m4, value.m5, value.m6, value.m7, value.m8, value.m9, value.m10, value.m11, value.m12, value.m13, value.m14, value.m15), + value.backfaceHidden, + ) + chain.perspectiveTransform(plan, value.originX, value.originY) + } + // The node and everything in it are drawn into one layer first, and the layer is // faded once. Fading each child separately would show where they overlap, which // CSS `opacity` does not. diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index b098b8c1..bc21b462 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -904,6 +904,7 @@ sealed interface Modifier { data class Clip(val enabled: Boolean) : Modifier data class Alpha(val value: kotlin.Float) : Modifier data class Transform(val a: kotlin.Float, val b: kotlin.Float, val c: kotlin.Float, val d: kotlin.Float, val e: kotlin.Float, val f: kotlin.Float, val originX: kotlin.Float, val originY: kotlin.Float) : Modifier + data class Transform3D(val m0: kotlin.Float, val m1: kotlin.Float, val m2: kotlin.Float, val m3: kotlin.Float, val m4: kotlin.Float, val m5: kotlin.Float, val m6: kotlin.Float, val m7: kotlin.Float, val m8: kotlin.Float, val m9: kotlin.Float, val m10: kotlin.Float, val m11: kotlin.Float, val m12: kotlin.Float, val m13: kotlin.Float, val m14: kotlin.Float, val m15: kotlin.Float, val originX: kotlin.Float, val originY: kotlin.Float, val backfaceHidden: Boolean) : Modifier } /** How the segment from one keyframe to the next is timed. */ @@ -1122,7 +1123,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 2379870722881034395L + const val SCHEMA_HASH: Long = 2872876509146935595L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2329,6 +2330,7 @@ object Protocol { 24 -> 0 25 -> 0 26 -> 2 + 27 -> 8 else -> throw ProtocolException("unknown modifier tag $tag", offset) } @@ -2363,6 +2365,7 @@ object Protocol { 24 -> Modifier.Clip(flag(first, offset)) 25 -> Modifier.Alpha(kotlin.Float.fromBits(first.toInt())) 26 -> Modifier.Transform(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset)) + 27 -> Modifier.Transform3D(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 32).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 32) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 40).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 40) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 48).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 48) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 56).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 56) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 64).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 64) ushr 32).toInt()), offset), flag(readU64(batch, base, available, at + 72), offset)) else -> throw ProtocolException("unknown modifier tag $tag", offset) } } diff --git a/compose-rust/android-kotlin/shared/Transform3DLayers.kt b/compose-rust/android-kotlin/shared/Transform3DLayers.kt new file mode 100644 index 00000000..43286a0b --- /dev/null +++ b/compose-rust/android-kotlin/shared/Transform3DLayers.kt @@ -0,0 +1,304 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.sqrt + +/** + * What this file is bound to. + * + * Everything below relies on how a Compose layer turns its properties into a matrix, and + * that is not part of Compose's documented surface. It was read from the Compose release + * named here, in `prepareTransformationMatrix` of `ui-graphics`: a layer moves its origin + * to zero, applies the rotation about Z, then Y, then X, then the scale, then the + * perspective that exists only while X or Y is rotated, and moves the origin back and adds + * the translation. The perspective term is `-1 / (cameraDistance * 72)`. + * + * A test compares [COMPOSE_VERSION] with the version the renderer module depends on, so + * moving to another Compose release fails that test until this file has been read against + * the new release. + */ +internal object Compose3DBinding { + const val COMPOSE_VERSION = "1.11.1-ext.1" + + /** Pixels of camera depth for one unit of `cameraDistance`. */ + const val PIXELS_PER_CAMERA_UNIT = 72f +} + +/** + * How a 4x4 CSS matrix is drawn with Compose layers, and nothing approximate about it. + * + * A node is flat: its content lies in the plane z = 0 and what CSS draws is the image of + * that plane. So only nine entries of the matrix matter, those of the homography + * + * ``` + * x' = (m0 x + m4 y + m12) / w y' = (m1 x + m5 y + m13) / w w = m3 x + m7 y + m15 + * ``` + * + * and every such homography is drawn exactly by the layers Compose has. After dividing by + * `m15`, the translation `t` is `(m12, m13)`, which leaves a homography `K` with a 2x2 + * linear part `F` and a perspective row `(g, h)`. + * + * - **No perspective** (`g = h = 0`): an affine map, drawn by the two layers of + * [affineTransform]. + * - **One layer** ([Kind.Single]): the one `graphicsLayer` whose rotationX, rotationY, + * rotationZ, scale, translation and cameraDistance produce `K`. A layer's own matrix is + * `S Q P` with `Q` a rotation, so two rows of `Q` are the two rows of `F` with the + * perspective row scaled in, and the three unknown scales and depth follow from those + * two rows being orthonormal. The depth falls out of `g h D^2 = -(F row 0 . F row 1)` + * when both `g` and `h` are present. With only one of them the depth is free, and it is + * taken to be the one that leaves that axis unscaled, which is the camera distance of + * the CSS `perspective()` that produced the matrix. + * - **Stacked** ([Kind.Stacked]): where no single layer matches (a skew together with + * perspective is the usual case), a layer with the same perspective row and the + * rotations that give it, followed by the affine layers of the remaining 2x2 map. Layers + * compose exactly, so the picture is the same one. + * + * Every layer is a `graphicsLayer`, so the layout is untouched and Compose maps a pointer + * through each layer's inverse, which is why input follows the drawn shape. + * + * Values are in dp. [Kind.Hidden] is a node that is not drawn and takes no input: a matrix + * with no inverse, a `w` that is not positive at the origin, or a back face with + * backface-visibility hidden. + */ +internal class Transform3DPlan { + enum class Kind { Hidden, Affine, Single, Stacked } + + var kind = Kind.Hidden + private set + + /** The perspective layer of [Kind.Single] and [Kind.Stacked], angles in degrees. */ + var rotationX = 0f + private set + var rotationY = 0f + private set + var rotationZ = 0f + private set + var scaleX = 1f + private set + var scaleY = 1f + private set + + /** Camera distance in dp. */ + var depth = 0f + private set + + /** The perspective layer's own translation, dp. Nonzero only for [Kind.Single]. */ + var translationX = 0f + private set + var translationY = 0f + private set + + /** `a, b, c, d, e, f` of the affine layers of [Kind.Affine] and [Kind.Stacked]. */ + val affine = FloatArray(6) + var affineOneLayer = true + private set + + /** Whether the node faces away from the viewer: the map reverses orientation. */ + var facingAway = false + private set + + /** + * Works out how [m], sixteen values in the column-major order of CSS `matrix3d`, is + * drawn. Allocates only the intermediate arithmetic, none of it kept. + */ + fun solve(m: FloatArray, backfaceHidden: Boolean) { + kind = Kind.Hidden + facingAway = false + affineOneLayer = true + val w0 = m[15].toDouble() + if (!(w0 > 0.0)) return + val inverse = 1.0 / w0 + val tx = m[12] * inverse + val ty = m[13] * inverse + val g = m[3] * inverse + val h = m[7] * inverse + val a00 = m[0] * inverse - tx * g + val a01 = m[1] * inverse - ty * g + val a10 = m[4] * inverse - tx * h + val a11 = m[5] * inverse - ty * h + val determinant = a00 * a11 - a01 * a10 + if (determinant == 0.0 || !determinant.isFinite()) return + facingAway = determinant < 0.0 + if (backfaceHidden && facingAway) return + + if (g == 0.0 && h == 0.0) { + affine[0] = a00.toFloat() + affine[1] = a01.toFloat() + affine[2] = a10.toFloat() + affine[3] = a11.toFloat() + affine[4] = tx.toFloat() + affine[5] = ty.toFloat() + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = if (affine.all { it.isFinite() }) Kind.Affine else Kind.Hidden + return + } + if (solveSingleLayer(a00, a01, a10, a11, g, h, tx, ty)) return + solveStacked(a00, a01, a10, a11, g, h, tx, ty) + } + + private fun solveSingleLayer( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ): Boolean { + val depthSquared = when { + g != 0.0 && h != 0.0 -> -(a00 * a10 + a01 * a11) / (g * h) + g != 0.0 -> unscaledDepthSquared(a00 * a00 + a01 * a01, g) + else -> unscaledDepthSquared(a10 * a10 + a11 * a11, h) + } + if (!(depthSquared > 0.0) || !depthSquared.isFinite()) return false + val distance = sqrt(depthSquared) + val row0x = a00 + val row0y = a01 + val row0z = -g * distance + val row1x = a10 + val row1y = a11 + val row1z = -h * distance + val length0 = sqrt(row0x * row0x + row0y * row0y + row0z * row0z) + val length1 = sqrt(row1x * row1x + row1y * row1y + row1z * row1z) + if (!(length0 > 1e-12) || !(length1 > 1e-12)) return false + val q00 = row0x / length0 + val q01 = row0y / length0 + val q02 = row0z / length0 + val q10 = row1x / length1 + val q11 = row1y / length1 + val q12 = row1z / length1 + // The two rows have to be at right angles, or no rotation has them. + if (abs(q00 * q10 + q01 * q11 + q02 * q12) > ORTHOGONAL_TOLERANCE) return false + // The third row of the rotation is the cross product of the first two. + val q20 = q01 * q12 - q02 * q11 + val q21 = q02 * q10 - q00 * q12 + val q22 = q00 * q11 - q01 * q10 + val sinY = q20.coerceIn(-1.0, 1.0) + val cosY = sqrt(1.0 - sinY * sinY) + val x: Double + val z: Double + if (cosY > GIMBAL_LIMIT) { + x = atan2(-q21, q22) + z = atan2(-q10, q00) + } else { + // Looking along the Y axis, where X and Z turn the same way: all of it is Z. + x = 0.0 + z = atan2(q01, q11) + } + val results = floatArrayOf( + degrees(x), degrees(asin(sinY)), degrees(z), + length0.toFloat(), length1.toFloat(), distance.toFloat(), + tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return false + rotationX = results[0] + rotationY = results[1] + rotationZ = results[2] + scaleX = results[3] + scaleY = results[4] + depth = results[5] + translationX = results[6] + translationY = results[7] + kind = Kind.Single + return true + } + + /** + * The depth at which the one perspective axis leaves its own scale at one, which is the + * camera of the CSS `perspective()` that gave the matrix. A row already longer than + * one cannot have that, and takes the depth that turns it by a half right angle. + */ + private fun unscaledDepthSquared(rowLengthSquared: Double, perspective: Double): Double = + if (rowLengthSquared < 1.0) { + (1.0 - rowLengthSquared) / (perspective * perspective) + } else { + rowLengthSquared / (perspective * perspective) + } + + private fun solveStacked( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ) { + // A layer with this perspective row and no scale: sin(rotX) = -h D, and + // sin(rotY) cos(rotX) = g D. D keeps both below one half, so the cosines are fine. + val distance = 0.5 / max(abs(g), abs(h)) + val sinX = -h * distance + val cosX = sqrt(1.0 - sinX * sinX) + val sinY = g * distance / cosX + val cosY = sqrt(1.0 - sinY * sinY) + // That layer's own 2x2 map is [[cosY, sinY sinX], [0, cosX]], with determinant + // cosY cosX. What is left for the affine layers is its inverse times the target. + val determinant = cosY * cosX + val n00 = (cosX * a00 - sinY * sinX * a10) / determinant + val n01 = (cosX * a01 - sinY * sinX * a11) / determinant + val n10 = cosY * a10 / determinant + val n11 = cosY * a11 / determinant + val results = floatArrayOf( + degrees(asin(sinX)), degrees(asin(sinY)), distance.toFloat(), + n00.toFloat(), n01.toFloat(), n10.toFloat(), n11.toFloat(), tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return + rotationX = results[0] + rotationY = results[1] + rotationZ = 0f + scaleX = 1f + scaleY = 1f + depth = results[2] + translationX = 0f + translationY = 0f + for (index in 0 until 6) affine[index] = results[3 + index] + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = Kind.Stacked + } + + private fun degrees(radians: Double): Float = (radians * 180.0 / PI).toFloat() + + private companion object { + const val ORTHOGONAL_TOLERANCE = 1e-4 + + /** Below this cosine of the Y angle the X and Z angles cannot be told apart. */ + const val GIMBAL_LIMIT = 1e-6 + } +} + +/** + * Draws this node and its content through [plan], turning about the fraction of the node's + * size [originX], [originY]. Which layers that takes, and why they are exact, is on + * [Transform3DPlan]. + */ +internal fun Modifier.perspectiveTransform(plan: Transform3DPlan, originX: Float, originY: Float): Modifier { + val origin = TransformOrigin(originX, originY) + return when (plan.kind) { + Transform3DPlan.Kind.Hidden -> graphicsLayer { + // A layer that scales to nothing has no inverse, so nothing is drawn and no + // pointer is ever inside it. + transformOrigin = origin + alpha = 0f + scaleX = 0f + scaleY = 0f + } + Transform3DPlan.Kind.Affine -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + } + Transform3DPlan.Kind.Single -> graphicsLayer { perspectiveLayer(plan, origin) } + Transform3DPlan.Kind.Stacked -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + }.graphicsLayer { perspectiveLayer(plan, origin) } + } +} + +private fun GraphicsLayerScope.perspectiveLayer(plan: Transform3DPlan, origin: TransformOrigin) { + transformOrigin = origin + translationX = plan.translationX.dp.toPx() + translationY = plan.translationY.dp.toPx() + rotationX = plan.rotationX + rotationY = plan.rotationY + rotationZ = plan.rotationZ + scaleX = plan.scaleX + scaleY = plan.scaleY + cameraDistance = plan.depth.dp.toPx() / Compose3DBinding.PIXELS_PER_CAMERA_UNIT +} diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 03872346..a09e7bfe 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x2106ffd038ad189b +0x27de81f6c344712b diff --git a/compose-rust/src/bin/codegen.rs b/compose-rust/src/bin/codegen.rs index 8f3b535d..808c63b4 100644 --- a/compose-rust/src/bin/codegen.rs +++ b/compose-rust/src/bin/codegen.rs @@ -1,11 +1,12 @@ use compose_rust::codegen::{ ANDROID_BRIDGE_RELATIVE_PATH, ANDROID_FAST_NATIVE_RELATIVE_PATH, EVENT_VECTOR_RELATIVE_PATH, GENERATED_RELATIVE_PATH, JNI_RUST_RELATIVE_PATH, LINUX_HOST_DEF_RELATIVE_PATH, - MUTATION_VECTOR_RELATIVE_PATH, SCHEMA_HASH_RELATIVE_PATH, VECTOR_DESCRIPTION_RELATIVE_PATH, + MUTATION_VECTOR_RELATIVE_PATH, SCHEMA_HASH_RELATIVE_PATH, TRANSFORM_3D_VECTOR_RELATIVE_PATH, + VECTOR_DESCRIPTION_RELATIVE_PATH, WASM_RUST_RELATIVE_PATH, WEB_BRIDGE_RELATIVE_PATH, WEB_LOADER_RELATIVE_PATH, generate_android_bridge_kotlin, generate_event_vector, generate_fast_native_kotlin, generate_jni_rust, generate_kotlin, generate_linux_host_def, generate_mutation_vector, - generate_schema_hash, generate_vector_description, generate_wasm_rust, + generate_schema_hash, generate_transform_3d_vector, generate_vector_description, generate_wasm_rust, generate_web_bridge_kotlin, generate_web_loader_js, }; use std::path::{Path, PathBuf}; @@ -34,6 +35,10 @@ fn run() -> Result<(), Box> { manifest_dir.join(MUTATION_VECTOR_RELATIVE_PATH), &generate_mutation_vector()?, )?; + write( + manifest_dir.join(TRANSFORM_3D_VECTOR_RELATIVE_PATH), + &generate_transform_3d_vector()?, + )?; write( manifest_dir.join(EVENT_VECTOR_RELATIVE_PATH), &generate_event_vector()?, diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index cab41e2f..38bf231f 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -36,6 +36,7 @@ pub const GENERATED_RELATIVE_PATH: &str = "../renderer/desktop/src/protocol/Prot pub const SCHEMA_HASH_RELATIVE_PATH: &str = "schema-hash.txt"; pub const MUTATION_VECTOR_RELATIVE_PATH: &str = "tests/vectors/mutations.bin"; pub const EVENT_VECTOR_RELATIVE_PATH: &str = "tests/vectors/events.bin"; +pub const TRANSFORM_3D_VECTOR_RELATIVE_PATH: &str = "tests/vectors/transform3d.bin"; pub const VECTOR_DESCRIPTION_RELATIVE_PATH: &str = "tests/vectors/vectors.json"; const KOTLIN_PACKAGE: &str = "dev.darkpyonix.composerust.protocol"; @@ -2492,6 +2493,29 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { Ok(encoder.finish()?.to_vec()) } +/// The matrix the 3D transform vector carries: sixteen different values, so a value read +/// from the wrong half of the wrong word cannot pass. +pub const TRANSFORM_3D_VECTOR_MATRIX: [f32; 16] = [ + 0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, -6.0, 8.0, 1.0, +]; + +/// One batch holding one `Transform3D` on node 3, the only record 92 bytes long. It is a +/// file of its own so that every byte of the main mutation vector stays where it was. +pub fn generate_transform_3d_vector() -> Result, ProtocolError> { + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetModifier { + node_id: 3, + index: 0, + modifier: Modifier::Transform3D { + m: TRANSFORM_3D_VECTOR_MATRIX, + origin_x: 0.5, + origin_y: 0.25, + backface_hidden: true, + }, + })?; + Ok(encoder.finish()?.to_vec()) +} + pub fn generate_event_vector() -> Result, ProtocolError> { let events = [ HostEvent { @@ -2661,6 +2685,16 @@ pub fn generate_vector_description() -> String { "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {{\n let 한 = \"😀\";\n}}\n", "mut "], "assets": [{{ "assetId": 5, "kind": "Png", "bytes": "89504e47" }}] }}, + "transform3d": {{ + "file": "transform3d.bin", + "description": "One batch with one 92 byte Transform3D modifier on node 3: the column-major matrix, the origin (0.5, 0.25) and backface-visibility hidden", + "recordCount": 1, + "length": 104, + "matrix": [0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, -6.0, 8.0, 1.0], + "originX": 0.5, + "originY": 0.25, + "backfaceHidden": true + }}, "events": {{ "file": "events.bin", "description": "Eighteen independently decodable event records concatenated in schema order", diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 3ec84987..8a84096e 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1642,7 +1642,7 @@ const fn unpack_high(word: u64) -> f32 { /// The value's words with nothing past the first two, which is every modifier but the few /// whose schema entry gives them more. const fn two_words(first: u64, second: u64) -> [u64; MAX_MODIFIER_WORDS] { - [first, second, 0, 0, 0, 0] + [first, second, 0, 0, 0, 0, 0, 0, 0, 0] } fn float_word(value: f32) -> u64 { @@ -1713,6 +1713,10 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { right_paint.to_bits(), bottom_paint.to_bits(), left_paint.to_bits(), + 0, + 0, + 0, + 0, ], ), Modifier::CornerEach { @@ -1742,6 +1746,10 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { 0, 0, 0, + 0, + 0, + 0, + 0, ], ), Modifier::Clip(enabled) => (24, two_words(u64::from(*enabled), 0)), @@ -1764,6 +1772,30 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { pack_floats(*origin_x, *origin_y), 0, 0, + 0, + 0, + 0, + 0, + ], + ), + Modifier::Transform3D { + m, + origin_x, + origin_y, + backface_hidden, + } => ( + 27, + [ + pack_floats(m[0], m[1]), + pack_floats(m[2], m[3]), + pack_floats(m[4], m[5]), + pack_floats(m[6], m[7]), + pack_floats(m[8], m[9]), + pack_floats(m[10], m[11]), + pack_floats(m[12], m[13]), + pack_floats(m[14], m[15]), + pack_floats(*origin_x, *origin_y), + u64::from(*backface_hidden), ], ), } @@ -1779,7 +1811,18 @@ fn decode_paint(bits: u64) -> Result { } fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { - let [first, second, third, fourth, fifth, sixth] = *words; + let [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth, + tenth, + ] = *words; match tag { 0 => Ok(Modifier::Empty), 1 => Ok(Modifier::Padding(f32::from_bits(first as u32))), @@ -1886,6 +1929,46 @@ fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { + let values = [ + unpack_low(first), + unpack_high(first), + unpack_low(second), + unpack_high(second), + unpack_low(third), + unpack_high(third), + unpack_low(fourth), + unpack_high(fourth), + unpack_low(fifth), + unpack_high(fifth), + unpack_low(sixth), + unpack_high(sixth), + unpack_low(seventh), + unpack_high(seventh), + unpack_low(eighth), + unpack_high(eighth), + unpack_low(ninth), + unpack_high(ninth), + ]; + // The same refusal as the 2D matrix: a NaN or an infinity would draw nothing + // on one side and something on the other. + if values.iter().any(|value| !value.is_finite()) { + return Err(ProtocolError::InvalidModifier(27)); + } + let backface_hidden = match tenth { + 0 => false, + 1 => true, + _ => return Err(ProtocolError::InvalidModifier(27)), + }; + let mut m = [0.0; 16]; + m.copy_from_slice(&values[..16]); + Ok(Modifier::Transform3D { + m, + origin_x: values[16], + origin_y: values[17], + backface_hidden, + }) + } other => Err(ProtocolError::InvalidModifier(other)), } } diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 32a062ca..7ef6e164 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -47,6 +47,18 @@ pub enum FieldSlot { SixthLow, SixthHigh, Sixth, + SeventhLow, + SeventhHigh, + Seventh, + EighthLow, + EighthHigh, + Eighth, + NinthLow, + NinthHigh, + Ninth, + TenthLow, + TenthHigh, + Tenth, } impl FieldSlot { @@ -65,6 +77,14 @@ impl FieldSlot { Self::FifthHigh => (4, true), Self::SixthLow | Self::Sixth => (5, false), Self::SixthHigh => (5, true), + Self::SeventhLow | Self::Seventh => (6, false), + Self::SeventhHigh => (6, true), + Self::EighthLow | Self::Eighth => (7, false), + Self::EighthHigh => (7, true), + Self::NinthLow | Self::Ninth => (8, false), + Self::NinthHigh => (8, true), + Self::TenthLow | Self::Tenth => (9, false), + Self::TenthHigh => (9, true), } } } @@ -88,7 +108,7 @@ pub struct VariantSchema { } /// The most words any modifier's value takes, the two every record has included. -pub const MAX_MODIFIER_WORDS: usize = 6; +pub const MAX_MODIFIER_WORDS: usize = 10; /// The `extra_words` the schema gives a modifier tag, or `None` for a tag it does not list. pub const fn modifier_extra_words(tag: u16) -> Option { @@ -152,7 +172,7 @@ pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox;", "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts;", - "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform;", + "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,Transform3D;", "keys=Enter;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged;", "windowsizeclasses=Compact,Medium,Expanded;", @@ -241,6 +261,18 @@ const fn schema_hash() -> u64 { FieldSlot::SixthLow => 15, FieldSlot::SixthHigh => 16, FieldSlot::Sixth => 17, + FieldSlot::SeventhLow => 18, + FieldSlot::SeventhHigh => 19, + FieldSlot::Seventh => 20, + FieldSlot::EighthLow => 21, + FieldSlot::EighthHigh => 22, + FieldSlot::Eighth => 23, + FieldSlot::NinthLow => 24, + FieldSlot::NinthHigh => 25, + FieldSlot::Ninth => 26, + FieldSlot::TenthLow => 27, + FieldSlot::TenthHigh => 28, + FieldSlot::Tenth => 29, }], ); field_index += 1; @@ -1662,6 +1694,25 @@ pub enum Modifier { origin_x: f32, origin_y: f32, }, + /// Draws this node and everything in it through a 4x4 matrix, the way CSS 3D + /// `transform` does, without changing its layout. Pointer input follows the drawn + /// shape. + /// + /// `m` is column-major, in the order of CSS `matrix3d(...)`: element `m[4 * column + + /// row]`. A point `(x, y, z, 1)` goes to `M * (x, y, z, 1)`, and the screen position is + /// the first two results divided by the fourth. Translations are in dp. A perspective + /// term is already inside the matrix, so the Renderer is never sent one separately. The + /// origin means what it means for [`Modifier::Transform`] and its z is zero. + /// + /// `backface_hidden` is CSS `backface-visibility: hidden`: a node whose back is turned + /// to the viewer is not drawn and takes no input. A matrix with no inverse draws + /// nothing. No value may be NaN or infinite. + Transform3D { + m: [f32; 16], + origin_x: f32, + origin_y: f32, + backface_hidden: bool, + }, } impl Modifier { @@ -2120,6 +2171,103 @@ const TRANSFORM_FIELDS: &[FieldSchema] = &[ slot: FieldSlot::FourthHigh, }, ]; +const TRANSFORM_3D_FIELDS: &[FieldSchema] = &[ + FieldSchema { + name: "m0", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FirstLow, + }, + FieldSchema { + name: "m1", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FirstHigh, + }, + FieldSchema { + name: "m2", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SecondLow, + }, + FieldSchema { + name: "m3", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SecondHigh, + }, + FieldSchema { + name: "m4", + ty: FieldType::FiniteFloat, + slot: FieldSlot::ThirdLow, + }, + FieldSchema { + name: "m5", + ty: FieldType::FiniteFloat, + slot: FieldSlot::ThirdHigh, + }, + FieldSchema { + name: "m6", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FourthLow, + }, + FieldSchema { + name: "m7", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FourthHigh, + }, + FieldSchema { + name: "m8", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FifthLow, + }, + FieldSchema { + name: "m9", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FifthHigh, + }, + FieldSchema { + name: "m10", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SixthLow, + }, + FieldSchema { + name: "m11", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SixthHigh, + }, + FieldSchema { + name: "m12", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SeventhLow, + }, + FieldSchema { + name: "m13", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SeventhHigh, + }, + FieldSchema { + name: "m14", + ty: FieldType::FiniteFloat, + slot: FieldSlot::EighthLow, + }, + FieldSchema { + name: "m15", + ty: FieldType::FiniteFloat, + slot: FieldSlot::EighthHigh, + }, + FieldSchema { + name: "originX", + ty: FieldType::FiniteFloat, + slot: FieldSlot::NinthLow, + }, + FieldSchema { + name: "originY", + ty: FieldType::FiniteFloat, + slot: FieldSlot::NinthHigh, + }, + FieldSchema { + name: "backfaceHidden", + ty: FieldType::Bool, + slot: FieldSlot::Tenth, + }, +]; const ENABLED_FIELD: &[FieldSchema] = &[FieldSchema { name: "enabled", ty: FieldType::Bool, @@ -2294,6 +2442,14 @@ pub const MODIFIER_SCHEMA: &[VariantSchema] = &[ extra_words: 2, fields: TRANSFORM_FIELDS, }, + // Sixteen matrix values, the origin and a flag are ten words, eight more than the two + // every record has: 92 bytes. + VariantSchema { + name: "Transform3D", + tag: 27, + extra_words: 8, + fields: TRANSFORM_3D_FIELDS, + }, ]; #[derive(Clone, Debug, PartialEq)] diff --git a/compose-rust/tests/generated_protocol.rs b/compose-rust/tests/generated_protocol.rs index 9f921958..bd4dfc5a 100644 --- a/compose-rust/tests/generated_protocol.rs +++ b/compose-rust/tests/generated_protocol.rs @@ -1,6 +1,6 @@ use compose_rust::codegen::{ LINKED_HOST_FUNCTION, generate_event_vector, generate_kotlin, generate_linux_host_def, - generate_mutation_vector, generate_vector_description, + generate_mutation_vector, generate_transform_3d_vector, generate_vector_description, }; use compose_rust::protocol::{ BatchEncoder, HostEvent, Mutation, PropertyValue, decode_batch, decode_event, encode_event, @@ -74,6 +74,11 @@ fn fr7_generated_vectors_match_schema() { generate_mutation_vector().unwrap(), "mutation vector is stale; run `cargo run -p compose-rust --bin codegen`", ); + assert_eq!( + include_bytes!("vectors/transform3d.bin").as_slice(), + generate_transform_3d_vector().unwrap(), + "3D transform vector is stale; run `cargo run -p compose-rust --bin codegen`", + ); assert_eq!( include_bytes!("vectors/events.bin").as_slice(), generate_event_vector().unwrap(), diff --git a/compose-rust/tests/html_elements.rs b/compose-rust/tests/html_elements.rs index e7256cd0..8ccee976 100644 --- a/compose-rust/tests/html_elements.rs +++ b/compose-rust/tests/html_elements.rs @@ -132,6 +132,15 @@ fn every_modifier() -> Vec { origin_x: 0.5, origin_y: 0.25, }, + Modifier::Transform3D { + m: [ + 0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, + -6.0, 8.0, 1.0, + ], + origin_x: 0.5, + origin_y: 0.25, + backface_hidden: true, + }, ] } @@ -141,7 +150,8 @@ fn tag_of(modifier: &Modifier) -> u16 { } /// Each new modifier round-trips, and its record is as long as its tag says: 28 bytes for -/// a value of two words, 36 for the shadow's three and 60 for the border's six. +/// a value of two words, 36 for the shadow's three, 60 for the border's six and 92 for the +/// 3D transform's ten. #[test] fn fr42_every_modifier_round_trips_at_the_length_its_tag_fixes() { let modifiers = every_modifier(); @@ -176,6 +186,8 @@ fn fr42_every_modifier_round_trips_at_the_length_its_tag_fixes() { (23, 36), (24, 28), (25, 28), + (26, 44), + (27, 92), ] { assert_eq!( 28 + 8 * usize::from(modifier_extra_words(tag).unwrap()), diff --git a/compose-rust/tests/transform3d.rs b/compose-rust/tests/transform3d.rs new file mode 100644 index 00000000..b98b1acc --- /dev/null +++ b/compose-rust/tests/transform3d.rs @@ -0,0 +1,124 @@ +//! CSS 3D transforms on the wire. + +use compose_rust::Modifier; +use compose_rust::codegen::TRANSFORM_3D_VECTOR_MATRIX; +use compose_rust::protocol::{BatchEncoder, Mutation, ProtocolError, decode_batch}; + +fn encode(mutations: &[Mutation<'_>]) -> Vec { + let mut encoder = BatchEncoder::default(); + for mutation in mutations { + encoder.encode(mutation).unwrap(); + } + encoder.finish().unwrap().to_vec() +} + +fn transform_3d(m: [f32; 16], origin_x: f32, origin_y: f32, backface_hidden: bool) -> Modifier { + Modifier::Transform3D { + m, + origin_x, + origin_y, + backface_hidden, + } +} + +const IDENTITY: [f32; 16] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, +]; + +/// A 3D transform is one 92 byte record: the 28 every modifier has and eight more words. +/// The sixteen matrix values come first in column-major order, two `f32` to a word with +/// the first in the low half, then the origin, then a whole word for the backface flag. +/// The checked-in vector carries the same bytes and the main vector is not touched. +#[test] +fn fr41_3_transform3d_round_trips() { + let modifier = transform_3d(TRANSFORM_3D_VECTOR_MATRIX, 0.5, 0.25, true); + let record = Mutation::SetModifier { + node_id: 3, + index: 0, + modifier, + }; + let bytes = encode(std::slice::from_ref(&record)); + assert_eq!(bytes.len(), 12 + 92); + let body = &bytes[12..]; + assert_eq!(&body[0..4], &[3, 0, 92, 0], "tag 3, length 92"); + assert_eq!( + u16::from_le_bytes([body[10], body[11]]), + 27, + "modifier tag 27" + ); + let floats: Vec = body[12..84] + .chunks(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + assert_eq!(&floats[..16], &TRANSFORM_3D_VECTOR_MATRIX, "column-major"); + assert_eq!(&floats[16..], &[0.5, 0.25], "origin at offsets 76 and 80"); + assert_eq!( + u64::from_le_bytes(body[84..92].try_into().unwrap()), + 1, + "backface_hidden is a whole word at offset 84" + ); + assert_eq!(decode_batch(&bytes), Ok(vec![record])); + + let vector = include_bytes!("vectors/transform3d.bin"); + assert_eq!(vector.as_slice(), bytes.as_slice(), "the checked-in vector"); + + let visible = encode(&[Mutation::SetModifier { + node_id: 3, + index: 0, + modifier: transform_3d(IDENTITY, 0.5, 0.5, false), + }]); + assert_eq!(&visible[12 + 84..], &[0; 8], "backface visible is zero"); +} + +/// A matrix or an origin that is not a number is a protocol error, and so are a backface +/// flag that is neither 0 nor 1 and a record whose length is not the one the tag fixes. +#[test] +fn fr41_3_non_finite_is_a_protocol_error() { + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for field in 0..18 { + let mut m = IDENTITY; + let (mut origin_x, mut origin_y) = (0.5, 0.5); + match field { + 16 => origin_x = bad, + 17 => origin_y = bad, + _ => m[field] = bad, + } + let bytes = encode(&[Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: transform_3d(m, origin_x, origin_y, false), + }]); + assert_eq!( + decode_batch(&bytes), + Err(ProtocolError::InvalidModifier(27)), + "{bad} in field {field}" + ); + } + } + + let mut bytes = encode(&[Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: transform_3d(IDENTITY, 0.5, 0.5, false), + }]); + bytes[12 + 84..12 + 92].copy_from_slice(&2_u64.to_le_bytes()); + assert_eq!( + decode_batch(&bytes), + Err(ProtocolError::InvalidModifier(27)), + "a flag of 2" + ); + + let mut bytes = encode(&[Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: transform_3d(IDENTITY, 0.5, 0.5, false), + }]); + // Cut the record to the 44 bytes of the 2D transform and the batch with it. + bytes.truncate(12 + 44); + bytes[4..8].copy_from_slice(&56_u32.to_le_bytes()); + bytes[14..16].copy_from_slice(&44_u16.to_le_bytes()); + assert_eq!( + decode_batch(&bytes), + Err(ProtocolError::InvalidRecordLength) + ); +} diff --git a/compose-rust/tests/vectors/transform3d.bin b/compose-rust/tests/vectors/transform3d.bin new file mode 100644 index 0000000000000000000000000000000000000000..53602a0f71135cfd901802160dcc8e6ed93d8cd1 GIT binary patch literal 104 zcmWlRK@EUF3 0 25 -> 0 26 -> 2 + 27 -> 8 else -> throw ProtocolException("unknown modifier tag $tag", offset) } @@ -2363,6 +2365,7 @@ object Protocol { 24 -> Modifier.Clip(flag(first, offset)) 25 -> Modifier.Alpha(kotlin.Float.fromBits(first.toInt())) 26 -> Modifier.Transform(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset)) + 27 -> Modifier.Transform3D(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 32).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 32) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 40).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 40) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 48).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 48) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 56).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 56) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 64).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 64) ushr 32).toInt()), offset), flag(readU64(batch, base, available, at + 72), offset)) else -> throw ProtocolException("unknown modifier tag $tag", offset) } } diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index ad19aa7e..db30d66a 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -225,6 +225,18 @@ internal fun List.toComposeModifier( } } + // CSS 3D `transform`: one matrix, drawn through the layers that reproduce it + // exactly, so the layout is untouched and a pointer is tested against the shape + // that is drawn. The plan is worked out here, once for each value of the record. + is ProtocolModifier.Transform3D -> { + val plan = Transform3DPlan() + plan.solve( + floatArrayOf(value.m0, value.m1, value.m2, value.m3, value.m4, value.m5, value.m6, value.m7, value.m8, value.m9, value.m10, value.m11, value.m12, value.m13, value.m14, value.m15), + value.backfaceHidden, + ) + chain.perspectiveTransform(plan, value.originX, value.originY) + } + // The node and everything in it are drawn into one layer first, and the layer is // faded once. Fading each child separately would show where they overlap, which // CSS `opacity` does not. diff --git a/renderer/desktop/src/renderer/Transform3DLayers.kt b/renderer/desktop/src/renderer/Transform3DLayers.kt new file mode 100644 index 00000000..43286a0b --- /dev/null +++ b/renderer/desktop/src/renderer/Transform3DLayers.kt @@ -0,0 +1,304 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.sqrt + +/** + * What this file is bound to. + * + * Everything below relies on how a Compose layer turns its properties into a matrix, and + * that is not part of Compose's documented surface. It was read from the Compose release + * named here, in `prepareTransformationMatrix` of `ui-graphics`: a layer moves its origin + * to zero, applies the rotation about Z, then Y, then X, then the scale, then the + * perspective that exists only while X or Y is rotated, and moves the origin back and adds + * the translation. The perspective term is `-1 / (cameraDistance * 72)`. + * + * A test compares [COMPOSE_VERSION] with the version the renderer module depends on, so + * moving to another Compose release fails that test until this file has been read against + * the new release. + */ +internal object Compose3DBinding { + const val COMPOSE_VERSION = "1.11.1-ext.1" + + /** Pixels of camera depth for one unit of `cameraDistance`. */ + const val PIXELS_PER_CAMERA_UNIT = 72f +} + +/** + * How a 4x4 CSS matrix is drawn with Compose layers, and nothing approximate about it. + * + * A node is flat: its content lies in the plane z = 0 and what CSS draws is the image of + * that plane. So only nine entries of the matrix matter, those of the homography + * + * ``` + * x' = (m0 x + m4 y + m12) / w y' = (m1 x + m5 y + m13) / w w = m3 x + m7 y + m15 + * ``` + * + * and every such homography is drawn exactly by the layers Compose has. After dividing by + * `m15`, the translation `t` is `(m12, m13)`, which leaves a homography `K` with a 2x2 + * linear part `F` and a perspective row `(g, h)`. + * + * - **No perspective** (`g = h = 0`): an affine map, drawn by the two layers of + * [affineTransform]. + * - **One layer** ([Kind.Single]): the one `graphicsLayer` whose rotationX, rotationY, + * rotationZ, scale, translation and cameraDistance produce `K`. A layer's own matrix is + * `S Q P` with `Q` a rotation, so two rows of `Q` are the two rows of `F` with the + * perspective row scaled in, and the three unknown scales and depth follow from those + * two rows being orthonormal. The depth falls out of `g h D^2 = -(F row 0 . F row 1)` + * when both `g` and `h` are present. With only one of them the depth is free, and it is + * taken to be the one that leaves that axis unscaled, which is the camera distance of + * the CSS `perspective()` that produced the matrix. + * - **Stacked** ([Kind.Stacked]): where no single layer matches (a skew together with + * perspective is the usual case), a layer with the same perspective row and the + * rotations that give it, followed by the affine layers of the remaining 2x2 map. Layers + * compose exactly, so the picture is the same one. + * + * Every layer is a `graphicsLayer`, so the layout is untouched and Compose maps a pointer + * through each layer's inverse, which is why input follows the drawn shape. + * + * Values are in dp. [Kind.Hidden] is a node that is not drawn and takes no input: a matrix + * with no inverse, a `w` that is not positive at the origin, or a back face with + * backface-visibility hidden. + */ +internal class Transform3DPlan { + enum class Kind { Hidden, Affine, Single, Stacked } + + var kind = Kind.Hidden + private set + + /** The perspective layer of [Kind.Single] and [Kind.Stacked], angles in degrees. */ + var rotationX = 0f + private set + var rotationY = 0f + private set + var rotationZ = 0f + private set + var scaleX = 1f + private set + var scaleY = 1f + private set + + /** Camera distance in dp. */ + var depth = 0f + private set + + /** The perspective layer's own translation, dp. Nonzero only for [Kind.Single]. */ + var translationX = 0f + private set + var translationY = 0f + private set + + /** `a, b, c, d, e, f` of the affine layers of [Kind.Affine] and [Kind.Stacked]. */ + val affine = FloatArray(6) + var affineOneLayer = true + private set + + /** Whether the node faces away from the viewer: the map reverses orientation. */ + var facingAway = false + private set + + /** + * Works out how [m], sixteen values in the column-major order of CSS `matrix3d`, is + * drawn. Allocates only the intermediate arithmetic, none of it kept. + */ + fun solve(m: FloatArray, backfaceHidden: Boolean) { + kind = Kind.Hidden + facingAway = false + affineOneLayer = true + val w0 = m[15].toDouble() + if (!(w0 > 0.0)) return + val inverse = 1.0 / w0 + val tx = m[12] * inverse + val ty = m[13] * inverse + val g = m[3] * inverse + val h = m[7] * inverse + val a00 = m[0] * inverse - tx * g + val a01 = m[1] * inverse - ty * g + val a10 = m[4] * inverse - tx * h + val a11 = m[5] * inverse - ty * h + val determinant = a00 * a11 - a01 * a10 + if (determinant == 0.0 || !determinant.isFinite()) return + facingAway = determinant < 0.0 + if (backfaceHidden && facingAway) return + + if (g == 0.0 && h == 0.0) { + affine[0] = a00.toFloat() + affine[1] = a01.toFloat() + affine[2] = a10.toFloat() + affine[3] = a11.toFloat() + affine[4] = tx.toFloat() + affine[5] = ty.toFloat() + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = if (affine.all { it.isFinite() }) Kind.Affine else Kind.Hidden + return + } + if (solveSingleLayer(a00, a01, a10, a11, g, h, tx, ty)) return + solveStacked(a00, a01, a10, a11, g, h, tx, ty) + } + + private fun solveSingleLayer( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ): Boolean { + val depthSquared = when { + g != 0.0 && h != 0.0 -> -(a00 * a10 + a01 * a11) / (g * h) + g != 0.0 -> unscaledDepthSquared(a00 * a00 + a01 * a01, g) + else -> unscaledDepthSquared(a10 * a10 + a11 * a11, h) + } + if (!(depthSquared > 0.0) || !depthSquared.isFinite()) return false + val distance = sqrt(depthSquared) + val row0x = a00 + val row0y = a01 + val row0z = -g * distance + val row1x = a10 + val row1y = a11 + val row1z = -h * distance + val length0 = sqrt(row0x * row0x + row0y * row0y + row0z * row0z) + val length1 = sqrt(row1x * row1x + row1y * row1y + row1z * row1z) + if (!(length0 > 1e-12) || !(length1 > 1e-12)) return false + val q00 = row0x / length0 + val q01 = row0y / length0 + val q02 = row0z / length0 + val q10 = row1x / length1 + val q11 = row1y / length1 + val q12 = row1z / length1 + // The two rows have to be at right angles, or no rotation has them. + if (abs(q00 * q10 + q01 * q11 + q02 * q12) > ORTHOGONAL_TOLERANCE) return false + // The third row of the rotation is the cross product of the first two. + val q20 = q01 * q12 - q02 * q11 + val q21 = q02 * q10 - q00 * q12 + val q22 = q00 * q11 - q01 * q10 + val sinY = q20.coerceIn(-1.0, 1.0) + val cosY = sqrt(1.0 - sinY * sinY) + val x: Double + val z: Double + if (cosY > GIMBAL_LIMIT) { + x = atan2(-q21, q22) + z = atan2(-q10, q00) + } else { + // Looking along the Y axis, where X and Z turn the same way: all of it is Z. + x = 0.0 + z = atan2(q01, q11) + } + val results = floatArrayOf( + degrees(x), degrees(asin(sinY)), degrees(z), + length0.toFloat(), length1.toFloat(), distance.toFloat(), + tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return false + rotationX = results[0] + rotationY = results[1] + rotationZ = results[2] + scaleX = results[3] + scaleY = results[4] + depth = results[5] + translationX = results[6] + translationY = results[7] + kind = Kind.Single + return true + } + + /** + * The depth at which the one perspective axis leaves its own scale at one, which is the + * camera of the CSS `perspective()` that gave the matrix. A row already longer than + * one cannot have that, and takes the depth that turns it by a half right angle. + */ + private fun unscaledDepthSquared(rowLengthSquared: Double, perspective: Double): Double = + if (rowLengthSquared < 1.0) { + (1.0 - rowLengthSquared) / (perspective * perspective) + } else { + rowLengthSquared / (perspective * perspective) + } + + private fun solveStacked( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ) { + // A layer with this perspective row and no scale: sin(rotX) = -h D, and + // sin(rotY) cos(rotX) = g D. D keeps both below one half, so the cosines are fine. + val distance = 0.5 / max(abs(g), abs(h)) + val sinX = -h * distance + val cosX = sqrt(1.0 - sinX * sinX) + val sinY = g * distance / cosX + val cosY = sqrt(1.0 - sinY * sinY) + // That layer's own 2x2 map is [[cosY, sinY sinX], [0, cosX]], with determinant + // cosY cosX. What is left for the affine layers is its inverse times the target. + val determinant = cosY * cosX + val n00 = (cosX * a00 - sinY * sinX * a10) / determinant + val n01 = (cosX * a01 - sinY * sinX * a11) / determinant + val n10 = cosY * a10 / determinant + val n11 = cosY * a11 / determinant + val results = floatArrayOf( + degrees(asin(sinX)), degrees(asin(sinY)), distance.toFloat(), + n00.toFloat(), n01.toFloat(), n10.toFloat(), n11.toFloat(), tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return + rotationX = results[0] + rotationY = results[1] + rotationZ = 0f + scaleX = 1f + scaleY = 1f + depth = results[2] + translationX = 0f + translationY = 0f + for (index in 0 until 6) affine[index] = results[3 + index] + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = Kind.Stacked + } + + private fun degrees(radians: Double): Float = (radians * 180.0 / PI).toFloat() + + private companion object { + const val ORTHOGONAL_TOLERANCE = 1e-4 + + /** Below this cosine of the Y angle the X and Z angles cannot be told apart. */ + const val GIMBAL_LIMIT = 1e-6 + } +} + +/** + * Draws this node and its content through [plan], turning about the fraction of the node's + * size [originX], [originY]. Which layers that takes, and why they are exact, is on + * [Transform3DPlan]. + */ +internal fun Modifier.perspectiveTransform(plan: Transform3DPlan, originX: Float, originY: Float): Modifier { + val origin = TransformOrigin(originX, originY) + return when (plan.kind) { + Transform3DPlan.Kind.Hidden -> graphicsLayer { + // A layer that scales to nothing has no inverse, so nothing is drawn and no + // pointer is ever inside it. + transformOrigin = origin + alpha = 0f + scaleX = 0f + scaleY = 0f + } + Transform3DPlan.Kind.Affine -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + } + Transform3DPlan.Kind.Single -> graphicsLayer { perspectiveLayer(plan, origin) } + Transform3DPlan.Kind.Stacked -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + }.graphicsLayer { perspectiveLayer(plan, origin) } + } +} + +private fun GraphicsLayerScope.perspectiveLayer(plan: Transform3DPlan, origin: TransformOrigin) { + transformOrigin = origin + translationX = plan.translationX.dp.toPx() + translationY = plan.translationY.dp.toPx() + rotationX = plan.rotationX + rotationY = plan.rotationY + rotationZ = plan.rotationZ + scaleX = plan.scaleX + scaleY = plan.scaleY + cameraDistance = plan.depth.dp.toPx() / Compose3DBinding.PIXELS_PER_CAMERA_UNIT +} diff --git a/renderer/desktop/test/Transform3DProtocolTest.kt b/renderer/desktop/test/Transform3DProtocolTest.kt new file mode 100644 index 00000000..8582a095 --- /dev/null +++ b/renderer/desktop/test/Transform3DProtocolTest.kt @@ -0,0 +1,80 @@ +package dev.darkpyonix.composerust.test + +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.Protocol +import dev.darkpyonix.composerust.protocol.ProtocolException +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * The 3D transform record against the bytes the Rust Host writes: one 92 byte `SetModifier` + * on node 3, the column-major matrix, the origin and the backface flag. + */ +class Transform3DProtocolTest { + + private val expected = ProtocolModifier.Transform3D( + 0.5f, -0.25f, 1.5f, 0.125f, + -2.0f, 3.0f, 0.75f, -0.5f, + 4.0f, -1.25f, 2.5f, -0.0625f, + 12.0f, -6.0f, 8.0f, 1.0f, + 0.5f, 0.25f, + true, + ) + + @Test + fun fr41_3_transform3d_round_trips() { + val bytes = vector() + assertEquals(104, bytes.size) + assertEquals(listOf(Mutation.SetModifier(3, 0, expected)), decode(bytes)) + // The layout the Host writes: modifier tag 27 at offset 22 of the file, the + // matrix from offset 24, the origin at 88 and 92, the flag as a whole word at 96. + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(92, buffer.getShort(14).toInt()) + assertEquals(27, buffer.getShort(22).toInt()) + assertEquals(0.5f, buffer.getFloat(24)) + assertEquals(1.0f, buffer.getFloat(24 + 4 * 15)) + assertEquals(0.5f, buffer.getFloat(88)) + assertEquals(0.25f, buffer.getFloat(92)) + assertEquals(1L, buffer.getLong(96)) + } + + @Test + fun fr41_3_non_finite_is_a_protocol_error() { + for (bad in listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY)) { + for (field in 0 until 18) { + val bytes = vector() + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putFloat(24 + 4 * field, bad) + assertFailsWith("$bad in field $field") { decode(bytes) } + } + } + // A flag that is neither 0 nor 1. + val flag = vector() + ByteBuffer.wrap(flag).order(ByteOrder.LITTLE_ENDIAN).putLong(96, 2L) + assertFailsWith("a flag of 2") { decode(flag) } + // A record whose length is not the one the tag fixes. + val shortRecord = vector() + ByteBuffer.wrap(shortRecord).order(ByteOrder.LITTLE_ENDIAN).putShort(14, 44) + assertFailsWith("a length of 44") { decode(shortRecord) } + } + + private fun decode(bytes: ByteArray): List { + val mutations = mutableListOf() + Protocol.decode(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), mutations::add) + return mutations + } + + private fun vector(): ByteArray { + var directory: File? = File(System.getProperty("user.dir")).absoluteFile + while (directory != null) { + val candidate = File(directory, "compose-rust/tests/vectors/transform3d.bin") + if (candidate.isFile) return candidate.readBytes() + directory = directory.parentFile + } + error("protocol vector transform3d.bin not found above ${System.getProperty("user.dir")}") + } +} diff --git a/renderer/desktop/test/Transform3DTest.kt b/renderer/desktop/test/Transform3DTest.kt new file mode 100644 index 00000000..b71da09d --- /dev/null +++ b/renderer/desktop/test/Transform3DTest.kt @@ -0,0 +1,401 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PixelMap +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.Density +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.tooling.HostResponse +import dev.darkpyonix.composerust.ui.Compose3DBinding +import dev.darkpyonix.composerust.ui.Transform3DPlan +import dev.darkpyonix.composerust.ui.node.RenderNodeObserver +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import java.io.File +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin +import kotlin.math.tan +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val WHITE = 0xFFFFFFFF.toInt() +private const val RED = 0xFFFF0000.toInt() +private const val ROOT = 1 +private const val BOX = 2 +private const val CLICK = 78L + +/** A CSS 4x4 matrix in the column-major order of `matrix3d`, as doubles. */ +private class Css3D(val m: DoubleArray) { + operator fun times(other: Css3D): Css3D { + val out = DoubleArray(16) + for (column in 0 until 4) { + for (row in 0 until 4) { + var sum = 0.0 + for (k in 0 until 4) sum += m[4 * k + row] * other.m[4 * column + k] + out[4 * column + row] = sum + } + } + return Css3D(out) + } + + /** Where the point (x, y, 0) lands on the screen, after the division by w. */ + fun apply(x: Double, y: Double): Offset { + val w = m[3] * x + m[7] * y + m[15] + return Offset(((m[0] * x + m[4] * y + m[12]) / w).toFloat(), ((m[1] * x + m[5] * y + m[13]) / w).toFloat()) + } + + /** The same matrix turning about the point (x, y), the way `transform-origin` does. */ + fun about(x: Double, y: Double): Css3D = translate(x, y) * this * translate(-x, -y) + + fun modifier(originX: Float, originY: Float, backfaceHidden: Boolean = false) = ProtocolModifier.Transform3D( + m[0].toFloat(), m[1].toFloat(), m[2].toFloat(), m[3].toFloat(), + m[4].toFloat(), m[5].toFloat(), m[6].toFloat(), m[7].toFloat(), + m[8].toFloat(), m[9].toFloat(), m[10].toFloat(), m[11].toFloat(), + m[12].toFloat(), m[13].toFloat(), m[14].toFloat(), m[15].toFloat(), + originX, originY, backfaceHidden, + ) + + fun floats(): FloatArray = FloatArray(16) { m[it].toFloat() } + + companion object { + private fun identity() = DoubleArray(16).also { it[0] = 1.0; it[5] = 1.0; it[10] = 1.0; it[15] = 1.0 } + private fun radians(degrees: Double) = degrees * PI / 180.0 + + fun perspective(d: Double) = Css3D(identity().also { it[11] = -1.0 / d }) + fun rotateX(degrees: Double) = Css3D(identity().also { + val c = cos(radians(degrees)); val s = sin(radians(degrees)) + it[5] = c; it[6] = s; it[9] = -s; it[10] = c + }) + fun rotateY(degrees: Double) = Css3D(identity().also { + val c = cos(radians(degrees)); val s = sin(radians(degrees)) + it[0] = c; it[2] = -s; it[8] = s; it[10] = c + }) + fun rotateZ(degrees: Double) = Css3D(identity().also { + val c = cos(radians(degrees)); val s = sin(radians(degrees)) + it[0] = c; it[1] = s; it[4] = -s; it[5] = c + }) + fun skewX(degrees: Double) = Css3D(identity().also { it[4] = tan(radians(degrees)) }) + fun translate(x: Double, y: Double, z: Double = 0.0) = Css3D(identity().also { it[12] = x; it[13] = y; it[14] = z }) + fun scale(x: Double, y: Double) = Css3D(identity().also { it[0] = x; it[5] = y }) + } +} + +/** + * CSS 3D `transform` on a node: drawn through the layers that reproduce the matrix, with the + * layout untouched and pointer input following the drawn shape. + * + * Every test runs at a density of one, so a dp is a pixel. + */ +@OptIn(ExperimentalTestApi::class) +class Transform3DTest { + + private fun page(build: MutableList.() -> Unit): List = buildList { + add( + Mutation.SetTheme( + Theme( + designSystem = DesignSystem.Material3, + fallback = DesignSystem.Material3, + colorScheme = ColorScheme.Light, + adaptive = false, + ), + ), + ) + add(Mutation.Create(ROOT, WidgetKind.AbsoluteBox)) + add(Mutation.SetModifier(ROOT, 0, ProtocolModifier.RequiredSize(300f, 200f))) + add(Mutation.SetModifier(ROOT, 1, ProtocolModifier.Background(Paint.Literal(WHITE)))) + build() + } + + private fun MutableList.node( + id: Int, + parent: Int, + index: Int, + vararg modifiers: ProtocolModifier, + widget: WidgetKind = WidgetKind.Box, + ) { + add(Mutation.Create(id, widget)) + modifiers.forEachIndexed { position, modifier -> add(Mutation.SetModifier(id, position, modifier)) } + add(Mutation.Insert(parent, id, index)) + } + + private fun ComposeUiTest.show(connection: FakeHostConnection): ComposeRustHost { + lateinit var host: ComposeRustHost + setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + host = rememberComposeRustHost(connection) + ComposeRustContent(host) + } + } + waitForIdle() + return host + } + + private fun ComposeUiTest.picture(): PixelMap = + onNodeWithTag(nodeTestTag(ROOT)).captureToImage().toPixelMap() + + private fun ComposeUiTest.toRootBox(id: Int, x: Float, y: Float): Offset { + val root = onNodeWithTag(nodeTestTag(ROOT)).fetchSemanticsNode().layoutInfo.coordinates + val node = onNodeWithTag(nodeTestTag(id)).fetchSemanticsNode().layoutInfo.coordinates + return node.localToRoot(Offset(x, y)) - root.localToRoot(Offset.Zero) + } + + private fun ComposeUiTest.clickAt(x: Float, y: Float) { + onNodeWithTag(nodeTestTag(ROOT)).performMouseInput { + moveTo(Offset(x, y)) + press() + release() + } + waitForIdle() + } + + private fun assertColor(expected: Int, actual: Color, what: String) { + val wanted = Color(expected) + val close = abs(wanted.red - actual.red) <= 2f / 255f && + abs(wanted.green - actual.green) <= 2f / 255f && + abs(wanted.blue - actual.blue) <= 2f / 255f + assertTrue(close, "$what: expected $wanted, found $actual") + } + + private val corners = listOf(0.0 to 0.0, 100.0 to 0.0, 100.0 to 40.0, 0.0 to 40.0, 50.0 to 20.0) + + /** A 100 by 40 box at (50, 50) with [matrix] about the fractions (originX, originY) of it. */ + private fun ComposeUiTest.showBox( + matrix: Css3D, + originX: Float, + originY: Float, + backfaceHidden: Boolean = false, + painted: Boolean = false, + clickable: Boolean = false, + ): FakeHostConnection { + val connection = FakeHostConnection( + page { + val modifiers = buildList { + add(ProtocolModifier.Offset(50f, 50f)) + add(ProtocolModifier.RequiredSize(100f, 40f)) + add(matrix.modifier(originX, originY, backfaceHidden)) + if (painted) add(ProtocolModifier.Background(Paint.Literal(RED))) + if (clickable) add(ProtocolModifier.Clickable(CLICK)) + } + node(BOX, ROOT, 0, *modifiers.toTypedArray()) + }, + ) + show(connection) + return connection + } + + /** The corners and centre of the box, where CSS puts them, in the root box. */ + private fun expectedRoot(matrix: Css3D, originX: Float, originY: Float, x: Double, y: Double): Offset { + val about = matrix.about(originX * 100.0, originY * 40.0) + return about.apply(x, y) + Offset(50f, 50f) + } + + private fun assertCorners(name: String, matrix: Css3D, originX: Float, originY: Float) = runComposeUiTest { + showBox(matrix, originX, originY) + for ((x, y) in corners) { + val expected = expectedRoot(matrix, originX, originY, x, y) + val actual = toRootBox(BOX, x.toFloat(), y.toFloat()) + assertTrue( + hypot(expected.x - actual.x, expected.y - actual.y) < 1f, + "$name about ($originX, $originY): ($x, $y) should be at $expected, is at $actual", + ) + } + } + + /** + * The corners of a 100 by 40 box land within a pixel of the CSS computation for + * perspective(400px) with rotateY(45deg), rotateX(60deg), the two together, and a turn + * about the corner instead of the centre. + */ + @Test + fun fr41_3_rotate_perspective_corners_match_css() { + val cases = mapOf( + "perspective(400px) rotateY(45deg)" to Css3D.perspective(400.0) * Css3D.rotateY(45.0), + "perspective(400px) rotateX(60deg)" to Css3D.perspective(400.0) * Css3D.rotateX(60.0), + "perspective(400px) rotateX(60deg) rotateY(45deg)" to + Css3D.perspective(400.0) * Css3D.rotateX(60.0) * Css3D.rotateY(45.0), + "perspective(300px) rotateX(20deg) rotateY(-35deg) rotateZ(15deg)" to + Css3D.perspective(300.0) * Css3D.rotateX(20.0) * Css3D.rotateY(-35.0) * Css3D.rotateZ(15.0), + "rotateY(45deg) with no perspective" to Css3D.rotateY(45.0), + ) + for ((name, matrix) in cases) { + for ((originX, originY) in listOf(0.5f to 0.5f, 0f to 0f)) { + assertCorners(name, matrix, originX, originY) + } + } + } + + /** + * A skew together with perspective, and a translation inside the perspective, are no + * single layer's matrix. They are drawn by the stacked layers, and the corners match the + * CSS computation just as closely. The ones a single layer can draw are not stacked. + * The file that does this names the Compose release it was read against, and the module + * depends on that release. + */ + @Test + fun fr41_3_undecomposable_matrix_uses_the_adapter_path() { + fun kindOf(matrix: Css3D, backfaceHidden: Boolean = false): Transform3DPlan.Kind = + Transform3DPlan().also { it.solve(matrix.floats(), backfaceHidden) }.kind + + val single = Css3D.perspective(400.0) * Css3D.rotateY(45.0) + val skewed = Css3D.perspective(400.0) * Css3D.rotateY(40.0) * Css3D.skewX(15.0) + val moved = Css3D.perspective(400.0) * Css3D.rotateY(30.0) * Css3D.translate(10.0, 5.0) + assertEquals(Transform3DPlan.Kind.Single, kindOf(single), "rotateY under perspective is one layer") + assertEquals(Transform3DPlan.Kind.Stacked, kindOf(skewed), "a skew under perspective") + assertEquals(Transform3DPlan.Kind.Stacked, kindOf(moved), "a translation inside the perspective") + assertEquals(Transform3DPlan.Kind.Affine, kindOf(Css3D.rotateY(45.0)), "no perspective is the affine layers") + + val plan = Transform3DPlan().also { it.solve(single.floats(), false) } + assertTrue(abs(plan.rotationY - 45f) < 0.01f, "rotationY ${plan.rotationY}") + assertTrue(abs(plan.depth - 400f) < 0.1f, "the camera distance ${plan.depth}") + + for ((name, matrix) in mapOf("skewed" to skewed, "moved" to moved)) { + for ((originX, originY) in listOf(0.5f to 0.5f, 0f to 0f)) { + assertCorners(name, matrix, originX, originY) + } + } + + val declared = File(moduleFile("renderer/desktop/module.yaml")).readText() + assertTrue( + declared.contains("org.thisisthepy.compose.ui:ui:${Compose3DBinding.COMPOSE_VERSION}"), + "Transform3DLayers.kt was read against Compose ${Compose3DBinding.COMPOSE_VERSION}, " + + "but the module depends on another release: read the file against it first", + ) + } + + /** + * A pointer is tested against the shape that is drawn. A box turned 60 degrees about the + * vertical axis under perspective is not hit near its original left corner, is hit at + * its centre, and is hit above its original top edge where the near side has grown. + */ + @Test + fun fr41_3_hit_test_follows_the_shape() = runComposeUiTest { + val matrix = Css3D.perspective(400.0) * Css3D.rotateY(60.0) + val connection = showBox(matrix, 0.5f, 0.5f, clickable = true) + fun clicks() = connection.events.filterIsInstance().count { it.handlerId == CLICK } + fun root(x: Double, y: Double) = expectedRoot(matrix, 0.5f, 0.5f, x, y) + + // The original rectangle covers (50..150, 50..90). The near corner, the left one for + // this turn, has grown above it. + val grownCorner = root(0.0, 0.0) + val centre = root(50.0, 20.0) + val above = Offset( + grownCorner.x + (centre.x - grownCorner.x) * 0.05f, + grownCorner.y + (centre.y - grownCorner.y) * 0.05f, + ) + assertTrue(above.y < 50f, "the point chosen is above the original rectangle: $above") + + clickAt(53f, 53f) + assertEquals(0, clicks(), "near the original corner, which the turned box has left") + clickAt(centre.x, centre.y) + assertEquals(1, clicks(), "the centre") + clickAt(above.x, above.y) + assertEquals(2, clicks(), "outside the original rectangle, inside the turned shape") + } + + /** + * backface-visibility: hidden. Turned half way round the box shows its back and is not + * drawn, and is not hit; without the flag the same box is drawn mirrored. Turned less + * than a quarter it shows its front and is drawn with the flag too. + */ + @Test + fun fr41_3_backface_hidden() { + val half = Css3D.perspective(400.0) * Css3D.rotateY(180.0) + val slight = Css3D.perspective(400.0) * Css3D.rotateY(30.0) + fun centreIsRed(matrix: Css3D, hidden: Boolean): Pair { + var red = false + var clicks = 0 + runComposeUiTest { + val connection = showBox(matrix, 0.5f, 0.5f, backfaceHidden = hidden, painted = true, clickable = true) + val pixel = picture()[100, 70] + red = abs(pixel.red - 1f) < 0.02f && pixel.green < 0.02f && pixel.blue < 0.02f + clickAt(100f, 70f) + clicks = connection.events.filterIsInstance().count { it.handlerId == CLICK } + } + return red to clicks + } + assertEquals(false to 0, centreIsRed(half, hidden = true), "the back, hidden") + assertEquals(true to 1, centreIsRed(half, hidden = false), "the back, not hidden") + assertEquals(true to 1, centreIsRed(slight, hidden = true), "the front, with the flag set") + } + + /** + * A change to the matrix alone is one `SetModifier` and measures nothing again: not the + * node, and not what is inside it. + */ + @Test + fun fr41_3_update_sends_one_record_and_does_not_remeasure() = runComposeUiTest { + val compositions = mutableMapOf() + val measures = mutableMapOf() + RenderNodeObserver.onCompose = { id -> compositions[id] = (compositions[id] ?: 0) + 1 } + RenderNodeObserver.onMeasure = { id -> measures[id] = (measures[id] ?: 0) + 1 } + val before = Css3D.perspective(400.0) * Css3D.rotateY(10.0) + val after = Css3D.perspective(400.0) * Css3D.rotateY(50.0) + val connection = FakeHostConnection( + page { + node( + BOX, ROOT, 0, + ProtocolModifier.Offset(50f, 50f), + ProtocolModifier.RequiredSize(100f, 40f), + before.modifier(0.5f, 0.5f), + widget = WidgetKind.AbsoluteBox, + ) + node(3, BOX, 0, ProtocolModifier.RequiredSize(20f, 20f), widget = WidgetKind.AbsoluteBox) + }, + ) + connection.respondWith { HostResponse(listOf(Mutation.SetModifier(BOX, 2, after.modifier(0.5f, 0.5f)))) } + val host = show(connection) + val measuredBefore = measures.toMap() + val composedBefore = compositions.toMap() + + host.dispatch(HostEvent.Clicked(ROOT, 0)) + waitForIdle() + + val corner = toRootBox(BOX, 0f, 0f) + val expected = expectedRoot(after, 0.5f, 0.5f, 0.0, 0.0) + assertTrue(hypot(corner.x - expected.x, corner.y - expected.y) < 1f, "the new matrix is drawn: $corner, not $expected") + assertEquals(measuredBefore[BOX], measures[BOX], "the transformed node is not measured again") + assertEquals(measuredBefore[3], measures[3], "its child is not measured again") + assertEquals(composedBefore[3], compositions[3], "its child is not recomposed") + assertEquals(composedBefore[ROOT], compositions[ROOT], "its parent is not recomposed") + } + + @AfterTest + fun clearObservers() { + RenderNodeObserver.onCompose = null + RenderNodeObserver.onMeasure = null + } + + private fun moduleFile(relative: String): String { + var directory: File? = File(System.getProperty("user.dir")).absoluteFile + while (directory != null) { + val candidate = File(directory, relative) + if (candidate.isFile) return candidate.path + directory = directory.parentFile + } + error("$relative not found above ${System.getProperty("user.dir")}") + } +} diff --git a/renderer/ios/src/shared/Transform3DLayers.kt b/renderer/ios/src/shared/Transform3DLayers.kt new file mode 120000 index 00000000..1714c8e1 --- /dev/null +++ b/renderer/ios/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/Transform3DLayers.kt b/renderer/linux/src/shared/Transform3DLayers.kt new file mode 120000 index 00000000..1714c8e1 --- /dev/null +++ b/renderer/linux/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/Transform3DLayers.kt b/renderer/macos/src/shared/Transform3DLayers.kt new file mode 120000 index 00000000..1714c8e1 --- /dev/null +++ b/renderer/macos/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/web/src/shared/Transform3DLayers.kt b/renderer/web/src/shared/Transform3DLayers.kt new file mode 120000 index 00000000..1714c8e1 --- /dev/null +++ b/renderer/web/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/Transform3DLayers.kt b/renderer/windows/src/shared/Transform3DLayers.kt new file mode 120000 index 00000000..1714c8e1 --- /dev/null +++ b/renderer/windows/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file From 8fc59c25fbb5dbde2be96ae1c3753d3d164dc303 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:54:27 +0900 Subject: [PATCH 077/224] Docs: Track Transform animation on Transform3D nodes (FR-41.2, Draft) --- docs/SPEC.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index 5d3ab33e..e907c8f1 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2711,6 +2711,14 @@ dioxus-compose FR-34.2 수용 기준 5(`prefers-reduced-motion`)는 Host가 미 19. 컨테이너 하나의 애니메이션이 그 안의 글자를 모두 다시 칠합니다(렌더러 테스트, 테스트 시계). 빨강에서 파랑으로 가는 `Color` 애니메이션 하나를 컨테이너에 걸고 절반에서 멈추면, 그 안에서 자기 색이 없는 `Text`는 깊이와 상관없이(컨테이너 안의 `Column` 안, 그 안의 `Box` 안) 모두 빨강과 파랑의 중간색으로 그려지고, 자기 색이 있는 `Text`는 그 색을 지킵니다. 배치에는 `StartAnimation`이 하나뿐이고 어느 `Text`도 레코드를 받지 않으며, 재생하는 동안 그 `Text`들은 다시 구성되지 않습니다(`fr41_one_animation_on_a_container_recolours_every_text_inside_it`). 20. 테두리 색 애니메이션(렌더러 테스트, 테스트 시계). 너비 8의 `BorderEach`(네 변 빨강)와 너비 8의 `Border`(빨강)에 각각 빨강에서 파랑으로 가는 `BorderColor` 하나를 걸고 절반에서 멈추면, 네 변의 테두리 모두 중간색으로 그려지고 테두리 안쪽은 칠해지지 않습니다(`fr41_an_animated_border_colour_is_what_is_drawn`). +#### FR-41.2.1 3D 변환 노드의 `Transform` 애니메이션 (`Draft`) + +`Transform3D`(FR-41.3)를 단 노드에는 `Transform` 애니메이션을 걸 수 없습니다. 그 노드에는 `Transform` 수정자가 없으므로 렌더러는 이를 `ProtocolError`로 보고합니다. 3D 변환은 행렬 하나로 접혀 오므로, FR-41.2의 함수 목록 보간은 적용되지 않습니다. + +열린 질문은 3D 변환을 어떻게 애니메이션할지입니다. 후보는 CSS Transforms 2의 3D 행렬 보간입니다. 두 행렬을 이동, 확대, 기울임, 원근, 사원수 회전으로 분해하고, 칸마다 보간한 뒤 다시 합칩니다. 보간한 행렬은 FR-41.3의 쌓인 레이어로 매 프레임 그리므로, 적중 판정도 그 순간의 모양을 따릅니다. 이 방법이 맞는지, 키프레임 목록을 맞추는 규칙을 어떻게 적을지, 함수 단위 보간을 따로 둘지는 아직 정하지 않았습니다. + +수용 기준은 구현할 때 적습니다. + --- ### FR-41.3 3D 변환 (`Agreed`) From 19ded53b0e834a5e0a205639d38628fcab95c43f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:59:34 +0900 Subject: [PATCH 078/224] Fix: Run Flathub's builder and linter, and build the Flatpak test calculator on the Linux static renderer (FR-22.6) --- .github/scripts/heavy-checks.py | 1 + .github/workflows/packager-linux.yml | 117 +++++++++++++++++++-------- scripts/packager-linux/flatpak.sh | 26 +++--- 3 files changed, 96 insertions(+), 48 deletions(-) diff --git a/.github/scripts/heavy-checks.py b/.github/scripts/heavy-checks.py index 98686f1f..b4927635 100644 --- a/.github/scripts/heavy-checks.py +++ b/.github/scripts/heavy-checks.py @@ -53,6 +53,7 @@ KNOWN_HEAVY = [ "test-compose-from-fork.yml", "test-kn-desktop-renderer.yml", + "packager-linux.yml", "test-native-renderer.yml", "test-samples.yml", "test-windows-resize-metrics.yml", diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 309c7629..1b7f497e 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -9,18 +9,25 @@ # x86_64) is wrapped and started under a virtual display. # - flatpak: builds the same program from source with flatpak-builder, offline, with # every crate vendored, installs it, then builds 1.0.1 into the same repository -# and updates the installation with `flatpak update`. Then the same calculator -# is packaged as a prebuilt archive and started in the sandbox under a virtual +# and updates the installation with `flatpak update`. Then a calculator, built in +# this run as one executable with the Linux static renderer linked in, is +# packaged as a prebuilt archive and started in the sandbox under a virtual # display, and the Flathub submission for dev.darkpyonix.Ember is generated. -# Every manifest goes through Flathub's linter. +# Every manifest goes through Flathub's linter. The builder and the linter are +# Flathub's own, from the org.flatpak.Builder Flatpak, because the one in +# Ubuntu's archive does not know the options Flathub requires. # -# Runs when the packaging code changes, or by hand. It downloads Flatpak SDKs and builds a -# Rust program inside one, which is not something to repeat on every push. +# Heavy: the static renderer is built first (static-renderer.yml, the same definition the +# release uses), which takes hours, and the Flatpak SDKs are large. So it runs on request +# only: the run-heavy-ci label, or by hand. heavy-checks.yml holds the merge until it has +# passed on the pull request's head. name: Linux packaging on: - push: + pull_request: + # Only when asked: the run-heavy-ci label going on, or a push or reopen while it is on. + types: [synchronize, reopened, labeled] paths: - scripts/packager-linux/** - .github/scripts/packager-linux-calculator-v0.sh @@ -31,29 +38,45 @@ permissions: contents: read concurrency: - group: packager-linux-${{ github.event.pull_request.number || github.ref }} + # A run started by some other label going on gets a group of its own, so it cannot + # cancel a heavy run. + group: packager-linux-${{ github.event.pull_request.number || github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-heavy-ci' && format('-other-label-{0}', github.run_id) || '' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always CARGO_PROFILE_DEV_DEBUG: "0" - # The calculator from the published sample release: the renderer, its AWT and Skia - # companions and the executable, already pointed at each other by relative paths. - CALCULATOR_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-x64.tar.gz - CALCULATOR_SHA256: ebc52967d02bfbd2eb22317cb23d023417ec2029eac811f3ce6b018e1950fded - CALCULATOR_ARM64_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/calculator-linux-arm64.tar.gz - CALCULATOR_ARM64_SHA256: b264dbc9efa2d7dc7e1ff9559743840d63c2aeca01f69a0e6d1ea1da9d97306f FLATPAK_RUNTIME: "26.08" # A screenshot for the test listings, pinned to a commit the way a store listing must be # (Ember's large icon; the images are stand-ins, the pinning and mirroring are real). SCREENSHOT_URL: https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/faa430a4b75ec45928623130ea2f874b628a7224/src-tauri/icons/128x128@2x.png - # The renderer release archive, built into the from-source Flatpak so that the step - # which points an executable at the renderer beside it runs for real. - RENDERER_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/v0.0.0/dioxus-compose-renderer-v0.0.0-linux-x64.tar.gz - RENDERER_SHA256: 5fe6ba31e359753a8e3c3a743f2f8ecadac409fda2634ba3b569e00f27ec19aa + # Flathub's builder and linter, run from the org.flatpak.Builder Flatpak. + FLATPAK_BUILDER: flatpak run --command=flatpak-builder org.flatpak.Builder + FLATPAK_BUILDER_LINT: flatpak run --command=flatpak-builder-lint org.flatpak.Builder jobs: + requested: + name: Heavy checks requested + if: >- + github.event_name != 'pull_request' || + (github.event.action == 'labeled' && github.event.label.name == 'run-heavy-ci') || + (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-heavy-ci')) + runs-on: ubuntu-latest + steps: + - run: echo "Heavy checks requested by ${{ github.event_name }} ${{ github.event.action }}" + + # The Linux renderer, from the reusable workflow the release uses. The calculator below + # links it, so it is one executable. The called workflow has no concurrency group of + # its own (it only runs inside a caller), so this workflow's group covers it. + static-renderer: + name: renderer + needs: requested + uses: ./.github/workflows/static-renderer.yml + with: + target: linux-x64 + rust: + needs: requested name: Packager tests runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -104,6 +127,7 @@ jobs: appimage: name: AppImage (${{ matrix.arch }}) + needs: requested runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: @@ -223,6 +247,7 @@ jobs: flatpak: name: Flatpak builds and updates + needs: [requested, static-renderer] runs-on: ubuntu-24.04 timeout-minutes: 90 steps: @@ -238,7 +263,10 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils dbus-daemon patchelf xvfb xauth imagemagick + # No flatpak-builder from apt: it is too old for the options Flathub requires. + # Flathub's own builder is installed below as the org.flatpak.Builder Flatpak. + sudo apt-get install -y --no-install-recommends flatpak elfutils dbus-daemon patchelf xvfb xauth imagemagick \ + build-essential pkg-config libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true flatpak --user install --noninteractive flathub org.flatpak.Builder \ @@ -262,15 +290,17 @@ jobs: --dioxus-toml scripts/packager-linux/demo/Dioxus.toml --version 1.0.0 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ - --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ --out work/flatpak-src --build work/repo --lint id=dev.darkpyonix.compose.UpdateDemo flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" - # The renderer travelled beside the executable and the executable looks beside - # itself for libraries. - flatpak run --devel --command=sh "$id" -c 'ls /app/lib/update-demo; readelf -d /app/lib/update-demo/update-demo | grep -E "RUNPATH|RPATH|NEEDED"' - flatpak run --command=sh "$id" -c 'test -f /app/lib/update-demo/libdioxus_compose_renderer.so' + # One executable: it runs, and no renderer library is among what it loads. + flatpak run --devel --command=sh "$id" -c 'ls /app/lib/update-demo; readelf -d /app/lib/update-demo/update-demo | grep -E "RUNPATH|RPATH|NEEDED" || true' + flatpak run --command=sh "$id" -c ' + set -e + test -x /app/lib/update-demo/update-demo + ! readelf -d /app/lib/update-demo/update-demo | grep -qi "renderer" + ! ls /app/lib/update-demo | grep -qi "renderer"' cat work/flatpak-src/$id.json - name: Build 1.0.1 into the same repository, and flatpak update installs it @@ -290,20 +320,40 @@ jobs: --dioxus-toml scripts/packager-linux/demo/Dioxus.toml --version 1.0.1 \ --source-path source.tar.gz --cargo-lock Cargo.lock \ --package packager-linux-demo --bin update-demo \ - --renderer-url "$RENDERER_URL" --renderer-sha256 "$RENDERER_SHA256" \ --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ --out work/flatpak-src - (cd work/flatpak-src && flatpak-builder \ - --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") + (cd work/flatpak-src && $FLATPAK_BUILDER \ + --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean \ + --install-deps-from=flathub --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") flatpak --user update --noninteractive "$id" flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" flatpak --user info "$id" echo "### Flatpak 1.0.0 updated to 1.0.1 with flatpak update" >> "$GITHUB_STEP_SUMMARY" - - name: Package a calculator as a prebuilt archive and start it in the sandbox + - name: Download the static renderer + uses: actions/download-artifact@v4 + with: + pattern: compose-rust-renderer-v*-linux-x64 + path: renderer-artifact + merge-multiple: true + + - name: Build the calculator against it, as one executable + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/renderer" work/calculator/calculator + tar -xzf renderer-artifact/*.tar.gz -C "$RUNNER_TEMP/renderer" + COMPOSE_RUST_RENDERER_DIR="$RUNNER_TEMP/renderer" \ + cargo build --release -p sample-calculator --features compose-rust/native-renderer + cp target/release/sample-calculator work/calculator/calculator/ + exe=work/calculator/calculator/sample-calculator + readelf -d "$exe" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' || true + # The renderer is linked in: no library of its own is needed beside it. + ! readelf -d "$exe" | grep -qi renderer + tar -czf work/calculator-linux-x64.tar.gz -C work/calculator calculator + + - name: Package the calculator as a prebuilt archive and start it in the sandbox run: | set -euo pipefail - .github/scripts/packager-linux-calculator-v0.sh work mkdir -p work/flatpak-calculator cp work/calculator-linux-x64.tar.gz work/flatpak-calculator/ # flatpak talks to its system helper over the session bus, which a runner has @@ -331,8 +381,8 @@ jobs: - name: Prepare the Flathub submission for dev.darkpyonix.Ember # What flathub/dev.darkpyonix.Ember will hold, generated and linted. Ember has no # release yet, so two inputs are stand-ins and the listing is not submitted: - # the archives are the published calculator samples, and the screenshot is - # Ember's large icon, both pinned. For a real release, + # the archive is the calculator built above (x86_64 only), and the screenshot is + # Ember's large icon, pinned. For a real release, # scripts/packager-linux/flathub/ember.sh takes the tag and fills in Ember's own # archives, digests and the screenshot pinned to that tag. env: @@ -343,15 +393,16 @@ jobs: mkdir -p work/ember-icons curl -fsSL -o work/ember-icons/128.png "$raw/src-tauri/icons/128x128.png" curl -fsSL -o work/ember-icons/256.png "$raw/src-tauri/icons/128x128@2x.png" + mkdir -p work/flathub-ember + cp work/calculator-linux-x64.tar.gz work/flathub-ember/ dbus-run-session -- scripts/packager-linux/flatpak.sh \ --dioxus-toml scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml \ --app-id dev.darkpyonix.Ember --version 0.0.0 \ --icon work/ember-icons/128.png --icon work/ember-icons/256.png \ --screenshot "$raw/src-tauri/icons/128x128@2x.png" --screenshot-caption "Ember" \ - --prebuilt-url "$CALCULATOR_URL" --prebuilt-sha256 "$CALCULATOR_SHA256" --prebuilt-arch x86_64 \ - --prebuilt-url "$CALCULATOR_ARM64_URL" --prebuilt-sha256 "$CALCULATOR_ARM64_SHA256" --prebuilt-arch aarch64 \ + --prebuilt-path calculator-linux-x64.tar.gz \ --out work/flathub-ember --lint - rm -rf work/flathub-ember/.flatpak-builder work/flathub-ember/build + rm -rf work/flathub-ember/.flatpak-builder work/flathub-ember/build work/flathub-ember/calculator-linux-x64.tar.gz find work/flathub-ember -type f | sort cat work/flathub-ember/dev.darkpyonix.Ember.metainfo.xml diff --git a/scripts/packager-linux/flatpak.sh b/scripts/packager-linux/flatpak.sh index 3955df9c..36730e74 100755 --- a/scripts/packager-linux/flatpak.sh +++ b/scripts/packager-linux/flatpak.sh @@ -20,9 +20,11 @@ # them (an app ID whose domain is not verified yet) are only # settled by Flathub # -# The build uses flatpak-builder from the host (FLATPAK_BUILDER overrides it). The linter -# is Flathub's own, from org.flatpak.Builder, installed for the user when missing. flatpak -# itself must be installed. +# The build and the linter both come from Flathub's own org.flatpak.Builder, installed for +# the user when missing, because a distribution's flatpak-builder is often too old for the +# options Flathub requires (--compose-url-policy, screenshot mirroring). FLATPAK_BUILDER +# and FLATPAK_BUILDER_LINT replace the commands, each a command line that takes the +# builder's or the linter's arguments. flatpak itself must be installed. # # Downloads (the generator) go to PACKAGER_LINUX_CACHE, default .scratch/packager-linux/tools # in this repository. packager-linux is run through `cargo run` unless PACKAGER_LINUX @@ -102,18 +104,12 @@ fi manifest="$out/$(basename "$(packager flatpak "${passthrough[@]}")")" echo "== $manifest" -# The build runs with the host's flatpak-builder when there is one. Flathub's own builder, -# org.flatpak.Builder, runs in a sandbox whose user installation is its own, so the -# runtimes installed on the host are invisible to it and it cannot install them either. -# It is still what lints, because the linter only reads files. +# Flathub's recommended way to run both tools (docs.flathub.org/docs/for-app-authors). builder() { - if [[ -n "${FLATPAK_BUILDER:-}" ]]; then - $FLATPAK_BUILDER "$@" - elif command -v flatpak-builder >/dev/null; then - flatpak-builder "$@" - else - flatpak run --command=flatpak-builder org.flatpak.Builder "$@" - fi + ${FLATPAK_BUILDER:-flatpak run --command=flatpak-builder org.flatpak.Builder} "$@" +} +linter() { + ${FLATPAK_BUILDER_LINT:-flatpak run --command=flatpak-builder-lint org.flatpak.Builder} "$@" } if [[ -n "$build_repo" || $lint -eq 1 ]]; then @@ -142,7 +138,7 @@ if [[ $lint -eq 1 ]]; then local name="$1" shift echo "== flatpak-builder-lint $*" - if flatpak run --command=flatpak-builder-lint org.flatpak.Builder "$@" \ + if linter "$@" \ >"$out/lint-$name.json"; then echo " clean" else From 49960f303a81f4fab4d38c726d13e206516c2497 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:00:31 +0900 Subject: [PATCH 079/224] Fix: Build the packaging test calculator on the static renderer for AppImage and Flatpak, both architectures (FR-22.6) --- .../scripts/packager-linux-calculator-v0.sh | 56 -------- .github/workflows/packager-linux.yml | 135 +++++++++++------- 2 files changed, 87 insertions(+), 104 deletions(-) delete mode 100755 .github/scripts/packager-linux-calculator-v0.sh diff --git a/.github/scripts/packager-linux-calculator-v0.sh b/.github/scripts/packager-linux-calculator-v0.sh deleted file mode 100755 index 9d1138e7..00000000 --- a/.github/scripts/packager-linux-calculator-v0.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Stages a calculator whose executable and renderer belong together, for the packaging -# workflow to wrap and start. x86_64 only. -# -# .github/scripts/packager-linux-calculator-v0.sh -# -# leaves /calculator/ (the executable, and the renderer in lib/ beside it) and -# /calculator-linux-x64.tar.gz (the same directory as a release archive). -# -# Why this and not a published sample: the sample-v0.1.1 Linux archives do not start. -# Their executables carry no rpath, the x86_64 renderer asks the executable for a host -# function it does not export (a renderer and host from different commits), and the -# renderer sits beside the executable rather than in lib/, where AWT looks for its -# companions. So the workflow builds the calculator at the v0.0.0 tag of -# DarkPyonix/dioxus-compose against that tag's own released renderer. At that tag the -# renderer is a feature that is off by default, so the build turns it on. That is a Rust -# build only; the renderer is the published artifact, checked by its digest. - -set -euo pipefail - -work="$1" -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -renderer_url=https://github.com/DarkPyonix/dioxus-compose/releases/download/v0.0.0/dioxus-compose-renderer-v0.0.0-linux-x64.tar.gz -renderer_sha256=5fe6ba31e359753a8e3c3a743f2f8ecadac409fda2634ba3b569e00f27ec19aa - -[[ "$(uname -m)" == x86_64 ]] || { echo "error: the v0.0.0 renderer was published for x86_64 only" >&2; exit 1; } -mkdir -p "$work" -work="$(cd "$work" && pwd)" - -if [[ ! -d "$work/dioxus-compose" ]]; then - git clone --quiet --depth 1 --branch v0.0.0 https://github.com/DarkPyonix/dioxus-compose "$work/dioxus-compose" -fi -mkdir -p "$work/renderer" -curl --fail --location --silent --show-error --retry 3 -o "$work/renderer.tar.gz" "$renderer_url" -echo "$renderer_sha256 $work/renderer.tar.gz" | sha256sum -c - -tar -xzf "$work/renderer.tar.gz" -C "$work/renderer" - -(cd "$work/dioxus-compose" && - DIOXUS_COMPOSE_RENDERER_DIR="$work/renderer" cargo build --quiet --release -p sample-calculator --features dioxus-compose/native-renderer) - -stage="$work/calculator/calculator" -rm -rf "$work/calculator" -mkdir -p "$stage/lib" -cp "$work/dioxus-compose/target/release/sample-calculator" "$stage/" -cp -a "$work/renderer/lib/." "$stage/lib/" -"$repo_root/scripts/bundle-renderer.sh" "$stage/sample-calculator" lib -# bundle-renderer.sh only sets the rpath when it had a path to rewrite. -patchelf --set-rpath '$ORIGIN/lib' "$stage/sample-calculator" -readelf -d "$stage/sample-calculator" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' -# The renderer is not optional: an executable built without it exits without a window. -readelf -d "$stage/sample-calculator" | grep -q 'libdioxus_compose_renderer.so' || { - echo "error: the calculator was built without the renderer" >&2 - exit 1 -} -tar -czf "$work/calculator-linux-x64.tar.gz" -C "$work/calculator" calculator -sha256sum "$work/calculator-linux-x64.tar.gz" diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 1b7f497e..085c96bd 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -4,13 +4,14 @@ # # - rust: the packager's tests, lint, and the lock file. # - appimage: builds a small program, wraps it as a signed AppImage, checks the contents -# and the metadata the desktop tools read. Then a real calculator with its -# renderer (built at the v0.0.0 tag against that tag's released renderer, -# x86_64) is wrapped and started under a virtual display. +# and the metadata the desktop tools read. Then the calculator (built in this +# run with the static renderer linked in) is wrapped and started under a +# virtual display, on both architectures. # - flatpak: builds the same program from source with flatpak-builder, offline, with # every crate vendored, installs it, then builds 1.0.1 into the same repository # and updates the installation with `flatpak update`. Then a calculator, built in -# this run as one executable with the Linux static renderer linked in, is +# this run (calculator job) as one executable with the Linux static renderer +# linked in, for x86_64 and aarch64, is # packaged as a prebuilt archive and started in the sandbox under a virtual # display, and the Flathub submission for dev.darkpyonix.Ember is generated. # Every manifest goes through Flathub's linter. The builder and the linter are @@ -30,7 +31,6 @@ on: types: [synchronize, reopened, labeled] paths: - scripts/packager-linux/** - - .github/scripts/packager-linux-calculator-v0.sh - .github/workflows/packager-linux.yml workflow_dispatch: @@ -71,9 +71,64 @@ jobs: static-renderer: name: renderer needs: requested + strategy: + fail-fast: false + matrix: + target: [linux-x64, linux-arm64] uses: ./.github/workflows/static-renderer.yml with: - target: linux-x64 + target: ${{ matrix.target }} + + calculator: + name: Calculator (${{ matrix.arch }}) + needs: [requested, static-renderer] + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x86_64 + target: linux-x64 + - runner: ubuntu-24.04-arm + arch: aarch64 + target: linux-arm64 + steps: + - uses: actions/checkout@v4 + + - name: Install the libraries a Linux application links + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential pkg-config \ + libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev + rustup toolchain install stable --profile minimal && rustup default stable + + - name: Download the static renderer + uses: actions/download-artifact@v4 + with: + pattern: compose-rust-renderer-v*-${{ matrix.target }} + path: renderer-artifact + merge-multiple: true + + - name: Build the calculator against it, as one executable + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/renderer" work/calculator/calculator + tar -xzf renderer-artifact/*.tar.gz -C "$RUNNER_TEMP/renderer" + COMPOSE_RUST_RENDERER_DIR="$RUNNER_TEMP/renderer" \ + cargo build --release -p sample-calculator --features compose-rust/native-renderer + cp target/release/sample-calculator work/calculator/calculator/ + exe=work/calculator/calculator/sample-calculator + readelf -d "$exe" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' || true + # The renderer is linked in: no library of its own is needed beside it. + ! readelf -d "$exe" | grep -qi renderer + tar -czf "work/calculator-${{ matrix.target }}.tar.gz" -C work/calculator calculator + + - uses: actions/upload-artifact@v4 + with: + name: calculator-${{ matrix.target }} + path: work/calculator-${{ matrix.target }}.tar.gz rust: needs: requested @@ -127,7 +182,7 @@ jobs: appimage: name: AppImage (${{ matrix.arch }}) - needs: requested + needs: [requested, calculator] runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: @@ -136,16 +191,12 @@ jobs: include: - runner: ubuntu-24.04 arch: x86_64 - calculator: calculator-linux-x64.tar.gz - calculator_sha256: ebc52967d02bfbd2eb22317cb23d023417ec2029eac811f3ce6b018e1950fded + target: linux-x64 - runner: ubuntu-24.04-arm arch: aarch64 - calculator: calculator-linux-arm64.tar.gz - calculator_sha256: b264dbc9efa2d7dc7e1ff9559743840d63c2aeca01f69a0e6d1ea1da9d97306f + target: linux-arm64 env: ARCH: ${{ matrix.arch }} - CALCULATOR_URL: https://github.com/DarkPyonix/dioxus-compose/releases/download/sample-v0.1.1/${{ matrix.calculator }} - CALCULATOR_SHA256: ${{ matrix.calculator_sha256 }} steps: - uses: actions/checkout@v4 @@ -204,26 +255,26 @@ jobs: cp "$app" work/UpdateDemo.AppImage work/UpdateDemo.AppImage --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" - - name: Wrap a calculator as an AppImage and start it - # A calculator whose executable and renderer belong together: the v0.0.0 tag - # built against its own released renderer - # (.github/scripts/packager-linux-calculator-v0.sh says why the published sample - # archives cannot be used). That renderer exists for x86_64 - # only, so on aarch64 there is nothing that can start, and the step says so. + - name: Download the calculator + uses: actions/download-artifact@v4 + with: + name: calculator-${{ matrix.target }} + path: work + + - name: Wrap the calculator as an AppImage and start it + # Built in this run from this tree as one executable with the static renderer + # linked in (the calculator job), so the same step runs on both architectures. run: | set -euo pipefail - if [[ "$ARCH" != x86_64 ]]; then - echo "::notice title=No aarch64 calculator::no renderer was published for linux-arm64 at v0.0.0" - exit 0 - fi - .github/scripts/packager-linux-calculator-v0.sh work + mkdir -p work/calc + tar -xzf "work/calculator-${{ matrix.target }}.tar.gz" -C work/calc # Unsigned: these are test builds, and the signing key is not created yet. scripts/packager-linux/appimage.sh \ --dioxus-toml samples/calculator/Dioxus.toml \ --overlay scripts/packager-linux/samples.toml \ --exec sample-calculator --icon samples/calculator/assets/icon.png \ --icon samples/calculator/assets/icon-256.png \ - --payload work/calculator/calculator --version 0.0.0 \ + --payload work/calc/calculator --version 0.0.0 \ --out-dir work/dist app=work/dist/Calculator-0.0.0-$ARCH.AppImage xvfb-run -a -s "-screen 0 1280x800x24" bash -c " @@ -247,7 +298,7 @@ jobs: flatpak: name: Flatpak builds and updates - needs: [requested, static-renderer] + needs: [requested, calculator] runs-on: ubuntu-24.04 timeout-minutes: 90 steps: @@ -330,32 +381,18 @@ jobs: flatpak --user info "$id" echo "### Flatpak 1.0.0 updated to 1.0.1 with flatpak update" >> "$GITHUB_STEP_SUMMARY" - - name: Download the static renderer + - name: Download the calculators uses: actions/download-artifact@v4 with: - pattern: compose-rust-renderer-v*-linux-x64 - path: renderer-artifact + pattern: calculator-linux-* + path: work/calc merge-multiple: true - - name: Build the calculator against it, as one executable - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/renderer" work/calculator/calculator - tar -xzf renderer-artifact/*.tar.gz -C "$RUNNER_TEMP/renderer" - COMPOSE_RUST_RENDERER_DIR="$RUNNER_TEMP/renderer" \ - cargo build --release -p sample-calculator --features compose-rust/native-renderer - cp target/release/sample-calculator work/calculator/calculator/ - exe=work/calculator/calculator/sample-calculator - readelf -d "$exe" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' || true - # The renderer is linked in: no library of its own is needed beside it. - ! readelf -d "$exe" | grep -qi renderer - tar -czf work/calculator-linux-x64.tar.gz -C work/calculator calculator - - name: Package the calculator as a prebuilt archive and start it in the sandbox run: | set -euo pipefail mkdir -p work/flatpak-calculator - cp work/calculator-linux-x64.tar.gz work/flatpak-calculator/ + cp work/calc/calculator-linux-x64.tar.gz work/flatpak-calculator/ # flatpak talks to its system helper over the session bus, which a runner has # none of until one is started. dbus-run-session -- scripts/packager-linux/flatpak.sh \ @@ -381,7 +418,7 @@ jobs: - name: Prepare the Flathub submission for dev.darkpyonix.Ember # What flathub/dev.darkpyonix.Ember will hold, generated and linted. Ember has no # release yet, so two inputs are stand-ins and the listing is not submitted: - # the archive is the calculator built above (x86_64 only), and the screenshot is + # the archives are the calculators built above (both architectures, as file:// sources), and the screenshot is # Ember's large icon, pinned. For a real release, # scripts/packager-linux/flathub/ember.sh takes the tag and fills in Ember's own # archives, digests and the screenshot pinned to that tag. @@ -394,15 +431,17 @@ jobs: curl -fsSL -o work/ember-icons/128.png "$raw/src-tauri/icons/128x128.png" curl -fsSL -o work/ember-icons/256.png "$raw/src-tauri/icons/128x128@2x.png" mkdir -p work/flathub-ember - cp work/calculator-linux-x64.tar.gz work/flathub-ember/ + x64="$PWD/work/calc/calculator-linux-x64.tar.gz" + arm64="$PWD/work/calc/calculator-linux-arm64.tar.gz" dbus-run-session -- scripts/packager-linux/flatpak.sh \ --dioxus-toml scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml \ --app-id dev.darkpyonix.Ember --version 0.0.0 \ --icon work/ember-icons/128.png --icon work/ember-icons/256.png \ --screenshot "$raw/src-tauri/icons/128x128@2x.png" --screenshot-caption "Ember" \ - --prebuilt-path calculator-linux-x64.tar.gz \ + --prebuilt-url "file://$x64" --prebuilt-sha256 "$(sha256sum "$x64" | cut -d' ' -f1)" --prebuilt-arch x86_64 \ + --prebuilt-url "file://$arm64" --prebuilt-sha256 "$(sha256sum "$arm64" | cut -d' ' -f1)" --prebuilt-arch aarch64 \ --out work/flathub-ember --lint - rm -rf work/flathub-ember/.flatpak-builder work/flathub-ember/build work/flathub-ember/calculator-linux-x64.tar.gz + rm -rf work/flathub-ember/.flatpak-builder work/flathub-ember/build find work/flathub-ember -type f | sort cat work/flathub-ember/dev.darkpyonix.Ember.metainfo.xml From 933496b443455c4be7351b1854bc8c1feb11068c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:04:36 +0900 Subject: [PATCH 080/224] Fix: Keep the widget dispatch the only when over the node's widget --- compose-rust/android-kotlin/shared/RenderNode.kt | 8 ++++---- renderer/desktop/src/renderer/RenderNode.kt | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 16cd7c29..c1371f73 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -142,10 +142,7 @@ fun RenderNode( // listens for keys is made one, ahead of its modifiers like the handler. Without it // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. // Widgets that are controls bring their own focus handling and are left alone. - val focusTarget = when (node.widget) { - WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget -> true - else -> false - } + val focusTarget = node.widget in KeyFocusTargets profiled.hostKeyEvents(nodeId, keyDownHandler, dispatcher) .let { if (focusTarget) it.focusable() else it } } @@ -683,3 +680,6 @@ object LayoutProfile { fun snapshot(): List> = totals.entries.map { Triple(it.key, it.value.nanos / 1e6, it.value.count) }.sortedByDescending { it.second } } + +/** The containers that become focus targets when they listen for keys. */ +private val KeyFocusTargets = setOf(WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget) diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 16cd7c29..c1371f73 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -142,10 +142,7 @@ fun RenderNode( // listens for keys is made one, ahead of its modifiers like the handler. Without it // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. // Widgets that are controls bring their own focus handling and are left alone. - val focusTarget = when (node.widget) { - WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget -> true - else -> false - } + val focusTarget = node.widget in KeyFocusTargets profiled.hostKeyEvents(nodeId, keyDownHandler, dispatcher) .let { if (focusTarget) it.focusable() else it } } @@ -683,3 +680,6 @@ object LayoutProfile { fun snapshot(): List> = totals.entries.map { Triple(it.key, it.value.nanos / 1e6, it.value.count) }.sortedByDescending { it.second } } + +/** The containers that become focus targets when they listen for keys. */ +private val KeyFocusTargets = setOf(WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget) From 2cfd783e83a2344a37c08ac80876b2676467848c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:14:44 +0900 Subject: [PATCH 081/224] Chore: Record Compose 1.11.1-ext.2 for the 3D transform adapter The adapter's COMPOSE_VERSION pin moves from 1.11.1-ext.1 to 1.11.1-ext.2 in the desktop source (shared by the symlinked platform modules) and in the Android staged copy. module.yaml is bumped separately, so the Transform3D pin test (FR-41.3) stays red until that change lands. --- compose-rust/android-kotlin/shared/Transform3DLayers.kt | 2 +- renderer/desktop/src/renderer/Transform3DLayers.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/android-kotlin/shared/Transform3DLayers.kt b/compose-rust/android-kotlin/shared/Transform3DLayers.kt index 43286a0b..73b5d00b 100644 --- a/compose-rust/android-kotlin/shared/Transform3DLayers.kt +++ b/compose-rust/android-kotlin/shared/Transform3DLayers.kt @@ -27,7 +27,7 @@ import kotlin.math.sqrt * the new release. */ internal object Compose3DBinding { - const val COMPOSE_VERSION = "1.11.1-ext.1" + const val COMPOSE_VERSION = "1.11.1-ext.2" /** Pixels of camera depth for one unit of `cameraDistance`. */ const val PIXELS_PER_CAMERA_UNIT = 72f diff --git a/renderer/desktop/src/renderer/Transform3DLayers.kt b/renderer/desktop/src/renderer/Transform3DLayers.kt index 43286a0b..73b5d00b 100644 --- a/renderer/desktop/src/renderer/Transform3DLayers.kt +++ b/renderer/desktop/src/renderer/Transform3DLayers.kt @@ -27,7 +27,7 @@ import kotlin.math.sqrt * the new release. */ internal object Compose3DBinding { - const val COMPOSE_VERSION = "1.11.1-ext.1" + const val COMPOSE_VERSION = "1.11.1-ext.2" /** Pixels of camera depth for one unit of `cameraDistance`. */ const val PIXELS_PER_CAMERA_UNIT = 72f From debc3b36e0aa8a6adc1350b42108a4adb474780c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:38:01 +0900 Subject: [PATCH 082/224] Feat: Package sample releases as .app/.dmg, .zip/.msix and AppImage, add desktop-only dry run (FR-22.6) Windows samples now link the Kotlin/Native static renderer (one executable) instead of the GraalVM DLL. Velopack is not part of this; it remains open under FR-22.6. --- .github/workflows/test-samples.yml | 238 +++++++++++++++++++---------- 1 file changed, 159 insertions(+), 79 deletions(-) diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index f5e9ed19..47635002 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -22,9 +22,28 @@ # two should be versioned apart so a sample can be rebuilt without implying a library # release, and a library release is not held up by a sample that does not build. # -# Each sample links the renderer for its platform. On macOS and Linux that is the static -# archive, built by static-renderer.yml, so each sample is one executable; on Windows the -# renderer library still ships beside it. +# Each sample links the renderer for its platform, built as a Kotlin/Native static library +# (static-renderer.yml on macOS and Linux, windows-static.yml on Windows), so each sample is +# one executable on every desktop platform. +# +# What a release carries, per sample and target, named `-.`: +# - every desktop target: `.tar.gz` of the executable; +# - macos-aarch64: a `.app` signed ad hoc (packager-macos, verified with +# `codesign --verify --deep --strict`) inside a `.dmg`; +# - linux-x64, linux-arm64: an `.AppImage` (packager-linux); +# - windows-x64: a `.zip` and an unsigned `.msix` (packager-windows, sideload channel). +# Android ships an APK, iOS a simulator bundle, the web one archive of pages. Velopack +# installers and updates are not part of this release; that is still open under FR-22.6. +# +# Tag and release naming: +# - The tag is `sample-v` (compose-rust/Cargo.toml) and goes on a merge +# commit of `main` only. The release job refuses a tag whose commit is not on `main`, +# or whose version is not the crate's. +# - The release for the tag is never `latest` (that belongs to the library's `v*` +# releases). `samples-latest` is a pre-release that moves to the newest tag and holds +# the same files. +# - A dispatch with no tag, or with `desktop_only`, builds and uploads workflow artifacts +# and creates no release (a dry run). `desktop_only` skips Android, iOS and the web. name: Samples test @@ -49,6 +68,11 @@ on: required: false type: string default: '' + desktop_only: + description: 'Build the desktop targets only (skip Android, iOS and the web). A tag is ignored for release purposes: nothing is published' + required: false + type: boolean + default: false concurrency: # One run per pull request: a new commit cancels the older run. A tag push or a dispatch @@ -86,17 +110,18 @@ jobs: target: ${{ matrix.target }} ref: ${{ inputs.tag || github.ref }} - fork-desktop: - # The Windows renderer's desktop module asks for the fork's Compose and skiko-awt by version. + # The Windows renderer: Kotlin/Native, linked into the executable, so a Windows sample + # is one file like the others. This replaces the GraalVM DLL the samples used to ship + # beside the program. Its long first half is cached on exactly its inputs. + windows-static: + name: renderer (windows-x64) needs: requested - name: Build the Compose fork's desktop modules - uses: ./.github/workflows/fork-desktop-modules.yml + uses: ./.github/workflows/windows-static.yml build: name: Build the sample (${{ matrix.target }}) - needs: [requested, static-renderer, fork-desktop] - # Windows builds its own renderer and does not need the static ones, and one platform's - # renderer failing does not stop the others' samples. + needs: [requested, static-renderer, windows-static] + # One platform's renderer failing does not stop the others' samples. if: ${{ !cancelled() && needs.requested.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 @@ -116,12 +141,6 @@ jobs: - runner: windows-2022 target: windows-x64 static: false - renderer: powershell .\renderer\desktop\scripts\build-native-windows.ps1 - # `dist`, not `dist/lib`. Windows puts the DLL in `dist\bin` and the JDK's - # font configuration in `dist\lib`, because AWT reads that from java.home and - # the renderer sets java.home to `dist`. The search order looks in a given - # directory and then in its `bin` and `lib`, so naming the parent finds both. - dist: renderer/build/native-image/dist steps: - uses: actions/checkout@v4 with: @@ -152,14 +171,6 @@ jobs: fi echo "link.exe now resolves to: $(command -v link || echo 'nothing on PATH')" - - name: Install upstream GraalVM 25 - if: runner.os == 'Windows' - uses: graalvm/setup-graalvm@v1 - with: - java-version: '25' - distribution: 'graalvm' - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install the libraries a Linux application links # Development packages, because linking names libX11.so and friends. The # executable needs only their runtime halves, which a desktop already has. @@ -170,32 +181,6 @@ jobs: libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev \ desktop-file-utils appstream gnupg - - name: Cache the Kotlin toolchain and Maven dependencies - if: runner.os == 'Windows' - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: kotlin-${{ matrix.target }}-${{ hashFiles('renderer/**/module.yaml') }} - restore-keys: kotlin-${{ matrix.target }}- - - - name: Take the Compose fork's desktop modules - if: runner.os == 'Windows' - uses: actions/download-artifact@v4 - with: - name: compose-fork-desktop - path: ${{ runner.temp }}/compose-fork-desktop - - - name: Put them where the renderer's build looks - if: runner.os == 'Windows' - shell: bash - run: | - mkdir -p ~/.m2/repository/org/jetbrains - cp -R "$RUNNER_TEMP/compose-fork-desktop/." ~/.m2/repository/org/ - - - name: Build the Windows renderer - if: runner.os == 'Windows' - run: ${{ matrix.renderer }} - - name: Download the static renderer if: matrix.static uses: actions/download-artifact@v4 @@ -214,13 +199,18 @@ jobs: ls -la "$RUNNER_TEMP/renderer/lib" echo "RENDERER=$RUNNER_TEMP/renderer" >> "$GITHUB_ENV" + - name: Take the Windows renderer kit + if: runner.os == 'Windows' + uses: actions/download-artifact@v4 + with: + name: windows-static-renderer-kit + path: ${{ runner.temp }}/renderer-kit + - name: Point the build at the Windows renderer if: runner.os == 'Windows' shell: bash - # Absolute. A build script runs with its crate directory as the working - # directory, not the workspace root, so a relative path here points at - # compose-rust/renderer/... and finds nothing. - run: echo "RENDERER=${{ github.workspace }}/${{ matrix.dist }}" >> "$GITHUB_ENV" + # Absolute: a build script runs with its crate directory as the working directory. + run: echo "DXC_WINDOWS_NATIVE_LIB=$(cygpath -m "$RUNNER_TEMP/renderer-kit")" >> "$GITHUB_ENV" - name: Build every sample against it # A sample that does not build is skipped rather than allowed to end the run. The @@ -232,7 +222,7 @@ jobs: shell: bash run: | set -uo pipefail - export COMPOSE_RUST_RENDERER_DIR="$RENDERER" + [[ -z "${RENDERER:-}" ]] || export COMPOSE_RUST_RENDERER_DIR="$RENDERER" : > built.txt : > skipped.txt for manifest in samples/*/Cargo.toml; do @@ -254,6 +244,8 @@ jobs: done echo "built:"; cat built.txt echo "skipped:"; cat skipped.txt + mkdir -p artifacts + [[ ! -s skipped.txt ]] || sed "s/\$/ ${{ matrix.target }}/" skipped.txt > "artifacts/skipped-${{ matrix.target }}.txt" # Nothing at all is a different case. That is the renderer or the toolchain # being wrong rather than one sample being wrong, and shipping an empty platform # quietly would hide it. @@ -263,9 +255,8 @@ jobs: fi - name: Package each sample - # On macOS and Linux a sample is one executable with the renderer inside it, and - # that file is the whole archive. On Windows it still runs beside the renderer - # library it was linked against, so the two are shipped together. + # On every desktop platform a sample is one executable with the renderer inside it, + # and that file is the whole archive. shell: bash run: | set -euo pipefail @@ -275,13 +266,7 @@ jobs: stage="staging/$app" mkdir -p "$stage" if [[ "${{ runner.os }}" == "Windows" ]]; then - cp -R "${{ matrix.dist }}/." "$stage/" - # The executable goes in beside the DLL rather than above it. Windows - # searches the directory the executable is in, and the renderer's DLL lives - # in `bin` because AWT reads its font configuration from the `lib` next to - # it: the renderer sets java.home to their parent, so the two have to stay - # where they are and the program comes to them. - cp "target/release/sample-$app.exe" "$stage/bin/" + cp "target/release/sample-$app.exe" "$stage/" else cp "target/release/sample-$app" "$stage/" # Nothing may travel with it, so nothing it names may be anything but the @@ -295,6 +280,59 @@ jobs: tar -czf "artifacts/$app-${{ matrix.target }}.tar.gz" -C staging "$app" done < built.txt + - name: Make each macOS sample an ad hoc signed application in a disk image + # A sample that fails here keeps its archive and loses only the .app and .dmg. + # Ad hoc signing needs no Apple account; Gatekeeper asks once on a downloaded copy. + if: runner.os == 'macOS' + shell: bash + run: | + set -uo pipefail + command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' compose-rust/Cargo.toml | head -n 1)" + while read -r app; do + [[ -n "$app" ]] || continue + out="apps/$app" + mkdir -p "$out" + if uv run scripts/packager-macos/package-macos app \ + --manifest-dir "samples/$app" --executable "target/release/sample-$app" \ + --version "$version" --out "$out" \ + && bundle="$(find "$out" -maxdepth 1 -name '*.app' | head -n 1)" \ + && [[ -n "$bundle" ]] \ + && codesign --verify --deep --strict --verbose=2 "$bundle" \ + && uv run scripts/packager-macos/package-macos dmg --app "$bundle" \ + --out "artifacts/$app-${{ matrix.target }}.dmg"; then + ditto -c -k --keepParent "$bundle" "artifacts/$app-${{ matrix.target }}.app.zip" + else + echo "::warning title=No application bundle::sample-$app was not packaged as a signed .app and .dmg for ${{ matrix.target }}" + fi + done < built.txt + + - name: Zip and package each Windows sample + # A .zip of the executable, and an unsigned .msix. The MSIX is on the sideload + # channel with no identity from Partner Center: installing it needs developer mode, + # or signing it again with a certificate the machine trusts. + if: runner.os == 'Windows' + shell: bash + run: | + set -uo pipefail + cargo build --release -p compose-rust-msix --bin compose-rust-msix + while read -r app; do + [[ -n "$app" ]] || continue + powershell -NoProfile -Command "Compress-Archive -Force -Path 'staging/$app/sample-$app.exe' -DestinationPath 'artifacts/$app-${{ matrix.target }}.zip'" \ + || echo "::warning title=No zip::sample-$app was not zipped for ${{ matrix.target }}" + out="msix/$app"; mkdir -p "$out" + if target/release/compose-rust-msix.exe --project "samples/$app" \ + --exe "target/release/sample-$app.exe" --channel sideload \ + --out "$out" --summary "$out/summary.txt"; then + pkg="$(sed -n 's/^package=//p' "$out/summary.txt" | tr -d '\r' | head -n 1)" + if [[ -f "$pkg" ]]; then cp "$pkg" "artifacts/$app-${{ matrix.target }}.msix" + else echo "::warning title=No MSIX::the packager reported no package for sample-$app"; fi + else + echo "::warning title=No MSIX::sample-$app was not packaged as an MSIX for ${{ matrix.target }}" + fi + done < built.txt + - name: Wrap each Linux sample as an AppImage # The same staged directory, as one file. A sample that fails here keeps its # archive and loses only the AppImage. @@ -307,7 +345,8 @@ jobs: TAG: ${{ inputs.tag || github.ref_name }} run: | set -uo pipefail - version="${TAG#sample-v}" + # The crate's version, so a dry run on a branch is named like the release. + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' compose-rust/Cargo.toml | head -n 1)" sign=() if [[ -n "$SIGNING_KEY" ]]; then export GNUPGHOME="$PWD/.scratch/gnupg" && mkdir -p -m 700 "$GNUPGHOME" @@ -341,8 +380,8 @@ jobs: set -euo pipefail cd artifacts shopt -s nullglob - if command -v sha256sum >/dev/null; then sha256sum *.tar.gz *.AppImage > "SHA256SUMS-${{ matrix.target }}" - else shasum -a 256 *.tar.gz *.AppImage > "SHA256SUMS-${{ matrix.target }}"; fi + if command -v sha256sum >/dev/null; then sha256sum *.tar.gz *.AppImage *.zip *.msix *.dmg > "SHA256SUMS-${{ matrix.target }}" + else shasum -a 256 *.tar.gz *.AppImage *.zip *.msix *.dmg > "SHA256SUMS-${{ matrix.target }}"; fi cat "SHA256SUMS-${{ matrix.target }}" - uses: actions/upload-artifact@v4 @@ -353,6 +392,7 @@ jobs: android: needs: requested + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} name: Build the sample (Android) runs-on: ubuntu-24.04 timeout-minutes: 120 @@ -414,6 +454,7 @@ jobs: ios: needs: requested + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} name: Build the sample (iOS, simulator) runs-on: macos-26 timeout-minutes: 120 @@ -469,6 +510,7 @@ jobs: web: needs: requested + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} name: Build the sample (web) runs-on: ubuntu-24.04 timeout-minutes: 120 @@ -514,9 +556,15 @@ jobs: release: name: Attach the samples to the GitHub release needs: [build, android, ios, web] - # A tag, or a dispatch that names one. A pull request run and a dispatch without a tag - # build everything and stop there. - if: github.event_name == 'push' || inputs.tag != '' + # A tag, or a dispatch that names one, and not a desktop-only dispatch. A pull request + # run and a dispatch without a tag build everything and stop there (a dry run). The + # mobile and web jobs may be skipped; the desktop build must have run. + if: >- + ${{ !cancelled() && needs.build.result == 'success' && + (needs.android.result == 'success' || needs.android.result == 'skipped') && + (needs.ios.result == 'success' || needs.ios.result == 'skipped') && + (needs.web.result == 'success' || needs.web.result == 'skipped') && + (github.event_name == 'push' || (inputs.tag != '' && !inputs.desktop_only)) }} runs-on: ubuntu-latest permissions: contents: write @@ -524,6 +572,23 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Check the tag names a main merge commit and the crate version + env: + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -euo pipefail + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' compose-rust/Cargo.toml | head -n 1)" + if [[ "$TAG" != "sample-v$version" ]]; then + echo "::error title=Wrong tag::$TAG is not sample-v$version, the version in compose-rust/Cargo.toml" + exit 1 + fi + git fetch origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error title=Tag not on main::$TAG points at a commit that is not on main. Tag the merge commit of the release pull request into main." + exit 1 + fi - uses: actions/download-artifact@v4 with: @@ -544,12 +609,10 @@ jobs: run: | set -euo pipefail { - echo "Sample applications, one archive per sample per platform." + echo "Sample applications, per sample and platform. Files are named -.." echo - echo "A macOS or Linux archive contains one executable, with the renderer inside" - echo "it; it runs from anywhere. A Windows archive contains the program and the" - echo "renderer library it was linked against; run it from the extracted" - echo "directory. Android ships an" + echo "Every desktop archive contains one executable, with the renderer inside" + echo "it; it runs from anywhere. Android ships an" echo "APK per sample. iOS ships a simulator bundle per sample, because a" echo "bundle for a device has to be signed by a certificate Apple issued" echo "against a profile naming that device. The web archive is every sample" @@ -559,18 +622,35 @@ jobs: echo echo "Linux also ships each sample as an AppImage. Make it executable and run it." echo + echo "macOS also ships each sample as a .app inside a .dmg (and the .app as a" + echo ".app.zip). The app is signed ad hoc, not by an Apple developer identity, so" + echo "Gatekeeper blocks the first launch of a downloaded copy: right-click the app" + echo "and choose Open, or run xattr -dr com.apple.quarantine .app." + echo + echo "Windows also ships a .zip and an unsigned .msix. Installing the MSIX needs" + echo "Windows developer mode, or signing it again with a certificate the machine" + echo "trusts." + echo + echo "No Velopack installers or auto-update yet; that is a remaining part of" + echo "FR-22.6." + echo echo "Built from $GITHUB_REPOSITORY at $(git rev-parse --short HEAD) ($TAG)." echo echo "Built in this release:" echo - for archive in artifacts/*.tar.gz artifacts/*.AppImage artifacts/*.apk; do + for archive in artifacts/*.tar.gz artifacts/*.AppImage artifacts/*.dmg artifacts/*.zip artifacts/*.msix artifacts/*.apk; do [ -e "$archive" ] || continue echo "- $(basename "$archive")" done echo echo "A sample that does not build on a platform is skipped rather than" - echo "holding up the rest, so a pair missing from the list above failed to" - echo "compile there. The workflow log says which." + echo "holding up the rest. Skipped sample and platform pairs (empty means none" + echo "on the desktop; the workflow log says why):" + echo + for f in artifacts/skipped-*.txt; do + [ -s "$f" ] || continue + sed 's/^/- /' "$f" + done } > notes.md cat notes.md From a7825c56578421969400fae1c644e7d9eb5da6a7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:51:38 +0900 Subject: [PATCH 083/224] Fix: Build the checksum file list with find so an absent file type does not fail the step --- .github/workflows/test-samples.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 47635002..4eb840e5 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -379,9 +379,12 @@ jobs: run: | set -euo pipefail cd artifacts - shopt -s nullglob - if command -v sha256sum >/dev/null; then sha256sum *.tar.gz *.AppImage *.zip *.msix *.dmg > "SHA256SUMS-${{ matrix.target }}" - else shasum -a 256 *.tar.gz *.AppImage *.zip *.msix *.dmg > "SHA256SUMS-${{ matrix.target }}"; fi + # find, not a glob list: no platform makes every file type, and a pattern that + # matches nothing must not end the step under pipefail. + files="$(find . -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.zip' -o -name '*.msix' -o -name '*.dmg' \) | sed 's#^\./##' | sort -u)" + if command -v sha256sum >/dev/null; then sum=(sha256sum); else sum=(shasum -a 256); fi + # shellcheck disable=SC2086 + "${sum[@]}" $files > "SHA256SUMS-${{ matrix.target }}" cat "SHA256SUMS-${{ matrix.target }}" - uses: actions/upload-artifact@v4 From 5455f688b1a25e515a2aeb5659531e2d5aacfe86 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:06:29 +0900 Subject: [PATCH 084/224] Chore: Format codegen.rs --- compose-rust/src/bin/codegen.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/compose-rust/src/bin/codegen.rs b/compose-rust/src/bin/codegen.rs index 808c63b4..11706b01 100644 --- a/compose-rust/src/bin/codegen.rs +++ b/compose-rust/src/bin/codegen.rs @@ -2,12 +2,12 @@ use compose_rust::codegen::{ ANDROID_BRIDGE_RELATIVE_PATH, ANDROID_FAST_NATIVE_RELATIVE_PATH, EVENT_VECTOR_RELATIVE_PATH, GENERATED_RELATIVE_PATH, JNI_RUST_RELATIVE_PATH, LINUX_HOST_DEF_RELATIVE_PATH, MUTATION_VECTOR_RELATIVE_PATH, SCHEMA_HASH_RELATIVE_PATH, TRANSFORM_3D_VECTOR_RELATIVE_PATH, - VECTOR_DESCRIPTION_RELATIVE_PATH, - WASM_RUST_RELATIVE_PATH, WEB_BRIDGE_RELATIVE_PATH, WEB_LOADER_RELATIVE_PATH, - generate_android_bridge_kotlin, generate_event_vector, generate_fast_native_kotlin, - generate_jni_rust, generate_kotlin, generate_linux_host_def, generate_mutation_vector, - generate_schema_hash, generate_transform_3d_vector, generate_vector_description, generate_wasm_rust, - generate_web_bridge_kotlin, generate_web_loader_js, + VECTOR_DESCRIPTION_RELATIVE_PATH, WASM_RUST_RELATIVE_PATH, WEB_BRIDGE_RELATIVE_PATH, + WEB_LOADER_RELATIVE_PATH, generate_android_bridge_kotlin, generate_event_vector, + generate_fast_native_kotlin, generate_jni_rust, generate_kotlin, generate_linux_host_def, + generate_mutation_vector, generate_schema_hash, generate_transform_3d_vector, + generate_vector_description, generate_wasm_rust, generate_web_bridge_kotlin, + generate_web_loader_js, }; use std::path::{Path, PathBuf}; From 793527c1ec8a151e499f9f6c6b505fcae1e1def6 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:31:28 +0900 Subject: [PATCH 085/224] Test: Name the packager tests after the FR-22.6 criteria Signing tests are criterion 14, the Windows DPI manifest and SDK order tests are criterion 13, and the Flatpak manifest, release date and application id tests are criterion 9. The JSON and shell quoting test is an internal unit test and no longer carries a requirement prefix. --- scripts/packager-macos/tests/test_bundle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/packager-macos/tests/test_bundle.py b/scripts/packager-macos/tests/test_bundle.py index 5a92ee80..713a2b36 100644 --- a/scripts/packager-macos/tests/test_bundle.py +++ b/scripts/packager-macos/tests/test_bundle.py @@ -83,7 +83,7 @@ def test_fr22_6_2_renderer_directory_travels_whole_and_is_loaded_relatively(self ) self.assertEqual(bundle.absolute_references(self.app), []) - def test_fr22_6_2_every_piece_of_code_is_signed_ad_hoc(self): + def test_fr22_6_14_every_piece_of_code_is_signed_ad_hoc(self): subprocess.run(["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True) self.assertEqual(bundle.unsigned_code(self.app), []) for path in ( @@ -101,13 +101,13 @@ def test_fr22_6_2_every_piece_of_code_is_signed_ad_hoc(self): self.assertIn("Identifier=dev.example.demo", details) self.assertEqual(cli._verify(self.app), []) - def test_fr22_6_2_the_signed_application_runs(self): + def test_fr22_6_14_the_signed_application_runs(self): # The point of signing at all: Apple silicon kills unsigned arm64 code on sight, # and rewriting the load command left the linker's signature invalid. result = subprocess.run([str(self.app / "Contents" / "MacOS" / "sample-demo")]) self.assertEqual(result.returncode, 0) - def test_fr22_6_2_code_whose_signature_broke_is_reported(self): + def test_fr22_6_14_code_whose_signature_broke_is_reported(self): library = self.app / "Contents/Frameworks/lib/libskiko-stand-in.dylib" with open(library, "ab") as handle: handle.write(b"\0changed after signing") From 55ec447d1996cb8babac935057f411018460ec71 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:31:30 +0900 Subject: [PATCH 086/224] Test: Name the packager tests after the FR-22.6 criteria Signing tests are criterion 14, the Windows DPI manifest and SDK order tests are criterion 13, and the Flatpak manifest, release date and application id tests are criterion 9. The JSON and shell quoting test is an internal unit test and no longer carries a requirement prefix. --- compose-rust/tests/windows_manifest.rs | 6 +++--- scripts/packager-windows/src/sdk.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose-rust/tests/windows_manifest.rs b/compose-rust/tests/windows_manifest.rs index d87b54bb..c1e335ad 100644 --- a/compose-rust/tests/windows_manifest.rs +++ b/compose-rust/tests/windows_manifest.rs @@ -19,7 +19,7 @@ fn u32_at(bytes: &[u8], at: usize) -> u32 { } #[test] -fn fr22_6_the_manifest_declares_per_monitor_v2_awareness() { +fn fr22_6_13_the_manifest_declares_per_monitor_v2_awareness() { // Both elements: `dpiAwareness` is what Windows 10 1607 and later read, `dpiAware` // with `/pm` is the fallback for anything older, and the certification kit accepts // either. @@ -35,7 +35,7 @@ fn fr22_6_the_manifest_declares_per_monitor_v2_awareness() { } #[test] -fn fr22_6_the_resource_file_starts_with_the_empty_entry() { +fn fr22_6_13_the_resource_file_starts_with_the_empty_entry() { let res = manifest_resource(b""); // DataSize 0, HeaderSize 32, type ordinal 0, name ordinal 0, and zeros after. The // linker recognises a resource file by exactly these 32 bytes. @@ -47,7 +47,7 @@ fn fr22_6_the_resource_file_starts_with_the_empty_entry() { } #[test] -fn fr22_6_the_resource_file_holds_the_manifest_as_resource_one() { +fn fr22_6_13_the_resource_file_holds_the_manifest_as_resource_one() { let manifest = APPLICATION_MANIFEST.as_bytes(); let res = manifest_resource(manifest); let e = 32; diff --git a/scripts/packager-windows/src/sdk.rs b/scripts/packager-windows/src/sdk.rs index 18fc2024..cf5df88d 100644 --- a/scripts/packager-windows/src/sdk.rs +++ b/scripts/packager-windows/src/sdk.rs @@ -91,7 +91,7 @@ mod tests { use super::*; #[test] - fn fr22_6_sdk_versions_order_numerically() { + fn fr22_6_13_sdk_versions_order_numerically() { let mut v = [ sdk_version("10.0.9999.0").unwrap(), sdk_version("10.0.22621.0").unwrap(), From b18900703d846c68ef8302a9f69bf656dada5ee1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:31:32 +0900 Subject: [PATCH 087/224] Test: Name the packager tests after the FR-22.6 criteria Signing tests are criterion 14, the Windows DPI manifest and SDK order tests are criterion 13, and the Flatpak manifest, release date and application id tests are criterion 9. The JSON and shell quoting test is an internal unit test and no longer carries a requirement prefix. --- scripts/packager-linux/tests/metadata.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs index 99b285aa..bfce5ed0 100644 --- a/scripts/packager-linux/tests/metadata.rs +++ b/scripts/packager-linux/tests/metadata.rs @@ -220,7 +220,7 @@ fn fr22_6_3_bad_values_are_refused_before_any_file_is_written() { } #[test] -fn fr22_6_3_application_ids_follow_flatpak_and_dbus_rules() { +fn fr22_6_9_application_ids_follow_flatpak_and_dbus_rules() { for good in [ "dev.darkpyonix.composerust.samples.calculator", "org.example.My_App", @@ -508,7 +508,7 @@ fn fr22_6_3_appdir_refuses_a_payload_without_the_executable() { } #[test] -fn fr22_6_3_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { +fn fr22_6_9_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { let meta = meta(); assert_eq!( finish_args(&meta, false), @@ -542,7 +542,7 @@ fn icons_256() -> Vec { } #[test] -fn fr22_6_3_flatpak_manifest_for_a_prebuilt_archive() { +fn fr22_6_9_flatpak_manifest_for_a_prebuilt_archive() { let meta = meta(); let icons = icons_256(); let text = manifest( @@ -600,7 +600,7 @@ fn fr22_6_3_flatpak_manifest_for_a_prebuilt_archive() { } #[test] -fn fr22_6_3_flatpak_manifest_builds_from_source_offline() { +fn fr22_6_9_flatpak_manifest_builds_from_source_offline() { let mut meta = meta(); meta.runtime_version = Some("24.08".into()); let icons = icons_256(); @@ -658,7 +658,7 @@ fn fr22_6_3_flatpak_manifest_builds_from_source_offline() { } #[test] -fn fr22_6_3_json_and_shell_quoting_survive_awkward_text() { +fn json_and_shell_quoting_survive_awkward_text() { assert_eq!( Json::Object(vec![( "k\"ey".into(), @@ -673,7 +673,7 @@ fn fr22_6_3_json_and_shell_quoting_survive_awkward_text() { } #[test] -fn fr22_6_3_release_dates_are_reproducible() { +fn fr22_6_9_release_dates_are_reproducible() { assert_eq!(release_date(None, 0), "1970-01-01"); assert_eq!(release_date(None, 1_790_985_600), "2026-10-03"); assert_eq!(release_date(Some("951782400"), 1_790_985_600), "2000-02-29"); From 6848f788af941344e9884848fcd08c56fe0897e7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:08:00 +0900 Subject: [PATCH 088/224] Feat: Make state handles Copy with an arena and generations (#278) MutableState is now an arena index plus a generation. The value is owned by the slot group of the remember call that made it and is freed, with one Drop, when the group is removed. Released handles: set and update are no-ops with one debug warning per handle, get panics with Released and the remember call site, try_get/try_set/try_update return StateError::Released, and the generation stops an old handle reaching a reused slot. Handles are Send + Sync; identity is slot + generation. Covers FR-39.2.1 and the FR-39.5 criteria 3 to 7 (tests/state_handles.rs). No build or test was run in this change. --- compose-rust/src/runtime/composer.rs | 49 ++++ compose-rust/src/runtime/host.rs | 6 +- compose-rust/src/runtime/mod.rs | 14 +- compose-rust/src/runtime/state.rs | 412 ++++++++++++++++++++++----- compose-rust/src/ui/lazy.rs | 6 +- compose-rust/tests/state_handles.rs | 348 ++++++++++++++++++++++ 6 files changed, 754 insertions(+), 81 deletions(-) create mode 100644 compose-rust/tests/state_handles.rs diff --git a/compose-rust/src/runtime/composer.rs b/compose-rust/src/runtime/composer.rs index d7dec8c5..86e260cd 100644 --- a/compose-rust/src/runtime/composer.rs +++ b/compose-rust/src/runtime/composer.rs @@ -40,6 +40,9 @@ pub(crate) struct Group { pub(crate) generation: u32, pub(crate) alive: bool, pub(crate) slots: Vec>, + /// The states made by the remember call in each slot, by slot index. They are freed + /// when the slot is replaced or the group leaves the composition. + pub(crate) owned: Vec<(usize, super::state::StateKey)>, pub(crate) children: Vec, /// The Renderer node this group created, if it is a widget's group. pub(crate) node: Option, @@ -248,6 +251,7 @@ impl Composer { generation: 0, alive: true, slots: Vec::new(), + owned: Vec::new(), children: Vec::new(), node: None, scope: Some(Box::new(Scope::new())), @@ -315,6 +319,7 @@ impl Composer { generation: 0, alive: true, slots: Vec::new(), + owned: Vec::new(), children: Vec::new(), node: None, scope: None, @@ -545,6 +550,15 @@ impl Composer { /// Stores a fresh value in the current call site's slot, discarding whatever a call /// site of another type left there. pub(crate) fn remember_new(&mut self, value: T) { + self.remember_new_owning(value, Vec::new()); + } + + /// Like `remember_new`, and the states in `owned` are freed with the slot. + pub(crate) fn remember_new_owning( + &mut self, + value: T, + owned: Vec, + ) { let Some(frame) = self.frames.last_mut() else { return; }; @@ -555,9 +569,28 @@ impl Composer { if index < slots.len() { let old = std::mem::replace(&mut slots[index], Box::new(value)); self.forget_slot(old); + self.release_owned(group, Some(index)); } else { slots.push(Box::new(value)); } + self.groups[group as usize] + .owned + .extend(owned.into_iter().map(|key| (index, key))); + } + + /// Frees the states a group's slots own, all of them or those of one slot. The values + /// go to the graveyard, so they are dropped with the composer free. + fn release_owned(&mut self, group: GroupId, slot: Option) { + let owned = std::mem::take(&mut self.groups[group as usize].owned); + let (gone, kept): (Vec<_>, Vec<_>) = owned + .into_iter() + .partition(|(index, _)| slot.is_none_or(|slot| slot == *index)); + self.groups[group as usize].owned = kept; + for (_, key) in gone { + if let Some(value) = super::state::release_state(key) { + self.graveyard.push(value); + } + } } /// Mutable access to the current slot if it holds a `T`, advancing past it. @@ -623,6 +656,7 @@ impl Composer { for slot in slots.into_iter().rev() { self.forget_slot(slot); } + self.release_owned(group, None); if let Some(scope) = self.groups[group as usize].scope.take() { let generation = self.groups[group as usize].generation; for read in scope.reads.iter().chain(scope.previous_reads.iter()) { @@ -1132,3 +1166,18 @@ pub const fn call_site(path: &str, ordinal: u32) -> u64 { hash ^= ordinal as u64; hash.wrapping_mul(0x100_0000_01b3) } + +impl Drop for Composer { + fn drop(&mut self) { + // A composition that ends while groups are still in it frees their states too. + // The values are dropped last, with no lock held, because a destructor may use + // another state. + let mut values = Vec::new(); + for group in &mut self.groups { + for (_, key) in group.owned.drain(..) { + values.extend(super::state::release_state(key)); + } + } + drop(values); + } +} diff --git a/compose-rust/src/runtime/host.rs b/compose-rust/src/runtime/host.rs index 5ebb9fb2..8f2d411b 100644 --- a/compose-rust/src/runtime/host.rs +++ b/compose-rust/src/runtime/host.rs @@ -44,9 +44,9 @@ impl Recomposer { let (window, design, permission) = { let composer = composition.composer.borrow(); ( - composer.ambient.window.clone(), - composer.ambient.design.clone(), - composer.ambient.permission.clone(), + composer.ambient.window, + composer.ambient.design, + composer.ambient.permission, ) }; // The values themselves stay in the modules the Host publishes them to. A state diff --git a/compose-rust/src/runtime/mod.rs b/compose-rust/src/runtime/mod.rs index d60492e4..b72412b7 100644 --- a/compose-rust/src/runtime/mod.rs +++ b/compose-rust/src/runtime/mod.rs @@ -51,7 +51,7 @@ pub use effects::{ pub use host::{ComposeHost, Recomposer, application, launch, recomposer_for}; pub use seam::{Batch, Runtime}; pub use state::{ - DerivedState, MutableState, SnapshotMutationPolicy, State, current_design_system, + DerivedState, MutableState, SnapshotMutationPolicy, State, StateError, current_design_system, current_notification_permission, current_window_size, derived_state_of, mutable_state_of, mutable_state_of_with_policy, never_equal_policy, on_notification_activated, remember_node_size, structural_equality_policy, @@ -183,7 +183,9 @@ pub(crate) fn group_guard(key: u64) -> GroupGuard { /// The value is returned by clone, so what is remembered is usually a handle: a /// [`MutableState`], an `Rc`, a derived state. Outside a composition nothing is kept and /// `init` runs every time. +#[track_caller] pub fn remember(init: impl FnOnce() -> T) -> T { + let site = std::panic::Location::caller(); let found = with_composer_if_idle(|composer| { if composer.composing() { Some(composer.remembered::()) @@ -195,8 +197,10 @@ pub fn remember(init: impl FnOnce() -> T) -> T { match found { Some(Some(value)) => value, Some(None) => { + let capture = state::Capture::begin(site); let value = init(); - with_composer(|composer| composer.remember_new(value.clone())); + let owned = capture.finish(); + with_composer(|composer| composer.remember_new_owning(value.clone(), owned)); value } None => init(), @@ -205,11 +209,13 @@ pub fn remember(init: impl FnOnce() -> T) -> T { /// Like [`remember`], but computed again whenever `key` changes. Compose's /// `remember(key) { }`. +#[track_caller] pub fn remember_with_key(key: K, init: impl FnOnce() -> T) -> T where K: PartialEq + Clone + 'static, T: Clone + 'static, { + let site = std::panic::Location::caller(); let found = with_composer_if_idle(|composer| { if composer.composing() { Some(composer.remembered_if::<(K, T)>(|(stored, _)| *stored == key)) @@ -221,9 +227,11 @@ where match found { Some(Some((_, value))) => value, Some(None) => { + let capture = state::Capture::begin(site); let value = init(); + let owned = capture.finish(); // The cursor did not move, so this replaces the stale value where it stood. - with_composer(|composer| composer.remember_new((key, value.clone()))); + with_composer(|composer| composer.remember_new_owning((key, value.clone()), owned)); value } None => init(), diff --git a/compose-rust/src/runtime/state.rs b/compose-rust/src/runtime/state.rs index 02af0957..9677dc90 100644 --- a/compose-rust/src/runtime/state.rs +++ b/compose-rust/src/runtime/state.rs @@ -14,9 +14,12 @@ use super::composer::Observer; use super::{with_composer, with_composer_if_idle}; use crate::schema::{DesignSystem, NotificationPermission}; use crate::window::WindowSize; +use std::any::Any; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::fmt; +use std::marker::PhantomData; +use std::panic::Location; use std::rc::{Rc, Weak}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -70,43 +73,202 @@ struct StateCell { runtimes: Mutex>>, } -/// A value that composition observes. Compose's `MutableState`. +impl StateCell { + fn notify(&self) { + let mut told = false; + lock(&self.runtimes).retain(|runtime| match runtime.upgrade() { + Some(signal) => { + signal.push_dirty(self.id); + told = true; + true + } + None => false, + }); + // Inside a Host call the change is picked up before the call returns. Anywhere + // else, a worker above all, nothing would look until something asked for a frame. + if told && !crate::boundary::in_host_call() { + crate::boundary::request_frame_from_worker(); + } + } +} + +// ----- the arena --------------------------------------------------------------------- + +/// A place in the arena: the slot's index, and the generation it had when the state was +/// put there. A slot that is freed and used again has a higher generation, so a handle +/// to the old state never reaches the new one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct StateKey { + index: u32, + generation: u32, +} + +struct Entry { + generation: u32, + cell: Option>, +} + +struct Arena { + entries: Vec, + free: Vec, +} + +/// Every state's value lives here, not in the handle. One lock for the table, and a lock +/// of its own for each value, so a long read of one state does not hold up the others. +static ARENA: Mutex = Mutex::new(Arena { + entries: Vec::new(), + free: Vec::new(), +}); + +/// The handles that have been warned about, for a write after the state was freed. +static WARNED: Mutex> = Mutex::new(Vec::new()); + +fn allocate(cell: Arc) -> StateKey { + let mut arena = lock(&ARENA); + if let Some(index) = arena.free.pop() { + let entry = &mut arena.entries[index as usize]; + entry.cell = Some(cell); + return StateKey { + index, + generation: entry.generation, + }; + } + let index = arena.entries.len() as u32; + arena.entries.push(Entry { + generation: 0, + cell: Some(cell), + }); + StateKey { + index, + generation: 0, + } +} + +/// Frees a state and gives its value back to be dropped by the caller, once, with no lock +/// held. `None` when the key was already freed. +pub(crate) fn release_state(key: StateKey) -> Option> { + let mut arena = lock(&ARENA); + let entry = arena.entries.get_mut(key.index as usize)?; + if entry.generation != key.generation { + return None; + } + let cell = entry.cell.take()?; + entry.generation = entry.generation.wrapping_add(1); + arena.free.push(key.index); + Some(Box::new(cell)) +} + +// ----- who owns a state -------------------------------------------------------------- + +thread_local! { + /// The states made since each open `remember` began, and where that call is. + static PENDING: RefCell> = const { RefCell::new(Vec::new()) }; + static SITES: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +/// The span of a `remember` call's initialiser. States made inside it belong to the +/// slot the call fills. +pub(crate) struct Capture { + mark: usize, +} + +impl Capture { + pub(crate) fn begin(site: &'static Location<'static>) -> Self { + let mark = PENDING.with_borrow(Vec::len); + SITES.with_borrow_mut(|sites| sites.push(site)); + Self { mark } + } + + /// The states made since `begin`. + pub(crate) fn finish(self) -> Vec { + PENDING.with_borrow_mut(|pending| pending.split_off(self.mark.min(pending.len()))) + } +} + +impl Drop for Capture { + fn drop(&mut self) { + SITES.with_borrow_mut(|sites| { + sites.pop(); + }); + PENDING.with_borrow_mut(|pending| pending.truncate(self.mark)); + } +} + +/// Why a state could not be read or written. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StateError { + /// The `remember` call that made the state has left the composition, and the state + /// went with it. + Released, +} + +impl fmt::Display for StateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Released => formatter.write_str( + "Released: the state left the composition with the remember call that made it", + ), + } + } +} + +impl std::error::Error for StateError {} + +/// A handle to a value that composition observes. Compose's `MutableState`. +/// +/// The handle is `Copy`: it names a place in an arena, and the value is owned by the +/// composition. The value lives as long as the `remember` call that made it stays in the +/// composition and is dropped, once, when that call's group is removed. A handle used +/// after that is released: `set` and `update` do nothing, `get` panics, and the `try_` +/// forms return [`StateError::Released`]. A slot reused for another state has a new +/// generation, so an old handle never reaches the new value. /// -/// Cloning gives another handle to the same value. Two handles compare equal when they -/// are the same state, which is what makes a state a stable parameter: passing the same -/// state to a composable again does not make it run. +/// Two handles are equal when they name the same state, which is what makes a state a +/// stable parameter: passing the same state to a composable again does not make it run. +/// Equal values in two different states are not equal handles. /// -/// It is `Send` and `Sync` when the value is `Send`, so a worker thread can hold one and -/// write it. The write is visible at once; the composables that read it run again on the -/// next frame, which the write asks for. +/// A handle is `Send` and `Sync`, so a worker thread can take one by `move` and write it. +/// The write is visible at once; the composables that read it run again on the next +/// frame, which the write asks for. A read on a worker clones the value and subscribes to +/// nothing. /// /// Reading takes a lock for as long as the read lasts. Reading the same state again inside /// [`MutableState::with`] would wait on itself. pub struct MutableState { - cell: Arc>, + key: StateKey, + /// The `remember` call that made the state, for the message of a read that fails. + site: &'static Location<'static>, + marker: PhantomData T>, } impl Clone for MutableState { fn clone(&self) -> Self { - Self { - cell: Arc::clone(&self.cell), - } + *self } } +impl Copy for MutableState {} + impl PartialEq for MutableState { fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.cell, &other.cell) + self.key == other.key } } impl Eq for MutableState {} -impl fmt::Debug for MutableState { +impl std::hash::Hash for MutableState { + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} + +impl fmt::Debug for MutableState { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_tuple("MutableState") - .field(&*lock(&self.cell.value)) + .debug_struct("MutableState") + .field("index", &self.key.index) + .field("generation", &self.key.generation) .finish() } } @@ -118,83 +280,190 @@ impl fmt::Debug for MutableState { /// Text(format!("{}", count.get())); /// Button("More").on_click(move || count.update(|count| *count += 1)); /// ``` -pub fn mutable_state_of(value: T) -> MutableState { +/// +/// Made inside `remember`, the state belongs to that call. Made anywhere else, it is not +/// freed until the program ends. +#[track_caller] +pub fn mutable_state_of(value: T) -> MutableState { mutable_state_of_with_policy(value, structural_equality_policy()) } /// A state with a policy of its own for what counts as a change. -pub fn mutable_state_of_with_policy( +#[track_caller] +pub fn mutable_state_of_with_policy( value: T, policy: SnapshotMutationPolicy, ) -> MutableState { + let here = Location::caller(); + let cell: Arc = Arc::new(StateCell { + id: next_state_id(), + value: Mutex::new(value), + policy, + runtimes: Mutex::new(Vec::new()), + }); + let key = allocate(cell); + let site = SITES.with_borrow(|sites| sites.last().copied()); + if site.is_some() { + PENDING.with_borrow_mut(|pending| pending.push(key)); + } MutableState { - cell: Arc::new(StateCell { - id: next_state_id(), - value: Mutex::new(value), - policy, - runtimes: Mutex::new(Vec::new()), - }), + key, + site: site.unwrap_or(here), + marker: PhantomData, } } -impl MutableState { - /// The value, recorded as read by the scope composing now. +impl MutableState { + fn resolve(&self) -> Option>> { + let cell = { + let arena = lock(&ARENA); + let entry = arena.entries.get(self.key.index as usize)?; + if entry.generation != self.key.generation { + return None; + } + entry.cell.clone()? + }; + cell.downcast::>().ok() + } + + fn released(&self) -> ! { + panic!( + "Released: this MutableState was used after the remember call at {}:{} left the composition", + self.site.file(), + self.site.line() + ) + } + + fn warn_released(&self) { + if !cfg!(debug_assertions) { + return; + } + { + let mut warned = lock(&WARNED); + if warned.contains(&self.key) { + return; + } + warned.push(self.key); + } + eprintln!( + "compose-rust: a write to a released MutableState was ignored (made by the remember call at {}:{})", + self.site.file(), + self.site.line() + ); + } + + /// How many warnings this handle has caused: one in a debug build after its first + /// write while released, never in a release build. + #[doc(hidden)] + pub fn released_warning_count(&self) -> usize { + lock(&WARNED).iter().filter(|key| **key == self.key).count() + } + + /// Whether the state is still in the composition. + pub fn is_released(&self) -> bool { + self.resolve().is_none() + } + + /// The value, recorded as read by the scope composing now. Panics if the state is + /// released. pub fn get(&self) -> T where T: Clone, { - self.track(); - lock(&self.cell.value).clone() + self.try_get().unwrap_or_else(|_| self.released()) + } + + /// Like [`MutableState::get`], and `Err(StateError::Released)` if the state is + /// released. + pub fn try_get(&self) -> Result + where + T: Clone, + { + let cell = self.resolve().ok_or(StateError::Released)?; + Self::track(&cell); + let value = lock(&cell.value).clone(); + Ok(value) } /// The value, without recording a read. A scope that only peeks is not run again when - /// the value changes. + /// the value changes. Panics if the state is released. pub fn get_untracked(&self) -> T where T: Clone, { - lock(&self.cell.value).clone() + match self.resolve() { + Some(cell) => lock(&cell.value).clone(), + None => self.released(), + } } - /// Reads the value in place, recorded as read. + /// Reads the value in place, recorded as read. Panics if the state is released. pub fn with(&self, read: impl FnOnce(&T) -> R) -> R { - self.track(); - read(&lock(&self.cell.value)) + match self.resolve() { + Some(cell) => { + Self::track(&cell); + let value = lock(&cell.value); + read(&value) + } + None => self.released(), + } } - /// Replaces the value. Under the default policy an equal value changes nothing. + /// Replaces the value. Under the default policy an equal value changes nothing. Does + /// nothing if the state is released. pub fn set(&self, value: T) { + if self.try_set(value).is_err() { + self.warn_released(); + } + } + + /// Like [`MutableState::set`], and `Err(StateError::Released)` if the state is + /// released. + pub fn try_set(&self, value: T) -> Result<(), StateError> { + let cell = self.resolve().ok_or(StateError::Released)?; { - let mut current = lock(&self.cell.value); - if (self.cell.policy.equivalent)(¤t, &value) { - return; + let mut current = lock(&cell.value); + if (cell.policy.equivalent)(¤t, &value) { + return Ok(()); } *current = value; } - self.notify(); + cell.notify(); + Ok(()) } /// Changes the value in place. Always a change: there is no old value left to compare - /// against without a copy, and a caller who wants the comparison can use `set`. + /// against without a copy, and a caller who wants the comparison can use `set`. Does + /// nothing if the state is released. pub fn update(&self, change: impl FnOnce(&mut T)) { - change(&mut lock(&self.cell.value)); - self.notify(); + if self.try_update(change).is_err() { + self.warn_released(); + } + } + + /// Like [`MutableState::update`], and `Err(StateError::Released)` if the state is + /// released. + pub fn try_update(&self, change: impl FnOnce(&mut T)) -> Result<(), StateError> { + let cell = self.resolve().ok_or(StateError::Released)?; + change(&mut lock(&cell.value)); + cell.notify(); + Ok(()) } /// This state as a read-only [`State`]. pub fn as_state(&self) -> MutableState { - self.clone() + *self } - fn track(&self) { + fn track(cell: &Arc>) { let fresh = with_composer_if_idle(|composer| { composer - .record_read(self.cell.id) + .record_read(cell.id) .then(|| Arc::clone(&composer.signal)) }) .flatten(); if let Some(signal) = fresh { - let mut runtimes = lock(&self.cell.runtimes); + let mut runtimes = lock(&cell.runtimes); if !runtimes .iter() .any(|known| std::ptr::eq(known.as_ptr(), Arc::as_ptr(&signal))) @@ -203,23 +472,6 @@ impl MutableState { } } } - - fn notify(&self) { - let mut told = false; - lock(&self.cell.runtimes).retain(|runtime| match runtime.upgrade() { - Some(signal) => { - signal.push_dirty(self.cell.id); - told = true; - true - } - None => false, - }); - // Inside a Host call the change is picked up before the call returns. Anywhere - // else, a worker above all, nothing would look until something asked for a frame. - if told && !crate::boundary::in_host_call() { - crate::boundary::request_frame_from_worker(); - } - } } /// Something composition can read. Compose's `State`. @@ -228,7 +480,7 @@ pub trait State { fn value(&self) -> T; } -impl State for MutableState { +impl State for MutableState { fn value(&self) -> T { self.get() } @@ -383,6 +635,17 @@ pub(crate) struct Ambient { pub(crate) deriveds: HashMap>, } +impl Drop for Ambient { + fn drop(&mut self) { + let values = [ + release_state(self.window.key), + release_state(self.design.key), + release_state(self.permission.key), + ]; + drop(values); + } +} + impl Ambient { pub(crate) fn new() -> Self { Self { @@ -462,7 +725,7 @@ pub(crate) fn apply_state_changes() { /// The window's size, recorded as read: the composable runs again when the Renderer /// reports a different size class. pub fn current_window_size() -> WindowSize { - match with_composer_if_idle(|composer| composer.ambient.window.clone()) { + match with_composer_if_idle(|composer| composer.ambient.window) { Some(state) => state.get(), None => crate::window::window_size(), } @@ -474,7 +737,7 @@ pub fn current_window_size() -> WindowSize { /// for the screen that is a different screen under a different system, such as a /// calculator whose keys differ. pub fn current_design_system() -> DesignSystem { - match with_composer_if_idle(|composer| composer.ambient.design.clone()) { + match with_composer_if_idle(|composer| composer.ambient.design) { Some(state) => state.get(), None => crate::design::design_system(), } @@ -482,7 +745,7 @@ pub fn current_design_system() -> DesignSystem { /// Whether notifications may be shown, recorded as read. pub fn current_notification_permission() -> NotificationPermission { - match with_composer_if_idle(|composer| composer.ambient.permission.clone()) { + match with_composer_if_idle(|composer| composer.ambient.permission) { Some(state) => state.get(), None => crate::notification::notification_permission(), } @@ -513,20 +776,25 @@ pub fn remember_node_size() -> crate::window::NodeSize { return None; } if let Some(slot) = composer.slot_mut::() { - return Some((slot.token, slot.state.clone())); + return Some((slot.token, slot.state)); } let token = crate::window::next_node_token(); + let capture = Capture::begin(Location::caller()); let state = mutable_state_of(crate::window::node_size(token)); - let follow = state.clone(); + let owned = capture.finish(); + let follow = state; let subscription = crate::window::subscribe_node( token, std::sync::Arc::new(move || follow.set(crate::window::node_size(token))), ); - composer.remember_new(NodeSizeSlot { - token, - state: state.clone(), - _subscription: subscription, - }); + composer.remember_new_owning( + NodeSizeSlot { + token, + state, + _subscription: subscription, + }, + owned, + ); Some((token, state)) }) .flatten(); diff --git a/compose-rust/src/ui/lazy.rs b/compose-rust/src/ui/lazy.rs index 22310504..57e19ff6 100644 --- a/compose-rust/src/ui/lazy.rs +++ b/compose-rust/src/ui/lazy.rs @@ -169,7 +169,7 @@ impl LazyList { // them and nothing else. let range = remember(|| mutable_state_of((0_usize, 0_usize))); let total = scope.total(); - let request = range.clone(); + let request = range; let listener = EventCallback::Range(Rc::new(RefCell::new(move |asked: RangeRequest| { request.set((asked.start(), asked.count())); }))); @@ -230,8 +230,8 @@ fn Items(range: MutableState<(usize, usize)>, intervals: Rc>) { return; } { - let (range, intervals) = (range.clone(), Rc::clone(&intervals)); - scope.set_restart(move || Items(range.clone(), Rc::clone(&intervals))); + let (range, intervals) = (range, Rc::clone(&intervals)); + scope.set_restart(move || Items(range, Rc::clone(&intervals))); } let total: usize = intervals.iter().map(|interval| interval.count).sum(); let (start, count) = range.get(); diff --git a/compose-rust/tests/state_handles.rs b/compose-rust/tests/state_handles.rs new file mode 100644 index 00000000..0e055132 --- /dev/null +++ b/compose-rust/tests/state_handles.rs @@ -0,0 +1,348 @@ +//! Copy state handles: an arena index and a generation, owned by the `remember` call that +//! made them, through the public surface and a Host with no Dioxus anywhere. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use support::{Tree, kinds}; + +thread_local! { + static DROPS: Cell = const { Cell::new(0) }; + static HANDLE: Cell>> = const { Cell::new(None) }; + static HANDLES: RefCell)>> = const { RefCell::new(Vec::new()) }; + static SITE_LINE: Cell = const { Cell::new(0) }; + static READER_RUNS: Cell = const { Cell::new(0) }; +} + +/// A value that counts its drops on the thread that composes. +#[derive(Clone, Debug, PartialEq)] +struct Tracked(i32); + +impl Drop for Tracked { + fn drop(&mut self) { + DROPS.with(|drops| drops.set(drops.get() + 1)); + } +} + +fn drops() -> u32 { + DROPS.with(Cell::get) +} + +fn reset() { + DROPS.with(|drops| drops.set(0)); + HANDLE.with(|handle| handle.set(None)); + HANDLES.with_borrow_mut(Vec::clear); + READER_RUNS.with(|runs| runs.set(0)); +} + +fn start(content: impl Fn() + Send + Sync + 'static) -> (ComposeHost, Tree) { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + (host, tree) +} + +fn frame(host: &mut ComposeHost, tree: &mut Tree) -> Vec { + let batch = host.render_frame(0).expect("the frame encodes").to_vec(); + tree.apply(&batch); + kinds(&batch) +} + +#[composable] +fn Child() { + SITE_LINE.with(|line| line.set(line!() + 1)); + let state = remember(|| { + let state = mutable_state_of(Tracked(0)); + HANDLE.with(|handle| handle.set(Some(state))); + state + }); + Text(format!("child {}", state.with(|tracked| tracked.0))); +} + +#[composable] +fn Gate(show: MutableState) { + if show.get() { + Child(); + } + Text("after"); +} + +/// Starts a Gate that shows its child and returns the child's handle. +fn gate() -> (MutableState, MutableState, ComposeHost, Tree) { + reset(); + let show = mutable_state_of(true); + let (host, tree) = start(move || Gate(show)); + let handle = HANDLE + .with(Cell::take) + .expect("the child remembered its state"); + (show, handle, host, tree) +} + +/// Removes the child and lets the recomposition apply. +fn remove_child(show: MutableState, host: &mut ComposeHost, tree: &mut Tree) { + show.set(false); + frame(host, tree); +} + +// ----- Copy ------------------------------------------------------------------------------- + +#[test] +fn fr39_state_handle_is_copy_in_the_type_system() { + fn assert_copy() {} + assert_copy::>(); + assert_copy::>(); +} + +#[test] +fn fr39_state_handle_is_copy_into_two_move_closures_without_clone() { + let count = mutable_state_of(0); + let add_one = move || count.update(|value| *value += 1); + let add_ten = move || count.update(|value| *value += 10); + add_one(); + add_ten(); + assert_eq!(count.get(), 11, "both closures changed the same value"); +} + +// ----- release ---------------------------------------------------------------------------- + +#[test] +fn fr39_state_released_value_drops_once_after_the_group_is_removed() { + let (show, handle, mut host, mut tree) = gate(); + assert_eq!(drops(), 0); + assert!(!handle.is_released()); + remove_child(show, &mut host, &mut tree); + assert_eq!( + drops(), + 1, + "the value dropped when the recomposition applied" + ); + assert!(handle.is_released()); + frame(&mut host, &mut tree); + assert_eq!(drops(), 1, "and only once"); +} + +#[test] +fn fr39_state_released_value_survives_a_recomposition_that_keeps_the_group() { + let (show, handle, mut host, mut tree) = gate(); + handle.set(Tracked(7)); + frame(&mut host, &mut tree); + show.set(true); + show.update(|_| {}); + frame(&mut host, &mut tree); + assert!(!handle.is_released()); + assert_eq!(handle.with(|tracked| tracked.0), 7); + // The replaced value of the write above is the only drop so far. + assert_eq!(drops(), 1); +} + +#[composable] +fn Item(id: u32) { + let state = remember(|| { + let state = mutable_state_of(Tracked(id as i32)); + HANDLES.with_borrow_mut(|handles| handles.push((id, state))); + state + }); + Text(format!("item {} {}", id, state.with(|tracked| tracked.0))); +} + +#[composable] +fn Items(order: MutableState>) { + for id in order.get() { + key(id, || Item(id)); + } +} + +#[test] +fn fr39_state_released_keyed_item_that_moves_keeps_its_value_and_handle() { + reset(); + let order = mutable_state_of(vec![1_u32, 2, 3]); + let (mut host, mut tree) = start(move || Items(order)); + let before = HANDLES.with_borrow(Clone::clone); + order.set(vec![3, 1, 2]); + frame(&mut host, &mut tree); + assert_eq!(drops(), 0, "a move is not a release"); + for (id, handle) in &before { + assert!(!handle.is_released(), "item {id} kept its state"); + assert_eq!(handle.with(|tracked| tracked.0), *id as i32); + } + assert_eq!( + HANDLES.with_borrow(Vec::len), + 3, + "no item made its state again" + ); +} + +#[test] +fn fr39_state_released_keyed_item_that_leaves_is_released_and_the_others_are_not() { + reset(); + let order = mutable_state_of(vec![1_u32, 2, 3]); + let (mut host, mut tree) = start(move || Items(order)); + let handles = HANDLES.with_borrow(Clone::clone); + order.set(vec![1, 3]); + frame(&mut host, &mut tree); + assert_eq!(drops(), 1); + for (id, handle) in handles { + assert_eq!(handle.is_released(), id == 2, "item {id}"); + } +} + +// ----- use after release ------------------------------------------------------------------ + +#[test] +fn fr39_state_use_after_release_set_is_a_no_op() { + let (show, handle, mut host, mut tree) = gate(); + let other = mutable_state_of(1); + remove_child(show, &mut host, &mut tree); + let texts = tree.texts(); + handle.set(Tracked(5)); + handle.update(|tracked| tracked.0 += 1); + frame(&mut host, &mut tree); + assert_eq!(tree.texts(), texts, "the screen did not change"); + assert_eq!(other.get(), 1, "no other state changed"); + assert!(handle.is_released()); +} + +#[test] +fn fr39_state_use_after_release_warns_once_in_debug() { + let (show, handle, mut host, mut tree) = gate(); + remove_child(show, &mut host, &mut tree); + for round in 0..3 { + handle.set(Tracked(round)); + handle.update(|tracked| tracked.0 += 1); + } + let expected = usize::from(cfg!(debug_assertions)); + assert_eq!(handle.released_warning_count(), expected); +} + +#[test] +fn fr39_state_use_after_release_get_panics_with_the_creation_site() { + let (show, handle, mut host, mut tree) = gate(); + let line = SITE_LINE.with(Cell::get); + remove_child(show, &mut host, &mut tree); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle.get())) + .expect_err("a released state cannot be read"); + let message = panic + .downcast_ref::() + .cloned() + .or_else(|| panic.downcast_ref::<&str>().map(|text| (*text).to_owned())) + .expect("the panic carries a message"); + assert!(message.contains("Released"), "{message}"); + assert!( + message.contains(&format!("{}:{}", file!(), line)), + "{message} should name {}:{line}", + file!() + ); +} + +#[test] +fn fr39_state_use_after_release_try_returns_released() { + let (show, handle, mut host, mut tree) = gate(); + assert!(handle.try_get().is_ok()); + remove_child(show, &mut host, &mut tree); + assert_eq!(handle.try_get(), Err(StateError::Released)); + assert_eq!(handle.try_set(Tracked(1)), Err(StateError::Released)); + assert_eq!(handle.try_update(|_| {}), Err(StateError::Released)); +} + +#[test] +fn fr39_state_use_after_release_does_not_reach_a_reused_slot() { + let (show, old, mut host, mut tree) = gate(); + remove_child(show, &mut host, &mut tree); + show.set(true); + frame(&mut host, &mut tree); + let fresh = HANDLE.with(Cell::take).expect("the child made a new state"); + assert_ne!(old, fresh, "a new state is not the old handle"); + fresh.set(Tracked(42)); + assert_eq!(old.try_set(Tracked(1)), Err(StateError::Released)); + assert_eq!(old.try_get(), Err(StateError::Released)); + old.set(Tracked(2)); + assert_eq!( + fresh.with(|tracked| tracked.0), + 42, + "the old handle did not write the new value" + ); +} + +// ----- workers ---------------------------------------------------------------------------- + +#[test] +fn fr39_state_worker_handle_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); +} + +#[composable] +fn Reader(count: MutableState) { + READER_RUNS.with(|runs| runs.set(runs.get() + 1)); + Text(format!("{}", count.get())); +} + +#[test] +fn fr39_state_worker_write_lands_on_the_next_frame() { + reset(); + let count = mutable_state_of(0); + let (mut host, mut tree) = start(move || Reader(count)); + std::thread::spawn(move || { + assert_eq!(count.get(), 0, "a worker reads the current value"); + count.set(5); + }) + .join() + .unwrap(); + frame(&mut host, &mut tree); + assert_eq!(tree.texts(), vec!["5"]); +} + +#[test] +fn fr39_state_worker_and_ui_writes_in_one_frame_are_one_recomposition() { + reset(); + let count = mutable_state_of(0); + let (mut host, mut tree) = start(move || Reader(count)); + count.set(1); + std::thread::spawn(move || { + count.set(2); + count.update(|value| *value += 1); + }) + .join() + .unwrap(); + frame(&mut host, &mut tree); + assert_eq!( + READER_RUNS.with(Cell::get), + 2, + "one run for the first batch, one more" + ); + assert_eq!(tree.texts(), vec!["3"]); +} + +#[test] +fn fr39_state_worker_write_after_the_composition_is_gone_is_ignored() { + let (show, handle, mut host, mut tree) = gate(); + remove_child(show, &mut host, &mut tree); + let result = std::thread::spawn(move || { + handle.set(Tracked(9)); + handle.update(|tracked| tracked.0 += 1); + handle.try_set(Tracked(10)) + }) + .join() + .expect("the worker did not panic"); + assert_eq!(result, Err(StateError::Released)); +} + +// ----- identity --------------------------------------------------------------------------- + +#[test] +fn fr39_state_handle_identity_copies_are_equal_and_equal_values_are_not() { + let first = mutable_state_of(1); + let copy = first; + let other = mutable_state_of(1); + assert_eq!(first, copy); + assert_ne!( + first, other, + "the same value in another state is another state" + ); + let set: HashSet<_> = [first, copy, other].into_iter().collect(); + assert_eq!(set.len(), 2); +} From 301f11ec31ba7badc1d00313200948a32db91c12 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:14:09 +0900 Subject: [PATCH 089/224] Fix: Make the Copy state handles compile and drop the now needless clones Covers FR-39.2.1. Removes .clone() on Copy state handles flagged by clippy clone_on_copy, drops the redundant rebindings that left behind, and fixes three pre-existing all-targets lints in tests. --- compose-rust/tests/action_keys.rs | 2 +- compose-rust/tests/compose_allocations.rs | 4 +- compose-rust/tests/compose_animation.rs | 12 ++-- compose-rust/tests/compose_boundary.rs | 2 +- compose-rust/tests/compose_runtime.rs | 70 ++++++++++---------- compose-rust/tests/measure.rs | 2 +- compose-rust/tests/windows_crt.rs | 14 +++- samples/academic/src/lib.rs | 8 +-- samples/calculator/src/lib.rs | 12 ++-- samples/chat/src/lib.rs | 70 ++++++++++---------- samples/minimal/src/lib.rs | 12 ++-- samples/notepad/src/lib.rs | 78 ++++++++--------------- samples/podcast/src/lib.rs | 19 +++--- samples/selfcare/src/lib.rs | 18 ++---- samples/social/src/lib.rs | 24 +++---- samples/statistics/src/lib.rs | 2 +- samples/store/src/lib.rs | 47 ++++++-------- samples/todo/src/lib.rs | 65 ++++++++----------- 18 files changed, 210 insertions(+), 251 deletions(-) diff --git a/compose-rust/tests/action_keys.rs b/compose-rust/tests/action_keys.rs index 22910236..776ec344 100644 --- a/compose-rust/tests/action_keys.rs +++ b/compose-rust/tests/action_keys.rs @@ -43,7 +43,7 @@ fn Pad() { /// Only a button that names a kind sends one. #[test] fn fr30_1_only_a_button_that_names_a_kind_sends_one() { - let mut host = ComposeHost::with_content(|| Pad()); + let mut host = ComposeHost::with_content(Pad); let batch = host.rebuild().unwrap().to_vec(); let kinds: Vec<_> = decode_batch(&batch) .unwrap() diff --git a/compose-rust/tests/compose_allocations.rs b/compose-rust/tests/compose_allocations.rs index ac4b1312..2d842f52 100644 --- a/compose-rust/tests/compose_allocations.rs +++ b/compose-rust/tests/compose_allocations.rs @@ -77,8 +77,8 @@ fn Steady(count: MutableState) { #[test] fn fr39_a_frame_with_nothing_to_do_allocates_nothing() { let count = mutable_state_of(0); - let state = count.clone(); - let mut host = ComposeHost::with_content(move || Steady(state.clone())); + let state = count; + let mut host = ComposeHost::with_content(move || Steady(state)); host.rebuild().unwrap(); // Warm: the first frames size the scratch buffers the later ones reuse. for frame in 0..4 { diff --git a/compose-rust/tests/compose_animation.rs b/compose-rust/tests/compose_animation.rs index 35b39661..702b7a3a 100644 --- a/compose-rust/tests/compose_animation.rs +++ b/compose-rust/tests/compose_animation.rs @@ -69,8 +69,8 @@ fn start( #[test] fn fr41_compose_api_emits_the_same_records() { let faded = mutable_state_of(false); - let state = faded.clone(); - let (mut host, mut tree, first) = start(move || Fading(state.clone())); + let state = faded; + let (mut host, mut tree, first) = start(move || Fading(state)); assert!( !first .iter() @@ -124,8 +124,8 @@ fn fr41_compose_api_emits_the_same_records() { #[test] fn fr41_compose_api_animation_does_not_recompose() { let faded = mutable_state_of(false); - let state = faded.clone(); - let (mut host, mut tree, _) = start(move || Fading(state.clone())); + let state = faded; + let (mut host, mut tree, _) = start(move || Fading(state)); faded.set(true); let batch = host .render_frame(16_666_667) @@ -222,8 +222,8 @@ fn Moving(moved: MutableState) { #[test] fn fr41_compose_api_offset_and_colour_are_one_transition_each() { let moved = mutable_state_of(false); - let state = moved.clone(); - let (mut host, _, first) = start(move || Moving(state.clone())); + let state = moved; + let (mut host, _, first) = start(move || Moving(state)); let boxed = node_of(&first, WidgetKind::Box); moved.set(true); let changed = owned(host.render_frame(16_666_667).expect("the frame encodes")); diff --git a/compose-rust/tests/compose_boundary.rs b/compose-rust/tests/compose_boundary.rs index 72867a41..03451c20 100644 --- a/compose-rust/tests/compose_boundary.rs +++ b/compose-rust/tests/compose_boundary.rs @@ -18,7 +18,7 @@ fn Counter() { let count = remember(|| mutable_state_of(0)); Column().content(|| { Text(format!("{}", count.get())); - let more = count.clone(); + let more = count; Button("More").on_click(move || more.update(|count| *count += 1)); }); } diff --git a/compose-rust/tests/compose_runtime.rs b/compose-rust/tests/compose_runtime.rs index 557275c8..9f49406e 100644 --- a/compose-rust/tests/compose_runtime.rs +++ b/compose-rust/tests/compose_runtime.rs @@ -86,7 +86,7 @@ fn Sibling() { fn Parent(count: MutableState) { ran("parent"); Column().content(|| { - Reader(count.clone()); + Reader(count); Sibling(); }); } @@ -95,8 +95,8 @@ fn Parent(count: MutableState) { fn fr39_a_state_change_runs_only_the_scope_that_read_it() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parent(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Parent(state)); assert_eq!( (runs_of("parent"), runs_of("reader"), runs_of("sibling")), (1, 1, 1) @@ -138,8 +138,8 @@ fn Screen(count: MutableState) { fn fr39_a_composable_whose_parameters_are_unchanged_is_skipped() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Screen(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Screen(state)); count.set(5); let records = frame(&mut host, &mut tree); assert_eq!(runs_of("screen"), 2, "the scope that read the state ran"); @@ -156,8 +156,8 @@ fn fr39_a_composable_whose_parameters_are_unchanged_is_skipped() { fn fr39_an_unchanged_frame_sends_nothing() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parent(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Parent(state)); assert!(frame(&mut host, &mut tree).is_empty()); count.set(0); assert!( @@ -191,7 +191,7 @@ fn WithBranch(show: bool) { fn fr39_a_call_site_keeps_its_own_state_when_a_branch_before_it_disappears() { reset(); let show = mutable_state_of(true); - let state = show.clone(); + let state = show; let (mut host, mut tree) = start(move || WithBranch(state.get())); show.set(false); frame(&mut host, &mut tree); @@ -224,7 +224,7 @@ fn without_branch_groups(show: bool) { fn fr39_without_branch_groups_a_call_site_is_handed_another_ones_state() { reset(); let show = mutable_state_of(true); - let state = show.clone(); + let state = show; let (mut host, mut tree) = start(move || without_branch_groups(state.get())); show.set(false); frame(&mut host, &mut tree); @@ -246,7 +246,7 @@ fn Counted(rows: usize) { fn fr39_a_shorter_loop_drops_the_iterations_it_lost_and_a_longer_one_starts_them_fresh() { reset(); let rows = mutable_state_of(3_usize); - let state = rows.clone(); + let state = rows; let (mut host, mut tree) = start(move || Counted(state.get())); rows.set(1); frame(&mut host, &mut tree); @@ -280,7 +280,7 @@ fn Mode(mode: u8) { fn fr39_a_match_arm_that_changes_takes_its_state_with_it() { reset(); let mode = mutable_state_of(0_u8); - let state = mode.clone(); + let state = mode; let (mut host, mut tree) = start(move || Mode(state.get())); mode.set(1); frame(&mut host, &mut tree); @@ -353,8 +353,8 @@ fn ControlFlow(chaos: MutableState) { fn fr39_groups_close_on_return_continue_break_question_mark_and_unwind() { reset(); let chaos = mutable_state_of(false); - let state = chaos.clone(); - let (mut host, mut tree) = start(move || ControlFlow(state.clone())); + let state = chaos; + let (mut host, mut tree) = start(move || ControlFlow(state)); chaos.set(true); frame(&mut host, &mut tree); chaos.set(false); @@ -386,9 +386,9 @@ fn fr39_groups_close_on_return_continue_break_question_mark_and_unwind() { fn fr39_a_keyed_item_that_moves_is_moved_and_keeps_what_it_remembered() { reset(); let order = mutable_state_of(vec![1_u32, 2, 3]); - let state = order.clone(); + let state = order; let (mut host, mut tree) = start(move || { - let state = state.clone(); + let state = state; Column().content(move || { for id in state.get() { key(id, || { @@ -419,9 +419,9 @@ fn fr39_a_keyed_item_that_moves_is_moved_and_keeps_what_it_remembered() { fn fr39_writes_from_a_worker_are_one_recomposition_on_the_next_frame() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parent(state.clone())); - let worker = count.clone(); + let state = count; + let (mut host, mut tree) = start(move || Parent(state)); + let worker = count; std::thread::spawn(move || { worker.set(1); worker.set(2); @@ -445,10 +445,10 @@ fn WritesWhileComposing(target: MutableState) { fn fr39_a_write_during_composition_waits_for_the_next_frame() { reset(); let target = mutable_state_of(0); - let state = target.clone(); + let state = target; let (mut host, mut tree) = start(move || { - Reader(state.clone()); - WritesWhileComposing(state.clone()); + Reader(state); + WritesWhileComposing(state); }); assert_eq!( tree.texts(), @@ -469,7 +469,7 @@ fn ClickCounter() { let count = remember(|| mutable_state_of(0)); Column().content(|| { Text(format!("{}", count.get())); - let more = count.clone(); + let more = count; Button("More").on_click(move || more.update(|count| *count += 1)); }); } @@ -487,7 +487,7 @@ fn fr39_a_write_in_a_handler_is_recomposed_in_the_same_call() { #[composable] fn Parity(count: MutableState) { - let source = count.clone(); + let source = count; let even = remember(move || derived_state_of(move || source.get() % 2 == 0)); ran("parity"); Text(if even.get() { "even" } else { "odd" }); @@ -497,8 +497,8 @@ fn Parity(count: MutableState) { fn fr39_a_derived_state_runs_its_reader_only_when_its_value_changes() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parity(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Parity(state)); count.set(2); assert!(frame(&mut host, &mut tree).is_empty()); assert_eq!( @@ -549,8 +549,8 @@ fn fr39_effects_run_after_composition_restart_on_a_new_key_and_clean_up_when_the reset(); let id = mutable_state_of(1_u32); let present = mutable_state_of(true); - let (state, shown) = (id.clone(), present.clone()); - let (mut host, mut tree) = start(move || EffectsRoot(state.clone(), shown.clone())); + let (state, shown) = (id, present); + let (mut host, mut tree) = start(move || EffectsRoot(state, shown)); assert_eq!(take_log(), vec!["side", "setup 1", "launched 1"]); id.set(2); @@ -573,7 +573,7 @@ fn fr39_effects_run_after_composition_restart_on_a_new_key_and_clean_up_when_the #[composable] fn FrameClock() { let time = remember(|| mutable_state_of(0_u64)); - let writer = time.clone(); + let writer = time; launched_effect((), move || async move { loop { let now = with_frame_nanos(|now| now).await; @@ -597,7 +597,7 @@ fn fr39_a_future_waiting_for_a_frame_gets_that_frames_time() { #[composable] fn Worked() { let answer = remember(|| mutable_state_of(0_u32)); - let writer = answer.clone(); + let writer = answer; launched_effect((), move || async move { delay(std::time::Duration::from_millis(5)).await; let value = with_worker(|| 21 * 2).await; @@ -692,13 +692,13 @@ fn fr39_a_lazy_list_composes_the_window_it_is_asked_for_and_nothing_above_it() { #[composable] fn Deleter() { let deleted = remember(|| mutable_state_of(false)); - let restore = deleted.clone(); - let delete = deleted.clone(); + let restore = deleted; + let delete = deleted; Column().content(|| { Text(if deleted.get() { "gone" } else { "here" }); Button("Delete").on_click(move || { delete.set(true); - let restore = restore.clone(); + let restore = restore; Message::new("Deleted") .with_action("Undo", move |()| restore.set(false)) .show(); @@ -752,7 +752,7 @@ fn Greeting(name: &'static str) { fn fr39_a_static_string_parameter_is_compared_and_skipped() { reset(); let tick = mutable_state_of(0); - let state = tick.clone(); + let state = tick; let (mut host, mut tree) = start(move || { let _ = state.get(); Greeting("there"); @@ -793,11 +793,11 @@ fn text_field_texts(batch: &[u8], fields: &[u32]) -> Vec<(u32, String)> { fn fr5_a_text_field_starts_with_its_text_and_typing_is_not_sent_back() { reset(); let draft = mutable_state_of(String::new()); - let shown = draft.clone(); + let shown = draft; let mut host = ComposeHost::with_content(move || { // Read, so that typing runs the screen again. let _ = shown.get(); - let typed = shown.clone(); + let typed = shown; TextField() .text("Buy milk") .placeholder("Title") diff --git a/compose-rust/tests/measure.rs b/compose-rust/tests/measure.rs index e55721e8..65edba4f 100644 --- a/compose-rust/tests/measure.rs +++ b/compose-rust/tests/measure.rs @@ -191,7 +191,7 @@ fn pr3_measure_off_the_ui_thread_is_refused() { let mut host = host(); let refused = std::thread::spawn(|| { let host_side = matches!(measurer(), Err(MeasureError::OutsideHostCall)); - let mut requests = vec![0u8; MEASURE_RECORD_LEN]; + let mut requests = [0u8; MEASURE_RECORD_LEN]; requests[0] = 1; let mut results = [MeasureResult::default()]; // SAFETY: both buffers are live for the call. diff --git a/compose-rust/tests/windows_crt.rs b/compose-rust/tests/windows_crt.rs index 5dfdf2c5..33921375 100644 --- a/compose-rust/tests/windows_crt.rs +++ b/compose-rust/tests/windows_crt.rs @@ -158,13 +158,23 @@ fn nfr15_the_static_cxx_library_is_found_where_the_linker_looks() { std::fs::write(xwin.join("crt/lib/x86_64/libcpmt.lib"), b"!\n").unwrap(); assert_eq!( - find_msvc_library("libcpmt.lib", None, &[tools.clone()], &[xwin.clone()]), + find_msvc_library( + "libcpmt.lib", + None, + std::slice::from_ref(&tools), + std::slice::from_ref(&xwin) + ), Some(xwin.join("crt/lib/x86_64/libcpmt.lib")) ); std::fs::write(tools.join("lib/x64/libcpmt.lib"), b"!\n").unwrap(); assert_eq!( - find_msvc_library("libcpmt.lib", None, &[tools.clone()], &[xwin.clone()]), + find_msvc_library( + "libcpmt.lib", + None, + std::slice::from_ref(&tools), + std::slice::from_ref(&xwin) + ), Some(tools.join("lib/x64/libcpmt.lib")) ); diff --git a/samples/academic/src/lib.rs b/samples/academic/src/lib.rs index b996e266..3be3b078 100644 --- a/samples/academic/src/lib.rs +++ b/samples/academic/src/lib.rs @@ -238,7 +238,7 @@ fn plan_page(chosen: &MutableState) { // A strip rather than a segmented control. `Tabs` divides its width equally // and does not scroll, so four stages on a phone is four segments of seventy // dp with a button's own padding inside each, and every label breaks. - let strip = chosen.clone(); + let strip = *chosen; LazyRow() .modifier(Modifier.fill_max_width().height(STRIP_HEIGHT)) .content(move |list| { @@ -248,7 +248,7 @@ fn plan_page(chosen: &MutableState) { move |position| { let number = STAGES[position]; let current = strip.get(); - let pick = strip.clone(); + let pick = strip; Box() .modifier(Modifier.padding_role(SpaceRole::Xs)) .alignment(Alignment::Center) @@ -381,7 +381,7 @@ pub fn app() { let opened = remember(|| mutable_state_of(Option::<&'static str>::None)); let on_open: OpenSubject = { - let (opened, destination) = (opened.clone(), destination.clone()); + let (opened, destination) = (opened, destination); Rc::new(move |name: &'static str| { opened.set(Some(name)); destination.set(Destination::Plan); @@ -400,7 +400,7 @@ pub fn app() { .modifier(Modifier.background(Paint::Literal(palette::PAGE))) .content(|| { for choice in Destination::STRIP { - let pick = destination.clone(); + let pick = destination; key(choice.label(), || { // The reference's bar has no accent in it: every icon is white // and the selected one is marked by nothing but being the one diff --git a/samples/calculator/src/lib.rs b/samples/calculator/src/lib.rs index 825a105d..156fa119 100644 --- a/samples/calculator/src/lib.rs +++ b/samples/calculator/src/lib.rs @@ -246,8 +246,7 @@ pub fn app() { // One key press, whichever key it was. The tape is written here rather than in the // engine, because a tape is a thing the application keeps. let press: Press = { - let (calculator, entries, next_entry) = - (calculator.clone(), entries.clone(), next_entry.clone()); + let (calculator, entries, next_entry) = (calculator, entries, next_entry); Rc::new(move |label: &'static str| { let mut finished = None; calculator.update(|state| { @@ -273,14 +272,13 @@ pub fn app() { }; let recall: Rc = { - let (calculator, tape_open) = (calculator.clone(), tape_open.clone()); + let (calculator, tape_open) = (calculator, tape_open); Rc::new(move |value: f64| { calculator.update(|state| state.recall(value)); tape_open.set(false); }) }; let clear_tape: Rc = { - let entries = entries.clone(); Rc::new(move || { let thrown_away = entries.get_untracked(); entries.update(Vec::clear); @@ -289,7 +287,7 @@ pub fn app() { } else { "lines" }; - let restore = entries.clone(); + let restore = entries; Message::new(format!("Cleared {} {lines}", thrown_away.len())) .with_action("Undo", move |()| restore.set(thrown_away.clone())) .with_duration(MessageDuration::Long) @@ -312,7 +310,7 @@ pub fn app() { .modifier(Modifier.weight(1.0)); } if !tape_beside { - let open = tape_open.clone(); + let open = tape_open; Button("") .icon(IconRole::History) .variant(ButtonVariant::Text) @@ -368,7 +366,7 @@ pub fn app() { // The same tape, arriving from an edge, for windows with no room beside the // keys. - let close = tape_open.clone(); + let close = tape_open; Sheet(tape_open.get() && !tape_beside) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) diff --git a/samples/chat/src/lib.rs b/samples/chat/src/lib.rs index 036b77c5..d989e3c8 100644 --- a/samples/chat/src/lib.rs +++ b/samples/chat/src/lib.rs @@ -218,13 +218,13 @@ pub fn app() { let send: Rc = { let (messages, next_id, draft, turns, settings, conversations, current) = ( - messages.clone(), - next_id.clone(), - draft.clone(), + messages, + next_id, + draft, turns.clone(), - settings.clone(), - conversations.clone(), - current.clone(), + settings, + conversations, + current, ); Rc::new(move |text: String| { let text = text.trim().to_owned(); @@ -268,17 +268,14 @@ pub fn app() { token, turns.clone(), settings.get_untracked(), - messages.clone(), + messages, ); }) }; let start_conversation: Rc = { - let (next_conversation, conversations, current) = ( - next_conversation.clone(), - conversations.clone(), - current.clone(), - ); + let (next_conversation, conversations, current) = + (next_conversation, conversations, current); Rc::new(move || { let id = next_conversation.get_untracked(); next_conversation.set(id + 1); @@ -294,11 +291,11 @@ pub fn app() { let delete_current: Rc = { let (current, conversations, messages, turns, next_conversation) = ( - current.clone(), - conversations.clone(), - messages.clone(), + current, + conversations, + messages, turns.clone(), - next_conversation.clone(), + next_conversation, ); Rc::new(move || { let gone = current.get_untracked(); @@ -325,8 +322,7 @@ pub fn app() { } let next = conversations.with(|list| list[at.min(list.len() - 1)].id); current.set(next); - let (conversations, messages, current) = - (conversations.clone(), messages.clone(), current.clone()); + let (conversations, messages, current) = (conversations, messages, current); compose_rust::Message::new(format!("Deleted \u{201c}{}\u{201d}", removed.label())) .with_action("Undo", move |()| { conversations.update(|list| list.insert(at, removed.clone())); @@ -389,7 +385,7 @@ pub fn app() { .variant(ButtonVariant::Text) .color(Paint::Role(ColorRole::OnSurfaceVariant)) .on_click(move || start()); - let (dismiss, open) = (more_open.clone(), more_open.clone()); + let (dismiss, open) = (more_open, more_open); Menu(more_open.get()) .on_dismiss(move || dismiss.set(false)) .anchor(move || { @@ -400,7 +396,7 @@ pub fn app() { .on_click(move || open.set(true)); }) .content(|| { - let (more, settings_open) = (more_open.clone(), settings_open.clone()); + let (more, settings_open) = (more_open, settings_open); Button("Assistant settings") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -408,7 +404,7 @@ pub fn app() { more.set(false); settings_open.set(true); }); - let (more, delete) = (more_open.clone(), Rc::clone(&delete_current)); + let (more, delete) = (more_open, Rc::clone(&delete_current)); Button("Delete conversation") .variant(ButtonVariant::Text) .color(Paint::Role(ColorRole::Error)) @@ -467,7 +463,7 @@ pub fn app() { .icon(IconRole::Compose) .on_click(move || start()); if show_search { - let open = search_open.clone(); + let open = search_open; NavigationItem("Search") .icon(IconRole::Search) .on_click(move || open.set(true)); @@ -476,7 +472,7 @@ pub fn app() { NavigationItem("Videos").icon(IconRole::Video); NavigationItem("Library").icon(IconRole::Library); for conversation in recent.iter().cloned() { - let pick = current.clone(); + let pick = current; let id = conversation.id; key(id, || { NavigationItem(conversation.label()) @@ -510,7 +506,7 @@ pub fn app() { .alignment(Alignment::Center) .content(|| { let (keys, rows, messages) = - (keys.clone(), rows.clone(), messages.clone()); + (keys.clone(), rows.clone(), messages); LazyColumn() .modifier(Modifier.fill_max_width().fill_max_height()) .content(move |list| { @@ -539,12 +535,12 @@ pub fn app() { ); }); - let close = settings_open.clone(); + let close = settings_open; Sheet(settings_open.get()) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) .content(|| { - let (next, close) = (settings.clone(), settings_open.clone()); + let (next, close) = (settings, settings_open); settings_panel( current_settings, Rc::new(move |value| next.set(value)), @@ -552,7 +548,7 @@ pub fn app() { ); }); - let close = search_open.clone(); + let close = search_open; Sheet(search_open.get()) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) @@ -568,18 +564,18 @@ pub fn app() { Text("Search conversations") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let done = search_open.clone(); + let done = search_open; Button("Done") .variant(ButtonVariant::Filled) .on_click(move || done.set(false)); }); - let typed = search_query.clone(); + let typed = search_query; TextField() .modifier(Modifier.fill_max_width()) .placeholder("Search conversations") .on_value_change(move |value| typed.set(value)); if !search_query.get().is_empty() { - let clear = search_query.clone(); + let clear = search_query; Button("Clear search") .modifier(Modifier.fill_max_width()) .variant(ButtonVariant::Text) @@ -673,7 +669,7 @@ fn composer_row( .spacing(BESIDE_A_COMPOSER_KEY) .alignment(Alignment::CenterStart) .content(|| { - let (dismiss, open) = (more_open.clone(), more_open.clone()); + let (dismiss, open) = (*more_open, *more_open); Menu(more_open.get()) .on_dismiss(move || dismiss.set(false)) .anchor(move || { @@ -684,7 +680,7 @@ fn composer_row( .on_click(move || open.set(true)); }) .content(|| { - let (more, search) = (more_open.clone(), search_open.clone()); + let (more, search) = (*more_open, *search_open); Button("Search conversations") .icon(IconRole::Search) .variant(ButtonVariant::Text) @@ -694,7 +690,7 @@ fn composer_row( search.set(true); }); Divider(); - let (more, settings_open) = (more_open.clone(), settings_open.clone()); + let (more, settings_open) = (*more_open, *settings_open); Button("Settings") .icon(IconRole::Settings) .variant(ButtonVariant::Text) @@ -704,14 +700,14 @@ fn composer_row( settings_open.set(true); }); }); - let (typed, submit) = (draft.clone(), Rc::clone(send)); + let (typed, submit) = (*draft, Rc::clone(send)); TextField() .modifier(Modifier.weight(1.0)) .multiline(true) .placeholder("Message") .on_value_change(move |value| typed.set(value)) .on_submit(move |value: String| submit(value)); - let (dismiss, open) = (length_open.clone(), length_open.clone()); + let (dismiss, open) = (*length_open, *length_open); Menu(length_open.get()) .on_dismiss(move || dismiss.set(false)) .anchor(move || { @@ -722,7 +718,7 @@ fn composer_row( }) .content(|| { for length in Length::ALL { - let (close, settings) = (length_open.clone(), settings.clone()); + let (close, settings) = (*length_open, *settings); key(length.label(), || { Button(length.label()) .variant(ButtonVariant::Text) @@ -735,7 +731,7 @@ fn composer_row( }); } }); - let (pressed, current) = (Rc::clone(send), draft.clone()); + let (pressed, current) = (Rc::clone(send), *draft); Button("") .icon(IconRole::Send) .variant(ButtonVariant::Tonal) diff --git a/samples/minimal/src/lib.rs b/samples/minimal/src/lib.rs index 95cc96a5..3fb33803 100644 --- a/samples/minimal/src/lib.rs +++ b/samples/minimal/src/lib.rs @@ -112,7 +112,7 @@ fn toggle_row(label: &str, toggle: impl FnOnce()) { /// Everything a person can operate. fn controls_group(controls: &Controls) { - let amount = controls.amount.clone(); + let amount = controls.amount; let step: Rc = Rc::new(move |by: i32| amount.set((amount.get_untracked() + by).clamp(0, 99))); let reading = controls.reading.get(); @@ -163,7 +163,7 @@ fn controls_group(controls: &Controls) { .alignment(Alignment::CenterStart) .content(|| { Text("A").type_role(TypeRole::Caption); - let set = controls.reading.clone(); + let set = controls.reading; Slider(reading) .modifier(Modifier.weight(1.0)) // Ink on paper: the filled track is ink too. @@ -192,19 +192,19 @@ fn controls_group(controls: &Controls) { .space_role(SpaceRole::Sm) .content(|| { toggle_row("Notifications", || { - let set = controls.notify.clone(); + let set = controls.notify; Switch(controls.notify.get()) .on_change(move |value| set.set(value)); }); Separator(None); toggle_row("Remember me", || { - let set = controls.remember.clone(); + let set = controls.remember; Checkbox(controls.remember.get()) .on_change(move |value| set.set(value)); }); Separator(None); toggle_row("Daily digest", || { - let set = controls.digest.clone(); + let set = controls.digest; RadioButton(controls.digest.get()) .on_change(move |value| set.set(value)); }); @@ -571,7 +571,7 @@ pub fn app() { .modifier(Modifier.fill_max_width()) .content(|| { for choice in Group::STRIP { - let pick = group.clone(); + let pick = group; key(choice.label(), || { Button(choice.label()) .variant(ButtonVariant::Text) diff --git a/samples/notepad/src/lib.rs b/samples/notepad/src/lib.rs index 48b8b4c8..099f5e7d 100644 --- a/samples/notepad/src/lib.rs +++ b/samples/notepad/src/lib.rs @@ -244,14 +244,7 @@ pub fn app() { // The worker's result comes back here, on the UI thread, and the state writes happen // where every other state write in the app happens. let settle: Rc = { - let (busy, status, on_disk, text, path, stamp) = ( - busy.clone(), - status.clone(), - on_disk.clone(), - text.clone(), - path.clone(), - stamp.clone(), - ); + let (busy, status, on_disk, text, path, stamp) = (busy, status, on_disk, text, path, stamp); Rc::new(move |outcome: Outcome| { busy.set(false); match outcome { @@ -290,7 +283,7 @@ pub fn app() { // One piece of file work: a worker thread does the blocking call and the awaiting task // applies the answer on the UI thread. let run_on_worker: Rc = { - let (busy, scope, settle) = (busy.clone(), scope.clone(), Rc::clone(&settle)); + let (busy, scope, settle) = (busy, scope.clone(), Rc::clone(&settle)); Rc::new(move |work: Work| { busy.set(true); let settle = Rc::clone(&settle); @@ -316,14 +309,7 @@ pub fn app() { // different code path through the whole screen. let choose: Rc = { let (current, list_open, shelved, path, text, on_disk, status, stamp) = ( - current.clone(), - list_open.clone(), - shelved.clone(), - path.clone(), - text.clone(), - on_disk.clone(), - status.clone(), - stamp.clone(), + current, list_open, shelved, path, text, on_disk, status, stamp, ); Rc::new(move |id: u64| { if id == current.get_untracked() { @@ -358,15 +344,15 @@ pub fn app() { let new_document: Rc = { let (shelved, current, path, text, on_disk, next_document, status, stamp, list_open) = ( - shelved.clone(), - current.clone(), - path.clone(), - text.clone(), - on_disk.clone(), - next_document.clone(), - status.clone(), - stamp.clone(), - list_open.clone(), + shelved, + current, + path, + text, + on_disk, + next_document, + status, + stamp, + list_open, ); Rc::new(move || { let on_screen = Shelved { @@ -442,7 +428,7 @@ pub fn app() { // Only where the list is not already on screen. A button that opens what // you are looking at is a button that does nothing. if !list_beside { - let open = list_open.clone(); + let open = list_open; Button("Documents") .variant(ButtonVariant::Text) .on_click(move || open.set(true)); @@ -454,15 +440,15 @@ pub fn app() { .variant(ButtonVariant::Text) .enabled(!working) .on_click(move || start()); - let open = file_open.clone(); + let open = file_open; Button("File") .variant(ButtonVariant::Tonal) .on_click(move || open.set(true)); - let open = format_open.clone(); + let open = format_open; Button("Format") .variant(ButtonVariant::Text) .on_click(move || open.set(true)); - let (run, path, text) = (Rc::clone(&run_on_worker), path.clone(), text.clone()); + let (run, path, text) = (Rc::clone(&run_on_worker), path, text); Button("Save") .variant(ButtonVariant::Filled) .enabled(!working) @@ -484,7 +470,7 @@ pub fn app() { // sheet is where you ask. It is declared here rather than at the foot of the // screen because it belongs to the button in the bar above, and a sheet is // drawn over the page wherever it is declared. - let dismiss = file_open.clone(); + let dismiss = file_open; Sheet(file_open.get()) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) @@ -500,13 +486,13 @@ pub fn app() { Text("File") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let close = file_open.clone(); + let close = file_open; Button("Close") .variant(ButtonVariant::Filled) .on_click(move || close.set(false)); }); Separator(None); - let typed = path.clone(); + let typed = path; path_field( generation, current_path.clone(), @@ -524,11 +510,8 @@ pub fn app() { .type_role(TypeRole::Caption) .color(Paint::Role(ColorRole::OnSurfaceVariant)) .modifier(Modifier.weight(1.0)); - let (close, run, path) = ( - file_open.clone(), - Rc::clone(&run_on_worker), - path.clone(), - ); + let (close, run, path) = + (file_open, Rc::clone(&run_on_worker), path); Button("Open") .variant(ButtonVariant::Tonal) .enabled(!working) @@ -547,7 +530,7 @@ pub fn app() { // another permanent strip around the page. These choices affect the editor's // type role directly, so every design system supplies its own face, size and // weight for the selected document style. - let dismiss = format_open.clone(); + let dismiss = format_open; Sheet(format_open.get()) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) @@ -563,7 +546,7 @@ pub fn app() { Text("Formatting") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let close = format_open.clone(); + let close = format_open; Button("Done") .variant(ButtonVariant::Filled) .on_click(move || close.set(false)); @@ -580,19 +563,14 @@ pub fn app() { "Heading", TypeRole::Headline, style, - document_type.clone(), - ); - style_choice( - "Body", - TypeRole::Body, - style, - document_type.clone(), + document_type, ); + style_choice("Body", TypeRole::Body, style, document_type); style_choice( "Monospaced", TypeRole::Mono, style, - document_type.clone(), + document_type, ); }); Surface() @@ -611,7 +589,7 @@ pub fn app() { // The same list, arriving from an edge, for the windows with no room beside // the document. Which edge is the Renderer's decision. - let dismiss = list_open.clone(); + let dismiss = list_open; Sheet(list_open.get() && !list_beside) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) @@ -703,7 +681,7 @@ pub fn app() { Box() .modifier(Modifier.fill_max_width().weight(1.0)) .content(|| { - let typed = text.clone(); + let typed = text; editor( generation, contents.clone(), diff --git a/samples/podcast/src/lib.rs b/samples/podcast/src/lib.rs index 4da4f332..88371639 100644 --- a/samples/podcast/src/lib.rs +++ b/samples/podcast/src/lib.rs @@ -411,7 +411,7 @@ fn new_page(followed: MutableState>, on_play: Handler, on_open: .content(|| { for (index, show) in SHOWS.iter().enumerate() { let is_followed = following.contains(&index); - let list = followed.clone(); + let list = followed; key(show.name, || { show_row( show, @@ -669,7 +669,7 @@ fn player_page(found: &'static Episode, position: MutableState, on_back: Rc .alignment(Alignment::Center) .arrangement(Arrangement::Center) .content(|| { - let back = position.clone(); + let back = position; Button("\u{21ba} 15") .variant(ButtonVariant::Text) .color(palette::INK) @@ -685,7 +685,7 @@ fn player_page(found: &'static Episode, position: MutableState, on_back: Rc .shape_role(ShapeRole::Full), ) .on_click(move || play()); - let ahead = position.clone(); + let ahead = position; Button("15 \u{21bb}") .variant(ButtonVariant::Text) .color(palette::INK) @@ -748,10 +748,7 @@ pub fn app() { let position = remember(|| mutable_state_of(START_SECONDS)); let followed = remember(|| mutable_state_of(vec![0_usize])); - let on_play: Handler = { - let playing = playing.clone(); - Rc::new(move |id: u32| playing.set(Some(id))) - }; + let on_play: Handler = { Rc::new(move |id: u32| playing.set(Some(id))) }; // The player covers everything, including the bar along the bottom, because a full // screen player is what the reference draws and a bar under it would be a way out of @@ -769,8 +766,8 @@ pub fn app() { .fill_max_height(), ) .content(|| { - let close = playing.clone(); - player_page(found, position.clone(), Rc::new(move || close.set(None))); + let close = playing; + player_page(found, position, Rc::new(move || close.set(None))); }); }); return; @@ -795,7 +792,7 @@ pub fn app() { .fill_max_width_if(measure.is_none()), ) .content(|| { - let go = destination.clone(); + let go = destination; bottom_bar(current, Rc::new(move |choice| go.set(choice))); }); }); @@ -809,7 +806,7 @@ pub fn app() { .modifier(Modifier.width_if(measure)) .content(|| match current { Destination::New => { - new_page(followed.clone(), Rc::clone(&on_play), Rc::clone(&on_play)) + new_page(followed, Rc::clone(&on_play), Rc::clone(&on_play)) } Destination::Shows => shows_page(Rc::clone(&on_play)), Destination::You => you_page(followed.with(Vec::len)), diff --git a/samples/selfcare/src/lib.rs b/samples/selfcare/src/lib.rs index de5be5d2..6e0b54be 100644 --- a/samples/selfcare/src/lib.rs +++ b/samples/selfcare/src/lib.rs @@ -144,7 +144,7 @@ fn feeling_step(chosen: MutableState, on_skip: Action, on_next: Action) { } else { (palette::CHIP, palette::INK) }; - let choose = chosen.clone(); + let choose = chosen; key(option.label(), || { Button(option.label()) .variant(ButtonVariant::Filled) @@ -231,7 +231,7 @@ fn worry_step(picked: MutableState>, on_skip: Action, on_next: } else { ButtonVariant::Tonal }; - let list = picked.clone(); + let list = picked; key(worry, || { Button(worry) .variant(variant) @@ -600,14 +600,11 @@ fn page( chosen: MutableState, picked: MutableState>, ) { - let to = |next: Step| -> Action { - let step = step.clone(); - Rc::new(move || step.set(next)) - }; + let to = |next: Step| -> Action { Rc::new(move || step.set(next)) }; match destination { Destination::Today => match step.get() { - Step::Feeling => feeling_step(chosen.clone(), to(Step::Done), to(Step::Worry)), - Step::Worry => worry_step(picked.clone(), to(Step::Done), to(Step::Done)), + Step::Feeling => feeling_step(chosen, to(Step::Done), to(Step::Worry)), + Step::Worry => worry_step(picked, to(Step::Done), to(Step::Done)), Step::Done => checked_in(chosen.get(), picked.get(), to(Step::Feeling)), }, Destination::Listen => listen_page(), @@ -649,7 +646,7 @@ pub fn app() { .modifier(Modifier.background(Paint::Literal(palette::PAGE))) .content(|| { for choice in Destination::STRIP { - let go = destination.clone(); + let go = destination; key(choice.label(), || { NavigationItem(choice.label()) .icon(choice.icon()) @@ -670,8 +667,7 @@ pub fn app() { .modifier(Modifier.fill_max_size()) .alignment(Alignment::TopCenter) .content(|| { - let body = - || page(current, step.clone(), chosen.clone(), picked.clone()); + let body = || page(current, step, chosen, picked); if scrolls { ScrollColumn().modifier(frame).content(body); } else { diff --git a/samples/social/src/lib.rs b/samples/social/src/lib.rs index 6f9ef104..54222536 100644 --- a/samples/social/src/lib.rs +++ b/samples/social/src/lib.rs @@ -125,7 +125,7 @@ fn hero_card(found: &'static Course, size: (f32, f32), opened: MutableState>) { .alignment(Alignment::BottomStart) .content(|| { Image(scene(found)).modifier(Modifier.fill_max_size()); - play_badge(found.id, opened.clone()); + play_badge(found.id, opened); }); // The meta reads above the title, uppercase and in the quiet green the // reference uses for it. Under the title it would read as a footnote; above @@ -197,7 +197,7 @@ fn grid(items: Vec<&'static Course>, opened: MutableState>) { key(found.id, || { Box() .modifier(Modifier.weight(1.0)) - .content(|| tile_card(found, opened.clone())); + .content(|| tile_card(found, opened)); }); } // An odd shelf leaves a gap rather than letting the last card @@ -237,19 +237,19 @@ fn shelf_page( .color(Paint::Literal(palette::MUTED)); }); - hero_card(hero, HERO, opened.clone()); + hero_card(hero, HERO, opened); Text("Recommended for you") .type_role(TypeRole::Label) .color(Paint::Literal(palette::MUTED)); - grid(rest, opened.clone()); + grid(rest, opened); // The wide card the reference ends each shelf with: one course, given the // whole width, because a shelf that is all the same size has no shape. Text("Recommended category") .type_role(TypeRole::Label) .color(Paint::Literal(palette::MUTED)); - hero_card(last, WIDE, opened.clone()); + hero_card(last, WIDE, opened); }); } @@ -270,7 +270,7 @@ fn course_page(found: &'static Course, opened: MutableState>) { // is behind it, which is what a control over a picture needs; a text // button takes a colour chosen to read on a role's fill, and the // picture is not that fill. - let back = opened.clone(); + let back = opened; Button("\u{2190}") .variant(ButtonVariant::Tonal) .on_click(move || back.set(None)); @@ -356,7 +356,7 @@ pub fn app() { .fill_max_width_if(measure.is_none()) .fill_max_height(), ) - .content(|| course_page(found, opened.clone())); + .content(|| course_page(found, opened)); }); return; } @@ -370,7 +370,7 @@ pub fn app() { .bottom_bar(|| { Navigation(chosen.index()).content(|| { for choice in Destination::STRIP { - let pick = destination.clone(); + let pick = destination; key(choice.label(), || { NavigationItem(choice.label()) // The reference's bar is the page's own ink rather than an @@ -406,20 +406,20 @@ pub fn app() { Destination::Today, "Good evening", "Three minutes is enough to start with.", - opened.clone(), + opened, ), Destination::Meditate => shelf_page( Destination::Meditate, "Meditate", "Meditation for beginners and for people who have \ been at it a while.", - opened.clone(), + opened, ), Destination::Sleep => shelf_page( Destination::Sleep, "Sleep stories", "Something to listen to on the way down.", - opened.clone(), + opened, ), }); }); diff --git a/samples/statistics/src/lib.rs b/samples/statistics/src/lib.rs index cfc5c4cc..a6e7535c 100644 --- a/samples/statistics/src/lib.rs +++ b/samples/statistics/src/lib.rs @@ -355,7 +355,7 @@ pub fn app() { // The reference marks this with a chevron in the page's own ink. A text // button left to itself wears the design system's accent, which is the one // colour this design does not have. - let turn = page.clone(); + let turn = page; Button(">") .variant(ButtonVariant::Text) .color(Paint::Literal(palette::INK)) diff --git a/samples/store/src/lib.rs b/samples/store/src/lib.rs index 93c2ea7d..e01288cf 100644 --- a/samples/store/src/lib.rs +++ b/samples/store/src/lib.rs @@ -263,7 +263,7 @@ fn carousel(slide: MutableState) { .content(|| { Image(asset(AssetKind::Svg, catalogue::featured()[showing])) .modifier(Modifier.fill_max_size().padding_role(SpaceRole::Md)); - let turn = slide.clone(); + let turn = slide; Button("") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_size()) @@ -325,7 +325,7 @@ fn catalogue_screen( .color(palette::INK) .modifier(Modifier.fill_max_width()); - carousel(slide.clone()); + carousel(slide); // A strip that runs off the edge, not a segmented control. // @@ -334,7 +334,7 @@ fn catalogue_screen( // and a button inside one carries its own horizontal padding: every label // broke into a column of single letters. The reference's own strip runs past // the right edge with the last category half shown, which is this. - let strip = category.clone(); + let strip = category; LazyRow() .modifier(Modifier.fill_max_width().height(STRIP_HEIGHT)) .content(move |list| { @@ -344,7 +344,7 @@ fn catalogue_screen( move |position| { let choice = Category::STRIP[position]; let chosen = strip.get() == choice; - let pick = strip.clone(); + let pick = strip; Box().alignment(Alignment::Center).content(|| { // Every category is a label and one of them is the one you // are looking at, which is what the reference draws: the @@ -495,7 +495,7 @@ fn detail_screen( // page in the accent. The rest are the same // flat grey as the card the garment is drawn // on. - let pick = size.clone(); + let pick = size; Button(option) .modifier( Modifier @@ -521,7 +521,7 @@ fn detail_screen( .space_role(SpaceRole::Sm) .alignment(Alignment::CenterStart) .content(|| { - let fewer = quantity.clone(); + let fewer = quantity; Button("\u{2212}") .modifier( Modifier @@ -538,7 +538,7 @@ fn detail_screen( Text(count.to_string()) .type_role(TypeRole::Subtitle) .color(palette::INK); - let more = quantity.clone(); + let more = quantity; Button("+") .modifier( Modifier @@ -609,7 +609,7 @@ fn bag_screen(bag: MutableState>) { .content(|| { for (index, line) in lines.iter().copied().enumerate() { let last = index + 1 == lines.len(); - key(index, || bag_line(bag.clone(), index, line, last)); + key(index, || bag_line(bag, index, line, last)); } }); Spacer().modifier(Modifier.weight(1.0)); @@ -666,7 +666,7 @@ fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: Text(price(named.map_or(0, |found| found.cents) * line.quantity)) .type_role(TypeRole::BodyStrong) .color(palette::INK); - let lines = bag.clone(); + let lines = bag; Button("Remove") .variant(ButtonVariant::Text) .color(palette::ALERT) @@ -680,7 +680,7 @@ fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: let Some(removed) = removed else { return }; let name = catalogue::product(removed.product) .map_or("An item", |found| found.name); - let restore = lines.clone(); + let restore = lines; Message::new(format!("Removed {name}")) .with_action("Undo", move |()| { let at = index.min(restore.with(Vec::len)); @@ -872,14 +872,14 @@ pub fn app() { }); let on_open: Rc = { - let (destination, open) = (destination.clone(), open.clone()); + let (destination, open) = (destination, open); Rc::new(move |id: u32| { destination.set(Destination::Shop); open.set(Some(id)); }) }; let on_go: Rc = { - let (destination, open) = (destination.clone(), open.clone()); + let (destination, open) = (destination, open); Rc::new(move |choice: Destination| { destination.set(choice); if choice != Destination::Shop { @@ -896,27 +896,20 @@ pub fn app() { let body = || match (current, showing) { (Destination::Shop, Some(product)) => { - let back: Rc = { - let open = open.clone(); - Rc::new(move || open.set(None)) - }; + let back: Rc = { Rc::new(move || open.set(None)) }; detail_screen( product, - size.clone(), - quantity.clone(), + size, + quantity, back, - add_to_bag(bag.clone(), open.clone(), size.clone(), quantity.clone()), + add_to_bag(bag, open, size, quantity), ); } - (Destination::Shop, None) => catalogue_screen( - category.clone(), - slide.clone(), - bag.clone(), - Rc::clone(&on_open), - Rc::clone(&on_go), - ), + (Destination::Shop, None) => { + catalogue_screen(category, slide, bag, Rc::clone(&on_open), Rc::clone(&on_go)) + } (Destination::Search, _) => search_screen(Rc::clone(&on_open)), - (Destination::Bag, _) => bag_screen(bag.clone()), + (Destination::Bag, _) => bag_screen(bag), (Destination::Account, _) => account_screen(), }; diff --git a/samples/todo/src/lib.rs b/samples/todo/src/lib.rs index 0040a15e..663fd5b4 100644 --- a/samples/todo/src/lib.rs +++ b/samples/todo/src/lib.rs @@ -105,13 +105,13 @@ fn composer(add: Rc, draft: MutableState) { .space_role(SpaceRole::Sm) .alignment(Alignment::CenterStart) .content(|| { - let (typed, submit) = (draft.clone(), Rc::clone(&add)); + let (typed, submit) = (draft, Rc::clone(&add)); TextField() .modifier(Modifier.weight(1.0)) .placeholder("Add a task, then press Enter") .on_value_change(move |value| typed.set(value)) .on_submit(move |value: String| submit(value)); - let (pressed, current) = (Rc::clone(&add), draft.clone()); + let (pressed, current) = (Rc::clone(&add), draft); Button("Add") .variant(ButtonVariant::Filled) .on_click(move || pressed(current.get_untracked())); @@ -164,7 +164,7 @@ pub fn app() { let total = tasks.with(Vec::len); let add: Rc = { - let (tasks, next_id, draft) = (tasks.clone(), next_id.clone(), draft.clone()); + let (tasks, next_id, draft) = (tasks, next_id, draft); Rc::new(move |title: String| { let title = title.trim().to_owned(); if title.is_empty() { @@ -185,7 +185,7 @@ pub fn app() { }; let commit_edit: Rc = { - let (tasks, editing, edit_draft) = (tasks.clone(), editing.clone(), edit_draft.clone()); + let (tasks, editing, edit_draft) = (tasks, editing, edit_draft); Rc::new(move |text: String| { let Some(id) = editing.get_untracked() else { return; @@ -207,7 +207,6 @@ pub fn app() { // Reordering is expressed in what the user can see; each row works out its two // neighbours and this only performs the swap. let swap_tasks: Rc = { - let tasks = tasks.clone(); Rc::new(move |from: usize, to: usize| { tasks.update(|tasks| tasks.swap(from, to)); store::save(&tasks.get_untracked()); @@ -216,7 +215,7 @@ pub fn app() { // One way in for a new task, whether the field is on the list or in the sheet. let compose: Rc = { - let (add, compose_open) = (Rc::clone(&add), compose_open.clone()); + let (add, compose_open) = (Rc::clone(&add), compose_open); Rc::new(move |title: String| { add(title); compose_open.set(false); @@ -253,12 +252,8 @@ pub fn app() { .content(|| { let rows = rows.clone(); let keys = keys.clone(); - let (tasks, editing, edit_draft, menu_open) = ( - tasks.clone(), - editing.clone(), - edit_draft.clone(), - menu_open.clone(), - ); + let (tasks, editing, edit_draft, menu_open) = + (tasks, editing, edit_draft, menu_open); let (commit_edit, swap_tasks) = (Rc::clone(&commit_edit), Rc::clone(&swap_tasks)); LazyColumn() @@ -292,7 +287,7 @@ pub fn app() { .bottom_bar(|| { Navigation(current_filter.index()).content(|| { for choice in Filter::STRIP { - let pick = filter.clone(); + let pick = filter; key(choice.label(), || { NavigationItem(choice.label()) .icon(choice.icon()) @@ -320,13 +315,13 @@ pub fn app() { }); // Where the composer is not on the list, the bar is how a task is added. if stacked { - let open = compose_open.clone(); + let open = compose_open; Button("Add") .variant(ButtonVariant::Filled) .on_click(move || open.set(true)); } // Clearing throws work away, so it says so and offers the way back. - let clearing = tasks.clone(); + let clearing = tasks; Button(if stacked { "Clear" } else { "Clear completed" }) .variant(ButtonVariant::Text) .color(Paint::Role(ColorRole::Error)) @@ -337,7 +332,7 @@ pub fn app() { clearing.update(|tasks| tasks.retain(|task| !task.done)); store::save(&clearing.get_untracked()); let plural = if cleared == 1 { "task" } else { "tasks" }; - let restore = clearing.clone(); + let restore = clearing; Message::new(format!("Cleared {cleared} completed {plural}")) .with_action("Undo", move |()| { restore.set(before.clone()); @@ -346,7 +341,7 @@ pub fn app() { .with_duration(MessageDuration::Long) .show(); }); - let open = about_open.clone(); + let open = about_open; Button(ABOUT_LABEL) .variant(ButtonVariant::Text) .on_click(move || open.set(true)); @@ -371,7 +366,7 @@ pub fn app() { .space_role(SpaceRole::Md) .content(|| { if !stacked { - composer(Rc::clone(&compose), draft.clone()); + composer(Rc::clone(&compose), draft); } list(); }); @@ -379,7 +374,7 @@ pub fn app() { // The same composer, arriving from an edge, where there is no room for // it at the head of the list. - let close = compose_open.clone(); + let close = compose_open; Sheet(compose_open.get() && stacked) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) @@ -395,27 +390,26 @@ pub fn app() { Text("Add Task") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let close = compose_open.clone(); + let close = compose_open; Button("Close") .variant(ButtonVariant::Filled) .on_click(move || close.set(false)); }); Separator(None); if stacked { - composer(Rc::clone(&compose), draft.clone()); + composer(Rc::clone(&compose), draft); } }); }); // What this sample is, and the one control that belongs to the sample. - let dismiss = about_open.clone(); + let dismiss = about_open; Sheet(about_open.get()) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) .content(|| { let fill: Rc = { - let (tasks, next_id, about_open) = - (tasks.clone(), next_id.clone(), about_open.clone()); + let (tasks, next_id, about_open) = (tasks, next_id, about_open); Rc::new(move || { let start = next_id.get_untracked(); tasks.update(|list| { @@ -434,10 +428,7 @@ pub fn app() { about_open.set(false); }) }; - let close: Rc = { - let about_open = about_open.clone(); - Rc::new(move || about_open.set(false)) - }; + let close: Rc = { Rc::new(move || about_open.set(false)) }; about_panel(total, fill, close); }); }); @@ -470,7 +461,7 @@ fn task_row( .space_role(SpaceRole::Sm) .alignment(Alignment::CenterStart) .content(|| { - let toggle = tasks.clone(); + let toggle = *tasks; let done = task.done; Checkbox(task.done).on_change(move |_| { toggle.update(|tasks| tasks[index].done = !done); @@ -479,12 +470,12 @@ fn task_row( // The title takes the weight, so the actions line up down the list. if editing_this { let (typed, submit, lost, saved) = ( - edit_draft.clone(), + *edit_draft, Rc::clone(commit_edit), Rc::clone(commit_edit), Rc::clone(commit_edit), ); - let (lost_draft, saved_draft) = (edit_draft.clone(), edit_draft.clone()); + let (lost_draft, saved_draft) = (*edit_draft, *edit_draft); TextField() .modifier(Modifier.weight(1.0)) .text(task.title.clone()) @@ -507,7 +498,7 @@ fn task_row( .overflow(TextOverflow::Ellipsis); // What a row can have done to it lives behind one control. The // entries exist only while the menu is open. - let (dismiss, opener) = (menu_open.clone(), menu_open.clone()); + let (dismiss, opener) = (*menu_open, *menu_open); let id = task.id; Menu(open) .on_dismiss(move || dismiss.set(None)) @@ -548,7 +539,7 @@ fn menu_entries( swap_tasks: &Rc, ) { let id = task.id; - let (close, draft, edit) = (menu_open.clone(), edit_draft.clone(), editing.clone()); + let (close, draft, edit) = (*menu_open, *edit_draft, *editing); Button("Edit") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -557,7 +548,7 @@ fn menu_entries( draft.set(String::new()); edit.set(Some(id)); }); - let (close, swap) = (menu_open.clone(), Rc::clone(swap_tasks)); + let (close, swap) = (*menu_open, Rc::clone(swap_tasks)); Button("Move up") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -568,7 +559,7 @@ fn menu_entries( swap(index, above); } }); - let (close, swap) = (menu_open.clone(), Rc::clone(swap_tasks)); + let (close, swap) = (*menu_open, Rc::clone(swap_tasks)); Button("Move down") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -581,7 +572,7 @@ fn menu_entries( }); // Deleting throws work away without asking, so it says what it did and offers the // task back. - let (close, tasks) = (menu_open.clone(), tasks.clone()); + let (close, tasks) = (*menu_open, *tasks); Button("Delete") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -593,7 +584,7 @@ fn menu_entries( store::save(&tasks.get_untracked()); let Some(removed) = removed else { return }; let title = removed.title.clone(); - let restore = tasks.clone(); + let restore = tasks; Message::new(format!("Deleted \u{201c}{title}\u{201d}")) .with_action("Undo", move |()| { let at = index.min(restore.with(Vec::len)); From 6620652239815f8aa9e895662b60027a885aead4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:14:41 +0900 Subject: [PATCH 090/224] Docs: Spell the composable attribute #[Composable] (#278) Owner decision 2026-10-25. Matches Compose's @Composable; snake_case for attribute macros is a convention, not a rule. Covers FR-39.1. --- docs/SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 75aa19d3..ed3f6362 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2399,7 +2399,7 @@ Notification::new("세션이 끝났습니다") #### 39.1 모양 ```rust -#[composable] +#[Composable] fn counter() { let count = remember(|| mutable_state_of(0)); Column(Modifier::new().fill_max_width(), || { @@ -2417,9 +2417,9 @@ fn main() { - **공통 컨테이너는 위치 인자로 부릅니다.** `Column`, `Row`, `Box`, `LazyColumn`, `LazyRow`는 `Column(modifier, || { .. })`처럼 `modifier`와 자식 클로저를 위치 인자로 받습니다. 자식 클로저는 항상 마지막 인자입니다. Compose의 `Column(modifier) { .. }`와 같은 순서입니다. - **속성이 많은 위젯은 빌더 체인을 유지합니다.** 어느 형태를 받는지는 다음 규칙이 정합니다. 위젯이 `modifier`와 자식 클로저 말고는 호출마다 값이 정해지는 속성이 없으면 위치 인자 형태를 받습니다. 그 밖의 속성이 하나라도 있으면 필수 값만 함수 인자로 받고 나머지는 체인으로 씁니다(`Text(..)`, `Button(label).modifier(..).on_click(..)`). Rust에 이름 있는 인자와 기본 인자가 없어서, 속성이 많은 위젯을 위치 인자로 받으면 쓰는 쪽이 매번 전부 적어야 하기 때문입니다. 위치 인자 컨테이너가 `space_role` 같은 속성을 더 필요로 하면 같은 위젯의 체인 형태(`Column().modifier(..).space_role(..).content(..)`)를 씁니다. 두 형태는 같은 Mutation을 내보냅니다. -- **속성 이름은 `#[composable]`입니다.** 소유자가 `#[Composable]`이 아니라 이 이름을 그대로 두기로 했습니다. 이유는 Rust의 속성 매크로가 snake_case라는 관례(`#[component]`, `#[tokio::main]`)를 따르는 것입니다. 위젯 이름은 FR-15의 Compose 이름(PascalCase)을 그대로 씁니다. +- **속성 이름은 `#[Composable]`입니다.** 소유자가 2026-10-25에 정했습니다. Compose의 `@Composable` 철자와 같아서, Compose를 아는 사람이 이름을 다시 배우지 않습니다. 속성 매크로 이름이 snake_case인 것은 관례이지 규칙이 아니며(`#[component]`, `#[tokio::main]`), 위젯 이름이 이미 Compose의 PascalCase를 쓰므로(FR-15) 속성도 같은 철자로 맞춥니다. 매크로 함수에는 `#[allow(non_snake_case)]`를 붙입니다. 소문자 별칭은 두지 않습니다(아래 이슈 #278의 이전 규칙과 같습니다). -- **composable은 함수입니다.** `#[composable]` 속성이 본문에 그룹을 넣고, 사용자는 그룹도 키도 손으로 쓰지 않습니다. 분기, `match`, 반복문, 조기 `return`, `continue`, `break`, `?`, 되감기를 지나도 그룹이 맞게 닫힙니다(INTENT D2가 기록한 클로저 방식의 실패를 되풀이하지 않습니다). +- **composable은 함수입니다.** `#[Composable]` 속성이 본문에 그룹을 넣고, 사용자는 그룹도 키도 손으로 쓰지 않습니다. 분기, `match`, 반복문, 조기 `return`, `continue`, `break`, `?`, 되감기를 지나도 그룹이 맞게 닫힙니다(INTENT D2가 기록한 클로저 방식의 실패를 되풀이하지 않습니다). - **이름은 Compose를 따릅니다.** Compose에 같은 개념이 있으면 그 이름을 Rust 관례(함수와 메서드는 snake_case, 위젯은 FR-15의 Compose 이름)로 옮깁니다. PR-7의 "Rust 공개 API는 Dioxus 관례" 행은 dioxus-compose 층에 대한 것이 됩니다. - **위젯 어휘는 하나입니다.** FR-15의 위젯과 FR-13의 프리미티브, FR-10의 Modifier가 같은 스키마(FR-7)에서 나옵니다. compose-rust API를 위해 위젯이나 속성을 따로 정의하지 않습니다. @@ -2486,7 +2486,7 @@ fn main() { #### 39.6 이행 -이 변경은 공개 API를 바꾸므로 compose-rust 1.0.0(2026-11-14) 전에, 한 릴리스 안에서 함께 끝냅니다(이슈 #278). 샘플 12개, 크레이트의 테스트, 가이드(`docs/guide/`의 영어와 한국어)와 README 예시가 같은 릴리스에서 새 모양으로 옮겨 갑니다. 1.0.0 전이므로 옛 호출 모양의 별칭을 두지 않습니다. dioxus-compose 어댑터는 `#[composable]`과 이 호출 모양을 쓰지 않으므로 영향이 없을 것으로 봅니다. 이것은 확인이 필요한 항목이고, 상태 핸들 구현(`Copy` 핸들)의 PR에서 확인합니다. +이 변경은 공개 API를 바꾸므로 compose-rust 1.0.0(2026-11-14) 전에, 한 릴리스 안에서 함께 끝냅니다(이슈 #278). 샘플 12개, 크레이트의 테스트, 가이드(`docs/guide/`의 영어와 한국어)와 README 예시가 같은 릴리스에서 새 모양으로 옮겨 갑니다. 1.0.0 전이므로 옛 호출 모양의 별칭을 두지 않습니다. dioxus-compose 어댑터는 `#[Composable]`과 이 호출 모양을 쓰지 않으므로 영향이 없을 것으로 봅니다. 이것은 확인이 필요한 항목이고, 상태 핸들 구현(`Copy` 핸들)의 PR에서 확인합니다. ### FR-40 HTML 텍스트 속성 (`Agreed`, 진행 중, 이슈 #106) From 17a169afa873ed126aac46e57ccbfa1ab944dc8c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:15:27 +0900 Subject: [PATCH 091/224] Refactor: Rename #[composable] to #[Composable] (#278) The proc macro function is named Composable with allow(non_snake_case), is re-exported as compose_rust::Composable, and every use in the crate, tests, benches, samples, guide, README and locales follows. No lowercase alias. Covers FR-39.1. --- CHANGELOG.md | 10 ++++ README.md | 6 +-- .../benches/fr39/candidate/src/main.rs | 2 +- compose-rust/macros/Cargo.toml | 2 +- compose-rust/macros/src/lib.rs | 11 +++-- compose-rust/src/lib.rs | 4 +- compose-rust/src/runtime/host.rs | 4 +- compose-rust/src/runtime/mod.rs | 8 ++-- compose-rust/src/ui/mod.rs | 2 +- compose-rust/tests/action_keys.rs | 2 +- compose-rust/tests/compose_allocations.rs | 2 +- compose-rust/tests/compose_animation.rs | 6 +-- compose-rust/tests/compose_boundary.rs | 2 +- compose-rust/tests/compose_runtime.rs | 48 +++++++++---------- docs/guide/en/architecture.html | 2 +- docs/guide/en/getting-started.html | 2 +- docs/guide/en/index.html | 4 +- docs/guide/en/overview.html | 6 +-- docs/guide/en/writing-ui.html | 10 ++-- docs/guide/ko/architecture.html | 2 +- docs/guide/ko/getting-started.html | 2 +- docs/guide/ko/index.html | 4 +- docs/guide/ko/overview.html | 6 +-- docs/guide/ko/writing-ui.html | 10 ++-- docs/locales/README_ko.md | 6 +-- samples/README.md | 2 +- samples/academic/src/lib.rs | 4 +- samples/calculator/src/lib.rs | 12 ++--- samples/chat/src/lib.rs | 8 ++-- samples/minimal/src/lib.rs | 2 +- samples/notepad/src/lib.rs | 12 ++--- samples/podcast/src/lib.rs | 16 +++---- samples/selfcare/src/lib.rs | 20 ++++---- samples/social/src/lib.rs | 12 ++--- samples/statistics/src/lib.rs | 12 ++--- samples/store/src/lib.rs | 26 +++++----- samples/todo/src/lib.rs | 10 ++-- 37 files changed, 155 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b904eba0..9efb893b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### The composable attribute is spelled `#[Composable]` + +The attribute that turns a function into a composable now has Compose's spelling, +`#[Composable]`, and is exported as `compose_rust::Composable`. Rust attribute names are +snake_case by convention only, and the widgets already use Compose's PascalCase names. The +lowercase `#[composable]` is gone, with no alias: the crate is before 1.0, so one release +moves every use. Rename each `#[composable]` in your code to `#[Composable]`. + ## v0.0.3 ### The host can measure text, and text carries CSS properties diff --git a/README.md b/README.md index 0b2c7c75..7c76dbee 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ compose-rust is four things put together: What the 0.0.2 crate contains: -- **The authoring API.** `#[composable]`, `remember`, the states and effects, the `Recomposer`, +- **The authoring API.** `#[Composable]`, `remember`, the states and effects, the `Recomposer`, `launch` and one function per widget ([#23](https://github.com/DarkPyonix/compose-rust/issues/23), [#64](https://github.com/DarkPyonix/compose-rust/issues/64)). It is new in 0.0.2, with animation: `Modifier.graphics_layer`, `Modifier.alpha`, and @@ -76,7 +76,7 @@ What the 0.0.2 crate contains: use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn counter() { let count = remember(|| mutable_state_of(0)); Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| { @@ -93,7 +93,7 @@ fn main() { A widget is a statement: `Text("Hello")` describes a widget, its methods set what Compose takes as named arguments, and a container's children are the closure given to `content`. -`#[composable]` inserts the groups, so you never write them by hand, and they close correctly +`#[Composable]` inserts the groups, so you never write them by hand, and they close correctly through branches, early `return`, `?` and unwinding. Names follow Compose. The widget vocabulary is the one in the schema; nothing is defined twice. The [guide](https://darkpyonix.dev/compose-rust/en/writing-ui.html) covers the rest. diff --git a/compose-rust/benches/fr39/candidate/src/main.rs b/compose-rust/benches/fr39/candidate/src/main.rs index ec4bddd2..504dd906 100644 --- a/compose-rust/benches/fr39/candidate/src/main.rs +++ b/compose-rust/benches/fr39/candidate/src/main.rs @@ -24,7 +24,7 @@ extern "C" fn request_frame() { /// The same sweep screen as the Dioxus path's, written with composables: a column of keyed /// texts that all read the same counter, and the button that moves it. -#[composable] +#[Composable] fn sweep() { let count = remember(|| mutable_state_of(0_u64)); let slots = SLOTS.load(Ordering::Relaxed); diff --git a/compose-rust/macros/Cargo.toml b/compose-rust/macros/Cargo.toml index 0396cc42..e2f71676 100644 --- a/compose-rust/macros/Cargo.toml +++ b/compose-rust/macros/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.3" edition.workspace = true license.workspace = true rust-version.workspace = true -description = "The #[composable] attribute for compose-rust's slot table runtime." +description = "The #[Composable] attribute for compose-rust's slot table runtime." repository = "https://github.com/DarkPyonix/compose-rust" keywords = ["ui", "compose", "macro"] categories = ["gui"] diff --git a/compose-rust/macros/src/lib.rs b/compose-rust/macros/src/lib.rs index d03b8f2b..ea922e4c 100644 --- a/compose-rust/macros/src/lib.rs +++ b/compose-rust/macros/src/lib.rs @@ -1,4 +1,4 @@ -//! `#[composable]`: what the Compose compiler plugin does to a function, done from outside +//! `#[Composable]`: what the Compose compiler plugin does to a function, done from outside //! the compiler. //! //! The plugin does three things to a `@Composable` function, and this does the same three @@ -52,7 +52,7 @@ use syn::{ /// Turns a function into a composable. /// /// ```ignore -/// #[composable] +/// #[Composable] /// fn Greeting(name: String) { /// let clicks = remember(|| mutable_state_of(0)); /// Text(format!("Hello, {name}: {}", clicks.get())); @@ -60,12 +60,13 @@ use syn::{ /// } /// ``` /// -/// `#[composable(nonrestartable)]` keeps the groups and the skipping but never re-runs the +/// `#[Composable(nonrestartable)]` keeps the groups and the skipping but never re-runs the /// function on its own: a state it reads re-runs its caller instead. That is what an /// associated function needs, because the runtime re-runs a function by calling it by its /// bare name. #[proc_macro_attribute] -pub fn composable(attribute: TokenStream, item: TokenStream) -> TokenStream { +#[allow(non_snake_case)] +pub fn Composable(attribute: TokenStream, item: TokenStream) -> TokenStream { let options = match parse_options(attribute.into()) { Ok(options) => options, Err(error) => return error.to_compile_error().into(), @@ -93,7 +94,7 @@ fn parse_options(attribute: proc_macro2::TokenStream) -> syn::Result { } else { Err(syn::Error::new( ident.span(), - "#[composable] takes no argument except `nonrestartable`", + "#[Composable] takes no argument except `nonrestartable`", )) } } diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 23951d57..3d0522af 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -1,6 +1,6 @@ #![deny(unsafe_op_in_unsafe_fn)] -// `#[composable]` expands into paths that start at `::compose_rust`, so that an application +// `#[Composable]` expands into paths that start at `::compose_rust`, so that an application // that depends on this crate alone can use it. This makes the same paths resolve inside the // crate, where its own composables are written. extern crate self as compose_rust; @@ -75,7 +75,7 @@ pub use notification::{ request_notification_permission, withdraw_notification, }; pub use palette::{Palette, PaletteViolation}; -pub use runtime::{Batch, ComposeHost, Recomposer, Runtime, application, composable, launch}; +pub use runtime::{Batch, ComposeHost, Recomposer, Runtime, Composable, application, launch}; pub use schema::{ Alignment, Arrangement, AssetKind, ButtonKind, ButtonVariant, Chrome, Color, ColorRole, ColorScheme, DecorationKind, DesignSystem, EventPayload, GenericFamily, HoverPhase, IconRole, diff --git a/compose-rust/src/runtime/host.rs b/compose-rust/src/runtime/host.rs index 5ebb9fb2..d7c8b0b1 100644 --- a/compose-rust/src/runtime/host.rs +++ b/compose-rust/src/runtime/host.rs @@ -245,7 +245,7 @@ impl crate::LaunchBuilder { /// ends the process with a failing status if the renderer loop could not run at all. /// /// ```ignore - /// #[composable] + /// #[Composable] /// fn app() { /// Text("Hello"); /// } @@ -273,7 +273,7 @@ impl crate::LaunchBuilder { /// Runs a composable as the application, with every launch choice left to its default. /// /// ```ignore -/// #[composable] +/// #[Composable] /// fn counter() { /// let count = remember(|| mutable_state_of(0)); /// let more = count.clone(); diff --git a/compose-rust/src/runtime/mod.rs b/compose-rust/src/runtime/mod.rs index d60492e4..7b610b8e 100644 --- a/compose-rust/src/runtime/mod.rs +++ b/compose-rust/src/runtime/mod.rs @@ -5,7 +5,7 @@ //! use compose_rust::runtime::*; //! use compose_rust::ui::*; //! -//! #[composable] +//! #[Composable] //! fn Counter() { //! let count = remember(|| mutable_state_of(0)); //! Column().content(|| { @@ -28,7 +28,7 @@ //! changed becomes a record. //! //! **Positional memoisation.** [`remember`] belongs to its call site: the group path the -//! `#[composable]` macro builds around every function and every branch, and the order of +//! `#[Composable]` macro builds around every function and every branch, and the order of //! calls inside each group. A branch that stops being taken takes what it remembered with //! it; a loop that gets shorter drops the iterations it lost. Where order is not identity, //! because items move, [`key`] says which item is which. @@ -43,7 +43,7 @@ pub(crate) mod host; mod seam; pub(crate) mod state; -pub use compose_rust_macros::composable; +pub use compose_rust_macros::Composable; pub use effects::{ CoroutineScope, DisposableEffectResult, delay, disposable_effect, launched_effect, on_dispose, remember_coroutine_scope, side_effect, with_frame_nanos, with_worker, @@ -300,7 +300,7 @@ pub fn current_recompose_scope() -> Option { .flatten() } -/// What `#[composable]` expands into. Not for calling by hand: the macro's guarantees are +/// What `#[Composable]` expands into. Not for calling by hand: the macro's guarantees are /// what make these safe to use. #[doc(hidden)] pub mod __private { diff --git a/compose-rust/src/ui/mod.rs b/compose-rust/src/ui/mod.rs index 7115210b..3326f972 100644 --- a/compose-rust/src/ui/mod.rs +++ b/compose-rust/src/ui/mod.rs @@ -5,7 +5,7 @@ //! use compose_rust::runtime::*; //! use compose_rust::ui::*; //! -//! #[composable] +//! #[Composable] //! fn Greeting(name: String) { //! Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| { //! Text(format!("Hello, {name}")).type_role(TypeRole::Title); diff --git a/compose-rust/tests/action_keys.rs b/compose-rust/tests/action_keys.rs index 22910236..3465fe86 100644 --- a/compose-rust/tests/action_keys.rs +++ b/compose-rust/tests/action_keys.rs @@ -34,7 +34,7 @@ use compose_rust::ComposeHost; use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn Pad() { Button("7").kind(ButtonKind::ActionKey); Button("plain"); diff --git a/compose-rust/tests/compose_allocations.rs b/compose-rust/tests/compose_allocations.rs index ac4b1312..395bd1d4 100644 --- a/compose-rust/tests/compose_allocations.rs +++ b/compose-rust/tests/compose_allocations.rs @@ -66,7 +66,7 @@ impl AllocationMeasurement { } } -#[composable] +#[Composable] fn Steady(count: MutableState) { Column().content(|| { Text(format!("{}", count.get())); diff --git a/compose-rust/tests/compose_animation.rs b/compose-rust/tests/compose_animation.rs index 35b39661..19befd6c 100644 --- a/compose-rust/tests/compose_animation.rs +++ b/compose-rust/tests/compose_animation.rs @@ -22,7 +22,7 @@ thread_local! { static COMPOSED: Cell = const { Cell::new(0) }; } -#[composable] +#[Composable] fn Fading(faded: MutableState) { COMPOSED.with(|composed| composed.set(composed.get() + 1)); let target = if faded.get() { 0.0 } else { 1.0 }; @@ -147,7 +147,7 @@ fn fr41_compose_api_animation_does_not_recompose() { ); } -#[composable] +#[Composable] fn Turned() { Box() .modifier(Modifier.size(40.0, 40.0).graphics_layer(GraphicsLayer { @@ -200,7 +200,7 @@ fn fr41_compose_api_graphics_layer_is_a_transform_and_an_alpha() { assert!(modifiers.contains(&Wire::Alpha(0.25)), "{modifiers:?}"); } -#[composable] +#[Composable] fn Moving(moved: MutableState) { let (offset, colour) = if moved.get() { ((24.0, 8.0), Paint::Role(ColorRole::Primary)) diff --git a/compose-rust/tests/compose_boundary.rs b/compose-rust/tests/compose_boundary.rs index 72867a41..3a919f2d 100644 --- a/compose-rust/tests/compose_boundary.rs +++ b/compose-rust/tests/compose_boundary.rs @@ -13,7 +13,7 @@ use compose_rust::schema::{EventPayload, LoopMode, PROTOCOL_VERSION, PropertyKin use compose_rust::ui::*; use support::Tree; -#[composable] +#[Composable] fn Counter() { let count = remember(|| mutable_state_of(0)); Column().content(|| { diff --git a/compose-rust/tests/compose_runtime.rs b/compose-rust/tests/compose_runtime.rs index 557275c8..310ef338 100644 --- a/compose-rust/tests/compose_runtime.rs +++ b/compose-rust/tests/compose_runtime.rs @@ -70,19 +70,19 @@ fn click(host: &mut ComposeHost, tree: &mut Tree, label: &str, nth: usize) -> Ve // ----- skipping and scope invalidation ---------------------------------------------------- -#[composable] +#[Composable] fn Reader(count: MutableState) { ran("reader"); Text(format!("{}", count.get())); } -#[composable] +#[Composable] fn Sibling() { ran("sibling"); Text("fixed"); } -#[composable] +#[Composable] fn Parent(count: MutableState) { ran("parent"); Column().content(|| { @@ -118,13 +118,13 @@ fn fr39_a_state_change_runs_only_the_scope_that_read_it() { assert_eq!(tree.texts(), vec!["1", "fixed"]); } -#[composable] +#[Composable] fn Label(text: String) { ran("label"); Text(text); } -#[composable] +#[Composable] fn Screen(count: MutableState) { ran("screen"); let shown = count.get(); @@ -179,7 +179,7 @@ fn bump(name: &str) { note(format!("{name}:{}#{}", slot.0, slot.1.get())); } -#[composable] +#[Composable] fn WithBranch(show: bool) { if show { bump("first"); @@ -211,7 +211,7 @@ fn fr39_a_call_site_keeps_its_own_state_when_a_branch_before_it_disappears() { ); } -/// The same body without the groups `#[composable]` puts around a branch: the control. A +/// The same body without the groups `#[Composable]` puts around a branch: the control. A /// slot table without groups addresses state by flat position, and this is what that does. fn without_branch_groups(show: bool) { if show { @@ -235,7 +235,7 @@ fn fr39_without_branch_groups_a_call_site_is_handed_another_ones_state() { ); } -#[composable] +#[Composable] fn Counted(rows: usize) { for index in 0..rows { bump(&format!("row{index}")); @@ -266,7 +266,7 @@ fn fr39_a_shorter_loop_drops_the_iterations_it_lost_and_a_longer_one_starts_them ); } -#[composable] +#[Composable] fn Mode(mode: u8) { match mode { 0 => bump("zero"), @@ -299,7 +299,7 @@ fn fr39_a_match_arm_that_changes_takes_its_state_with_it() { ); } -#[composable] +#[Composable] fn Early(stop: bool) { bump("before"); if stop { @@ -308,7 +308,7 @@ fn Early(stop: bool) { bump("tail"); } -#[composable] +#[Composable] fn Looping(limit: usize) { for index in 0..5 { if index == 1 { @@ -321,7 +321,7 @@ fn Looping(limit: usize) { } } -#[composable] +#[Composable] fn Fallible(fail: bool) -> Result<(), String> { bump("start"); if fail { @@ -331,7 +331,7 @@ fn Fallible(fail: bool) -> Result<(), String> { Ok(()) } -#[composable] +#[Composable] fn Panicky(panic: bool) { bump("calm"); if panic { @@ -339,7 +339,7 @@ fn Panicky(panic: bool) { } } -#[composable] +#[Composable] fn ControlFlow(chaos: MutableState) { let on = chaos.get(); Early(on); @@ -435,7 +435,7 @@ fn fr39_writes_from_a_worker_are_one_recomposition_on_the_next_frame() { assert_eq!(tree.texts(), vec!["3", "fixed"]); } -#[composable] +#[Composable] fn WritesWhileComposing(target: MutableState) { ran("writer"); target.set(7); @@ -464,7 +464,7 @@ fn fr39_a_write_during_composition_waits_for_the_next_frame() { ); } -#[composable] +#[Composable] fn ClickCounter() { let count = remember(|| mutable_state_of(0)); Column().content(|| { @@ -485,7 +485,7 @@ fn fr39_a_write_in_a_handler_is_recomposed_in_the_same_call() { assert_eq!(tree.texts(), vec!["2", "More"]); } -#[composable] +#[Composable] fn Parity(count: MutableState) { let source = count.clone(); let even = remember(move || derived_state_of(move || source.get() % 2 == 0)); @@ -523,7 +523,7 @@ impl Drop for NoteOnDrop { } } -#[composable] +#[Composable] fn Effects(id: u32) { side_effect(|| note("side")); launched_effect(id, move || async move { @@ -537,7 +537,7 @@ fn Effects(id: u32) { }); } -#[composable] +#[Composable] fn EffectsRoot(id: MutableState, present: MutableState) { if present.get() { Effects(id.get()); @@ -570,7 +570,7 @@ fn fr39_effects_run_after_composition_restart_on_a_new_key_and_clean_up_when_the assert!(take_log().is_empty()); } -#[composable] +#[Composable] fn FrameClock() { let time = remember(|| mutable_state_of(0_u64)); let writer = time.clone(); @@ -594,7 +594,7 @@ fn fr39_a_future_waiting_for_a_frame_gets_that_frames_time() { } } -#[composable] +#[Composable] fn Worked() { let answer = remember(|| mutable_state_of(0_u32)); let writer = answer.clone(); @@ -626,7 +626,7 @@ fn fr39_work_handed_to_a_worker_resumes_on_the_ui_thread_and_recomposes() { // ----- lists ------------------------------------------------------------------------------ -#[composable] +#[Composable] fn Rows(count: usize) { ran("rows"); LazyColumn().content(move |list| { @@ -689,7 +689,7 @@ fn fr39_a_lazy_list_composes_the_window_it_is_asked_for_and_nothing_above_it() { // ----- the Host as a whole ---------------------------------------------------------------- -#[composable] +#[Composable] fn Deleter() { let deleted = remember(|| mutable_state_of(false)); let restore = deleted.clone(); @@ -742,7 +742,7 @@ fn fr39_a_message_action_runs_without_dioxus() { assert_eq!(tree.texts(), vec!["here", "Delete"]); } -#[composable] +#[Composable] fn Greeting(name: &'static str) { ran("greeting"); Text(format!("Hello, {name}")); diff --git a/docs/guide/en/architecture.html b/docs/guide/en/architecture.html index 1400ad1e..01ed4781 100644 --- a/docs/guide/en/architecture.html +++ b/docs/guide/en/architecture.html @@ -80,7 +80,7 @@

Host and Renderer

Host
The Rust side. Owns your code, your domain logic, and the runtime that builds the tree.
Runtime
-
Whatever builds and changes the tree, behind the Runtime trait. For an application written with #[composable] that is compose-rust's own recomposition runtime, the Recomposer.
+
Whatever builds and changes the tree, behind the Runtime trait. For an application written with #[Composable] that is compose-rust's own recomposition runtime, the Recomposer.
Renderer
The ahead-of-time compiled Kotlin and Compose library, including the schema interpreter. On the desktop it draws into a window that the Compose fork provides.
Schema
diff --git a/docs/guide/en/getting-started.html b/docs/guide/en/getting-started.html index e2766099..9de5f915 100644 --- a/docs/guide/en/getting-started.html +++ b/docs/guide/en/getting-started.html @@ -144,7 +144,7 @@

Your first application

use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn app() { let name = remember(|| mutable_state_of(String::new())); let typed = name.clone(); diff --git a/docs/guide/en/index.html b/docs/guide/en/index.html index c4761848..615fc094 100644 --- a/docs/guide/en/index.html +++ b/docs/guide/en/index.html @@ -95,7 +95,7 @@

Compose-shaped UI in Rust,drawn by Compose.

-
#[composable]
+
#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0)).content(|| {
@@ -130,7 +130,7 @@ 

Where it stands today

- + diff --git a/docs/guide/en/overview.html b/docs/guide/en/overview.html index b8556a57..cfae5c2d 100644 --- a/docs/guide/en/overview.html +++ b/docs/guide/en/overview.html @@ -128,7 +128,7 @@

Status

- +
The keypad
PartState
Boundary, protocol, schema, renderer downloadWorks in 0.0.2
Authoring API: #[composable], remember, launchPartial: in 0.0.2 as a pre-1.0 API that may change, stable from 1.0.0 (planned for 2026-11-14)
Authoring API: #[Composable], remember, launchPartial: in 0.0.2 as a pre-1.0 API that may change, stable from 1.0.0 (planned for 2026-11-14)
macOS, Android, WebWorks end to end
Linux, Windows, iOSBuilds and starts
The Runtime traitWorksThe interface that whatever builds the tree implements, plus the types around it: assets, custom drawing, input events, notifications, messages, palettes and window size classes.
Drawing, transforms and animationsWorksNew in 0.0.2. HTML/CSS drawing elements: AbsoluteBox and the modifiers RequiredSize, BorderEach, CornerEach, Shadow, Clip and Alpha. A Transform modifier for 2D CSS transforms. Animations the renderer plays itself, through StartAnimation, ControlAnimation, AnimationEvent and ReducedMotionChanged. A layout container's text colour is inherited by the text inside it, and a border's colour can animate.
The renderer downloadWorksThe build script fetches the prebuilt renderer for your target, checks it and links it. See Getting started.
The authoring APIPartial#[composable], remember, the states and effects, the Recomposer, launch and one function per widget. New in 0.0.2. It is a pre-1.0 API that may still change, and it is stable from 1.0.0, planned for 2026-11-14 (#23, #64). Animation is in it: Modifier.graphics_layer, Modifier.alpha and the animate_*_as_state functions, which the renderer plays with nothing composed again. 3D transforms are not built yet (#105).
The authoring APIPartial#[Composable], remember, the states and effects, the Recomposer, launch and one function per widget. New in 0.0.2. It is a pre-1.0 API that may still change, and it is stable from 1.0.0, planned for 2026-11-14 (#23, #64). Animation is in it: Modifier.graphics_layer, Modifier.alpha and the animate_*_as_state functions, which the renderer plays with nothing composed again. 3D transforms are not built yet (#105).
@@ -143,7 +143,7 @@

What it looks like

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -159,7 +159,7 @@ 

What it looks like

- #[composable] inserts the groups, so you never write them by hand, and they close + #[Composable] inserts the groups, so you never write them by hand, and they close correctly through branches, early return, ? and unwinding. Names follow Compose. The widget vocabulary is the one in the schema, so nothing is defined twice. Writing UI describes the rest of the API, and eleven diff --git a/docs/guide/en/writing-ui.html b/docs/guide/en/writing-ui.html index 3ca3bfb8..66548a18 100644 --- a/docs/guide/en/writing-ui.html +++ b/docs/guide/en/writing-ui.html @@ -70,7 +70,7 @@

Composables, state and events

A composable is a function

- Mark a function with #[composable] and call widgets inside it. Two glob imports + Mark a function with #[Composable] and call widgets inside it. Two glob imports bring in everything: compose_rust::runtime for state and effects, and compose_rust::ui for the widgets and Modifier.

@@ -80,7 +80,7 @@

A composable is a function

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -96,7 +96,7 @@ 

A composable is a function

- #[composable] does what the Compose compiler plugin does to a + #[Composable] does what the Compose compiler plugin does to a @Composable function. It gives the call a group, gives every branch of an if and a match a group of its own, and makes the function skippable and restartable. You never write groups by hand. They close correctly through @@ -147,7 +147,7 @@

Your own composables

rust
-
#[composable]
+
#[Composable]
 fn task_row(title: String, done: bool, on_toggle: Rc<dyn Fn(bool)>) {
     Row()
         .modifier(Modifier.fill_max_width())
@@ -469,7 +469,7 @@ 

Animation Works

use compose_rust::ui::*; use compose_rust::{AnimationSpec, animate_float_as_state}; -#[composable] +#[Composable] fn fading(faded: MutableState<bool>) { let target = if faded.get() { 0.0 } else { 1.0 }; Box() diff --git a/docs/guide/ko/architecture.html b/docs/guide/ko/architecture.html index 2b3d1192..a716f6b2 100644 --- a/docs/guide/ko/architecture.html +++ b/docs/guide/ko/architecture.html @@ -80,7 +80,7 @@

Host와 Renderer

Host
Rust 쪽입니다. 사용자의 코드, 도메인 로직, 트리를 만드는 런타임을 갖습니다.
Runtime
-
트리를 만들고 바꾸는 것으로, Runtime 트레이트 뒤에 있습니다. #[composable] 로 쓴 애플리케이션에서는 compose-rust 자신의 recomposition 런타임인 Recomposer 입니다.
+
트리를 만들고 바꾸는 것으로, Runtime 트레이트 뒤에 있습니다. #[Composable] 로 쓴 애플리케이션에서는 compose-rust 자신의 recomposition 런타임인 Recomposer 입니다.
Renderer
미리 컴파일된 Kotlin과 Compose 라이브러리이며, 스키마 인터프리터를 포함합니다. 데스크톱에서는 Compose 포크가 내주는 창에 그립니다.
스키마
diff --git a/docs/guide/ko/getting-started.html b/docs/guide/ko/getting-started.html index 4b583807..f11412d9 100644 --- a/docs/guide/ko/getting-started.html +++ b/docs/guide/ko/getting-started.html @@ -142,7 +142,7 @@

첫 애플리케이션

use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn app() { let name = remember(|| mutable_state_of(String::new())); let typed = name.clone(); diff --git a/docs/guide/ko/index.html b/docs/guide/ko/index.html index 2bc4cf5a..c06523b4 100644 --- a/docs/guide/ko/index.html +++ b/docs/guide/ko/index.html @@ -94,7 +94,7 @@

Rust로 쓰는 Compose 모양의 UI,그리는 것은 Comp
-
#[composable]
+
#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0)).content(|| {
@@ -129,7 +129,7 @@ 

현재 상태

부분상태 경계, 프로토콜, 스키마, 렌더러 다운로드0.0.2에 구현 - 작성 API: #[composable], remember, launch부분: 0.0.2에 있는 1.0 전의 API라 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정 + 작성 API: #[Composable], remember, launch부분: 0.0.2에 있는 1.0 전의 API라 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정 macOS, Android, Web처음부터 끝까지 동작 Linux, Windows, iOS빌드되고 시작됨 diff --git a/docs/guide/ko/overview.html b/docs/guide/ko/overview.html index 7224371c..35d0512d 100644 --- a/docs/guide/ko/overview.html +++ b/docs/guide/ko/overview.html @@ -127,7 +127,7 @@

현재 상태

Runtime 트레이트구현트리를 만드는 쪽이 구현하는 인터페이스와 그 주변 타입입니다. 에셋, 커스텀 드로잉, 입력 이벤트, 알림, 메시지, 팔레트, 창 크기 클래스가 있습니다. 그리기, 변환, 애니메이션구현0.0.2에서 새로 들어왔습니다. HTML/CSS 그리기 원소인 AbsoluteBox 와 Modifier RequiredSize, BorderEach, CornerEach, Shadow, Clip, Alpha 입니다. 2D CSS 변환을 위한 Transform Modifier가 있습니다. 렌더러가 스스로 재생하는 애니메이션은 StartAnimation, ControlAnimation, AnimationEvent, ReducedMotionChanged 로 다룹니다. 레이아웃 컨테이너의 글자 색은 그 안의 텍스트가 물려받고, 테두리 색도 애니메이션할 수 있습니다. 렌더러 다운로드구현빌드 스크립트가 대상에 맞는 미리 빌드된 렌더러를 받아 검증하고 링크합니다. 시작하기를 보십시오. - 작성 API부분#[composable], remember, 상태와 effect, Recomposer, launch, 위젯마다 하나인 함수입니다. 0.0.2에서 새로 들어왔습니다. 1.0 전의 API라 아직 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정됩니다(#23, #64). 애니메이션도 들어 있습니다. Modifier.graphics_layer, Modifier.alpha, 그리고 렌더러가 재생하는 동안 아무것도 다시 compose하지 않는 animate_*_as_state 함수입니다. 3D 변환은 아직 만들어지지 않았습니다(#105). + 작성 API부분#[Composable], remember, 상태와 effect, Recomposer, launch, 위젯마다 하나인 함수입니다. 0.0.2에서 새로 들어왔습니다. 1.0 전의 API라 아직 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정됩니다(#23, #64). 애니메이션도 들어 있습니다. Modifier.graphics_layer, Modifier.alpha, 그리고 렌더러가 재생하는 동안 아무것도 다시 compose하지 않는 animate_*_as_state 함수입니다. 3D 변환은 아직 만들어지지 않았습니다(#105).
@@ -142,7 +142,7 @@

어떻게 생겼나

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -158,7 +158,7 @@ 

어떻게 생겼나

- #[composable] 이 그룹을 넣으므로 그룹을 손으로 쓰지 않으며, 분기, 조기 + #[Composable] 이 그룹을 넣으므로 그룹을 손으로 쓰지 않으며, 분기, 조기 return, ?, 되감기를 지나도 그룹이 맞게 닫힙니다. 이름은 Compose를 따릅니다. 위젯 어휘는 스키마에 있는 그것 하나이므로 두 번 정의하지 않습니다. 작성 API의 나머지는 UI 작성하기에 있고, 샘플 열한 개가 diff --git a/docs/guide/ko/writing-ui.html b/docs/guide/ko/writing-ui.html index 9fc25301..2882b570 100644 --- a/docs/guide/ko/writing-ui.html +++ b/docs/guide/ko/writing-ui.html @@ -70,7 +70,7 @@

컴포저블, 상태, 이벤트

컴포저블은 함수입니다

- 함수에 #[composable] 을 붙이고 그 안에서 위젯을 부릅니다. glob import 두 줄이면 + 함수에 #[Composable] 을 붙이고 그 안에서 위젯을 부릅니다. glob import 두 줄이면 전부 들어옵니다. 상태와 effect는 compose_rust::runtime 에서, 위젯과 Modifier 는 compose_rust::ui 에서 옵니다.

@@ -80,7 +80,7 @@

컴포저블은 함수입니다

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -96,7 +96,7 @@ 

컴포저블은 함수입니다

- #[composable] 은 Compose 컴파일러 플러그인이 @Composable 함수에 하는 + #[Composable] 은 Compose 컴파일러 플러그인이 @Composable 함수에 하는 일을 합니다. 호출에 그룹을 주고, if 와 match 의 가지마다 따로 그룹을 주고, 함수를 건너뛸 수 있고 다시 시작할 수 있게 만듭니다. 그룹을 손으로 쓰는 일은 없습니다. 그룹은 continue, break, return, ?, 되감기를 @@ -144,7 +144,7 @@

직접 만드는 컴포저블

rust
-
#[composable]
+
#[Composable]
 fn task_row(title: String, done: bool, on_toggle: Rc<dyn Fn(bool)>) {
     Row()
         .modifier(Modifier.fill_max_width())
@@ -460,7 +460,7 @@ 

애니메이션 구현

use compose_rust::ui::*; use compose_rust::{AnimationSpec, animate_float_as_state}; -#[composable] +#[Composable] fn fading(faded: MutableState<bool>) { let target = if faded.get() { 0.0 } else { 1.0 }; Box() diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index c89e0953..c187ac10 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -39,7 +39,7 @@ compose-rust는 네 가지를 합친 것입니다. 0.0.2 크레이트에 있는 것: -- **작성 API.** `#[composable]`, `remember`, 상태와 effect, `Recomposer`, `launch`, 그리고 +- **작성 API.** `#[Composable]`, `remember`, 상태와 effect, `Recomposer`, `launch`, 그리고 위젯마다 하나씩인 함수입니다([#23](https://github.com/DarkPyonix/compose-rust/issues/23), [#64](https://github.com/DarkPyonix/compose-rust/issues/64)). 0.0.2에서 새로 들어왔고, 애니메이션도 함께 있습니다. `Modifier.graphics_layer`, `Modifier.alpha`, 그리고 `animate_float_as_state`, `animate_color_as_state`, `animate_offset_as_state` 이며, 그 값은 @@ -70,7 +70,7 @@ compose-rust는 네 가지를 합친 것입니다. use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn counter() { let count = remember(|| mutable_state_of(0)); Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| { @@ -86,7 +86,7 @@ fn main() { ``` 위젯은 문장 하나입니다. `Text("Hello")`가 위젯을 기술하고, 그 메서드들이 Compose가 이름 붙은 -인자로 받는 것을 정하며, 컨테이너의 자식은 `content`에 넘긴 클로저입니다. `#[composable]`이 그룹을 +인자로 받는 것을 정하며, 컨테이너의 자식은 `content`에 넘긴 클로저입니다. `#[Composable]`이 그룹을 넣으므로 그룹을 손으로 쓰지 않으며, 분기, 조기 `return`, `?`, 되감기를 지나도 그룹이 맞게 닫힙니다. 이름은 Compose를 따릅니다. 위젯 어휘는 스키마에 있는 그것 하나이고, 두 번 정의하지 않습니다. 나머지는 [가이드](https://darkpyonix.dev/compose-rust/ko/writing-ui.html)에 있습니다. diff --git a/samples/README.md b/samples/README.md index 475a0b20..0793068d 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,7 +1,7 @@ # Sample apps Each of these is a real application written the way someone using this project would -write one: Rust only, on compose-rust's authoring API (`#[composable]` functions, widgets as +write one: Rust only, on compose-rust's authoring API (`#[Composable]` functions, widgets as statements, `remember` and `MutableState`), no Kotlin and no Dioxus. They exist to be used rather than admired. The widget vocabulary was chosen by reasoning diff --git a/samples/academic/src/lib.rs b/samples/academic/src/lib.rs index b996e266..791e06a7 100644 --- a/samples/academic/src/lib.rs +++ b/samples/academic/src/lib.rs @@ -176,7 +176,7 @@ fn skills_page(on_open: &OpenSubject) { /// One lesson as a card. What is locked is quiet and says so in a word, because a padlock /// is an icon and an icon cannot be placed from application code. -#[composable] +#[Composable] fn lesson_card(lesson: &'static Lesson) { let (fill, ink) = if lesson.open { (ColorRole::SurfaceContainer, ColorRole::OnSurface) @@ -367,7 +367,7 @@ fn profile_page() { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/calculator/src/lib.rs b/samples/calculator/src/lib.rs index 825a105d..c93b3f22 100644 --- a/samples/calculator/src/lib.rs +++ b/samples/calculator/src/lib.rs @@ -6,7 +6,7 @@ //! columns of keys along the bottom. //! //! Written with compose-rust's own API: state lives in `mutable_state_of`, screens are -//! `#[composable]` functions, and a widget is a statement. No Kotlin and no protocol types. +//! `#[Composable]` functions, and a widget is a statement. No Kotlin and no protocol types. //! //! There is no keyboard entry. A keypad is pressed, and a text field kept on screen only to //! hold the focus would be development scaffolding under the keypad, doing nothing a @@ -66,7 +66,7 @@ fn color_for(label: &str) -> Option { type Press = Rc; /// The keypad, in the shape this platform's calculator has. -#[composable] +#[Composable] fn keypad(apple: bool, press: Press) { Column() .modifier(Modifier.fill_max_width().fill_max_height()) @@ -95,7 +95,7 @@ fn keypad(apple: bool, press: Press) { } /// The memory keys, as the row the Windows and Deepin references give them. -#[composable] +#[Composable] fn memory_row(memory_set: bool, press: Press) { Row() .modifier(Modifier.fill_max_width()) @@ -119,7 +119,7 @@ fn memory_row(memory_set: bool, press: Press) { } /// The readout: what is being worked out, and what it comes to. -#[composable] +#[Composable] fn readout(status: String, display: String) { Box().modifier(Modifier.fill_max_width()).content(|| { Column() @@ -160,7 +160,7 @@ struct TapeEntry { const TAPE_LABEL: &str = "History"; /// The tape: what has been worked out, newest at the top. -#[composable] +#[Composable] fn tape(entries: Vec, recall: Rc, clear: Rc) { Column() .modifier(Modifier.fill_max_width().fill_max_height()) @@ -224,7 +224,7 @@ const INSTRUMENT_SHARE: f32 = 3.0; const TAPE_SHARE: f32 = 2.0; /// The calculator. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); // The one thing a role cannot answer: Apple's calculator has different keys. diff --git a/samples/chat/src/lib.rs b/samples/chat/src/lib.rs index 036b77c5..2e1e86a1 100644 --- a/samples/chat/src/lib.rs +++ b/samples/chat/src/lib.rs @@ -117,7 +117,7 @@ fn thread_width(window: &WindowSize) -> Option { const THREAD_COLUMN: f32 = 787.0; /// The assistant's settings, in a sheet. -#[composable] +#[Composable] fn settings_panel(settings: Settings, change: Rc, close: Rc) { Column() .modifier(Modifier.fill_max_width()) @@ -188,7 +188,7 @@ fn settings_panel(settings: Settings, change: Rc, close: Rc>) { let index = rows[position]; let message = messages.with(|list| list[index].clone()); @@ -650,7 +650,7 @@ fn message_row(rows: &[usize], position: usize, messages: &MutableState, diff --git a/samples/minimal/src/lib.rs b/samples/minimal/src/lib.rs index 95cc96a5..04ad5f3a 100644 --- a/samples/minimal/src/lib.rs +++ b/samples/minimal/src/lib.rs @@ -497,7 +497,7 @@ fn colour_group() { } /// The component sheet. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/notepad/src/lib.rs b/samples/notepad/src/lib.rs index 48b8b4c8..90acfc11 100644 --- a/samples/notepad/src/lib.rs +++ b/samples/notepad/src/lib.rs @@ -35,7 +35,7 @@ type Work = std::boxed::Box Outcome + Send + 'static>; /// field is keyed by `stamp`, which changes only then. A new key is a new field, and the /// field takes the contents it was built with. Ordinary typing leaves `stamp` alone, so the /// caret and the IME composition are never disturbed and nothing is written back. -#[composable] +#[Composable] fn editor(stamp: u64, contents: String, type_role: TypeRole, on_edit: Rc) { key(stamp, || { // What the field was built with, kept for as long as this key lives. Later writes @@ -56,7 +56,7 @@ fn editor(stamp: u64, contents: String, type_role: TypeRole, on_edit: Rc) { key(stamp, || { let initial = remember(|| contents.clone()); @@ -81,7 +81,7 @@ fn path_field(stamp: u64, contents: String, on_edit: Rc) { /// /// `measure` is `None` on a window with nothing to spare, where the row fills the bar and /// the bar's own inset is already the page's. -#[composable] +#[Composable] fn document_bar(measure: Option, working: bool, children: impl FnOnce()) { Column().modifier(Modifier.fill_max_width()).content(|| { TopAppBar().modifier(Modifier.fill_max_width()).content(|| { @@ -138,7 +138,7 @@ const DOCUMENT_SHARE: f32 = 3.0; /// The same element stands beside the document on a desktop window and arrives in a sheet /// on anything narrower. It is written once because it is one list: a list that had to be /// authored twice would drift the first time either copy changed. -#[composable] +#[Composable] fn document_list( entries: Vec<(u64, String)>, current: u64, @@ -187,7 +187,7 @@ fn document_list( } /// One of the three document styles in the format sheet. -#[composable] +#[Composable] fn style_choice( label: &'static str, role: TypeRole, @@ -205,7 +205,7 @@ fn style_choice( } /// The editor. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); // A document is read across its lines, so past a certain width a page that keeps diff --git a/samples/podcast/src/lib.rs b/samples/podcast/src/lib.rs index 4da4f332..2c6877e1 100644 --- a/samples/podcast/src/lib.rs +++ b/samples/podcast/src/lib.rs @@ -280,7 +280,7 @@ fn episode_card(found: &Episode, on_play: &Handler) { /// it and an outlined white button, and the rest are the same shape on the page with a /// hairline round them and the orange on the button instead. That is the reference: one /// row picked out, the others quiet. -#[composable] +#[Composable] fn show_row(show: &Show, followed: bool, recommended: bool, on_toggle: Rc) { let ink = if recommended { palette::PAGE @@ -340,7 +340,7 @@ fn show_row(show: &Show, followed: bool, recommended: bool, on_toggle: Rc>, on_play: Handler, on_open: Handler) { let following = followed.get(); Column() @@ -435,7 +435,7 @@ fn new_page(followed: MutableState>, on_play: Handler, on_open: } /// Every episode, shortest first, as a plain list. -#[composable] +#[Composable] fn shows_page(on_play: Handler) { let mut ordered: Vec<&'static Episode> = EPISODES.iter().collect(); ordered.sort_by_key(|found| found.seconds); @@ -467,7 +467,7 @@ fn shows_page(on_play: Handler) { } /// One row of the plain list, with the hairline under every row but the last. -#[composable] +#[Composable] fn shows_row(found: &'static Episode, last: bool, on_play: Handler) { Column().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -509,7 +509,7 @@ fn shows_row(found: &'static Episode, last: bool, on_play: Handler) { } /// What you follow and what you have listened to. -#[composable] +#[Composable] fn you_page(followed: usize) { let listened: u32 = EPISODES.iter().map(|found| found.seconds).sum(); Column() @@ -561,7 +561,7 @@ fn you_page(followed: usize) { /// The player: the cover full bleed at the top of a white page, with the transport under /// it. -#[composable] +#[Composable] fn player_page(found: &'static Episode, position: MutableState, on_back: Rc) { let show = show_of(found); let at = position.get(); @@ -701,7 +701,7 @@ fn player_page(found: &'static Episode, position: MutableState, on_back: Rc /// labelled bar with a selection pill under a hairline. The reference is three circles /// sitting on the page, the one you are on filled with the accent, and that is a shape no /// design system would be right to give a set of destinations. -#[composable] +#[Composable] fn bottom_bar(destination: Destination, on_go: Handler) { Row() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Sm)) @@ -734,7 +734,7 @@ fn bottom_bar(destination: Destination, on_go: Handler) { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/selfcare/src/lib.rs b/samples/selfcare/src/lib.rs index de5be5d2..3e97c2f3 100644 --- a/samples/selfcare/src/lib.rs +++ b/samples/selfcare/src/lib.rs @@ -90,7 +90,7 @@ enum Step { type Action = Rc; /// The mood picker: the face, the strip under it, and the question. -#[composable] +#[Composable] fn feeling_step(chosen: MutableState, on_skip: Action, on_next: Action) { let current = chosen.get(); let (fill, _ink) = current.pair(); @@ -200,7 +200,7 @@ fn feeling_step(chosen: MutableState, on_skip: Action, on_next: Action) { } /// The worry picker: ten chips, any number of them chosen. -#[composable] +#[Composable] fn worry_step(picked: MutableState>, on_skip: Action, on_next: Action) { let chosen = picked.get(); // Two to a row. There is no wrapping row in the vocabulary, so a grid is rows of a @@ -277,7 +277,7 @@ fn worry_step(picked: MutableState>, on_skip: Action, on_next: } /// What the check-in leaves behind: the day, in one line, and a way back into it. -#[composable] +#[Composable] fn checked_in(chosen: Mood, worries: Vec<&'static str>, again: Action) { let (fill, ink) = chosen.pair(); let summary = if worries.is_empty() { @@ -360,7 +360,7 @@ fn session_card(session: &Session) { } /// The listening screen: a greeting, a search field and the sessions. -#[composable] +#[Composable] fn listen_page() { Column() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Md)) @@ -411,7 +411,7 @@ fn listen_page() { } /// One row of "Special for you", with the hairline under every row but the last. -#[composable] +#[Composable] fn special_row(session: &'static Session, separated: bool) { Column().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -442,7 +442,7 @@ fn special_row(session: &'static Session, separated: bool) { } /// Everything there is, as a plain list. -#[composable] +#[Composable] fn library_page() { Column() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Md)) @@ -486,7 +486,7 @@ fn library_page() { } /// The profile: who this is, how the week went, and the settings under it. -#[composable] +#[Composable] fn profile_page(chosen: Mood) { let (fill, ink) = chosen.pair(); Column() @@ -572,7 +572,7 @@ fn profile_page(chosen: Mood) { } /// One entry under the profile, with the hairline under every entry but the last. -#[composable] +#[Composable] fn settings_row(entry: &'static str, separated: bool) { Column().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -593,7 +593,7 @@ fn settings_row(entry: &'static str, separated: bool) { } /// The page for one destination, and for the check-in the step it is on. -#[composable] +#[Composable] fn page( destination: Destination, step: MutableState, @@ -616,7 +616,7 @@ fn page( } } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/social/src/lib.rs b/samples/social/src/lib.rs index 6f9ef104..aea2f03e 100644 --- a/samples/social/src/lib.rs +++ b/samples/social/src/lib.rs @@ -99,7 +99,7 @@ fn play_badge(id: u32, opened: MutableState>) { /// A reading surface under it puts both sides of the promise back. The reference does the /// same thing with a gradient scrim, which is the same idea drawn more softly than a /// closed drawing vocabulary can say. -#[composable] +#[Composable] fn hero_card(found: &'static Course, size: (f32, f32), opened: MutableState>) { Box() .modifier( @@ -141,7 +141,7 @@ fn hero_card(found: &'static Course, size: (f32, f32), opened: MutableState>) { Column() .modifier(Modifier.fill_max_width()) @@ -180,7 +180,7 @@ fn tile_card(found: &'static Course, opened: MutableState>) { /// /// Two, because there is no wrapping row in the vocabulary: a grid is rows of a fixed /// count and the code says how many rather than the layout working it out from the width. -#[composable] +#[Composable] fn grid(items: Vec<&'static Course>, opened: MutableState>) { Column() .modifier(Modifier.fill_max_width()) @@ -213,7 +213,7 @@ fn grid(items: Vec<&'static Course>, opened: MutableState>) { } /// The page a destination shows: a hero, then its shelf. -#[composable] +#[Composable] fn shelf_page( destination: Destination, heading: &'static str, @@ -254,7 +254,7 @@ fn shelf_page( } /// One course opened: its picture, what it is, and its sessions. -#[composable] +#[Composable] fn course_page(found: &'static Course, opened: MutableState>) { let (strong, quiet, ink) = found.palette.roles(); Column() @@ -330,7 +330,7 @@ fn course_page(found: &'static Course, opened: MutableState>) { } /// The app. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/statistics/src/lib.rs b/samples/statistics/src/lib.rs index cfc5c4cc..40eaa4e6 100644 --- a/samples/statistics/src/lib.rs +++ b/samples/statistics/src/lib.rs @@ -77,7 +77,7 @@ fn week_bars() -> Vec { /// is placed at a point and takes a rung of the ladder, which is enough for a day label /// under a column; a reading this size is the loudest thing on the screen and wants the /// ladder's own metrics and the ellipsis behaviour that goes with a widget. -#[composable] +#[Composable] fn dial_card() { Box() .modifier( @@ -116,7 +116,7 @@ fn dial_card() { /// The panel is the reference's sage and the chart's ink is the palette's, held to it by /// the chart tests, so the chart is readable on the panel whichever design system draws /// the rest. -#[composable] +#[Composable] fn costs_panel(title_role: TypeRole, chart: (f32, f32)) { let (width, height) = chart; let bars = week_bars(); @@ -142,7 +142,7 @@ fn costs_panel(title_role: TypeRole, chart: (f32, f32)) { /// On both pages, because a page that says how much was spent this week and never says /// what it went on is the top half of the reference with the bottom half missing, and that /// missing half is the empty third at the foot of the window. -#[composable] +#[Composable] fn sources_strip() { LazyRow() .modifier(Modifier.fill_max_width().height(SOURCE_TILE)) @@ -196,7 +196,7 @@ fn sources_strip() { /// The overview: the dial, the costs panel, a second reading beside it, and what the /// money went on. -#[composable] +#[Composable] fn today_page() { Column() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Md)) @@ -292,7 +292,7 @@ fn today_page() { } /// The costs in full: the whole page is the panel, and the sources run off the edge. -#[composable] +#[Composable] fn costs_page() { Column() .modifier( @@ -331,7 +331,7 @@ fn costs_page() { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/store/src/lib.rs b/samples/store/src/lib.rs index 93c2ea7d..9fa94cf4 100644 --- a/samples/store/src/lib.rs +++ b/samples/store/src/lib.rs @@ -113,7 +113,7 @@ impl Destination { /// marks the destination you are on is that its icon is drawn in the accent. The button /// carries no text and sits over the drawing, so the press lands on it whatever the /// design system drew. -#[composable] +#[Composable] fn icon_button(picture: &'static [u8], tint: Paint, on_click: Rc) { Box() .modifier(Modifier.size(ICON_TAP, ICON_TAP)) @@ -150,7 +150,7 @@ fn logo_mark() -> DrawList { /// The top of the catalogue: the mark on the left, and what you can reach from anywhere /// on the right. -#[composable] +#[Composable] fn header(waiting: u32, on_go: Rc) { Row() .modifier(Modifier.fill_max_width()) @@ -190,7 +190,7 @@ fn header(waiting: u32, on_go: Rc) { /// The card is the control. The reference has no button on it, and a tile cannot be /// tapped: `Modifier::Clickable` is on the wire but no container widget exposes it, so /// what carries the press is a `Button` with no label, filling the picture area. -#[composable] +#[Composable] fn tile(product: &'static Product, height: f32, on_open: Rc) { let id = product.id; Box() @@ -219,7 +219,7 @@ fn tile(product: &'static Product, height: f32, on_open: Rc) { /// No weight of its own. The cell's parent in the grid is a `Column`, where weight is /// vertical, and a vertical weight inside a column that is measuring its own height comes /// out as a height of zero. The width share belongs to the wrapper in the row above. -#[composable] +#[Composable] fn grid_cell(product: &'static Product, on_open: Rc) { Column() .modifier(Modifier.fill_max_width()) @@ -248,7 +248,7 @@ fn grid_cell(product: &'static Product, on_open: Rc) { /// belongs to the Renderer and is never reported back: dots driven by one would be drawn /// in the right place and never move. Pressing the card turns it, which is the nearest /// thing to the reference's swipe that the vocabulary has. -#[composable] +#[Composable] fn carousel(slide: MutableState) { let showing = slide.get() % FEATURED; Box() @@ -302,7 +302,7 @@ fn carousel(slide: MutableState) { /// The catalogue: the header, the heading, the carousel, the category strip and a two-up /// grid. -#[composable] +#[Composable] fn catalogue_screen( category: MutableState, slide: MutableState, @@ -394,7 +394,7 @@ fn catalogue_screen( } /// One garment: the picture, what it is, what size, how many, and what it costs. -#[composable] +#[Composable] fn detail_screen( product: &'static Product, size: MutableState<&'static str>, @@ -572,7 +572,7 @@ fn detail_screen( } /// What is waiting to be paid for. -#[composable] +#[Composable] fn bag_screen(bag: MutableState>) { let lines = bag.get(); let sum = total(&lines); @@ -641,7 +641,7 @@ fn bag_screen(bag: MutableState>) { } /// One line of the bag, with the hairline under it unless it is the last. -#[composable] +#[Composable] fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: bool) { let named = catalogue::product(line.product); Column().modifier(Modifier.fill_max_width()).content(|| { @@ -697,7 +697,7 @@ fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: } /// Search, which is a field and the shelves as a list. -#[composable] +#[Composable] fn search_screen(on_open: Rc) { Column() .modifier( @@ -765,7 +765,7 @@ fn search_screen(on_open: Rc) { } /// The account page, which is a short list of the things a shop keeps about a person. -#[composable] +#[Composable] fn account_screen() { Column() .modifier( @@ -822,7 +822,7 @@ fn account_screen() { /// labelled bar with a selection pill under a hairline. The reference is four bare icons /// on the page, the one you are on in the accent, and that is a shape no design system /// would be right to give a set of destinations. -#[composable] +#[Composable] fn bottom_bar(destination: Destination, on_go: Rc) { Row() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Sm)) @@ -845,7 +845,7 @@ fn bottom_bar(destination: Destination, on_go: Rc) { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { diff --git a/samples/todo/src/lib.rs b/samples/todo/src/lib.rs index 0040a15e..d11509d6 100644 --- a/samples/todo/src/lib.rs +++ b/samples/todo/src/lib.rs @@ -24,7 +24,7 @@ const BULK_COUNT: usize = 5_000; const ABOUT_LABEL: &str = "About"; /// The bar across the top of the window, with its contents held to the list's measure. -#[composable] +#[Composable] fn list_bar(measure: Option, done: usize, total: usize, children: impl FnOnce()) { Column().modifier(Modifier.fill_max_width()).content(|| { TopAppBar().modifier(Modifier.fill_max_width()).content(|| { @@ -52,7 +52,7 @@ fn list_bar(measure: Option, done: usize, total: usize, children: impl FnOn } /// What this sample is and the one control that only a sample has. -#[composable] +#[Composable] fn about_panel(total: usize, fill: Rc, close: Rc) { Column() .modifier(Modifier.fill_max_width()) @@ -97,7 +97,7 @@ fn about_panel(total: usize, fill: Rc, close: Rc) { } /// The composer: one grouped strip whose field grows with the window. -#[composable] +#[Composable] fn composer(add: Rc, draft: MutableState) { Surface().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -120,7 +120,7 @@ fn composer(add: Rc, draft: MutableState) { } /// The task list. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); // Past an expanded window the screen stops widening and centres. @@ -445,7 +445,7 @@ pub fn app() { } /// One row of the list, composed when the Renderer's window reaches it. -#[composable] +#[Composable] #[allow(clippy::too_many_arguments)] fn task_row( rows: &[usize], From 399680a9611fabea6d40179e8540b388715e44db Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:17:53 +0900 Subject: [PATCH 092/224] Fix: Make the #[Composable] rename build --- compose-rust/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 3d0522af..b64d974c 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -75,7 +75,7 @@ pub use notification::{ request_notification_permission, withdraw_notification, }; pub use palette::{Palette, PaletteViolation}; -pub use runtime::{Batch, ComposeHost, Recomposer, Runtime, Composable, application, launch}; +pub use runtime::{Batch, Composable, ComposeHost, Recomposer, Runtime, application, launch}; pub use schema::{ Alignment, Arrangement, AssetKind, ButtonKind, ButtonVariant, Chrome, Color, ColorRole, ColorScheme, DecorationKind, DesignSystem, EventPayload, GenericFamily, HoverPhase, IconRole, From 4cc64beec1ffd5d3ff1c4e09392dfab0d2533abf Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:15:56 +0900 Subject: [PATCH 093/224] Feat: Generate a call macro for every composable (FR-39.1.1, 39.1.2, 39.1.3) #[Composable] keeps the function and also emits a #[macro_export] #[doc(hidden)] macro __compose_call_, re-exported under the function's name at the function's visibility. The macro takes a positional prefix, then name = value in any order, and a trailing block or content = for the content closure. It expands to one call of the function with the arguments in parameter order. #[default(expr)] parameter attributes are stripped from the function and become the macro's defaults; modifier: Modifier defaults to Modifier::new(). Methods (receiver or nonrestartable) get no macro. The macro's docs list every parameter with its type and default. Tests: fr39_call_macro_*, fr39_function_form_*, fr39_default_*, fr39_call_macro_limits_* and fr39_call_macro_doc_* in compose-rust/tests/call_macro.rs and the macros crate; trybuild fixtures in compose-rust/macros/tests/ui (their .stderr files are generated by the builder). --- compose-rust/macros/Cargo.toml | 6 + compose-rust/macros/src/call_macro.rs | 464 ++++++++++++++++++ compose-rust/macros/src/lib.rs | 97 +++- compose-rust/macros/tests/ui.rs | 10 + .../tests/ui/fail_block_without_content.rs | 10 + .../tests/ui/fail_default_type_mismatch.rs | 10 + .../macros/tests/ui/fail_duplicate.rs | 10 + .../tests/ui/fail_duplicate_of_positional.rs | 10 + .../tests/ui/fail_method_has_no_macro.rs | 15 + .../macros/tests/ui/fail_missing_required.rs | 10 + .../tests/ui/fail_positional_after_named.rs | 10 + .../macros/tests/ui/fail_renamed_import.rs | 19 + .../tests/ui/fail_too_many_positional.rs | 10 + .../macros/tests/ui/fail_turbofish.rs | 10 + .../macros/tests/ui/fail_unknown_name.rs | 10 + compose-rust/macros/tests/ui/pass_forms.rs | 43 ++ compose-rust/tests/call_macro.rs | 256 ++++++++++ 17 files changed, 999 insertions(+), 1 deletion(-) create mode 100644 compose-rust/macros/src/call_macro.rs create mode 100644 compose-rust/macros/tests/ui.rs create mode 100644 compose-rust/macros/tests/ui/fail_block_without_content.rs create mode 100644 compose-rust/macros/tests/ui/fail_default_type_mismatch.rs create mode 100644 compose-rust/macros/tests/ui/fail_duplicate.rs create mode 100644 compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs create mode 100644 compose-rust/macros/tests/ui/fail_method_has_no_macro.rs create mode 100644 compose-rust/macros/tests/ui/fail_missing_required.rs create mode 100644 compose-rust/macros/tests/ui/fail_positional_after_named.rs create mode 100644 compose-rust/macros/tests/ui/fail_renamed_import.rs create mode 100644 compose-rust/macros/tests/ui/fail_too_many_positional.rs create mode 100644 compose-rust/macros/tests/ui/fail_turbofish.rs create mode 100644 compose-rust/macros/tests/ui/fail_unknown_name.rs create mode 100644 compose-rust/macros/tests/ui/pass_forms.rs create mode 100644 compose-rust/tests/call_macro.rs diff --git a/compose-rust/macros/Cargo.toml b/compose-rust/macros/Cargo.toml index e2f71676..49f80ab1 100644 --- a/compose-rust/macros/Cargo.toml +++ b/compose-rust/macros/Cargo.toml @@ -19,3 +19,9 @@ bench = false proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "visit", "visit-mut"] } + +[dev-dependencies] +# The compile-fail fixtures in tests/ui expand `#[Composable]` the way an application does, +# so they need the crate the expansion names. A dev-dependency cycle is allowed. +compose-rust = { path = ".." } +trybuild = "1" diff --git a/compose-rust/macros/src/call_macro.rs b/compose-rust/macros/src/call_macro.rs new file mode 100644 index 00000000..f3fa855a --- /dev/null +++ b/compose-rust/macros/src/call_macro.rs @@ -0,0 +1,464 @@ +//! The call macro `#[Composable]` generates next to a composable function. +//! +//! `#[Composable] fn Name(a: A, b: B, content: impl Fn())` keeps its function form and also +//! gets a macro of the same name that takes the arguments positionally first and by name +//! after that, fills in `#[default(..)]` values, and expands to one call of the function. +//! +//! The macro is a small state machine written as `macro_rules!` rules, generated per +//! function. Its state is the argument list so far, one bracketed slot per parameter, and +//! the arguments not read yet: +//! +//! * `@p`: reading the positional prefix; `K` parameters are filled. A positional +//! argument fills slot `K`. A `name = value` argument or a trailing `{ .. }` block moves +//! to the named phase. +//! * `@n`: reading `name = value` arguments in any order. Each parameter has a rule for the +//! empty slot (fill it) and one for a filled slot (duplicate). +//! * `@call`: every argument has been read. Empty slots of parameters with a default take +//! it, an empty slot of a required parameter is an error naming it, and when none is +//! left the rule emits `Name(slot0, slot1, ..)`. +//! +//! Nothing is bound to a local, so the arguments stay inside the function call where +//! closures take their parameter types from the function's bounds, and they are evaluated in +//! parameter order. +//! +//! The rules call the macro again by the name the caller used for it. A rule cannot name the +//! macro by an absolute path: inside the crate that defines it, the compiler refuses +//! `crate::__compose_call_Name` for a macro that another macro expanded. The function is +//! called by its own name for the same reason. + +use proc_macro2::{Group, Punct, Spacing, TokenStream, TokenTree}; +use quote::{format_ident, quote}; +use syn::{Attribute, Ident, ItemFn, Type, Visibility}; + +/// One parameter, as the call macro sees it. +pub(crate) struct CallParam { + pub ident: Ident, + pub ty: Type, + /// The default expression, normalised so it can be expanded at a call site. `None` is a + /// required parameter. + pub default: Option, +} + +/// The default of `modifier: Modifier` when the parameter has no `#[default]`. +pub(crate) fn implicit_default(ident: &Ident, ty: &Type) -> Option { + let is_modifier = matches!(ty, Type::Path(path) + if path.qself.is_none() + && path.path.segments.last().is_some_and(|segment| segment.ident == "Modifier")); + (ident == "modifier" && is_modifier).then(|| quote!(::compose_rust::ui::Modifier::new())) +} + +/// Rewrites what a default expression cannot keep when it moves to the call site: a path +/// that starts at `crate` has to keep meaning the crate that wrote the default. +pub(crate) fn normalise_default(tokens: TokenStream) -> TokenStream { + tokens + .into_iter() + .flat_map(|tree| match tree { + TokenTree::Ident(ident) if ident == "crate" => vec![ + TokenTree::Punct(Punct::new('$', Spacing::Alone)), + TokenTree::Ident(ident), + ], + TokenTree::Group(group) => { + let mut rebuilt = Group::new(group.delimiter(), normalise_default(group.stream())); + rebuilt.set_span(group.span()); + vec![TokenTree::Group(rebuilt)] + } + other => vec![other], + }) + .collect() +} + +/// Token text with the spaces `TokenStream::to_string` puts around punctuation taken out. +fn tidy(mut text: String) -> String { + for (from, to) in [ + (" :: ", "::"), + (":: ", "::"), + (" < ", "<"), + (" <", "<"), + ("< ", "<"), + (" >", ">"), + (" ,", ","), + (" (", "("), + ("( ", "("), + (" )", ")"), + ("& ", "&"), + ] { + text = text.replace(from, to); + } + text +} + +/// The text of a type for the macro's documentation. +fn type_text(ty: &Type) -> String { + tidy(quote!(#ty).to_string()) +} + +fn default_text(default: &TokenStream) -> String { + tidy(default.to_string().replace("$ crate", "crate")) +} + +/// The documentation lines of the macro: the function's own text, then every parameter in +/// declaration order. rust-analyzer shows a macro's docs but not the signature of the +/// function it calls, so this is what an editor hover has to go on. +fn documentation(function: &ItemFn, params: &[CallParam]) -> Vec { + let mut lines: Vec = Vec::new(); + for attribute in &function.attrs { + if !attribute.path().is_ident("doc") { + continue; + } + if let syn::Meta::NameValue(meta) = &attribute.meta { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) = &meta.value + { + lines.push(text.value()); + } + } + } + if !lines.is_empty() { + lines.push(String::new()); + } + let name = &function.sig.ident; + lines.push(format!( + " Call macro of `{name}`: positional arguments first, then `name = value` in any order." + )); + lines.push(String::new()); + lines.push(" Parameters, in declaration order:".to_string()); + lines.push(String::new()); + for param in params { + let ident = ¶m.ident; + let ty = type_text(¶m.ty); + let fate = match ¶m.default { + Some(default) => format!("default `{}`", default_text(default)), + None => "required".to_string(), + }; + lines.push(format!(" * `{ident}: {ty}`, {fate}")); + } + if params.is_empty() { + lines.push(" * none".to_string()); + } + lines +} + +/// Everything the call macro adds to the function: the exported macro and the re-export +/// under the function's name. The caller decides whether a function gets them at all: a +/// method has no name of its own to share with a macro. +pub(crate) fn generate(function: &ItemFn, params: &[CallParam]) -> TokenStream { + let name = &function.sig.ident; + let hidden = format_ident!("__compose_call_{}", name); + let count = params.len(); + let slot = |index: usize| format_ident!("s{}", index); + let has_content = params.last().is_some_and(|param| param.ident == "content"); + + // `[ $($s0:tt)* ] [ $($s1:tt)* ] ..`, with slot `which` matched as empty. + let capture = |empty: Option| -> TokenStream { + let slots = (0..count).map(|index| { + let var = slot(index); + if Some(index) == empty { + quote!([]) + } else { + quote!([ $($#var:tt)* ]) + } + }); + quote!(#(#slots)*) + }; + // The same slots, spliced back. `fill` replaces one slot's content. + let forward = |fill: Option<(usize, TokenStream)>| -> TokenStream { + let slots = (0..count).map(|index| match &fill { + Some((target, tokens)) if *target == index => quote!([ #tokens ]), + _ => { + let var = slot(index); + quote!([ $($#var)* ]) + } + }); + quote!(#(#slots)*) + }; + let names_list = params + .iter() + .map(|param| format!("`{}`", param.ident)) + .collect::>() + .join(", "); + let again = quote!(#name!); + + let mut rules: Vec = Vec::new(); + + // Read the positional prefix. State `@p` has filled the first K parameters. + for filled in 0..=count { + let state = format_ident!("p{}", filled); + let next = format_ident!("p{}", filled + 1); + let all = capture(None); + let kept = forward(None); + rules.push(quote! { + (@#state #all ; ) => { #again(@call #kept) }; + }); + if has_content { + rules.push(quote! { + (@#state #all ; { $($body:tt)* } $(,)?) => { + #again(@n #kept ; content = || { $($body)* }) + }; + }); + } else { + let message = format!( + "{name}! has no `content` parameter, so it takes no trailing block; the parameters are: {names_list}" + ); + rules.push(quote! { + (@#state #all ; { $($body:tt)* } $(,)?) => { ::core::compile_error!(#message) }; + }); + } + rules.push(quote! { + (@#state #all ; $given:ident = $($rest:tt)+) => { + #again(@n #kept ; $given = $($rest)+) + }; + }); + if filled < count { + let filled_in = forward(Some((filled, quote!($value)))); + rules.push(quote! { + (@#state #all ; $value:expr $(, $($rest:tt)*)?) => { + #again(@#next #filled_in ; $($($rest)*)?) + }; + }); + } else { + let message = format!( + "{name}! takes {count} argument(s) and was given more; the parameters are: {names_list}" + ); + rules.push(quote! { + (@#state #all ; $($rest:tt)+) => { ::core::compile_error!(#message) }; + }); + } + } + + // Read `name = value` arguments. + { + let all = capture(None); + let kept = forward(None); + rules.push(quote! { + (@n #all ; ) => { #again(@call #kept) }; + }); + if has_content { + rules.push(quote! { + (@n #all ; { $($body:tt)* } $(,)?) => { + #again(@n #kept ; content = || { $($body)* }) + }; + }); + } else { + let message = format!( + "{name}! has no `content` parameter, so it takes no trailing block; the parameters are: {names_list}" + ); + rules.push(quote! { + (@n #all ; { $($body:tt)* } $(,)?) => { ::core::compile_error!(#message) }; + }); + } + for (index, param) in params.iter().enumerate() { + let ident = ¶m.ident; + let dup_message = format!("argument `{ident}` of {name}! is given twice"); + let dup_slots = (0..count).map(|other| { + let other_var = slot(other); + if other == index { + quote!([ $($#other_var:tt)+ ]) + } else { + quote!([ $($#other_var:tt)* ]) + } + }); + rules.push(quote! { + (@n #(#dup_slots)* ; #ident = $value:expr $(, $($rest:tt)*)?) => { + ::core::compile_error!(#dup_message) + }; + }); + let empty = capture(Some(index)); + let filled_in = forward(Some((index, quote!($value)))); + rules.push(quote! { + (@n #empty ; #ident = $value:expr $(, $($rest:tt)*)?) => { + #again(@n #filled_in ; $($($rest)*)?) + }; + }); + } + let unknown = format!("{name}! has no parameter `"); + let unknown_tail = format!("`; the parameters are: {names_list}"); + rules.push(quote! { + (@n #all ; $given:ident = $($rest:tt)*) => { + ::core::compile_error!(::core::concat!(#unknown, ::core::stringify!($given), #unknown_tail)) + }; + }); + let positional = format!( + "a positional argument of {name}! follows a named one; name it too, the parameters are: {names_list}" + ); + rules.push(quote! { + (@n #all ; $($rest:tt)+) => { ::core::compile_error!(#positional) }; + }); + } + + // Every argument is read: defaults first, then the required ones that are missing. + for (index, param) in params.iter().enumerate() { + if let Some(default) = ¶m.default { + let empty = capture(Some(index)); + let filled_in = forward(Some((index, default.clone()))); + rules.push(quote! { + (@call #empty) => { #again(@call #filled_in) }; + }); + } + } + for (index, param) in params.iter().enumerate() { + if param.default.is_none() { + let ident = ¶m.ident; + let message = format!("missing required argument `{ident}` for {name}!"); + let empty = capture(Some(index)); + rules.push(quote! { + (@call #empty) => { ::core::compile_error!(#message) }; + }); + } + } + { + let all = capture(None); + let arguments = (0..count).map(|index| { + let var = slot(index); + quote!($($#var)*) + }); + rules.push(quote! { + (@call #all) => { #name(#(#arguments),*) }; + }); + } + // The entry rule comes last, so that a caller's arguments never meet the state rules. + let empty_slots = (0..count).map(|_| quote!([])); + rules.push(quote! { + ($($arguments:tt)*) => { #again(@p0 #(#empty_slots)* ; $($arguments)*) }; + }); + + let docs = documentation(function, params); + let docs = docs.iter().map(|line| quote!(#[doc = #line])); + let cfgs: Vec<&Attribute> = function + .attrs + .iter() + .filter(|attribute| attribute.path().is_ident("cfg")) + .collect(); + let visibility = match &function.vis { + Visibility::Inherited => quote!(), + other => quote!(#other), + }; + quote! { + #(#cfgs)* + #(#docs)* + #[macro_export] + #[doc(hidden)] + macro_rules! #hidden { + #(#rules)* + } + #(#cfgs)* + #[allow(unused_imports)] + #visibility use #hidden as #name; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::{Item, parse_quote}; + + fn expand(function: ItemFn, params: Vec) -> Vec { + let tokens = generate(&function, ¶ms); + syn::parse2::(tokens) + .expect("the generated items parse") + .items + } + + fn docs_of(item: &Item) -> Vec { + let Item::Macro(item) = item else { + panic!("the first item is the macro") + }; + item.attrs + .iter() + .filter_map(|attribute| match &attribute.meta { + syn::Meta::NameValue(meta) if attribute.path().is_ident("doc") => { + match &meta.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) => Some(text.value()), + _ => None, + } + } + _ => None, + }) + .collect() + } + + fn sample() -> (ItemFn, Vec) { + let function: ItemFn = parse_quote! { + /// Draws a field. + pub fn Field(value: String, label: impl Fn(), single_line: bool) {} + }; + let params = vec![ + CallParam { + ident: parse_quote!(value), + ty: parse_quote!(String), + default: None, + }, + CallParam { + ident: parse_quote!(label), + ty: parse_quote!(impl Fn()), + default: Some(quote!(|| {})), + }, + CallParam { + ident: parse_quote!(single_line), + ty: parse_quote!(bool), + default: Some(quote!(false)), + }, + ]; + (function, params) + } + + #[test] + fn fr39_call_macro_doc_lists_every_parameter_in_declaration_order() { + let (function, params) = sample(); + let items = expand(function, params); + let docs = docs_of(&items[0]).join("\n"); + let value = docs.find("`value: String`, required").expect("value"); + let label = docs.find("`label: impl Fn()`, default `|| {}`").expect("label"); + let line = docs + .find("`single_line: bool`, default `false`") + .expect("single_line"); + assert!(value < label && label < line, "{docs}"); + } + + #[test] + fn fr39_call_macro_doc_starts_with_the_function_docs() { + let (function, params) = sample(); + let items = expand(function, params); + assert_eq!(docs_of(&items[0])[0], " Draws a field."); + } + + #[test] + fn fr39_call_macro_doc_shows_the_implicit_modifier_default() { + let function: ItemFn = parse_quote!( + pub fn Panel(modifier: Modifier) {} + ); + let ident: Ident = parse_quote!(modifier); + let ty: Type = parse_quote!(Modifier); + let params = vec![CallParam { + default: implicit_default(&ident, &ty), + ident, + ty, + }]; + let docs = docs_of(&expand(function, params)[0]).join("\n"); + assert!(docs.contains("default `::compose_rust::ui::Modifier::new()`"), "{docs}"); + } + + #[test] + fn fr39_call_macro_is_hidden_exported_and_reexported_at_the_function_visibility() { + let (function, params) = sample(); + let items = expand(function, params); + let Item::Macro(item) = &items[0] else { panic!() }; + assert_eq!(item.mac.path.segments.last().unwrap().ident, "macro_rules"); + assert_eq!(item.ident.as_ref().unwrap(), "__compose_call_Field"); + let text = quote!(#(#items)*).to_string(); + assert!(text.contains("macro_export")); + assert!(text.contains("doc (hidden)")); + let Item::Use(reexport) = &items[1] else { panic!() }; + assert!(matches!(reexport.vis, Visibility::Public(_))); + } + + #[test] + fn fr39_call_macro_default_paths_keep_meaning_the_defining_crate() { + let normalised = normalise_default(quote!(crate::theme::DEFAULT)).to_string(); + assert_eq!(normalised, "$ crate :: theme :: DEFAULT"); + } +} diff --git a/compose-rust/macros/src/lib.rs b/compose-rust/macros/src/lib.rs index ea922e4c..ddcedbcd 100644 --- a/compose-rust/macros/src/lib.rs +++ b/compose-rust/macros/src/lib.rs @@ -39,6 +39,9 @@ //! `Clone + PartialEq` is found out at compile time by method resolution and treated the //! same way. Nothing here needs the function's author to say which is which. +mod call_macro; + +use call_macro::{CallParam, implicit_default, normalise_default}; use proc_macro::TokenStream; use proc_macro2::Span; use quote::{format_ident, quote}; @@ -72,12 +75,104 @@ pub fn Composable(attribute: TokenStream, item: TokenStream) -> TokenStream { Err(error) => return error.to_compile_error().into(), }; let mut function = parse_macro_input!(item as ItemFn); + let defaults = match take_defaults(&mut function) { + Ok(defaults) => defaults, + Err(error) => return error.to_compile_error().into(), + }; + // A function that re-runs itself by bare name is an associated function, and a method + // has no name of its own to share with a macro, so only the restartable form gets one. + let call_params = options + .restartable + .then(|| call_params(&function, defaults)) + .flatten(); match expand(&mut function, &options) { - Ok(()) => quote!(#function).into(), + Ok(()) => { + let call_macro = call_params.map(|params| call_macro::generate(&function, ¶ms)); + quote!(#function #call_macro).into() + } Err(error) => error.to_compile_error().into(), } } +/// Removes the `#[default(expr)]` attributes from the parameters and returns the +/// expressions, one entry per parameter. +fn take_defaults(function: &mut ItemFn) -> syn::Result>> { + let mut defaults = Vec::new(); + for input in &mut function.sig.inputs { + let attrs = match input { + FnArg::Receiver(receiver) => &mut receiver.attrs, + FnArg::Typed(typed) => &mut typed.attrs, + }; + let mut default = None; + let mut kept = Vec::new(); + for attribute in attrs.drain(..) { + if !attribute.path().is_ident("default") { + kept.push(attribute); + continue; + } + if default.is_some() { + return Err(syn::Error::new_spanned( + attribute, + "a parameter takes one #[default(..)]", + )); + } + default = Some(attribute.parse_args::().map_err(|error| { + syn::Error::new( + error.span(), + "#[default(..)] takes the default value as an expression, \ + like #[default(false)]", + ) + })?); + } + *attrs = kept; + if default.is_some() && matches!(input, FnArg::Receiver(_)) { + return Err(syn::Error::new_spanned(input, "self has no default")); + } + defaults.push(default); + } + Ok(defaults) +} + +/// The parameters as the call macro needs them, or `None` for a function the macro cannot +/// name: one with a receiver, a pattern instead of a name, or `Self` in a type. +fn call_params(function: &ItemFn, defaults: Vec>) -> Option> { + let mut params = Vec::new(); + for (input, default) in function.sig.inputs.iter().zip(defaults) { + let FnArg::Typed(typed) = input else { + return None; + }; + let Pat::Ident(pattern) = &*typed.pat else { + return None; + }; + if pattern.by_ref.is_some() || pattern.subpat.is_some() || mentions_self(&typed.ty) { + return None; + } + let ident = pattern.ident.clone(); + let ty = (*typed.ty).clone(); + let default = match default { + Some(expr) => Some(normalise_default(quote!(#expr))), + None => implicit_default(&ident, &ty), + }; + params.push(CallParam { ident, ty, default }); + } + Some(params) +} + +fn mentions_self(ty: &Type) -> bool { + struct Find(bool); + impl<'ast> Visit<'ast> for Find { + fn visit_path(&mut self, path: &'ast syn::Path) { + if path.segments.first().is_some_and(|first| first.ident == "Self") { + self.0 = true; + } + visit::visit_path(self, path); + } + } + let mut find = Find(false); + find.visit_type(ty); + find.0 +} + struct Options { restartable: bool, } diff --git a/compose-rust/macros/tests/ui.rs b/compose-rust/macros/tests/ui.rs new file mode 100644 index 00000000..a4b81f05 --- /dev/null +++ b/compose-rust/macros/tests/ui.rs @@ -0,0 +1,10 @@ +//! What the generated call macro accepts and what it refuses, compiled the way an +//! application compiles it. The `.stderr` files next to the fixtures are the messages a +//! caller reads, so a change to one is a change to what the macro tells its users. + +#[test] +fn fr39_call_macro_errors_name_the_parameter() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_*.rs"); + cases.compile_fail("tests/ui/fail_*.rs"); +} diff --git a/compose-rust/macros/tests/ui/fail_block_without_content.rs b/compose-rust/macros/tests/ui/fail_block_without_content.rs new file mode 100644 index 00000000..7293c570 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_block_without_content.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String) { + let _ = value; +} + +fn main() { + Field!(String::new(), { let _ = 1; }); +} diff --git a/compose-rust/macros/tests/ui/fail_default_type_mismatch.rs b/compose-rust/macros/tests/ui/fail_default_type_mismatch.rs new file mode 100644 index 00000000..458cbbf8 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_default_type_mismatch.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(#[default("not a bool")] single_line: bool) { + let _ = single_line; +} + +fn main() { + Field!(); +} diff --git a/compose-rust/macros/tests/ui/fail_duplicate.rs b/compose-rust/macros/tests/ui/fail_duplicate.rs new file mode 100644 index 00000000..b713b293 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(String::new(), single_line = true, single_line = false); +} diff --git a/compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs new file mode 100644 index 00000000..2b0b090d --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(String::new(), value = String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_method_has_no_macro.rs b/compose-rust/macros/tests/ui/fail_method_has_no_macro.rs new file mode 100644 index 00000000..d8b009e7 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_method_has_no_macro.rs @@ -0,0 +1,15 @@ +use compose_rust::Composable; + +struct Screen; + +impl Screen { + #[Composable(nonrestartable)] + fn show(label: String) { + let _ = label; + } +} + +fn main() { + Screen::show(String::new()); + show!(String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_missing_required.rs b/compose-rust/macros/tests/ui/fail_missing_required.rs new file mode 100644 index 00000000..801b0f91 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_missing_required.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(single_line = true); +} diff --git a/compose-rust/macros/tests/ui/fail_positional_after_named.rs b/compose-rust/macros/tests/ui/fail_positional_after_named.rs new file mode 100644 index 00000000..2fb9d183 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_positional_after_named.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(single_line = true, String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_renamed_import.rs b/compose-rust/macros/tests/ui/fail_renamed_import.rs new file mode 100644 index 00000000..303bc38c --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_renamed_import.rs @@ -0,0 +1,19 @@ +use compose_rust::Composable; + +mod ui { + use compose_rust::Composable; + + #[Composable] + pub fn Field(value: String) { + let _ = value; + } +} + +use ui::Field as Input; + +fn main() { + // The function form works under the new name. The macro form finds the function by its + // own name, which this scope does not have. + Input(String::new()); + Input!(String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_too_many_positional.rs b/compose-rust/macros/tests/ui/fail_too_many_positional.rs new file mode 100644 index 00000000..0e2e4b43 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_too_many_positional.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String) { + let _ = value; +} + +fn main() { + Field!(String::new(), 1); +} diff --git a/compose-rust/macros/tests/ui/fail_turbofish.rs b/compose-rust/macros/tests/ui/fail_turbofish.rs new file mode 100644 index 00000000..7984ae40 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_turbofish.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn List(items: Vec) { + let _ = items; +} + +fn main() { + List!::(vec![1]); +} diff --git a/compose-rust/macros/tests/ui/fail_unknown_name.rs b/compose-rust/macros/tests/ui/fail_unknown_name.rs new file mode 100644 index 00000000..cd5446eb --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_unknown_name.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(String::new(), singel_line = true); +} diff --git a/compose-rust/macros/tests/ui/pass_forms.rs b/compose-rust/macros/tests/ui/pass_forms.rs new file mode 100644 index 00000000..42391081 --- /dev/null +++ b/compose-rust/macros/tests/ui/pass_forms.rs @@ -0,0 +1,43 @@ +use compose_rust::Composable; +use compose_rust::ui::Modifier; + +#[Composable] +pub fn Field( + value: String, + on_change: impl Fn(String) + 'static, + modifier: Modifier, + #[default(|| {})] label: impl Fn() + 'static, + #[default(false)] single_line: bool, + #[default(|| {})] content: impl Fn() + 'static, +) { + let _ = (value, on_change, modifier, label, single_line, content); +} + +#[Composable] +fn Required(first: i32, second: i32) { + let _ = (first, second); +} + +#[Composable] +fn Generic(items: Vec, #[default(Vec::new())] more: Vec) { + let _ = (items, more); +} + +fn main() { + // Function form: every argument, no defaults. + Field(String::new(), |_| {}, Modifier::new(), || {}, true, || {}); + // Positional prefix, then names in any order; closures infer from the bounds. + Field!(String::new(), |text| drop(text), single_line = true, label = || {}); + Field!(String::new(), on_change = |text| drop(text)); + // A trailing block is the content closure. + Field!(String::new(), |text| drop(text), { let _ = 1; }); + Field!(String::new(), |text| drop(text), single_line = true, { let _ = 1; }); + Field!(content = || {}, on_change = |text| drop(text), value = String::new()); + // Parameters can be given by name alone, in any order. + Required!(1, second = 2); + Required!(second = 2, first = 1); + // Generics are inferred, in both forms. + Generic(vec![1, 2], Vec::new()); + Generic!(vec![1, 2]); + Generic!(vec!["a"], more = vec!["b"]); +} diff --git a/compose-rust/tests/call_macro.rs b/compose-rust/tests/call_macro.rs new file mode 100644 index 00000000..1c6934a1 --- /dev/null +++ b/compose-rust/tests/call_macro.rs @@ -0,0 +1,256 @@ +//! The call macro `#[Composable]` generates next to every composable (FR-39.1.1 to +//! FR-39.1.3), through the public surface: functions written by an application and a Host +//! that composes them. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use std::cell::RefCell; +use support::Tree; + +thread_local! { + static LOG: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn note(line: impl Into) { + LOG.with_borrow_mut(|log| log.push(line.into())); +} + +fn take_log() -> Vec { + LOG.with_borrow_mut(std::mem::take) +} + +/// Composes `content` once and returns the tree it built. +fn compose(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +/// A value that records when it is evaluated, to see the order arguments run in. +fn traced(label: &'static str, value: T) -> T { + note(format!("eval {label}")); + value +} + +#[Composable] +fn Labelled( + text: String, + on_pick: impl Fn(String) + 'static, + modifier: Modifier, + #[default(String::from("none"))] hint: String, + #[default(false)] bold: bool, + #[default(|| {})] content: impl Fn() + 'static, +) { + on_pick(text.clone()); + note(format!( + "Labelled text={text} hint={hint} bold={bold} plain_modifier={}", + modifier == Modifier::new() + )); + content(); +} + +#[Composable] +fn Pair(first: i32, second: i32) { + note(format!("Pair {first} {second}")); +} + +#[test] +fn fr39_function_form_takes_every_argument_and_no_defaults() { + take_log(); + compose(|| { + Labelled( + "a".into(), + |_| {}, + Modifier::new(), + "h".into(), + true, + || note("content"), + ); + }); + assert_eq!( + take_log(), + vec!["Labelled text=a hint=h bold=true plain_modifier=true", "content"] + ); +} + +#[test] +fn fr39_call_macro_positional_prefix_then_named_in_any_order() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}, bold = true, hint = "h".into()); + Labelled!("b".into(), |_| {}, hint = "x".into(), bold = false); + }); + assert_eq!( + take_log(), + vec![ + "Labelled text=a hint=h bold=true plain_modifier=true", + "Labelled text=b hint=x bold=false plain_modifier=true", + ] + ); +} + +#[test] +fn fr39_call_macro_names_alone_fill_a_signature() { + take_log(); + compose(|| { + Labelled!(on_pick = |_| {}, text = "n".into()); + Pair!(second = 2, first = 1); + }); + assert_eq!( + take_log(), + vec![ + "Labelled text=n hint=none bold=false plain_modifier=true", + "Pair 1 2" + ] + ); +} + +#[test] +fn fr39_call_macro_closure_parameters_infer_from_the_function_bounds() { + take_log(); + compose(|| { + // `text` is a String only because `on_pick: impl Fn(String)` says so. + Labelled!("c".into(), |text| note(format!("picked {}", text.len()))); + }); + assert_eq!(take_log()[0], "picked 1"); +} + +#[test] +fn fr39_call_macro_trailing_block_is_the_content_closure() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}, { note("block") }); + Labelled!("b".into(), |_| {}, bold = true, { note("block after names") }); + Labelled!("c".into(), |_| {}, content = || note("named content")); + }); + let log = take_log(); + assert!(log.contains(&"block".to_string())); + assert!(log.contains(&"block after names".to_string())); + assert!(log.contains(&"named content".to_string())); +} + +#[test] +fn fr39_call_macro_evaluates_arguments_in_parameter_order() { + take_log(); + compose(|| { + Pair!(second = traced("second", 2), first = traced("first", 1)); + }); + assert_eq!(take_log(), vec!["eval first", "eval second", "Pair 1 2"]); +} + +#[test] +fn fr39_default_modifier_is_modifier_new_and_other_defaults_apply_in_macro_form() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}); + }); + assert_eq!( + take_log(), + vec!["Labelled text=a hint=none bold=false plain_modifier=true"] + ); +} + +#[test] +fn fr39_default_modifier_can_be_given_and_then_differs() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}, modifier = Modifier::new().fill_max_width()); + }); + assert_eq!( + take_log(), + vec!["Labelled text=a hint=none bold=false plain_modifier=false"] + ); +} + +thread_local! { + static DEFAULTS: RefCell = const { RefCell::new(0) }; +} + +fn counted() -> u32 { + DEFAULTS.with_borrow_mut(|count| { + *count += 1; + *count + }) +} + +#[Composable] +fn Counted(#[default(counted())] value: u32) { + note(format!("value {value}")); +} + +#[test] +fn fr39_default_expressions_run_at_the_call_site_each_time() { + take_log(); + DEFAULTS.with_borrow_mut(|count| *count = 0); + compose(|| { + Counted!(); + Counted!(); + Counted!(value = 9); + }); + assert_eq!(take_log(), vec!["value 1", "value 2", "value 9"]); +} + +#[Composable] +fn Generic( + items: Vec, + #[default(Vec::new())] more: Vec, +) { + note(format!("Generic {items:?} {more:?}")); +} + +#[test] +fn fr39_call_macro_limits_generics_are_inferred_like_the_function_form() { + take_log(); + compose(|| { + Generic(vec![1], vec![2]); + Generic!(vec![1u8]); + Generic!(vec!["a"], more = vec!["b"]); + }); + assert_eq!( + take_log(), + vec!["Generic [1] [2]", "Generic [1] []", "Generic [\"a\"] [\"b\"]"] + ); +} + +#[Composable] +fn Leaf(text: String) { + let clicks = remember(|| mutable_state_of(0)); + Text(format!("{text}:{}", clicks.get())); +} + +#[test] +fn fr39_call_macro_expands_to_the_same_tree_as_the_function_form() { + let function_form = compose(|| { + Column().content(|| { + Leaf("a".into()); + Leaf("b".into()); + }); + }); + let macro_form = compose(|| { + Column().content(|| { + Leaf!("a".into()); + Leaf!("b".into()); + }); + }); + assert_eq!(function_form.dump(), macro_form.dump()); +} + +#[test] +fn fr39_call_macro_expands_mixed_with_the_function_form_keeps_each_calls_state() { + // The slot key is made inside the function, so a call through the macro and a call + // through the function share one position and one identity. Mixing the two forms in one + // parent and recomposing must keep each call's remembered state apart and intact. + let mut host = ComposeHost::with_content(|| { + Column().content(|| { + Leaf("fn".into()); + Leaf!("macro".into()); + }); + }); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + assert_eq!(tree.texts(), vec!["fn:0", "macro:0"]); +} From 676a5f415e5a5e04c0af732e8438725ac2bcfb10 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:21:21 +0900 Subject: [PATCH 094/224] Fix: Make the generated call macros build and record their compile errors Covers FR-39.1.1 to 39.1.3. Prints the empty-closure default as `|| {}` in the macro docs, adds the trybuild stderr files and the trybuild lock entries. --- Cargo.lock | 185 +++++++++++++++++- compose-rust/macros/src/call_macro.rs | 18 +- compose-rust/macros/src/lib.rs | 6 +- .../ui/fail_block_without_content.stderr | 10 + .../ui/fail_default_type_mismatch.stderr | 17 ++ .../macros/tests/ui/fail_duplicate.stderr | 10 + .../ui/fail_duplicate_of_positional.stderr | 10 + .../tests/ui/fail_method_has_no_macro.stderr | 5 + .../tests/ui/fail_missing_required.stderr | 10 + .../ui/fail_positional_after_named.stderr | 10 + .../tests/ui/fail_renamed_import.stderr | 22 +++ .../tests/ui/fail_too_many_positional.stderr | 10 + .../macros/tests/ui/fail_turbofish.stderr | 5 + .../macros/tests/ui/fail_unknown_name.stderr | 10 + compose-rust/tests/call_macro.rs | 21 +- 15 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 compose-rust/macros/tests/ui/fail_block_without_content.stderr create mode 100644 compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr create mode 100644 compose-rust/macros/tests/ui/fail_duplicate.stderr create mode 100644 compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr create mode 100644 compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr create mode 100644 compose-rust/macros/tests/ui/fail_missing_required.stderr create mode 100644 compose-rust/macros/tests/ui/fail_positional_after_named.stderr create mode 100644 compose-rust/macros/tests/ui/fail_renamed_import.stderr create mode 100644 compose-rust/macros/tests/ui/fail_too_many_positional.stderr create mode 100644 compose-rust/macros/tests/ui/fail_turbofish.stderr create mode 100644 compose-rust/macros/tests/ui/fail_unknown_name.stderr diff --git a/Cargo.lock b/Cargo.lock index f7704d07..4883fbdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,11 +42,47 @@ dependencies = [ name = "compose-rust-macros" version = "0.0.3" dependencies = [ + "compose-rust", "proc-macro2", "quote", - "syn", + "syn 2.0.119", + "trybuild", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -88,7 +124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -225,6 +261,57 @@ dependencies = [ "sample-frames", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "syn" version = "2.0.119" @@ -236,6 +323,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-tuple" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -253,7 +366,61 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "trybuild" +version = "1.0.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62db9c92d704393fbf2132041720cc80b689f2d3f28521015c2ac866223c11b8" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-tuple", + "termcolor", + "toml", ] [[package]] @@ -361,3 +528,15 @@ name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/compose-rust/macros/src/call_macro.rs b/compose-rust/macros/src/call_macro.rs index f3fa855a..a8184e64 100644 --- a/compose-rust/macros/src/call_macro.rs +++ b/compose-rust/macros/src/call_macro.rs @@ -70,6 +70,7 @@ pub(crate) fn normalise_default(tokens: TokenStream) -> TokenStream { /// Token text with the spaces `TokenStream::to_string` puts around punctuation taken out. fn tidy(mut text: String) -> String { for (from, to) in [ + ("{ }", "{}"), (" :: ", "::"), (":: ", "::"), (" < ", "<"), @@ -412,7 +413,9 @@ mod tests { let items = expand(function, params); let docs = docs_of(&items[0]).join("\n"); let value = docs.find("`value: String`, required").expect("value"); - let label = docs.find("`label: impl Fn()`, default `|| {}`").expect("label"); + let label = docs + .find("`label: impl Fn()`, default `|| {}`") + .expect("label"); let line = docs .find("`single_line: bool`, default `false`") .expect("single_line"); @@ -439,20 +442,27 @@ mod tests { ty, }]; let docs = docs_of(&expand(function, params)[0]).join("\n"); - assert!(docs.contains("default `::compose_rust::ui::Modifier::new()`"), "{docs}"); + assert!( + docs.contains("default `::compose_rust::ui::Modifier::new()`"), + "{docs}" + ); } #[test] fn fr39_call_macro_is_hidden_exported_and_reexported_at_the_function_visibility() { let (function, params) = sample(); let items = expand(function, params); - let Item::Macro(item) = &items[0] else { panic!() }; + let Item::Macro(item) = &items[0] else { + panic!() + }; assert_eq!(item.mac.path.segments.last().unwrap().ident, "macro_rules"); assert_eq!(item.ident.as_ref().unwrap(), "__compose_call_Field"); let text = quote!(#(#items)*).to_string(); assert!(text.contains("macro_export")); assert!(text.contains("doc (hidden)")); - let Item::Use(reexport) = &items[1] else { panic!() }; + let Item::Use(reexport) = &items[1] else { + panic!() + }; assert!(matches!(reexport.vis, Visibility::Public(_))); } diff --git a/compose-rust/macros/src/lib.rs b/compose-rust/macros/src/lib.rs index ddcedbcd..26552799 100644 --- a/compose-rust/macros/src/lib.rs +++ b/compose-rust/macros/src/lib.rs @@ -162,7 +162,11 @@ fn mentions_self(ty: &Type) -> bool { struct Find(bool); impl<'ast> Visit<'ast> for Find { fn visit_path(&mut self, path: &'ast syn::Path) { - if path.segments.first().is_some_and(|first| first.ident == "Self") { + if path + .segments + .first() + .is_some_and(|first| first.ident == "Self") + { self.0 = true; } visit::visit_path(self, path); diff --git a/compose-rust/macros/tests/ui/fail_block_without_content.stderr b/compose-rust/macros/tests/ui/fail_block_without_content.stderr new file mode 100644 index 00000000..7df5fe45 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_block_without_content.stderr @@ -0,0 +1,10 @@ +error: Field! has no `content` parameter, so it takes no trailing block; the parameters are: `value` + --> tests/ui/fail_block_without_content.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(String::new(), { let _ = 1; }); + | ------------------------------------- in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr b/compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr new file mode 100644 index 00000000..99218d99 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr @@ -0,0 +1,17 @@ +error[E0308]: mismatched types + --> tests/ui/fail_default_type_mismatch.rs:4:20 + | +4 | fn Field(#[default("not a bool")] single_line: bool) { + | ----- ^^^^^^^^^^^^ expected `bool`, found `&str` + | | + | arguments to this function are incorrect +... +9 | Field!(); + | -------- in this macro invocation + | +note: function defined here + --> tests/ui/fail_default_type_mismatch.rs:4:4 + | +4 | fn Field(#[default("not a bool")] single_line: bool) { + | ^^^^^ ----------------- + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_duplicate.stderr b/compose-rust/macros/tests/ui/fail_duplicate.stderr new file mode 100644 index 00000000..5df6c11f --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate.stderr @@ -0,0 +1,10 @@ +error: argument `single_line` of Field! is given twice + --> tests/ui/fail_duplicate.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(String::new(), single_line = true, single_line = false); + | -------------------------------------------------------------- in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr new file mode 100644 index 00000000..0d4d4d24 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr @@ -0,0 +1,10 @@ +error: argument `value` of Field! is given twice + --> tests/ui/fail_duplicate_of_positional.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(String::new(), value = String::new()); + | -------------------------------------------- in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr b/compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr new file mode 100644 index 00000000..7fb6ddb4 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr @@ -0,0 +1,5 @@ +error: cannot find macro `show` in this scope + --> tests/ui/fail_method_has_no_macro.rs:14:5 + | +14 | show!(String::new()); + | ^^^^ diff --git a/compose-rust/macros/tests/ui/fail_missing_required.stderr b/compose-rust/macros/tests/ui/fail_missing_required.stderr new file mode 100644 index 00000000..f8704f02 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_missing_required.stderr @@ -0,0 +1,10 @@ +error: missing required argument `value` for Field! + --> tests/ui/fail_missing_required.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(single_line = true); + | -------------------------- in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_positional_after_named.stderr b/compose-rust/macros/tests/ui/fail_positional_after_named.stderr new file mode 100644 index 00000000..83572b80 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_positional_after_named.stderr @@ -0,0 +1,10 @@ +error: a positional argument of Field! follows a named one; name it too, the parameters are: `value`, `single_line` + --> tests/ui/fail_positional_after_named.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(single_line = true, String::new()); + | ----------------------------------------- in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_renamed_import.stderr b/compose-rust/macros/tests/ui/fail_renamed_import.stderr new file mode 100644 index 00000000..bb1fa07f --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_renamed_import.stderr @@ -0,0 +1,22 @@ +error: cannot find macro `Field` in this scope + --> tests/ui/fail_renamed_import.rs:7:12 + | + 7 | pub fn Field(value: String) { + | ^^^^^ +... +18 | Input!(String::new()); + | --------------------- in this macro invocation + | + = note: this error originates in the macro `Input` (in Nightly builds, run with -Z macro-backtrace for more info) +help: consider importing this macro through its public re-export + | + 1 + use crate::ui::Field; + | + +warning: unused import: `compose_rust::Composable` + --> tests/ui/fail_renamed_import.rs:1:5 + | +1 | use compose_rust::Composable; + | ^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/compose-rust/macros/tests/ui/fail_too_many_positional.stderr b/compose-rust/macros/tests/ui/fail_too_many_positional.stderr new file mode 100644 index 00000000..284fa23d --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_too_many_positional.stderr @@ -0,0 +1,10 @@ +error: Field! takes 1 argument(s) and was given more; the parameters are: `value` + --> tests/ui/fail_too_many_positional.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(String::new(), 1); + | ------------------------ in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_turbofish.stderr b/compose-rust/macros/tests/ui/fail_turbofish.stderr new file mode 100644 index 00000000..b714e2ef --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_turbofish.stderr @@ -0,0 +1,5 @@ +error: expected one of `(`, `[`, or `{`, found `::` + --> tests/ui/fail_turbofish.rs:9:10 + | +9 | List!::(vec![1]); + | ^^ expected one of `(`, `[`, or `{` diff --git a/compose-rust/macros/tests/ui/fail_unknown_name.stderr b/compose-rust/macros/tests/ui/fail_unknown_name.stderr new file mode 100644 index 00000000..b379c731 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_unknown_name.stderr @@ -0,0 +1,10 @@ +error: Field! has no parameter `singel_line`; the parameters are: `value`, `single_line` + --> tests/ui/fail_unknown_name.rs:3:1 + | +3 | #[Composable] + | ^^^^^^^^^^^^^ +... +9 | Field!(String::new(), singel_line = true); + | ----------------------------------------- in this macro invocation + | + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/tests/call_macro.rs b/compose-rust/tests/call_macro.rs index 1c6934a1..d38c652e 100644 --- a/compose-rust/tests/call_macro.rs +++ b/compose-rust/tests/call_macro.rs @@ -73,7 +73,10 @@ fn fr39_function_form_takes_every_argument_and_no_defaults() { }); assert_eq!( take_log(), - vec!["Labelled text=a hint=h bold=true plain_modifier=true", "content"] + vec![ + "Labelled text=a hint=h bold=true plain_modifier=true", + "content" + ] ); } @@ -124,7 +127,9 @@ fn fr39_call_macro_trailing_block_is_the_content_closure() { take_log(); compose(|| { Labelled!("a".into(), |_| {}, { note("block") }); - Labelled!("b".into(), |_| {}, bold = true, { note("block after names") }); + Labelled!("b".into(), |_| {}, bold = true, { + note("block after names") + }); Labelled!("c".into(), |_| {}, content = || note("named content")); }); let log = take_log(); @@ -158,7 +163,11 @@ fn fr39_default_modifier_is_modifier_new_and_other_defaults_apply_in_macro_form( fn fr39_default_modifier_can_be_given_and_then_differs() { take_log(); compose(|| { - Labelled!("a".into(), |_| {}, modifier = Modifier::new().fill_max_width()); + Labelled!( + "a".into(), + |_| {}, + modifier = Modifier::new().fill_max_width() + ); }); assert_eq!( take_log(), @@ -212,7 +221,11 @@ fn fr39_call_macro_limits_generics_are_inferred_like_the_function_form() { }); assert_eq!( take_log(), - vec!["Generic [1] [2]", "Generic [1] []", "Generic [\"a\"] [\"b\"]"] + vec![ + "Generic [1] [2]", + "Generic [1] []", + "Generic [\"a\"] [\"b\"]" + ] ); } From a4bcb9f536c2557eb4516f60cda6cacf3ad337c6 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:22:24 +0900 Subject: [PATCH 095/224] Docs: Call macro errors point at the offending argument (#278) Adds the rule and its acceptance criterion to FR-39 (39.1, 39.5.8). The call shape is unchanged. --- docs/SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index ed3f6362..d8ff2675 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2417,6 +2417,7 @@ fn main() { - **공통 컨테이너는 위치 인자로 부릅니다.** `Column`, `Row`, `Box`, `LazyColumn`, `LazyRow`는 `Column(modifier, || { .. })`처럼 `modifier`와 자식 클로저를 위치 인자로 받습니다. 자식 클로저는 항상 마지막 인자입니다. Compose의 `Column(modifier) { .. }`와 같은 순서입니다. - **속성이 많은 위젯은 빌더 체인을 유지합니다.** 어느 형태를 받는지는 다음 규칙이 정합니다. 위젯이 `modifier`와 자식 클로저 말고는 호출마다 값이 정해지는 속성이 없으면 위치 인자 형태를 받습니다. 그 밖의 속성이 하나라도 있으면 필수 값만 함수 인자로 받고 나머지는 체인으로 씁니다(`Text(..)`, `Button(label).modifier(..).on_click(..)`). Rust에 이름 있는 인자와 기본 인자가 없어서, 속성이 많은 위젯을 위치 인자로 받으면 쓰는 쪽이 매번 전부 적어야 하기 때문입니다. 위치 인자 컨테이너가 `space_role` 같은 속성을 더 필요로 하면 같은 위젯의 체인 형태(`Column().modifier(..).space_role(..).content(..)`)를 씁니다. 두 형태는 같은 Mutation을 내보냅니다. +- **호출 매크로의 오류는 잘못된 인자를 가리킵니다.** `#[Composable]`이 만드는 호출 매크로의 컴파일 오류(없는 이름, 빠진 필수 인자, 중복 인자, 이름 뒤의 위치 인자, 너무 많은 위치 인자, `content`가 없는 함수의 뒤따르는 블록)는 호출하는 쪽의 잘못된 인자 토큰을 가리킵니다. `#[Composable]` 속성 줄을 가리키지 않습니다. 쓰는 사람이 고칠 자리가 호출 자리이기 때문입니다. 빠진 필수 인자는 가리킬 토큰이 없으므로 매크로 호출의 닫는 구분자나 매크로 이름을 가리킵니다. 호출 모양은 바뀌지 않습니다. - **속성 이름은 `#[Composable]`입니다.** 소유자가 2026-10-25에 정했습니다. Compose의 `@Composable` 철자와 같아서, Compose를 아는 사람이 이름을 다시 배우지 않습니다. 속성 매크로 이름이 snake_case인 것은 관례이지 규칙이 아니며(`#[component]`, `#[tokio::main]`), 위젯 이름이 이미 Compose의 PascalCase를 쓰므로(FR-15) 속성도 같은 철자로 맞춥니다. 매크로 함수에는 `#[allow(non_snake_case)]`를 붙입니다. 소문자 별칭은 두지 않습니다(아래 이슈 #278의 이전 규칙과 같습니다). - **composable은 함수입니다.** `#[Composable]` 속성이 본문에 그룹을 넣고, 사용자는 그룹도 키도 손으로 쓰지 않습니다. 분기, `match`, 반복문, 조기 `return`, `continue`, `break`, `?`, 되감기를 지나도 그룹이 맞게 닫힙니다(INTENT D2가 기록한 클로저 방식의 실패를 되풀이하지 않습니다). @@ -2481,6 +2482,7 @@ fn main() { 5. **해제된 뒤의 사용.** 해제된 핸들의 `set`과 `update`는 아무 일도 하지 않고 다른 상태와 화면을 바꾸지 않습니다(fr39_state_use_after_release_set_is_a_no_op). 디버그 빌드에서 경고 로그가 핸들마다 정확히 한 번 나옵니다(fr39_state_use_after_release_warns_once_in_debug). `get`은 패닉하고 메시지에 `Released`와 `remember`의 파일과 줄이 들어 있습니다(fr39_state_use_after_release_get_panics_with_the_creation_site). `try_get`, `try_set`, `try_update`는 `StateError::Released`를 돌려줍니다(fr39_state_use_after_release_try_returns_released). 칸이 다른 값에 재사용된 뒤에도 옛 핸들이 새 값을 읽거나 쓰지 못합니다(fr39_state_use_after_release_does_not_reach_a_reused_slot). 6. **워커 스레드.** `MutableState`가 `Send + Sync`입니다(컴파일 시점 확인). 워커 스레드의 쓰기가 다음 프레임에 반영되고, 한 프레임 안의 UI 쓰기와 워커 쓰기가 recomposition 한 번이 되며, composition이 떠난 뒤의 워커 쓰기가 오류 없이 무시됩니다. 이름 패턴 `fr39_state_worker_*`. 7. **같음.** 같은 칸의 핸들 복사본은 같고, 값이 같은 서로 다른 상태의 핸들은 같지 않습니다. 이름 패턴 `fr39_state_handle_identity_*`. +8. **호출 매크로 오류의 자리.** 위 6가지 컴파일 오류 각각이 호출 자리의 잘못된 인자 토큰(빠진 필수 인자는 매크로 호출의 닫는 구분자나 이름)을 가리키고, `#[Composable]` 줄을 가리키지 않습니다. trybuild의 `.stderr` 스팬으로 확인합니다. 이름 패턴 `fr39_call_macro_compile_fail_*`. 위 이름 패턴은 구현과 함께 실제 테스트 이름으로 바뀝니다. 이 기준은 구현이 들어올 때까지 `Agreed`입니다. From 6db5a73f40e270f75487167c1f9cc9a03598c409 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:23:45 +0900 Subject: [PATCH 096/224] Feat: Report call macro errors at the offending argument (#278) The generated macro_rules forwards the parameter list and the caller's tokens to the hidden proc macro __compose_call_check, which reads the arguments and reports each error on the caller's own tokens. Covers FR-39 39.5.8 (fr39_call_macro_compile_fail_*). The trybuild .stderr files are stale until the builder regenerates them. --- compose-rust/macros/src/call_macro.rs | 418 ++++++++++++++------------ compose-rust/macros/src/lib.rs | 11 + compose-rust/macros/tests/ui.rs | 2 +- compose-rust/src/lib.rs | 2 + 4 files changed, 237 insertions(+), 196 deletions(-) diff --git a/compose-rust/macros/src/call_macro.rs b/compose-rust/macros/src/call_macro.rs index a8184e64..1e86ca57 100644 --- a/compose-rust/macros/src/call_macro.rs +++ b/compose-rust/macros/src/call_macro.rs @@ -4,31 +4,26 @@ //! gets a macro of the same name that takes the arguments positionally first and by name //! after that, fills in `#[default(..)]` values, and expands to one call of the function. //! -//! The macro is a small state machine written as `macro_rules!` rules, generated per -//! function. Its state is the argument list so far, one bracketed slot per parameter, and -//! the arguments not read yet: +//! The generated `macro_rules!` has one rule. It forwards the function's name, one entry per +//! parameter (its name and, when it has one, its default expression) and the caller's +//! argument tokens, untouched, to the hidden proc macro [`check`]. The proc macro reads the +//! arguments, so an error is reported with `syn::Error::new_spanned` on the caller's own +//! tokens and points at the offending argument. A `compile_error!` written in a +//! `macro_rules!` body would point at the `#[Composable]` attribute instead. //! -//! * `@p`: reading the positional prefix; `K` parameters are filled. A positional -//! argument fills slot `K`. A `name = value` argument or a trailing `{ .. }` block moves -//! to the named phase. -//! * `@n`: reading `name = value` arguments in any order. Each parameter has a rule for the -//! empty slot (fill it) and one for a filled slot (duplicate). -//! * `@call`: every argument has been read. Empty slots of parameters with a default take -//! it, an empty slot of a required parameter is an error naming it, and when none is -//! left the rule emits `Name(slot0, slot1, ..)`. +//! On success the proc macro emits `Name(arg0, arg1, ..)`. Nothing is bound to a local, so +//! the arguments stay inside the function call where closures take their parameter types +//! from the function's bounds, and they are evaluated in parameter order. //! -//! Nothing is bound to a local, so the arguments stay inside the function call where -//! closures take their parameter types from the function's bounds, and they are evaluated in -//! parameter order. -//! -//! The rules call the macro again by the name the caller used for it. A rule cannot name the -//! macro by an absolute path: inside the crate that defines it, the compiler refuses -//! `crate::__compose_call_Name` for a macro that another macro expanded. The function is -//! called by its own name for the same reason. +//! The rule names the proc macro as `::compose_rust::__compose_call_check!`, which works +//! from any crate (and inside compose-rust through `extern crate self`). The function is +//! called by its own name, so the caller's scope decides what it means. use proc_macro2::{Group, Punct, Spacing, TokenStream, TokenTree}; use quote::{format_ident, quote}; -use syn::{Attribute, Ident, ItemFn, Type, Visibility}; +use quote::ToTokens; +use syn::parse::{Parse, ParseStream}; +use syn::{Attribute, Expr, Ident, ItemFn, Token, Type, Visibility}; /// One parameter, as the call macro sees it. pub(crate) struct CallParam { @@ -147,182 +142,18 @@ fn documentation(function: &ItemFn, params: &[CallParam]) -> Vec { pub(crate) fn generate(function: &ItemFn, params: &[CallParam]) -> TokenStream { let name = &function.sig.ident; let hidden = format_ident!("__compose_call_{}", name); - let count = params.len(); - let slot = |index: usize| format_ident!("s{}", index); - let has_content = params.last().is_some_and(|param| param.ident == "content"); - - // `[ $($s0:tt)* ] [ $($s1:tt)* ] ..`, with slot `which` matched as empty. - let capture = |empty: Option| -> TokenStream { - let slots = (0..count).map(|index| { - let var = slot(index); - if Some(index) == empty { - quote!([]) - } else { - quote!([ $($#var:tt)* ]) - } - }); - quote!(#(#slots)*) - }; - // The same slots, spliced back. `fill` replaces one slot's content. - let forward = |fill: Option<(usize, TokenStream)>| -> TokenStream { - let slots = (0..count).map(|index| match &fill { - Some((target, tokens)) if *target == index => quote!([ #tokens ]), - _ => { - let var = slot(index); - quote!([ $($#var)* ]) - } - }); - quote!(#(#slots)*) - }; - let names_list = params - .iter() - .map(|param| format!("`{}`", param.ident)) - .collect::>() - .join(", "); - let again = quote!(#name!); - - let mut rules: Vec = Vec::new(); - - // Read the positional prefix. State `@p` has filled the first K parameters. - for filled in 0..=count { - let state = format_ident!("p{}", filled); - let next = format_ident!("p{}", filled + 1); - let all = capture(None); - let kept = forward(None); - rules.push(quote! { - (@#state #all ; ) => { #again(@call #kept) }; - }); - if has_content { - rules.push(quote! { - (@#state #all ; { $($body:tt)* } $(,)?) => { - #again(@n #kept ; content = || { $($body)* }) - }; - }); - } else { - let message = format!( - "{name}! has no `content` parameter, so it takes no trailing block; the parameters are: {names_list}" - ); - rules.push(quote! { - (@#state #all ; { $($body:tt)* } $(,)?) => { ::core::compile_error!(#message) }; - }); - } - rules.push(quote! { - (@#state #all ; $given:ident = $($rest:tt)+) => { - #again(@n #kept ; $given = $($rest)+) - }; - }); - if filled < count { - let filled_in = forward(Some((filled, quote!($value)))); - rules.push(quote! { - (@#state #all ; $value:expr $(, $($rest:tt)*)?) => { - #again(@#next #filled_in ; $($($rest)*)?) - }; - }); - } else { - let message = format!( - "{name}! takes {count} argument(s) and was given more; the parameters are: {names_list}" - ); - rules.push(quote! { - (@#state #all ; $($rest:tt)+) => { ::core::compile_error!(#message) }; - }); - } - } - - // Read `name = value` arguments. - { - let all = capture(None); - let kept = forward(None); - rules.push(quote! { - (@n #all ; ) => { #again(@call #kept) }; - }); - if has_content { - rules.push(quote! { - (@n #all ; { $($body:tt)* } $(,)?) => { - #again(@n #kept ; content = || { $($body)* }) - }; - }); - } else { - let message = format!( - "{name}! has no `content` parameter, so it takes no trailing block; the parameters are: {names_list}" - ); - rules.push(quote! { - (@n #all ; { $($body:tt)* } $(,)?) => { ::core::compile_error!(#message) }; - }); - } - for (index, param) in params.iter().enumerate() { - let ident = ¶m.ident; - let dup_message = format!("argument `{ident}` of {name}! is given twice"); - let dup_slots = (0..count).map(|other| { - let other_var = slot(other); - if other == index { - quote!([ $($#other_var:tt)+ ]) - } else { - quote!([ $($#other_var:tt)* ]) - } - }); - rules.push(quote! { - (@n #(#dup_slots)* ; #ident = $value:expr $(, $($rest:tt)*)?) => { - ::core::compile_error!(#dup_message) - }; - }); - let empty = capture(Some(index)); - let filled_in = forward(Some((index, quote!($value)))); - rules.push(quote! { - (@n #empty ; #ident = $value:expr $(, $($rest:tt)*)?) => { - #again(@n #filled_in ; $($($rest)*)?) - }; - }); - } - let unknown = format!("{name}! has no parameter `"); - let unknown_tail = format!("`; the parameters are: {names_list}"); - rules.push(quote! { - (@n #all ; $given:ident = $($rest:tt)*) => { - ::core::compile_error!(::core::concat!(#unknown, ::core::stringify!($given), #unknown_tail)) - }; - }); - let positional = format!( - "a positional argument of {name}! follows a named one; name it too, the parameters are: {names_list}" - ); - rules.push(quote! { - (@n #all ; $($rest:tt)+) => { ::core::compile_error!(#positional) }; - }); - } - - // Every argument is read: defaults first, then the required ones that are missing. - for (index, param) in params.iter().enumerate() { - if let Some(default) = ¶m.default { - let empty = capture(Some(index)); - let filled_in = forward(Some((index, default.clone()))); - rules.push(quote! { - (@call #empty) => { #again(@call #filled_in) }; - }); - } - } - for (index, param) in params.iter().enumerate() { - if param.default.is_none() { - let ident = ¶m.ident; - let message = format!("missing required argument `{ident}` for {name}!"); - let empty = capture(Some(index)); - rules.push(quote! { - (@call #empty) => { ::core::compile_error!(#message) }; - }); + let entries = params.iter().map(|param| { + let ident = ¶m.ident; + match ¶m.default { + Some(default) => quote!((#ident [ #default ])), + None => quote!((#ident)), } - } - { - let all = capture(None); - let arguments = (0..count).map(|index| { - let var = slot(index); - quote!($($#var)*) - }); - rules.push(quote! { - (@call #all) => { #name(#(#arguments),*) }; - }); - } - // The entry rule comes last, so that a caller's arguments never meet the state rules. - let empty_slots = (0..count).map(|_| quote!([])); - rules.push(quote! { - ($($arguments:tt)*) => { #again(@p0 #(#empty_slots)* ; $($arguments)*) }; }); + let rule = quote! { + ($($arguments:tt)*) => { + ::compose_rust::__compose_call_check! { #name [ #(#entries)* ] $($arguments)* } + }; + }; let docs = documentation(function, params); let docs = docs.iter().map(|line| quote!(#[doc = #line])); @@ -341,7 +172,7 @@ pub(crate) fn generate(function: &ItemFn, params: &[CallParam]) -> TokenStream { #[macro_export] #[doc(hidden)] macro_rules! #hidden { - #(#rules)* + #rule } #(#cfgs)* #[allow(unused_imports)] @@ -349,6 +180,181 @@ pub(crate) fn generate(function: &ItemFn, params: &[CallParam]) -> TokenStream { } } +/// The input of [`check`]: `Name [ (param) (param [default]) .. ] arguments..`. +struct CheckInput { + name: Ident, + params: Vec<(Ident, Option)>, + arguments: TokenStream, +} + +impl Parse for CheckInput { + fn parse(input: ParseStream) -> syn::Result { + let name: Ident = input.parse()?; + let list; + syn::bracketed!(list in input); + let mut params = Vec::new(); + while !list.is_empty() { + let entry; + syn::parenthesized!(entry in list); + let ident: Ident = entry.parse()?; + let default = if entry.is_empty() { + None + } else { + let tokens; + syn::bracketed!(tokens in entry); + Some(tokens.parse::()?) + }; + params.push((ident, default)); + } + Ok(CheckInput { + name, + params, + arguments: input.parse()?, + }) + } +} + +/// One argument as the caller wrote it. +enum Argument { + Positional(Expr), + Named(Ident, Expr), + Block(proc_macro2::Group), +} + +fn parse_arguments(input: ParseStream) -> syn::Result> { + let mut arguments = Vec::new(); + while !input.is_empty() { + if input.peek(syn::token::Brace) { + let group: proc_macro2::Group = input.parse()?; + arguments.push(Argument::Block(group)); + // A block ends the list, with an optional comma after it. + let _: Option = input.parse()?; + if !input.is_empty() { + return Err(input.error("nothing can follow the trailing block")); + } + break; + } + if input.peek(Ident) && input.peek2(Token![=]) && !input.peek2(Token![==]) { + let ident: Ident = input.parse()?; + let _: Token![=] = input.parse()?; + arguments.push(Argument::Named(ident, input.parse()?)); + } else { + arguments.push(Argument::Positional(input.parse()?)); + } + if input.is_empty() { + break; + } + let _: Token![,] = input.parse()?; + } + Ok(arguments) +} + +/// Reads the caller's arguments against the parameter list and emits the call, or the +/// error on the offending argument's own tokens. +pub(crate) fn check(input: TokenStream) -> syn::Result { + let CheckInput { + name, + params, + arguments, + } = syn::parse2(input)?; + let names_list = params + .iter() + .map(|(ident, _)| format!("`{ident}`")) + .collect::>() + .join(", "); + let count = params.len(); + let has_content = params.last().is_some_and(|(ident, _)| ident == "content"); + // Where a missing argument is reported: the arguments the caller did write, or the + // call itself when there are none. + let whole = arguments.clone(); + let arguments = syn::parse::Parser::parse2(parse_arguments, arguments)?; + + let mut slots: Vec> = vec![None; count]; + let mut positional = 0; + let mut named_seen = false; + for argument in arguments { + match argument { + Argument::Positional(expr) => { + if named_seen { + return Err(syn::Error::new_spanned( + &expr, + format!( + "a positional argument of {name}! follows a named one; name it too, the parameters are: {names_list}" + ), + )); + } + if positional >= count { + return Err(syn::Error::new_spanned( + &expr, + format!( + "{name}! takes {count} argument(s) and was given more; the parameters are: {names_list}" + ), + )); + } + slots[positional] = Some(expr.into_token_stream()); + positional += 1; + } + Argument::Named(ident, expr) => { + named_seen = true; + let Some(index) = params.iter().position(|(param, _)| *param == ident) else { + return Err(syn::Error::new_spanned( + &ident, + format!( + "{name}! has no parameter `{ident}`; the parameters are: {names_list}" + ), + )); + }; + if slots[index].is_some() { + return Err(syn::Error::new_spanned( + &ident, + format!("argument `{ident}` of {name}! is given twice"), + )); + } + slots[index] = Some(expr.into_token_stream()); + } + Argument::Block(group) => { + named_seen = true; + if !has_content { + return Err(syn::Error::new_spanned( + &group, + format!( + "{name}! has no `content` parameter, so it takes no trailing block; the parameters are: {names_list}" + ), + )); + } + let last = count - 1; + if slots[last].is_some() { + return Err(syn::Error::new_spanned( + &group, + format!("argument `content` of {name}! is given twice"), + )); + } + slots[last] = Some(quote!(|| #group)); + } + } + } + + let mut values = Vec::with_capacity(count); + for ((ident, default), slot) in params.iter().zip(slots) { + match (slot, default) { + (Some(tokens), _) => values.push(tokens), + (None, Some(default)) => values.push(default.clone()), + (None, None) => { + let span = if whole.is_empty() { + proc_macro2::Span::call_site() + } else { + whole.clone().into_iter().last().map_or_else(proc_macro2::Span::call_site, |tree| tree.span()) + }; + return Err(syn::Error::new( + span, + format!("missing required argument `{ident}` for {name}!"), + )); + } + } + } + Ok(quote!(#name(#(#values),*))) +} + #[cfg(test)] mod tests { use super::*; @@ -471,4 +477,26 @@ mod tests { let normalised = normalise_default(quote!(crate::theme::DEFAULT)).to_string(); assert_eq!(normalised, "$ crate :: theme :: DEFAULT"); } + + fn run(arguments: TokenStream) -> Result { + let input = quote!(Field [ (value) (label [ || {} ]) (single_line [ false ]) ] #arguments); + check(input) + .map(|tokens| tokens.to_string()) + .map_err(|error| error.to_string()) + } + + #[test] + fn fr39_call_macro_check_emits_the_call_in_parameter_order_with_defaults() { + let text = run(quote!(single_line = true, value = a)).unwrap().replace(' ', ""); + assert_eq!(text, "Field(a,||{},true)"); + } + + #[test] + fn fr39_call_macro_check_names_the_offending_argument() { + assert!(run(quote!(a, nope = 1)).unwrap_err().contains("no parameter `nope`")); + assert!(run(quote!(a, value = 1)).unwrap_err().contains("given twice")); + assert!(run(quote!(single_line = true, a)).unwrap_err().contains("follows a named one")); + assert!(run(quote!(a, b, c, d)).unwrap_err().contains("was given more")); + assert!(run(quote!(single_line = true)).unwrap_err().contains("missing required argument `value`")); + } } diff --git a/compose-rust/macros/src/lib.rs b/compose-rust/macros/src/lib.rs index 26552799..168ec5b1 100644 --- a/compose-rust/macros/src/lib.rs +++ b/compose-rust/macros/src/lib.rs @@ -94,6 +94,17 @@ pub fn Composable(attribute: TokenStream, item: TokenStream) -> TokenStream { } } +/// The expansion of every generated call macro. Hidden: it is the generated +/// `macro_rules!`'s way of reading its arguments with the caller's spans, not an API. +#[doc(hidden)] +#[proc_macro] +pub fn __compose_call_check(input: TokenStream) -> TokenStream { + match call_macro::check(input.into()) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + /// Removes the `#[default(expr)]` attributes from the parameters and returns the /// expressions, one entry per parameter. fn take_defaults(function: &mut ItemFn) -> syn::Result>> { diff --git a/compose-rust/macros/tests/ui.rs b/compose-rust/macros/tests/ui.rs index a4b81f05..f0100f7d 100644 --- a/compose-rust/macros/tests/ui.rs +++ b/compose-rust/macros/tests/ui.rs @@ -3,7 +3,7 @@ //! caller reads, so a change to one is a change to what the macro tells its users. #[test] -fn fr39_call_macro_errors_name_the_parameter() { +fn fr39_call_macro_compile_fail_points_at_the_offending_argument() { let cases = trybuild::TestCases::new(); cases.pass("tests/ui/pass_*.rs"); cases.compile_fail("tests/ui/fail_*.rs"); diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index b64d974c..5c4fd70b 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -75,6 +75,8 @@ pub use notification::{ request_notification_permission, withdraw_notification, }; pub use palette::{Palette, PaletteViolation}; +#[doc(hidden)] +pub use compose_rust_macros::__compose_call_check; pub use runtime::{Batch, Composable, ComposeHost, Recomposer, Runtime, application, launch}; pub use schema::{ Alignment, Arrangement, AssetKind, ButtonKind, ButtonVariant, Chrome, Color, ColorRole, From 8956c0c9477ee1c0c48e46cf8c0b1ac782531bb8 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:30:05 +0900 Subject: [PATCH 097/224] Test: Regenerate call macro compile-fail expectations with argument spans (FR-39) Covers the FR-39 criterion that call macro errors point at the offending argument. Also applies rustfmt to call_macro.rs. --- compose-rust/macros/src/call_macro.rs | 42 +++++++++++++++---- .../ui/fail_block_without_content.stderr | 9 +--- .../macros/tests/ui/fail_duplicate.stderr | 9 +--- .../ui/fail_duplicate_of_positional.stderr | 9 +--- .../tests/ui/fail_missing_required.stderr | 9 +--- .../ui/fail_positional_after_named.stderr | 9 +--- .../tests/ui/fail_renamed_import.stderr | 6 +-- .../tests/ui/fail_too_many_positional.stderr | 9 +--- .../macros/tests/ui/fail_unknown_name.stderr | 9 +--- compose-rust/src/lib.rs | 4 +- 10 files changed, 53 insertions(+), 62 deletions(-) diff --git a/compose-rust/macros/src/call_macro.rs b/compose-rust/macros/src/call_macro.rs index 1e86ca57..712580d7 100644 --- a/compose-rust/macros/src/call_macro.rs +++ b/compose-rust/macros/src/call_macro.rs @@ -20,8 +20,8 @@ //! called by its own name, so the caller's scope decides what it means. use proc_macro2::{Group, Punct, Spacing, TokenStream, TokenTree}; -use quote::{format_ident, quote}; use quote::ToTokens; +use quote::{format_ident, quote}; use syn::parse::{Parse, ParseStream}; use syn::{Attribute, Expr, Ident, ItemFn, Token, Type, Visibility}; @@ -343,7 +343,11 @@ pub(crate) fn check(input: TokenStream) -> syn::Result { let span = if whole.is_empty() { proc_macro2::Span::call_site() } else { - whole.clone().into_iter().last().map_or_else(proc_macro2::Span::call_site, |tree| tree.span()) + whole + .clone() + .into_iter() + .last() + .map_or_else(proc_macro2::Span::call_site, |tree| tree.span()) }; return Err(syn::Error::new( span, @@ -487,16 +491,38 @@ mod tests { #[test] fn fr39_call_macro_check_emits_the_call_in_parameter_order_with_defaults() { - let text = run(quote!(single_line = true, value = a)).unwrap().replace(' ', ""); + let text = run(quote!(single_line = true, value = a)) + .unwrap() + .replace(' ', ""); assert_eq!(text, "Field(a,||{},true)"); } #[test] fn fr39_call_macro_check_names_the_offending_argument() { - assert!(run(quote!(a, nope = 1)).unwrap_err().contains("no parameter `nope`")); - assert!(run(quote!(a, value = 1)).unwrap_err().contains("given twice")); - assert!(run(quote!(single_line = true, a)).unwrap_err().contains("follows a named one")); - assert!(run(quote!(a, b, c, d)).unwrap_err().contains("was given more")); - assert!(run(quote!(single_line = true)).unwrap_err().contains("missing required argument `value`")); + assert!( + run(quote!(a, nope = 1)) + .unwrap_err() + .contains("no parameter `nope`") + ); + assert!( + run(quote!(a, value = 1)) + .unwrap_err() + .contains("given twice") + ); + assert!( + run(quote!(single_line = true, a)) + .unwrap_err() + .contains("follows a named one") + ); + assert!( + run(quote!(a, b, c, d)) + .unwrap_err() + .contains("was given more") + ); + assert!( + run(quote!(single_line = true)) + .unwrap_err() + .contains("missing required argument `value`") + ); } } diff --git a/compose-rust/macros/tests/ui/fail_block_without_content.stderr b/compose-rust/macros/tests/ui/fail_block_without_content.stderr index 7df5fe45..3d20ce8c 100644 --- a/compose-rust/macros/tests/ui/fail_block_without_content.stderr +++ b/compose-rust/macros/tests/ui/fail_block_without_content.stderr @@ -1,10 +1,5 @@ error: Field! has no `content` parameter, so it takes no trailing block; the parameters are: `value` - --> tests/ui/fail_block_without_content.rs:3:1 + --> tests/ui/fail_block_without_content.rs:9:27 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(String::new(), { let _ = 1; }); - | ------------------------------------- in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^^^^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_duplicate.stderr b/compose-rust/macros/tests/ui/fail_duplicate.stderr index 5df6c11f..99fe306a 100644 --- a/compose-rust/macros/tests/ui/fail_duplicate.stderr +++ b/compose-rust/macros/tests/ui/fail_duplicate.stderr @@ -1,10 +1,5 @@ error: argument `single_line` of Field! is given twice - --> tests/ui/fail_duplicate.rs:3:1 + --> tests/ui/fail_duplicate.rs:9:47 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(String::new(), single_line = true, single_line = false); - | -------------------------------------------------------------- in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr index 0d4d4d24..5321f2e5 100644 --- a/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr +++ b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr @@ -1,10 +1,5 @@ error: argument `value` of Field! is given twice - --> tests/ui/fail_duplicate_of_positional.rs:3:1 + --> tests/ui/fail_duplicate_of_positional.rs:9:27 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(String::new(), value = String::new()); - | -------------------------------------------- in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_missing_required.stderr b/compose-rust/macros/tests/ui/fail_missing_required.stderr index f8704f02..eff90dc4 100644 --- a/compose-rust/macros/tests/ui/fail_missing_required.stderr +++ b/compose-rust/macros/tests/ui/fail_missing_required.stderr @@ -1,10 +1,5 @@ error: missing required argument `value` for Field! - --> tests/ui/fail_missing_required.rs:3:1 + --> tests/ui/fail_missing_required.rs:9:26 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(single_line = true); - | -------------------------- in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^^^^ diff --git a/compose-rust/macros/tests/ui/fail_positional_after_named.stderr b/compose-rust/macros/tests/ui/fail_positional_after_named.stderr index 83572b80..604070fa 100644 --- a/compose-rust/macros/tests/ui/fail_positional_after_named.stderr +++ b/compose-rust/macros/tests/ui/fail_positional_after_named.stderr @@ -1,10 +1,5 @@ error: a positional argument of Field! follows a named one; name it too, the parameters are: `value`, `single_line` - --> tests/ui/fail_positional_after_named.rs:3:1 + --> tests/ui/fail_positional_after_named.rs:9:32 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(single_line = true, String::new()); - | ----------------------------------------- in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^^^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_renamed_import.stderr b/compose-rust/macros/tests/ui/fail_renamed_import.stderr index bb1fa07f..46676f88 100644 --- a/compose-rust/macros/tests/ui/fail_renamed_import.stderr +++ b/compose-rust/macros/tests/ui/fail_renamed_import.stderr @@ -1,14 +1,14 @@ -error: cannot find macro `Field` in this scope +error[E0425]: cannot find function, tuple struct or tuple variant `Field` in this scope --> tests/ui/fail_renamed_import.rs:7:12 | 7 | pub fn Field(value: String) { - | ^^^^^ + | ^^^^^ not found in this scope ... 18 | Input!(String::new()); | --------------------- in this macro invocation | = note: this error originates in the macro `Input` (in Nightly builds, run with -Z macro-backtrace for more info) -help: consider importing this macro through its public re-export +help: consider importing this function | 1 + use crate::ui::Field; | diff --git a/compose-rust/macros/tests/ui/fail_too_many_positional.stderr b/compose-rust/macros/tests/ui/fail_too_many_positional.stderr index 284fa23d..8f918e75 100644 --- a/compose-rust/macros/tests/ui/fail_too_many_positional.stderr +++ b/compose-rust/macros/tests/ui/fail_too_many_positional.stderr @@ -1,10 +1,5 @@ error: Field! takes 1 argument(s) and was given more; the parameters are: `value` - --> tests/ui/fail_too_many_positional.rs:3:1 + --> tests/ui/fail_too_many_positional.rs:9:27 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(String::new(), 1); - | ------------------------ in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^ diff --git a/compose-rust/macros/tests/ui/fail_unknown_name.stderr b/compose-rust/macros/tests/ui/fail_unknown_name.stderr index b379c731..f743ad3c 100644 --- a/compose-rust/macros/tests/ui/fail_unknown_name.stderr +++ b/compose-rust/macros/tests/ui/fail_unknown_name.stderr @@ -1,10 +1,5 @@ error: Field! has no parameter `singel_line`; the parameters are: `value`, `single_line` - --> tests/ui/fail_unknown_name.rs:3:1 + --> tests/ui/fail_unknown_name.rs:9:27 | -3 | #[Composable] - | ^^^^^^^^^^^^^ -... 9 | Field!(String::new(), singel_line = true); - | ----------------------------------------- in this macro invocation - | - = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) + | ^^^^^^^^^^^ diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 5c4fd70b..1f868c88 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -65,6 +65,8 @@ pub use code::{ CodeChange, CodeHover, CodeRange, Decoration, Decorations, EditRejected, Position, SaveRequest, SyntaxSpan, SyntaxSpans, }; +#[doc(hidden)] +pub use compose_rust_macros::__compose_call_check; pub use design::design_system; pub use drawing::{DrawCommand, DrawList, DrawListBuilder}; pub use fonts::{FontRef, FontRefs}; @@ -75,8 +77,6 @@ pub use notification::{ request_notification_permission, withdraw_notification, }; pub use palette::{Palette, PaletteViolation}; -#[doc(hidden)] -pub use compose_rust_macros::__compose_call_check; pub use runtime::{Batch, Composable, ComposeHost, Recomposer, Runtime, application, launch}; pub use schema::{ Alignment, Arrangement, AssetKind, ButtonKind, ButtonVariant, Chrome, Color, ColorRole, From 91718fa64763232b117059627d01ffa835153011 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:31:01 +0900 Subject: [PATCH 098/224] Docs: A missing required argument points at the last argument (#278) macro_rules receives only the tokens inside the brackets, so the closing delimiter and the macro name never reach the code that reports the error. --- docs/SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 3ade5e96..a87af549 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2452,7 +2452,7 @@ TextField!(value, on_change, label = || Text("이름"), single_line = true); - **필수 매개변수는 위치로든 이름으로든 반드시 줘야 합니다.** 빠지면 컴파일 오류이고, 메시지에 매개변수 이름이 들어갑니다(예: missing required argument value for TextField!). - **모르는 이름은 컴파일 오류**이며 메시지에 그 이름과 쓸 수 있는 이름 목록이 들어갑니다. - **같은 매개변수를 두 번 주면 컴파일 오류**입니다. 위치 인자로 채운 매개변수를 이름으로 또 주는 경우도 포함합니다. -- **호출 매크로의 오류는 잘못된 인자를 가리킵니다.** 위 컴파일 오류(없는 이름, 빠진 필수 인자, 중복 인자, 이름 뒤의 위치 인자, 너무 많은 위치 인자, `content`가 없는 함수의 뒤따르는 블록)는 호출하는 쪽의 잘못된 인자 토큰을 가리키고 `#[Composable]` 속성 줄을 가리키지 않습니다. 고칠 자리가 호출 자리이기 때문입니다. 빠진 필수 인자는 가리킬 토큰이 없으므로 매크로 호출의 닫는 구분자나 매크로 이름을 가리킵니다. +- **호출 매크로의 오류는 잘못된 인자를 가리킵니다.** 위 컴파일 오류(없는 이름, 빠진 필수 인자, 중복 인자, 이름 뒤의 위치 인자, 너무 많은 위치 인자, `content`가 없는 함수의 뒤따르는 블록)는 호출하는 쪽의 잘못된 인자 토큰을 가리키고 `#[Composable]` 속성 줄을 가리키지 않습니다. 고칠 자리가 호출 자리이기 때문입니다. 빠진 필수 인자는 가리킬 토큰이 없으므로 인자 목록의 마지막 인자를 가리키고, 인자가 하나도 없으면 매크로 호출을 가리킵니다. `macro_rules!`는 괄호 안의 토큰만 받아서 닫는 구분자와 매크로 이름 토큰을 오류를 내는 쪽에 넘길 수 없기 때문입니다. - **클로저는 보통의 식입니다.** `on_change = |s| state.set(s)`, `label = || Text("이름")`처럼 인자 자리에 그대로 씁니다. 매크로는 인자 식을 그대로 함수 호출로 넘기므로 클로저의 매개변수 타입은 함수 호출과 똑같이 매개변수의 `Fn` 바운드에서 추론됩니다. 매크로가 클로저를 감싸거나 복제하지 않습니다. - **내용 클로저는 이름으로 주거나(`content = || { .. }`), 마지막에 중괄호 블록으로 줍니다.** `Column!(modifier = m, { Text("a"); })`처럼, 인자 목록의 맨 끝에 쉼표로 구분된 `{ .. }` 블록이 있으면 `|| { .. }`로 읽어 내용 클로저(마지막 매개변수)에 넣습니다. 내용 클로저가 이미 주어졌거나 함수에 내용 클로저가 없으면 컴파일 오류입니다. - **평가 순서.** 인자 식은 쓴 순서가 아니라 매개변수 순서로 평가되고, 기본값 식은 그 매개변수 자리에서 평가됩니다. 매크로는 임시 변수를 만들지 않으므로 인자의 임시값 수명은 함수 호출의 것과 같습니다. 쓴 순서대로 평가하려면 인자를 먼저 `let`에 묶어야 하는데, 그러면 클로저가 함수 매개변수의 타입을 보지 못해 `|v| ..`의 `v` 같은 클로저 매개변수 타입을 추론하지 못합니다. 그래서 인자 식을 함수 호출 자리에 그대로 둡니다. Kotlin은 이름 있는 인자를 쓴 순서대로 평가하므로 이 점이 Compose와 다르고, 가이드에 그렇게 적습니다(소유자 승인 2026-10-25). @@ -2545,7 +2545,7 @@ TextField!(value, on_change, label = || Text("이름"), single_line = true); 7. **같음.** 같은 칸의 핸들 복사본은 같고, 값이 같은 서로 다른 상태의 핸들은 같지 않습니다. 이름 패턴 `fr39_state_handle_identity_*`. 8. **호출 매크로의 인자 규칙.** 위치 접두사 뒤에 이름 있는 인자가 순서와 무관하게 들어갑니다. 기본값이 있는 인자를 생략할 수 있고, 후행 `{ .. }` 블록과 `content = || { .. }`가 같은 호출입니다. 인자 식은 매개변수 순서로 평가됩니다. 이름 패턴 `fr39_call_macro_args_*`. -9. **호출 매크로의 컴파일 오류.** `trybuild`로 다음 각각이 컴파일에 실패하고 메시지가 해당 매개변수 이름을 포함합니다. 모르는 이름, 필수 인자 빠짐, 같은 인자를 두 번(위치와 이름의 중복 포함), 이름 있는 인자 뒤의 위치 인자, 내용 클로저를 두 번 줌, 내용 클로저가 없는 함수에 후행 블록. 각 오류의 주 스팬은 호출 자리의 잘못된 인자 토큰(빠진 필수 인자는 매크로 호출의 닫는 구분자나 이름)이고 `#[Composable]` 줄이 아니며, trybuild의 `.stderr`로 확인합니다. 매크로 크레이트(`compose-rust/macros`)에 `trybuild`를 dev-dependency로 더하고, 실패 사례와 기대 출력은 `compose-rust/macros/tests/ui/`에 두며, 기대 출력은 CI의 stable 도구 체인으로 고정합니다. 이름 패턴 `fr39_call_macro_compile_fail_*`. +9. **호출 매크로의 컴파일 오류.** `trybuild`로 다음 각각이 컴파일에 실패하고 메시지가 해당 매개변수 이름을 포함합니다. 모르는 이름, 필수 인자 빠짐, 같은 인자를 두 번(위치와 이름의 중복 포함), 이름 있는 인자 뒤의 위치 인자, 내용 클로저를 두 번 줌, 내용 클로저가 없는 함수에 후행 블록. 각 오류의 주 스팬은 호출 자리의 잘못된 인자 토큰(빠진 필수 인자는 마지막 인자, 인자가 없으면 매크로 호출)이고 `#[Composable]` 줄이 아니며, trybuild의 `.stderr`로 확인합니다. 매크로 크레이트(`compose-rust/macros`)에 `trybuild`를 dev-dependency로 더하고, 실패 사례와 기대 출력은 `compose-rust/macros/tests/ui/`에 두며, 기대 출력은 CI의 stable 도구 체인으로 고정합니다. 이름 패턴 `fr39_call_macro_compile_fail_*`. 10. **기본값.** `#[default(expr)]`가 없는 매개변수가 필수이고, `modifier`의 기본값이 `Modifier::new()`이며, 기본값이 매크로 형태에서만 적용되고 함수 형태는 모든 인자를 받습니다. 기본값 식이 호출 위치에서 평가됩니다. 이름 패턴 `fr39_default_*`. 11. **한계의 확인.** 제네릭(타입 인자 추론, const 제네릭)과 `impl Trait` 인자의 composable을 매크로 형태로 부를 수 있고, 메서드에 붙은 `#[Composable]`은 함수 형태만 만듭니다. 이름을 바꿔 가져온 경우 매크로가 컴파일 오류를 내는 것을 `trybuild`로 확인합니다. 이름 패턴 `fr39_call_macro_limits_*`. 12. **생성 비용 없음.** 같은 호출의 함수 형태와 매크로 형태가 같은 함수 호출로 펼쳐지는 것(`cargo expand` 출력 비교 또는 호출 수 확인)을 보입니다. 이름 패턴 `fr39_call_macro_expands_*`. From 90db2a3c1a7a663a65c8f9fd7d8470fea03e945b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:45:09 +0900 Subject: [PATCH 099/224] Feat: Link the Kotlin/Native static renderer on Windows by default Windows resolves the static kit (renderer/build/windows, or the windows-x64 release tarball at its root) as RendererKind::Static and links it with link_windows_native. DXC_WINDOWS_NATIVE_LIB stays an override. The GraalVM DLL path for Windows is removed. --- compose-rust/build.rs | 160 ++-------------------- compose-rust/build/renderer_dir.rs | 33 +++-- compose-rust/tests/renderer_resolution.rs | 105 ++++++++++++++ 3 files changed, 133 insertions(+), 165 deletions(-) diff --git a/compose-rust/build.rs b/compose-rust/build.rs index 08e26012..fe421466 100644 --- a/compose-rust/build.rs +++ b/compose-rust/build.rs @@ -191,13 +191,16 @@ fn main() { ("macos", _) => Some(renderer_build.join("macos")), ("linux", "x86_64") => Some(renderer_build.join("linux")), ("linux", "aarch64") => Some(renderer_build.join("linux-arm64")), + // What build-windows.sh writes: the Kotlin/Native static kit. Windows has no + // GraalVM DLL to fall back to. + ("windows", _) => Some(renderer_build.join("windows")), _ => None, }; let shared_dir = renderer_build.join("native-image/dist/lib"); let workspace_dirs: Vec<&Path> = static_dir .as_deref() .into_iter() - .chain(std::iter::once(shared_dir.as_path())) + .chain((target_os != "windows").then_some(shared_dir.as_path())) .collect(); let cache_root = cache_root(); @@ -235,6 +238,7 @@ fn main() { let lib_dir = renderer.lib_dir; check_schema_agreement(&lib_dir); match renderer.kind { + RendererKind::Static if target_os == "windows" => link_windows_native(&lib_dir), RendererKind::Static => link_static(&target_os, &target, &lib_dir), RendererKind::Shared => link_shared(&target_os, &lib_dir), } @@ -342,63 +346,13 @@ fn link_static(target_os: &str, target: &str, lib_dir: &Path) { println!("cargo:rustc-cfg=renderer_linked"); } -/// Links a shared renderer: the GraalVM native image, which Windows ships and a checkout -/// can still build for macOS and Linux. +/// Links a shared renderer: the GraalVM native image, which a checkout can still build for +/// macOS and Linux. Windows has none; its renderer is the static kit. fn link_shared(target_os: &str, lib_dir: &Path) { println!("cargo:rerun-if-changed={}", lib_dir.display()); println!("cargo:rustc-link-search=native={}", lib_dir.display()); println!("cargo:rustc-cfg=renderer_linked"); - if target_os == "windows" { - // MSVC links against the import library the native image produced beside the DLL, - // and it carries the same `lib` prefix the image was named with. - println!("cargo:rustc-link-lib=dylib=libcompose_rust_renderer"); - // The exports the Renderer looks up are not asked for here. - // - // They were, with `/EXPORT:` link arguments, and that reached this package's own - // binaries and nothing else: Cargo does not pass a dependency's link arguments on - // to whatever depends on it. So every application built on this crate linked with - // no export table, the Renderer could not reach back into it, and the window came - // up empty. It looked like it worked because the only Windows binaries anyone had - // run were this package's own examples. - // - // The directive travels inside the rlib now, in the `.drectve` section that the - // MSVC linker reads out of every object it links. See the `global_asm!` block in - // src/boundary.rs. - // The subsystem is not set here, and the reason is the same one the export - // directive ran into: a build script's link arguments reach this package's own - // targets and stop. Setting it here broke the crate's own cdylib, which has no - // `main` for `/ENTRY:mainCRTStartup` to point at, and would not have reached a - // single application even if it had worked. - // - // An application says it for itself, with `#![windows_subsystem = "windows"]` at - // the top of its `main.rs`. That is one line, it is the ordinary way to do it in - // Rust, and the samples here carry it. Without it a window application also opens - // a console, and closing that console kills it. - // - // The renderer attaches to a parent console at startup so that saying it costs no - // diagnostics: run from a terminal the messages still appear, run from Explorer - // there is no terminal to open. - // Windows has no rpath and no name inside the file that the loader consults: a - // DLL is found on the loader's search path and nowhere else. The other platforms - // name the library after where it sits, which does nothing here. - // - // Telling the person building to put a directory on PATH was the whole of the fix - // and it was not enough. It makes adding this crate to a Cargo.toml two steps - // instead of one, and it breaks things that have nothing to do with drawing: the - // code generator in this package links the renderer only because it lives in the - // same package, and on Windows it died on startup with STATUS_DLL_NOT_FOUND - // before it had generated a line. - // - // The loader searches the directory the executable is in, so the renderer is put - // there. Cargo does not tell a build script where that is, but OUT_DIR is - // `//build/-/out`, so three levels up is the - // profile directory where binaries land, and `examples/` and `deps/` beside it - // are where examples and tests land. - copy_renderer_beside_executables(lib_dir); - return; - } - println!("cargo:rustc-link-lib=dylib=compose_rust_renderer"); // No rpath. It would not reach an application that merely depends on this crate, // because Cargo does not pass a dependency's link arguments on, and emitting one here @@ -460,10 +414,10 @@ fn link_windows_native(dir: &Path) { ] { if !dir.join(piece).is_file() { panic!( - "\n\ncompose-rust: DXC_WINDOWS_NATIVE_LIB is {}, which has no {piece} ({what}).\n\n\ + "\n\ncompose-rust: the Windows renderer kit at {} has no {piece} ({what}).\n\n\ renderer/desktop/scripts/build-windows.sh writes the whole \ - directory; build the renderer again, or point DXC_WINDOWS_NATIVE_LIB at the \ - directory it wrote.\n\n", + directory; build the renderer again, or set DXC_WINDOWS_NATIVE_LIB (or \ + COMPOSE_RUST_RENDERER_DIR) to the directory it wrote.\n\n", dir.display() ); } @@ -1141,100 +1095,6 @@ fn read_hash(path: &Path) -> Option { .filter(|value| !value.is_empty()) } -/// Puts the renderer where a Windows loader and AWT will both find it. -/// -/// Two separate requirements, and satisfying only the first is worse than satisfying -/// neither, because the program then starts and dies later with a message about a file -/// nobody asked for. -/// -/// The loader searches the directory the executable is in, so the libraries are copied -/// there flat. That alone is what a program needs in order to start, and it is what the -/// code generator in this package needs, which links the renderer only because it shares a -/// package with it and died on startup before generating a line. -/// -/// AWT then wants a distribution around it. The renderer sets `java.home` to the parent of -/// wherever it was loaded from, and AWT resolves its own libraries against `java.home/bin`. -/// A flat copy beside an executable in `target/release/examples` therefore sends AWT to -/// `target/release/bin`, which is a directory nobody made. So the distribution is copied -/// whole, keeping `bin` and `lib`, into the parent of each place an executable lands. -/// -/// Cargo does not tell a build script where binaries land. OUT_DIR is -/// `//build/-/out`, so three levels up is the profile -/// directory, `examples/` and `deps/` beside it are where examples and tests land, and the -/// parents of those two are the profile directory and the target directory. -fn copy_renderer_beside_executables(lib_dir: &Path) { - let Some(out_dir) = std::env::var_os("OUT_DIR") else { - return; - }; - let out_dir = PathBuf::from(out_dir); - let Some(profile_dir) = out_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - else { - return; - }; - - // Flat, for the loader: every executable's own directory. - for directory in [ - profile_dir.to_path_buf(), - profile_dir.join("examples"), - profile_dir.join("deps"), - ] { - copy_newer_files(lib_dir, &directory); - } - - // Whole, for AWT: the parent of every executable's directory. The distribution root is - // the parent of the directory the libraries are in, which is `bin` on Windows and - // `lib` everywhere else. - let Some(distribution) = lib_dir.parent() else { - return; - }; - let mut roots = vec![profile_dir.to_path_buf()]; - if let Some(target_dir) = profile_dir.parent() { - roots.push(target_dir.to_path_buf()); - } - for root in roots { - for child in ["bin", "lib"] { - let source = distribution.join(child); - if source.is_dir() { - copy_newer_files(&source, &root.join(child)); - } - } - } -} - -/// Copies the files of one directory into another, skipping what is already newer there. -/// -/// These are tens of megabytes and they are copied into five places, so every build would -/// otherwise move half a gigabyte to no purpose. -fn copy_newer_files(from: &Path, to: &Path) { - let Ok(entries) = std::fs::read_dir(from) else { - return; - }; - if std::fs::create_dir_all(to).is_err() { - return; - } - for entry in entries.flatten() { - let source = entry.path(); - if !source.is_file() { - continue; - } - let destination = to.join(entry.file_name()); - let newer = match (source.metadata(), destination.metadata()) { - (Ok(from), Ok(to)) => match (from.modified(), to.modified()) { - (Ok(from), Ok(to)) => from > to, - _ => true, - }, - (Ok(_), Err(_)) => true, - _ => false, - }; - if newer { - let _ = std::fs::copy(&source, &destination); - } - } -} - fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { diff --git a/compose-rust/build/renderer_dir.rs b/compose-rust/build/renderer_dir.rs index cd82cd87..00a70437 100644 --- a/compose-rust/build/renderer_dir.rs +++ b/compose-rust/build/renderer_dir.rs @@ -45,8 +45,9 @@ pub const PUBLISHED_TARGETS: &[&str] = &["macos-aarch64", "windows-x64", "linux- /// The archive is what the release ships for macOS and Linux. It is Kotlin/Native, with /// Compose, Skia and ICU inside it, and it is linked into the application, so the /// application is one executable that needs nothing but the system's own libraries. The -/// shared library is the GraalVM native image, which is what Windows still ships and what -/// a checkout can still build and point the build at. +/// shared library is the GraalVM native image, which a checkout can still build for macOS +/// and Linux. Windows has no shared renderer: its distribution is the static kit that +/// `build-windows.sh` writes (the archive with `native/`, `gcc/` and `skiko/` beside it). /// /// Which one a distribution is, is read from what is in it rather than from the platform. /// That is what lets a crate version whose release still carries shared libraries go on @@ -63,7 +64,7 @@ pub enum RendererKind { /// The release publishes these targets as a static archive. The others are a shared /// library for now; the build script says which in its messages, and links whichever /// the distribution holds. -pub const STATIC_TARGETS: &[&str] = &["macos-aarch64", "linux-x64", "linux-arm64"]; +pub const STATIC_TARGETS: &[&str] = &["macos-aarch64", "windows-x64", "linux-x64", "linux-arm64"]; /// The static archive a distribution of the Renderer contains when it is one. Kotlin/Native /// names it the same way on every platform it builds one for. @@ -86,6 +87,16 @@ pub fn renderer_file(target_os: &str, kind: RendererKind) -> &'static str { /// at. The archive wins where a directory somehow holds both, because it is what makes the /// application one file. pub fn find_renderer_in(dir: &Path, target_os: &str) -> Option<(PathBuf, RendererKind)> { + // The Windows kit is the directory itself: the archive sits at its root with the MSVC + // bridge libraries beside it, and linking needs all of them, so a `lib/` or `bin/` + // holding only an archive is not a kit. There is no Windows DLL to find. + if target_os == "windows" { + let file = renderer_static_file(target_os); + return dir + .join(file) + .is_file() + .then(|| (dir.to_path_buf(), RendererKind::Static)); + } for kind in [RendererKind::Static, RendererKind::Shared] { let file = renderer_file(target_os, kind); for candidate in [dir.join("lib"), dir.join("bin"), dir.to_path_buf()] { @@ -102,9 +113,6 @@ pub fn find_renderer_in(dir: &Path, target_os: &str) -> Option<(PathBuf, Rendere /// what its loader will look for. pub fn renderer_lib_file(target_os: &str) -> &'static str { match target_os { - // The Windows build names the image `libcompose_rust_renderer`, so the DLL keeps - // the `lib` prefix that Windows itself would not have added. - "windows" => "libcompose_rust_renderer.dll", "macos" => "libcompose_rust_renderer.dylib", _ => "libcompose_rust_renderer.so", } @@ -112,14 +120,9 @@ pub fn renderer_lib_file(target_os: &str) -> &'static str { /// Where a platform's build script puts the renderer inside the distribution. /// -/// Windows keeps the DLL beside the AWT and Skia DLLs in `bin`, because the loader -/// searches the directory of the module that needs them and they have to be found -/// together. The other platforms put the shared library in `lib`. -pub fn renderer_lib_subdir(target_os: &str) -> &'static str { - match target_os { - "windows" => "bin", - _ => "lib", - } +/// Every shared library goes in `lib`. Windows has none: its renderer is the static kit. +pub fn renderer_lib_subdir(_target_os: &str) -> &'static str { + "lib" } /// One line holding the crate version the artifact was built for. The release packaging @@ -1026,7 +1029,7 @@ pub fn every_failure_message(sample_dir: &Path) -> Vec { "linux-x64", ), unexpected_layout_message(&tarball, sample_dir, "macos"), - no_static_link_message("windows-x64", sample_dir), + no_static_link_message("freebsd-x64", sample_dir), version_mismatch_message(sample_dir, "0.1.0", version, target), build_it_yourself(version, target), ] diff --git a/compose-rust/tests/renderer_resolution.rs b/compose-rust/tests/renderer_resolution.rs index 72f15903..b76617a1 100644 --- a/compose-rust/tests/renderer_resolution.rs +++ b/compose-rust/tests/renderer_resolution.rs @@ -490,6 +490,111 @@ fn nfr14_a_workspace_static_build_comes_before_the_native_image() { assert_eq!(archive.lib_dir, static_dir); } +// ------------------------------------------------------------------------------------- +// Windows: the static kit is the renderer +// ------------------------------------------------------------------------------------- + +const WINDOWS_TARGET: &str = "windows-x64"; +const WINDOWS_OS: &str = "windows"; + +/// What `build-windows.sh` writes into `renderer/build/windows`, and what the release +/// tarball carries at its root: the archive with `native/`, `gcc/` and `skiko/` beside it. +fn windows_kit(root: &Path) -> PathBuf { + std::fs::create_dir_all(root.join("native")).unwrap(); + std::fs::write(root.join(renderer_static_file(WINDOWS_OS)), ARCHIVE_BYTES).unwrap(); + std::fs::write(root.join("native/dxc-windows-native.lib"), b"lib").unwrap(); + root.to_path_buf() +} + +/// Windows ships the Kotlin/Native static kit, so it is a static target, and the +/// GraalVM DLL is no longer a renderer there. +#[test] +fn nfr14_windows_is_a_static_target() { + assert!(STATIC_TARGETS.contains(&WINDOWS_TARGET)); + assert!(PUBLISHED_TARGETS.contains(&WINDOWS_TARGET)); +} + +/// The directory `build-windows.sh` writes resolves as a static renderer, kit root and all. +#[test] +fn nfr14_a_windows_workspace_kit_is_static() { + let temp = TempDir::new("windows-workspace"); + let kit = windows_kit(&temp.path().join("build/windows")); + let renderer = acquire_renderer(&Request { + env_dir: None, + workspace_dirs: &[kit.as_path()], + cache_root: Some(&temp.path().join("cache")), + crate_version: SAMPLE_VERSION, + target: WINDOWS_TARGET, + target_os: WINDOWS_OS, + fetch: None, + }) + .expect("the kit is there"); + assert_eq!(renderer.source, RendererSource::Workspace); + assert_eq!(renderer.kind, RendererKind::Static); + assert_eq!(renderer.lib_dir, kit); +} + +/// A downloaded `compose-rust-renderer-v-windows-x64.tar.gz` is unpacked and linked as +/// the static kit, with its sibling directories intact. +#[test] +fn nfr15_a_windows_tarball_is_unpacked_as_the_static_kit() { + let temp = TempDir::new("windows-download"); + let release = temp.path().join("release"); + windows_kit(&release.join("staging")); + let prepared = pack( + &release.join("staging"), + &release, + Checksum::Correct, + WINDOWS_TARGET, + ); + let served = prepared.clone(); + let fetch = move |url: &str, destination: &Path| -> Result<(), FetchError> { + let source = if url.ends_with(".sha256") { + sibling_checksum(&served) + } else { + served.clone() + }; + std::fs::copy(source, destination) + .map(|_| ()) + .map_err(|error| FetchError::Failed(error.to_string())) + }; + let cache = temp.path().join("cache"); + let renderer = acquire_for( + WINDOWS_TARGET, + WINDOWS_OS, + &temp, + None, + Network::Allowed(&fetch), + &cache, + ) + .expect("the release serves this target"); + assert_eq!(renderer.source, RendererSource::Download); + assert_eq!(renderer.kind, RendererKind::Static); + assert!(renderer.lib_dir.join("native/dxc-windows-native.lib").is_file()); + assert!(renderer.lib_dir.join(renderer_static_file(WINDOWS_OS)).is_file()); +} + +/// A leftover GraalVM DLL is not a renderer on Windows: the directory holding only it +/// is refused instead of linked. +#[test] +fn nfr14_a_windows_dll_alone_is_not_a_renderer() { + let temp = TempDir::new("windows-dll"); + let bin = temp.path().join("dist/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("libcompose_rust_renderer.dll"), b"MZ").unwrap(); + let cache = temp.path().join("cache"); + let error = acquire_for( + WINDOWS_TARGET, + WINDOWS_OS, + &temp, + Some(&temp.path().join("dist")), + Network::Forbidden, + &cache, + ) + .expect_err("a DLL is not the Windows renderer any more"); + assert!(error.contains(renderer_static_file(WINDOWS_OS)), "{error}"); +} + // ------------------------------------------------------------------------------------- // Refusals // ------------------------------------------------------------------------------------- From 3fd01966a75cd8529f06f60eb7fe81fe70cac96d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:47:46 +0900 Subject: [PATCH 100/224] Fix: Format the Windows renderer tests --- compose-rust/tests/renderer_resolution.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/renderer_resolution.rs b/compose-rust/tests/renderer_resolution.rs index b76617a1..5009fa55 100644 --- a/compose-rust/tests/renderer_resolution.rs +++ b/compose-rust/tests/renderer_resolution.rs @@ -570,8 +570,18 @@ fn nfr15_a_windows_tarball_is_unpacked_as_the_static_kit() { .expect("the release serves this target"); assert_eq!(renderer.source, RendererSource::Download); assert_eq!(renderer.kind, RendererKind::Static); - assert!(renderer.lib_dir.join("native/dxc-windows-native.lib").is_file()); - assert!(renderer.lib_dir.join(renderer_static_file(WINDOWS_OS)).is_file()); + assert!( + renderer + .lib_dir + .join("native/dxc-windows-native.lib") + .is_file() + ); + assert!( + renderer + .lib_dir + .join(renderer_static_file(WINDOWS_OS)) + .is_file() + ); } /// A leftover GraalVM DLL is not a renderer on Windows: the directory holding only it From f4d3ad3cb8e630e99ef9cd91202a271280f17565 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:55:35 +0900 Subject: [PATCH 101/224] Test: Prove the Windows default resolution in the consumer check The consumer check no longer exports DXC_WINDOWS_NATIVE_LIB: the path-dependency fixture finds renderer/build/windows. A short extra build proves the variable still overrides. --- scripts/check-windows-consumer.sh | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/check-windows-consumer.sh b/scripts/check-windows-consumer.sh index 18e9691f..cc6ddbd4 100755 --- a/scripts/check-windows-consumer.sh +++ b/scripts/check-windows-consumer.sh @@ -7,8 +7,9 @@ # with the MSVC tools installed (Visual Studio or its Build Tools, "Desktop development with # C++"), and cl.exe and dumpbin.exe reachable on PATH or through vswhere. # -# the directory desktop/scripts/build-windows.sh writes; the application -# finds it through DXC_WINDOWS_NATIVE_LIB +# the directory desktop/scripts/build-windows.sh writes, which has to be +# renderer/build/windows of this checkout: the application is given no +# variable and finds it by the crate's default resolution # where the applications are copied to; emptied first, and it should be # somewhere that has nothing to do with the build # @@ -101,8 +102,13 @@ while :; do directory="$parent" done -export DXC_WINDOWS_NATIVE_LIB -DXC_WINDOWS_NATIVE_LIB="$(cygpath -w "$renderer")" +# No variable names the renderer. A consumer with a path dependency on this crate finds the +# kit build-windows.sh wrote in renderer/build/windows, which is what this proves; the +# override has a short check of its own in 1c. +unset DXC_WINDOWS_NATIVE_LIB +[[ "$renderer" == "$(cd "$repo_root/renderer/build/windows" 2>/dev/null && pwd)" ]] || + fail "$renderer is not $repo_root/renderer/build/windows" \ + "The default resolution looks there and nowhere else in a checkout." # Target directories of its own inside the checkout, as the Linux check does, so this never # shares a build with anything else running there. The builds with the same flags share one, @@ -168,6 +174,23 @@ CARGO_TARGET_DIR="$repo_root/target/windows-consumer-check/release" \ fail "the release build did not link" "See fix-mingw-objects.py, point 3, for the LNK1223 case." [[ -f "$target/release/release/consumer.exe" ]] || fail "the release build produced no consumer.exe" +# The override still wins over the default: pointed at a directory that holds no kit, the build +# has to stop naming that directory, and not quietly link the kit it would have found anyway. +echo "== 1c. DXC_WINDOWS_NATIVE_LIB overrides the default" +empty_kit="$scratch/empty-kit" +mkdir -p "$empty_kit" +override_log="$scratch/override.log" +if DXC_WINDOWS_NATIVE_LIB="$(cygpath -w "$empty_kit")" \ + CARGO_TARGET_DIR="$repo_root/target/windows-consumer-check/override" \ + cargo build --manifest-path "$fixture/Cargo.toml" > "$override_log" 2>&1; then + fail "a build with DXC_WINDOWS_NATIVE_LIB at an empty directory succeeded" \ + "The variable is meant to beat the default resolution." +fi +grep -q 'has no libcompose_rust_renderer.a' "$override_log" || + { tail -30 "$override_log" >&2; fail "the override build failed, but not for the empty kit"; } +grep -q "empty-kit" "$override_log" || + fail "the override failure does not name the directory the variable pointed at" + echo "== 2. what it needs to start" only_windows "$binary" default_size="$(size_of "$binary")" From b8f04c4a5ec1539b092934bbb946a9c65275fd41 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:46:51 +0900 Subject: [PATCH 102/224] Feat: add RequestFocus mutation and TextField composition notifications (FR-65, #410) --- .../shared/CodeEditorSurface.kt | 11 +- .../android-kotlin/shared/ComposeRustHost.kt | 6 + .../android-kotlin/shared/HostCodeEditor.kt | 3 +- .../android-kotlin/shared/HostTextField.kt | 33 ++++- .../android-kotlin/shared/NodeTable.kt | 25 +++- .../android-kotlin/shared/Protocol.gen.kt | 10 +- .../android-kotlin/shared/RenderNode.kt | 32 ++++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/codegen.rs | 11 +- compose-rust/src/protocol.rs | 95 +++++++++++++- compose-rust/src/runtime/host.rs | 3 + compose-rust/src/runtime/seam.rs | 5 + compose-rust/src/schema.rs | 2 +- compose-rust/src/ui/widgets.rs | 18 ++- compose-rust/src/writer.rs | 1 + compose-rust/tests/vectors/mutations.bin | Bin 5327 -> 5335 bytes compose-rust/tests/vectors/vectors.json | 4 +- docs/SPEC.md | 18 +++ renderer/desktop/src/protocol/Protocol.gen.kt | 10 +- .../desktop/src/renderer/CodeEditorSurface.kt | 11 +- .../desktop/src/renderer/ComposeRustHost.kt | 6 + .../desktop/src/renderer/HostCodeEditor.kt | 3 +- .../desktop/src/renderer/HostTextField.kt | 33 ++++- renderer/desktop/src/renderer/NodeTable.kt | 25 +++- renderer/desktop/src/renderer/RenderNode.kt | 32 ++++- renderer/desktop/test/ProtocolVectorsTest.kt | 1 + renderer/desktop/test/TextFieldTest.kt | 120 ++++++++++++++++++ 27 files changed, 497 insertions(+), 23 deletions(-) diff --git a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt index 6d87e935..ea927a74 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt @@ -194,16 +194,17 @@ fun CodeEditorSurface( callbacks: CodeEditorCallbacks, modifier: Modifier = Modifier, path: CodeEditorPath = defaultCodeEditorPath, + focusRequest: Long = 0L, ) { when (path) { - CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false) - CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true) + CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) + CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true, focusRequest = focusRequest) CodeEditorPath.Drawn -> { val drawn = drawnCodeEditorSurface if (drawn != null) { drawn(model, look, callbacks, modifier) } else { - FieldSurface(model, look, callbacks, modifier, whole = false) + FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) } } } @@ -784,6 +785,7 @@ private fun FieldSurface( callbacks: CodeEditorCallbacks, modifier: Modifier, whole: Boolean, + focusRequest: Long = 0L, ) { val density = LocalDensity.current val rowPx = remember(look.textStyle, density) { rowHeightPx(look.textStyle, density) } @@ -806,6 +808,9 @@ private fun FieldSurface( // reader itself reads the field's layout state, so what draws from it still follows. val layoutHolder = remember { arrayOfNulls<() -> TextLayoutResult?>(1) } val focus = remember { FocusRequester() } + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focus.requestFocus() + } // Read here so the row map and the gutter follow every change of text and overlay. val contentRevision = model.contentRevision diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index bcf08d84..9d52f268 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.runtime.mutableStateOf @@ -371,6 +372,11 @@ private fun ComposeRustTree( // however many windows there are. val windowNodeId = LocalWindowNodeId.current val pageRoots = if (windowNodeId == 0) host.roots else host.table.node(windowNodeId)?.children?.toList().orEmpty() + val focusManager = LocalFocusManager.current + val clearFocusRevision = host.table.focusClearRevision + LaunchedEffect(clearFocusRevision) { + if (clearFocusRevision > 0L) focusManager.clearFocus() + } LaunchedEffect(host, frames, windowNodeId, servesFrames) { if (windowNodeId != 0 || !servesFrames) return@LaunchedEffect // Counted from the Host's start, not from the moment this loop began listening. The diff --git a/compose-rust/android-kotlin/shared/HostCodeEditor.kt b/compose-rust/android-kotlin/shared/HostCodeEditor.kt index 5f5399d2..1d698958 100644 --- a/compose-rust/android-kotlin/shared/HostCodeEditor.kt +++ b/compose-rust/android-kotlin/shared/HostCodeEditor.kt @@ -73,7 +73,8 @@ internal fun HostCodeEditor( }, ) } - CodeEditorSurface(model, look, callbacks, modifier) + val focusRequest = node.focusRequestRevision + CodeEditorSurface(model, look, callbacks, modifier, focusRequest = focusRequest) } /** What the design system says an editor looks like here, resolved to plain values. */ diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 0408e2a4..0306bfdc 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key @@ -88,6 +90,8 @@ internal fun HostTextField( } var appliedHostRevision by remember(nodeId) { mutableStateOf(0L) } var focused by remember(nodeId) { mutableStateOf(false) } + var reportedComposing by remember(nodeId) { mutableStateOf(false) } + val focusRequester = remember(nodeId) { FocusRequester() } val multiline = node.flag(PropertyKind.Multiline, default = false) val enabled = node.flag(PropertyKind.Enabled, default = true) @@ -95,8 +99,10 @@ internal fun HostTextField( val submitHandler = node.handler(PropertyKind.OnSubmit) val keyDownHandler = node.handler(PropertyKind.OnKeyDown) val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + val compositionHandler = node.handler(PropertyKind.OnCompositionChange) val placeholder = node.text(PropertyKind.Placeholder) val hostText = node.hostText + val focusRequest = node.focusRequestRevision val composing = value.composition != null // The field takes its type role and its colours from the design system, the same way a // Text does. @@ -130,6 +136,24 @@ internal fun HostTextField( } } + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focusRequester.requestFocus() + } + + val applyValueChange: (TextFieldValue) -> Unit = { next -> + val nowComposing = next.composition != null + value = next + if (nowComposing != reportedComposing) { + reportedComposing = nowComposing + if (compositionHandler != null) { + dispatcher.dispatch( + HostEvent.CompositionChanged(nodeId, compositionHandler, nowComposing), + ) + } + } + } + hostTextFieldValueHook?.invoke(applyValueChange) + // Change notification, at most one a frame. // // An edit is told to the Host on the next frame, carrying the latest text, so keys that @@ -167,6 +191,7 @@ internal fun HostTextField( } val inputModifier = modifier + .focusRequester(focusRequester) .onFocusChanged { state -> if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) @@ -254,7 +279,7 @@ internal fun HostTextField( BasicTextField( value = value, - onValueChange = { value = it }, + onValueChange = applyValueChange, modifier = inputModifier, enabled = enabled, singleLine = !multiline, @@ -310,6 +335,12 @@ internal fun HostTextField( */ private const val PLACEHOLDER_ALPHA = 0.55f +/** + * Test hook that receives the value-change callback of the mounted [HostTextField] so a test + * can drive an IME composition transition directly through `TextFieldValue.composition`. + */ +internal var hostTextFieldValueHook: (((TextFieldValue) -> Unit) -> Unit)? = null + /** The box around a field, or nothing where the system draws none in this state. */ private fun Modifier.fieldBorder(style: FieldStyle, focused: Boolean): Modifier { val width = if (focused) style.borderWidthFocused else style.borderWidth diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 22296a8a..29ccc2a8 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -46,6 +46,7 @@ class Node internal constructor(val id: Int, val widget: WidgetKind) { internal val children = mutableStateListOf() internal var parentId: Int = NodeTable.ROOT_ID internal var hostText by mutableStateOf(null) + internal var focusRequestRevision by mutableStateOf(0L) /** * A code editor's document and everything over it. Created with the node and kept for @@ -167,6 +168,10 @@ class NodeTable { private var revision = 0L + /** Increments on each `RequestFocus(0)` so the root composition clears active focus. */ + var focusClearRevision by mutableStateOf(0L) + private set + /** Null until the Host sends its first `SetTheme` record. */ var theme: Theme? by mutableStateOf(null) private set @@ -267,6 +272,7 @@ class NodeTable { window = null materials = 0 revision = 0 + focusClearRevision = 0 } fun apply(mutation: Mutation) { @@ -301,6 +307,7 @@ class NodeTable { // the batch. is Mutation.SetWindowTheme -> setWindowTheme(mutation) is Mutation.FocusWindow -> focusWindow(mutation) + is Mutation.RequestFocus -> requestFocus(mutation) // The bytes are read here, once, and never again: what a frame carries is the // id. A kind this Renderer cannot read, or a release of something that was // never registered, is reported and the rest of the batch still applies. @@ -387,6 +394,20 @@ class NodeTable { focusRequests += mutation.nodeId } + private fun requestFocus(mutation: Mutation.RequestFocus) { + if (mutation.nodeId == ROOT_ID) { + revision += 1 + focusClearRevision = revision + return + } + val node = nodes[mutation.nodeId] ?: return fail( + TableError.UNKNOWN_NODE, + "RequestFocus for unknown node ${mutation.nodeId}", + ) + revision += 1 + node.focusRequestRevision = revision + } + /** The code editor a command names, or null after reporting that it names none. */ private fun codeNode(nodeId: Int, command: String): Pair? { val node = nodes[nodeId] ?: run { @@ -977,9 +998,11 @@ class NodeTable { PropertyKind.OnVisibleRangeChange, PropertyKind.OnEditorFocusChange, PropertyKind.OnFoldChange, - PropertyKind.OnCompositionChange, -> widget == WidgetKind.CodeEditor + PropertyKind.OnCompositionChange -> + widget == WidgetKind.CodeEditor || widget == WidgetKind.TextField + // Files over a node and files let go on it. Only the widget that exists // to receive them, because a handler is attached whether or not a screen // supplied one: on any container these would cost every container in the diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 5c768f65..5dc613ff 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -1243,6 +1243,9 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation } sealed interface HostEvent { @@ -1295,7 +1298,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 5001727070868787964L + const val SCHEMA_HASH: Long = 2165073138301532475L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1323,6 +1326,7 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1472,6 +1476,10 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index e2dcaef2..69495546 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText @@ -22,6 +23,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key @@ -199,7 +202,34 @@ fun RenderNode( node.pressedShape(theme), ) } - val modifier = platformFileDrop(pressable, node, dispatcher) + val focusRequest = node.focusRequestRevision + val focusRequester = if (focusRequest > 0L && + node.widget != WidgetKind.TextField && + node.widget != WidgetKind.CodeEditor + ) { + remember(nodeId) { FocusRequester() } + } else { + null + } + if (focusRequester != null) { + LaunchedEffect(focusRequest) { + focusRequester.requestFocus() + } + } + val focusableModifier = if (focusRequester == null) { + pressable + } else { + val withRequester = pressable.focusRequester(focusRequester) + if (clickHandler == null && + node.widget in CLICKABLE_CONTAINERS && + node.modifiers.none { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + ) { + withRequester.focusable() + } else { + withRequester + } + } + val modifier = platformFileDrop(focusableModifier, node, dispatcher) // A layout made of glass tells what is inside it so. A field in a glass bar is part of // the bar in the systems that draw glass, and it can only take that shape if it knows // what it is sitting on. diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index bdbe581b..68b40b6a 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x4569b4454a96d2fc +0x1e0be28c5933f93b diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index ea696cac..9ac0d38b 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -462,6 +462,9 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation } "#, @@ -593,6 +596,7 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -742,6 +746,10 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the @@ -2978,6 +2986,7 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { theme: Theme::unified(DesignSystem::Material3).with_color_scheme(ColorScheme::Dark), }, Mutation::FocusWindow { node_id: 20 }, + Mutation::RequestFocus { node_id: 1 }, ]; let mut encoder = BatchEncoder::default(); for mutation in &mutations { @@ -3266,7 +3275,7 @@ pub fn generate_vector_description() -> String { "mutations": {{ "file": "mutations.bin", "description": "One batch covering every record, property value, modifier layout, drawing command, asset, message, notification command, palette entry, text run, split pane property, the HTML and CSS drawing elements, the animations the Renderer plays and code editor record", - "recordCount": 102, + "recordCount": 103, "palette": [ {{ "role": "Primary", "scheme": "Light", "argb": "ffe8590c" }}, {{ "role": "Primary", "scheme": "Dark", "argb": "ffff8a4c" }}, diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 093673fa..1d3b03b5 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -35,6 +35,7 @@ const TAG_SET_FOLDED: u16 = 22; // 23 and 24 are the windows': a theme for one window, and bringing one forward. const TAG_SET_WINDOW_THEME: u16 = 23; const TAG_FOCUS_WINDOW: u16 = 24; +const TAG_REQUEST_FOCUS: u16 = 25; /// The head of a `StartAnimation` record, before its keyframes. const ANIMATION_HEADER_LEN: usize = 52; /// A keyframe before its value: offset, timing, step position, flags and four numbers. @@ -240,6 +241,10 @@ pub enum Mutation<'a> { FocusWindow { node_id: u32, }, + /// Moves input focus to `node_id`, or clears focus when `node_id` is 0. + RequestFocus { + node_id: u32, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1338,6 +1343,10 @@ impl BatchEncoder { self.begin_record(TAG_FOCUS_WINDOW, 4); self.put_u32(*node_id); } + Mutation::RequestFocus { node_id } => { + self.begin_record(TAG_REQUEST_FOCUS, 4); + self.put_u32(*node_id); + } Mutation::SetWindow(window) => { self.begin_record(TAG_SET_WINDOW, 28); self.put_u16(window.chrome as u16); @@ -1777,6 +1786,9 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { TAG_FOCUS_WINDOW if len == 8 => Mutation::FocusWindow { node_id: read_u32(bytes, payload)?, }, + TAG_REQUEST_FOCUS if len == 8 => Mutation::RequestFocus { + node_id: read_u32(bytes, payload)?, + }, TAG_SET_WINDOW if len == 32 => { let chrome = read_u16(bytes, payload)?; let title_bar = read_u16(bytes, payload + 2)?; @@ -1945,7 +1957,7 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { | TAG_START_ANIMATION | TAG_CONTROL_ANIMATION | TAG_SET_SELECTION..=TAG_SET_FOLDED - | TAG_SET_WINDOW_THEME..=TAG_FOCUS_WINDOW => { + | TAG_SET_WINDOW_THEME..=TAG_REQUEST_FOCUS => { return Err(ProtocolError::InvalidRecordLength); } other => return Err(ProtocolError::InvalidTag(other)), @@ -3152,4 +3164,85 @@ mod tests { Some(8) ); } + + #[test] + fn fr65_request_focus_round_trips_in_eight_bytes() { + for node_id in [0_u32, 7, u32::MAX] { + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::RequestFocus { node_id }).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + assert_eq!(bytes.len(), ENVELOPE_LEN + 8); + assert_eq!(read_u16(&bytes, ENVELOPE_LEN).unwrap(), TAG_REQUEST_FOCUS); + assert_eq!(read_u16(&bytes, ENVELOPE_LEN + 2).unwrap(), 8); + assert_eq!(read_u32(&bytes, ENVELOPE_LEN + 4).unwrap(), node_id); + assert_eq!( + decode_batch(&bytes).unwrap(), + vec![Mutation::RequestFocus { node_id }] + ); + } + let mut encoder = BatchEncoder::default(); + encoder + .encode(&Mutation::RequestFocus { node_id: 7 }) + .unwrap(); + let mut bad = encoder.finish().unwrap().to_vec(); + bad.extend_from_slice(&[0; 4]); + let records_len = u32::try_from(bad.len()).unwrap(); + bad[4..8].copy_from_slice(&records_len.to_le_bytes()); + bad[ENVELOPE_LEN + 2..ENVELOPE_LEN + 4].copy_from_slice(&12_u16.to_le_bytes()); + assert_eq!(decode_batch(&bad), Err(ProtocolError::InvalidRecordLength)); + } + + #[test] + fn fr65_text_field_on_composition_change_delivers_bool() { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let calls = Arc::new(AtomicUsize::new(0)); + let calls_for_root = Arc::clone(&calls); + let mut host = crate::ComposeHost::with_content(move || { + let calls_inner = Arc::clone(&calls_for_root); + crate::ui::TextField().on_composition_change(move |composing| { + calls_inner.fetch_add(if composing { 10 } else { 1 }, Ordering::SeqCst); + }); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + let prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnCompositionChange, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_composition_change must emit OnCompositionChange"); + let mut wire = Vec::new(); + encode_event( + &HostEvent { + node_id: prop.0, + handler_id: prop.1, + payload: crate::EventPayload::CompositionChanged { composing: true }, + }, + &mut wire, + ) + .unwrap(); + host.dispatch_event(&wire).unwrap(); + encode_event( + &HostEvent { + node_id: prop.0, + handler_id: prop.1, + payload: crate::EventPayload::CompositionChanged { composing: false }, + }, + &mut wire, + ) + .unwrap(); + host.dispatch_event(&wire).unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 11); + drop(seen); + } } diff --git a/compose-rust/src/runtime/host.rs b/compose-rust/src/runtime/host.rs index b859c2a5..293ab9f0 100644 --- a/compose-rust/src/runtime/host.rs +++ b/compose-rust/src/runtime/host.rs @@ -382,6 +382,9 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result { (*handler.borrow_mut())(*value) } + (EventCallback::Value(handler), EventPayload::CompositionChanged { composing }) => { + (*handler.borrow_mut())(if *composing { 1.0 } else { 0.0 }) + } (EventCallback::Range(handler), EventPayload::RangeRequested { start, count }) => { (*handler.borrow_mut())(RangeRequest::new(*start, *count)) } diff --git a/compose-rust/src/runtime/seam.rs b/compose-rust/src/runtime/seam.rs index 3a58880b..215d19cc 100644 --- a/compose-rust/src/runtime/seam.rs +++ b/compose-rust/src/runtime/seam.rs @@ -240,6 +240,11 @@ impl Batch { pub fn append_text_node(&mut self, node_id: u32, text: &str) { self.write(Mutation::AppendText { node_id, text }); } + + /// Requests input focus for `node_id`, or clears focus when `node_id` is 0. + pub fn request_focus(&mut self, node_id: u32) { + self.write(Mutation::RequestFocus { node_id }); + } } /// What builds and changes the tree, seen from the boundary. diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 0fd9cd79..c43f153c 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -188,7 +188,7 @@ pub const SCHEMA_DESCRIPTOR: &str = concat!( "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", - "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow" + "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus" ); const fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 964d9588..870f2c4a 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -873,6 +873,7 @@ pub struct TextFieldScope { on_submit: Option>, on_focus_lost: Option>, on_key_down: Option>, + on_composition_change: Option>, done: bool, } @@ -892,6 +893,7 @@ pub fn TextField() -> TextFieldScope { on_submit: None, on_focus_lost: None, on_key_down: None, + on_composition_change: None, done: false, } } @@ -955,6 +957,12 @@ impl TextFieldScope { self } + /// Reports when an input method begins or ends an active composition in the field. + pub fn on_composition_change(mut self, handler: impl FnMut(bool) + 'static) -> Self { + self.on_composition_change = Some(std::boxed::Box::new(handler)); + self + } + fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let text = std::mem::take(&mut self.text); @@ -965,12 +973,20 @@ impl TextFieldScope { Some(handler) => EventCallback::Key(Rc::new(RefCell::new(handler))), None => EventCallback::Key(Rc::new(RefCell::new(|_: KeyEvent| {}))), }; - let listeners = vec![ + let mut listeners = vec![ ("onvaluechange", text_handler(self.on_value_change.take())), ("onsubmit", text_handler(self.on_submit.take())), ("onfocuslost", unit(self.on_focus_lost.take())), ("onkeydown", key_down), ]; + if let Some(mut handler) = self.on_composition_change.take() { + listeners.push(( + "oncompositionchange", + EventCallback::Value(Rc::new(RefCell::new(move |value: f64| { + handler(value != 0.0); + }))), + )); + } emit( widget_key("compose_rust::ui::TextField"), WidgetKind::TextField, diff --git a/compose-rust/src/writer.rs b/compose-rust/src/writer.rs index 069994cb..59685b5a 100644 --- a/compose-rust/src/writer.rs +++ b/compose-rust/src/writer.rs @@ -849,6 +849,7 @@ pub(crate) fn event_property(name: &str) -> Option { "windowunavailable" | "onwindowunavailable" => Some(PropertyKind::OnWindowUnavailable), "windowclosed" | "onwindowclosed" => Some(PropertyKind::OnWindowClosed), "windowdragover" | "onwindowdragover" => Some(PropertyKind::OnWindowDragOver), + "compositionchange" | "oncompositionchange" => Some(PropertyKind::OnCompositionChange), "change" | "onchange" => Some(PropertyKind::OnValueChange), _ => None, } diff --git a/compose-rust/tests/vectors/mutations.bin b/compose-rust/tests/vectors/mutations.bin index 40c38e379d686e7d9a207d7151ca01316816d69e..da5447e3d2a36b3fb9cba3a4209b1c8989846639 100644 GIT binary patch delta 376 zcmX@Fd0mr}fq{oXMUa6ZeIlnZqsl~Ec}AO!vE@vXPCzks1_lO3AZ7t#4h8`*?K$}^ zlRTsUWL{=v#=yyj%)yM|lUteH8KWkjWtPs26JlThB8CDW0n)_;#C!}E3?)E5&?<&n zAT0@`rvYhkAiWkyvjOSNPJqs&BGc}A0svE@vXRzNX!1_lO3AZ7t#4h8`*?Kt@@ zlRTsQWL{=vM$gHH%)yNQlUteH8G|ODWtPqi6JlThB8Chg0n)_;#C!}E3^_nP&?<&f zAT0@``+&4KkX{O;*?{zFD7_I#g8&l{@&jE4;)C>o>}Fz+0GsE)z{;>|b0EuNPDv(V z1_l+d29OpBpauqp6ri-)WL`dT#s`yC`Lr1&CI|8fOUj5aFesp@%mJ#jnOw`4%{XE5 zOFm`BX_I;R#YGnYRdOJy1acW>OjhI^xu~?U;O)U!QT` { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the diff --git a/renderer/desktop/src/renderer/CodeEditorSurface.kt b/renderer/desktop/src/renderer/CodeEditorSurface.kt index 6d87e935..ea927a74 100644 --- a/renderer/desktop/src/renderer/CodeEditorSurface.kt +++ b/renderer/desktop/src/renderer/CodeEditorSurface.kt @@ -194,16 +194,17 @@ fun CodeEditorSurface( callbacks: CodeEditorCallbacks, modifier: Modifier = Modifier, path: CodeEditorPath = defaultCodeEditorPath, + focusRequest: Long = 0L, ) { when (path) { - CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false) - CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true) + CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) + CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true, focusRequest = focusRequest) CodeEditorPath.Drawn -> { val drawn = drawnCodeEditorSurface if (drawn != null) { drawn(model, look, callbacks, modifier) } else { - FieldSurface(model, look, callbacks, modifier, whole = false) + FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) } } } @@ -784,6 +785,7 @@ private fun FieldSurface( callbacks: CodeEditorCallbacks, modifier: Modifier, whole: Boolean, + focusRequest: Long = 0L, ) { val density = LocalDensity.current val rowPx = remember(look.textStyle, density) { rowHeightPx(look.textStyle, density) } @@ -806,6 +808,9 @@ private fun FieldSurface( // reader itself reads the field's layout state, so what draws from it still follows. val layoutHolder = remember { arrayOfNulls<() -> TextLayoutResult?>(1) } val focus = remember { FocusRequester() } + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focus.requestFocus() + } // Read here so the row map and the gutter follow every change of text and overlay. val contentRevision = model.contentRevision diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index bcf08d84..9d52f268 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.runtime.mutableStateOf @@ -371,6 +372,11 @@ private fun ComposeRustTree( // however many windows there are. val windowNodeId = LocalWindowNodeId.current val pageRoots = if (windowNodeId == 0) host.roots else host.table.node(windowNodeId)?.children?.toList().orEmpty() + val focusManager = LocalFocusManager.current + val clearFocusRevision = host.table.focusClearRevision + LaunchedEffect(clearFocusRevision) { + if (clearFocusRevision > 0L) focusManager.clearFocus() + } LaunchedEffect(host, frames, windowNodeId, servesFrames) { if (windowNodeId != 0 || !servesFrames) return@LaunchedEffect // Counted from the Host's start, not from the moment this loop began listening. The diff --git a/renderer/desktop/src/renderer/HostCodeEditor.kt b/renderer/desktop/src/renderer/HostCodeEditor.kt index 5f5399d2..1d698958 100644 --- a/renderer/desktop/src/renderer/HostCodeEditor.kt +++ b/renderer/desktop/src/renderer/HostCodeEditor.kt @@ -73,7 +73,8 @@ internal fun HostCodeEditor( }, ) } - CodeEditorSurface(model, look, callbacks, modifier) + val focusRequest = node.focusRequestRevision + CodeEditorSurface(model, look, callbacks, modifier, focusRequest = focusRequest) } /** What the design system says an editor looks like here, resolved to plain values. */ diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 0408e2a4..0306bfdc 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key @@ -88,6 +90,8 @@ internal fun HostTextField( } var appliedHostRevision by remember(nodeId) { mutableStateOf(0L) } var focused by remember(nodeId) { mutableStateOf(false) } + var reportedComposing by remember(nodeId) { mutableStateOf(false) } + val focusRequester = remember(nodeId) { FocusRequester() } val multiline = node.flag(PropertyKind.Multiline, default = false) val enabled = node.flag(PropertyKind.Enabled, default = true) @@ -95,8 +99,10 @@ internal fun HostTextField( val submitHandler = node.handler(PropertyKind.OnSubmit) val keyDownHandler = node.handler(PropertyKind.OnKeyDown) val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + val compositionHandler = node.handler(PropertyKind.OnCompositionChange) val placeholder = node.text(PropertyKind.Placeholder) val hostText = node.hostText + val focusRequest = node.focusRequestRevision val composing = value.composition != null // The field takes its type role and its colours from the design system, the same way a // Text does. @@ -130,6 +136,24 @@ internal fun HostTextField( } } + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focusRequester.requestFocus() + } + + val applyValueChange: (TextFieldValue) -> Unit = { next -> + val nowComposing = next.composition != null + value = next + if (nowComposing != reportedComposing) { + reportedComposing = nowComposing + if (compositionHandler != null) { + dispatcher.dispatch( + HostEvent.CompositionChanged(nodeId, compositionHandler, nowComposing), + ) + } + } + } + hostTextFieldValueHook?.invoke(applyValueChange) + // Change notification, at most one a frame. // // An edit is told to the Host on the next frame, carrying the latest text, so keys that @@ -167,6 +191,7 @@ internal fun HostTextField( } val inputModifier = modifier + .focusRequester(focusRequester) .onFocusChanged { state -> if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) @@ -254,7 +279,7 @@ internal fun HostTextField( BasicTextField( value = value, - onValueChange = { value = it }, + onValueChange = applyValueChange, modifier = inputModifier, enabled = enabled, singleLine = !multiline, @@ -310,6 +335,12 @@ internal fun HostTextField( */ private const val PLACEHOLDER_ALPHA = 0.55f +/** + * Test hook that receives the value-change callback of the mounted [HostTextField] so a test + * can drive an IME composition transition directly through `TextFieldValue.composition`. + */ +internal var hostTextFieldValueHook: (((TextFieldValue) -> Unit) -> Unit)? = null + /** The box around a field, or nothing where the system draws none in this state. */ private fun Modifier.fieldBorder(style: FieldStyle, focused: Boolean): Modifier { val width = if (focused) style.borderWidthFocused else style.borderWidth diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 22296a8a..29ccc2a8 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -46,6 +46,7 @@ class Node internal constructor(val id: Int, val widget: WidgetKind) { internal val children = mutableStateListOf() internal var parentId: Int = NodeTable.ROOT_ID internal var hostText by mutableStateOf(null) + internal var focusRequestRevision by mutableStateOf(0L) /** * A code editor's document and everything over it. Created with the node and kept for @@ -167,6 +168,10 @@ class NodeTable { private var revision = 0L + /** Increments on each `RequestFocus(0)` so the root composition clears active focus. */ + var focusClearRevision by mutableStateOf(0L) + private set + /** Null until the Host sends its first `SetTheme` record. */ var theme: Theme? by mutableStateOf(null) private set @@ -267,6 +272,7 @@ class NodeTable { window = null materials = 0 revision = 0 + focusClearRevision = 0 } fun apply(mutation: Mutation) { @@ -301,6 +307,7 @@ class NodeTable { // the batch. is Mutation.SetWindowTheme -> setWindowTheme(mutation) is Mutation.FocusWindow -> focusWindow(mutation) + is Mutation.RequestFocus -> requestFocus(mutation) // The bytes are read here, once, and never again: what a frame carries is the // id. A kind this Renderer cannot read, or a release of something that was // never registered, is reported and the rest of the batch still applies. @@ -387,6 +394,20 @@ class NodeTable { focusRequests += mutation.nodeId } + private fun requestFocus(mutation: Mutation.RequestFocus) { + if (mutation.nodeId == ROOT_ID) { + revision += 1 + focusClearRevision = revision + return + } + val node = nodes[mutation.nodeId] ?: return fail( + TableError.UNKNOWN_NODE, + "RequestFocus for unknown node ${mutation.nodeId}", + ) + revision += 1 + node.focusRequestRevision = revision + } + /** The code editor a command names, or null after reporting that it names none. */ private fun codeNode(nodeId: Int, command: String): Pair? { val node = nodes[nodeId] ?: run { @@ -977,9 +998,11 @@ class NodeTable { PropertyKind.OnVisibleRangeChange, PropertyKind.OnEditorFocusChange, PropertyKind.OnFoldChange, - PropertyKind.OnCompositionChange, -> widget == WidgetKind.CodeEditor + PropertyKind.OnCompositionChange -> + widget == WidgetKind.CodeEditor || widget == WidgetKind.TextField + // Files over a node and files let go on it. Only the widget that exists // to receive them, because a handler is attached whether or not a screen // supplied one: on any container these would cost every container in the diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index e2dcaef2..69495546 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText @@ -22,6 +23,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key @@ -199,7 +202,34 @@ fun RenderNode( node.pressedShape(theme), ) } - val modifier = platformFileDrop(pressable, node, dispatcher) + val focusRequest = node.focusRequestRevision + val focusRequester = if (focusRequest > 0L && + node.widget != WidgetKind.TextField && + node.widget != WidgetKind.CodeEditor + ) { + remember(nodeId) { FocusRequester() } + } else { + null + } + if (focusRequester != null) { + LaunchedEffect(focusRequest) { + focusRequester.requestFocus() + } + } + val focusableModifier = if (focusRequester == null) { + pressable + } else { + val withRequester = pressable.focusRequester(focusRequester) + if (clickHandler == null && + node.widget in CLICKABLE_CONTAINERS && + node.modifiers.none { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + ) { + withRequester.focusable() + } else { + withRequester + } + } + val modifier = platformFileDrop(focusableModifier, node, dispatcher) // A layout made of glass tells what is inside it so. A field in a glass bar is part of // the bar in the systems that draw glass, and it can only take that shape if it knows // what it is sitting on. diff --git a/renderer/desktop/test/ProtocolVectorsTest.kt b/renderer/desktop/test/ProtocolVectorsTest.kt index 06c5cc09..5c434fa5 100644 --- a/renderer/desktop/test/ProtocolVectorsTest.kt +++ b/renderer/desktop/test/ProtocolVectorsTest.kt @@ -103,6 +103,7 @@ class ProtocolVectorsTest { // A window's theme and bringing one forward name a node the vector created. is Mutation.SetWindowTheme -> mutation.nodeId !in created is Mutation.FocusWindow -> mutation.nodeId !in created + is Mutation.RequestFocus -> mutation.nodeId !in created && mutation.nodeId != 0 is Mutation.Create -> false is Mutation.RegisterAsset -> true is Mutation.ReleaseAsset -> true diff --git a/renderer/desktop/test/TextFieldTest.kt b/renderer/desktop/test/TextFieldTest.kt index 1dfaef2c..4dd660fd 100644 --- a/renderer/desktop/test/TextFieldTest.kt +++ b/renderer/desktop/test/TextFieldTest.kt @@ -247,4 +247,124 @@ class TextFieldTest { val height = onNodeWithTag(nodeTestTag(FIELD)).fetchSemanticsNode().boundsInRoot.height / density.density assertEquals(400f, height, 1f, "multiline field with FillMaxHeight must fill its 400.dp container") } + + @Test + fun fr65_request_focus_moves_and_clears_focus() = runComposeUiTest { + val column = 10 + val second = 2 + val focusLostFirst = 31L + val focusLostSecond = 32L + val connection = FakeHostConnection( + listOf( + Mutation.Create(column, WidgetKind.Column), + Mutation.Create(FIELD, WidgetKind.TextField), + Mutation.SetProp(FIELD, PropertyKind.OnFocusLost, PropertyValue.Integer(focusLostFirst)), + Mutation.Insert(column, FIELD, 0), + Mutation.Create(second, WidgetKind.TextField), + Mutation.SetProp(second, PropertyKind.OnFocusLost, PropertyValue.Integer(focusLostSecond)), + Mutation.Insert(column, second, 1), + ), + ) + lateinit var host: ComposeRustHost + setContent { host = rememberComposeRustHost(connection); ComposeRustContent(host) } + waitForIdle() + + connection.respondWith { HostResponse(listOf(Mutation.RequestFocus(FIELD))) } + host.dispatch(HostEvent.Clicked(column, 0)) + waitForIdle() + + onNodeWithTag(nodeTestTag(FIELD)).performTextInput("a") + waitForIdle() + onNodeWithTag(nodeTestTag(FIELD)).assertTextEquals("a") + + connection.respondWith { HostResponse(listOf(Mutation.RequestFocus(second))) } + host.dispatch(HostEvent.Clicked(column, 0)) + waitForIdle() + + assertEquals( + listOf(HostEvent.FocusLost(FIELD, focusLostFirst)), + connection.events.filterIsInstance(), + ) + + connection.respondWith { HostResponse(listOf(Mutation.RequestFocus(0))) } + host.dispatch(HostEvent.Clicked(column, 0)) + waitForIdle() + + assertEquals( + listOf( + HostEvent.FocusLost(FIELD, focusLostFirst), + HostEvent.FocusLost(second, focusLostSecond), + ), + connection.events.filterIsInstance(), + ) + } + + @Test + fun fr65_request_focus_unknown_node_reports_error() = runComposeUiTest { + val connection = FakeHostConnection( + field(Mutation.RequestFocus(999)), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + + val errors = connection.events.filterIsInstance() + assertEquals(1, errors.size, "events were ${connection.events}") + assertEquals(dev.darkpyonix.composerust.ui.node.TableError.UNKNOWN_NODE, errors.single().code) + } + + @Test + fun fr65_text_field_reports_composition_changes() = runComposeUiTest { + val compositionHandler = 41L + var editorUpdate: ((androidx.compose.ui.text.input.TextFieldValue) -> Unit)? = null + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = { update -> editorUpdate = update } + try { + val connection = FakeHostConnection( + field(Mutation.SetProp(FIELD, PropertyKind.OnCompositionChange, PropertyValue.Integer(compositionHandler))), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + + val update = requireNotNull(editorUpdate) { "TextField did not install value hook" } + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "ㅎ", + selection = androidx.compose.ui.text.TextRange(1), + composition = androidx.compose.ui.text.TextRange(0, 1), + ), + ) + } + waitForIdle() + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "한", + selection = androidx.compose.ui.text.TextRange(1), + composition = androidx.compose.ui.text.TextRange(0, 1), + ), + ) + } + waitForIdle() + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "한", + selection = androidx.compose.ui.text.TextRange(1), + composition = null, + ), + ) + } + waitForIdle() + + assertEquals( + listOf( + HostEvent.CompositionChanged(FIELD, compositionHandler, true), + HostEvent.CompositionChanged(FIELD, compositionHandler, false), + ), + connection.events.filterIsInstance(), + ) + } finally { + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = null + } + } } From 0a6731197722596f9a2f7ce736015ba4b60e996c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:39:05 +0900 Subject: [PATCH 103/224] Fix: A weighted Row or Column child keeps room for its longest word (FR-70, #427) A weighted child gets the larger of its share and its min-content size on the main axis, and the Korean word boundary counts for plain wrapping text. Lazy lists and other subcompose layouts keep the stock weight, because they cannot be asked for intrinsics. Tests: fr70_unweighted_containers_wrap_their_content, fr70_weighted_text_keeps_room_for_the_longest_word; fr8 weighted list test unchanged. --- renderer/desktop/src/renderer/HostRichText.kt | 28 ++- renderer/desktop/src/renderer/RenderNode.kt | 195 +++++++++++++++++- .../desktop/src/renderer/TextResolution.kt | 3 +- renderer/desktop/test/RowContentSizeTest.kt | 132 ++++++++++++ 4 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 renderer/desktop/test/RowContentSizeTest.kt diff --git a/renderer/desktop/src/renderer/HostRichText.kt b/renderer/desktop/src/renderer/HostRichText.kt index 3393b22e..4bd48f00 100644 --- a/renderer/desktop/src/renderer/HostRichText.kt +++ b/renderer/desktop/src/renderer/HostRichText.kt @@ -6,6 +6,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.LinkAnnotation @@ -14,11 +20,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.MultiParagraphIntrinsics import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.SpanRecords import dev.darkpyonix.composerust.protocol.TypeRole +import dev.darkpyonix.composerust.protocol.WordBreak +import dev.darkpyonix.composerust.protocol.OverflowWrap import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.ui.node.AssetCache import dev.darkpyonix.composerust.ui.node.Node @@ -126,12 +135,13 @@ internal fun HostRichText( ) { val input = node.textInput() val density = textDensity(input, LocalDensity.current) + val fontResolver = LocalFontFamilyResolver.current val resolved = resolveText( input, theme, assets, density, - LocalFontFamilyResolver.current, + fontResolver, link = { run -> // A link is a press on a range, and a press is the event the boundary already // has. No new event tag for a thing that is already a click. @@ -143,6 +153,10 @@ internal fun HostRichText( ) }, ) + val wordFloor = if (input.softWrap && (input.wordBreak == null || input.wordBreak == WordBreak.Normal) && input.overflowWrap != OverflowWrap.Anywhere) { + val words = resolveText(input, theme, assets, density, fontResolver, intrinsicWordBreak = WordBreak.KeepAll) + kotlin.math.ceil(MultiParagraphIntrinsics(words.text, words.style, words.placeholders, density, fontResolver).minIntrinsicWidth).toInt() + } else 0 val runs = node.runsDecodeProblem() ?: resolved.runsProblem val fonts = node.fontListProblem() ?: resolved.problems.firstOrNull() val problem = when { @@ -156,7 +170,7 @@ internal fun HostRichText( CompositionLocalProvider(LocalDensity provides density) { BasicText( text = resolved.text, - modifier = modifier, + modifier = modifier.then(WordFloor(wordFloor)), style = resolved.style, overflow = resolved.overflow, softWrap = resolved.softWrap, @@ -167,6 +181,16 @@ internal fun HostRichText( } } +private class WordFloor(private val width: Int) : LayoutModifier { + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = + measurable.measure(constraints).let { placeable -> + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int = + maxOf(width, measurable.minIntrinsicWidth(height)) +} + /** * Says a set of runs is wrong, and goes on drawing the string without them. * diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 81070ee9..799cc701 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -1,5 +1,6 @@ package dev.darkpyonix.composerust.ui.node +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -27,6 +28,10 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable @@ -36,6 +41,8 @@ import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.distinctUntilChanged import kotlin.math.roundToInt @@ -202,17 +209,29 @@ fun RenderNode( val glass = node.isMadeOfGlass(theme) ProvideInheritedColor(nodeId, node, table, theme) { when (node.widget) { - WidgetKind.Column -> Column( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.width(IntrinsicSize.Min) else modifier, - verticalArrangement = node.verticalArrangement(theme), - horizontalAlignment = node.horizontalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } - - WidgetKind.Row -> Row( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.height(IntrinsicSize.Min) else modifier, - horizontalArrangement = node.horizontalArrangement(theme), - verticalAlignment = node.verticalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } + WidgetKind.Column, WidgetKind.Row -> { + val horizontal = node.widget == WidgetKind.Row + val sizedModifier = if (node.usesIntrinsicCrossAxis(table)) { + if (horizontal) modifier.height(IntrinsicSize.Min) else modifier.width(IntrinsicSize.Min) + } else modifier + val weights = node.children.map { table.node(it)?.modifiers?.weightOf() } + val intrinsicSafe = node.children.map { child -> !table.hasScrollingDescendant(child) } + if (weights.any { it != null }) { + WeightedStack( + node, table, dispatcher, sizedModifier, horizontal, weights, intrinsicSafe, + node.horizontalArrangement(theme), node.verticalArrangement(theme), + node.horizontalAlignment(), node.verticalAlignment(), glass, + ) + } else if (horizontal) { + Row(sizedModifier, node.horizontalArrangement(theme), node.verticalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } else { + Column(sizedModifier, node.verticalArrangement(theme), node.horizontalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } + } // A place files may be dropped is laid out as a plain box. Being this widget is // the whole of the difference: the drop target is set up for it and for nothing @@ -725,6 +744,160 @@ internal fun OnGlass(glass: Boolean, content: @Composable () -> Unit) { CompositionLocalProvider(LocalGlassDepth provides LocalGlassDepth.current + 1, content = content) } +/** Measures weighted children at least as large as their main-axis minimum intrinsic size. */ +@Composable +private fun WeightedStack( + node: Node, + table: NodeTable, + dispatcher: EventDispatcher, + modifier: Modifier, + horizontal: Boolean, + weights: List, + intrinsicSafe: List, + horizontalArrangement: Arrangement.Horizontal, + verticalArrangement: Arrangement.Vertical, + horizontalAlignment: Alignment.Horizontal, + verticalAlignment: Alignment.Vertical, + glass: Boolean, +) { + val policy = remember(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) { + WeightedStackPolicy(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) + } + Layout( + content = { + OnGlass(glass) { + node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } + } + }, + modifier = modifier.clipToBounds(), + measurePolicy = policy, + ) +} + +private class WeightedStackPolicy( + private val horizontal: Boolean, + private val weights: List, + private val intrinsicSafe: List, + private val horizontalArrangement: Arrangement.Horizontal, + private val verticalArrangement: Arrangement.Vertical, + private val horizontalAlignment: Alignment.Horizontal, + private val verticalAlignment: Alignment.Vertical, +) : MeasurePolicy { + private fun spacing(scope: androidx.compose.ui.unit.Density): Int = with(scope) { + (if (horizontal) horizontalArrangement.spacing else verticalArrangement.spacing).roundToPx() + } + + override fun MeasureScope.measure(measurables: List, constraints: Constraints): MeasureResult { + val mainMax = if (horizontal) constraints.maxWidth else constraints.maxHeight + val mainMin = if (horizontal) constraints.minWidth else constraints.minHeight + val crossMax = if (horizontal) constraints.maxHeight else constraints.maxWidth + val gap = spacing(this) + val places = arrayOfNulls(measurables.size) + var fixed = 0 + val gaps = gap * (measurables.size - 1).coerceAtLeast(0) + fun childConstraints(min: Int, max: Int) = if (horizontal) { + Constraints(minWidth = min, maxWidth = max, maxHeight = crossMax) + } else { + Constraints(minHeight = min, maxHeight = max, maxWidth = crossMax) + } + measurables.forEachIndexed { index, measurable -> + if (weights[index] == null) { + val available = if (mainMax == Constraints.Infinity) mainMax else (mainMax - fixed - gaps).coerceAtLeast(0) + val place = measurable.measure(childConstraints(0, available)) + places[index] = place + fixed += if (horizontal) place.width else place.height + } + } + val target = if (mainMax == Constraints.Infinity) mainMin else mainMax + val remaining = (target - fixed - gaps).coerceAtLeast(0) + val totalWeight = weights.filterNotNull().sum() + var distributed = 0 + var remainingWeight = totalWeight + measurables.forEachIndexed { index, measurable -> + val weight = weights[index] ?: return@forEachIndexed + val share = if (remainingWeight == weight) remaining - distributed else (remaining * weight / totalWeight).roundToInt().coerceAtMost((remaining - distributed).coerceAtLeast(0)) + distributed += share + remainingWeight -= weight + val intrinsic = intrinsic(index) { if (horizontal) measurable.minIntrinsicWidth(crossMax) else measurable.minIntrinsicHeight(crossMax) } + val size = maxOf(share, intrinsic).coerceAtMost(Constraints.Infinity - 1) + places[index] = measurable.measure(childConstraints(size, size)) + } + val children = places.map { it!! } + val sizes = children.map { if (horizontal) it.width else it.height }.toIntArray() + val contentMain = (sizes.fold(0L) { sum, size -> sum + size } + gaps).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val main = if (horizontal) constraints.constrainWidth(contentMain) else constraints.constrainHeight(contentMain) + val contentCross = children.maxOfOrNull { if (horizontal) it.height else it.width } ?: 0 + val cross = if (horizontal) constraints.constrainHeight(contentCross) else constraints.constrainWidth(contentCross) + val positions = IntArray(children.size) + if (horizontal) with(horizontalArrangement) { arrange(main, sizes, layoutDirection, positions) } + else with(verticalArrangement) { arrange(main, sizes, positions) } + return layout(if (horizontal) main else cross, if (horizontal) cross else main) { + children.forEachIndexed { index, place -> + if (horizontal) place.place(positions[index], verticalAlignment.align(place.height, cross)) + else place.place(horizontalAlignment.align(place.width, cross, layoutDirection), positions[index]) + } + } + } + + private fun IntrinsicMeasureScope.mainIntrinsic(children: List, cross: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val sizes = children.mapIndexed { index, child -> intrinsic(index) { + if (horizontal) { + if (min) child.minIntrinsicWidth(cross) else child.maxIntrinsicWidth(cross) + } else { + if (min) child.minIntrinsicHeight(cross) else child.maxIntrinsicHeight(cross) + } + } } + val unit = sizes.indices.filter { weights[it] != null }.maxOfOrNull { sizes[it] / weights[it]!! } ?: 0f + val weighted = (unit * weights.filterNotNull().sum()).roundToInt() + return (sizes.indices.filter { weights[it] == null }.sumOf { sizes[it].toLong() } + weighted + gap) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + private fun IntrinsicMeasureScope.crossIntrinsic(children: List, main: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val fixed = children.indices.filter { weights[it] == null }.map { index -> + val child = children[index] + intrinsic(index) { if (horizontal) child.maxIntrinsicWidth(Constraints.Infinity) else child.maxIntrinsicHeight(Constraints.Infinity) } + }.fold(0L) { sum, value -> sum + value } + val remaining = if (main == Constraints.Infinity) Constraints.Infinity else (main.toLong() - fixed - gap).coerceAtLeast(0).toInt() + val total = weights.filterNotNull().sum() + return children.indices.maxOfOrNull { index -> + val child = children[index] + val weight = weights[index] + val available = if (weight == null || remaining == Constraints.Infinity) Constraints.Infinity + else (remaining * weight / total).roundToInt() + val minimum = intrinsic(index) { if (horizontal) child.minIntrinsicWidth(Constraints.Infinity) else child.minIntrinsicHeight(Constraints.Infinity) } + val size = if (weight == null) available else maxOf(available, minimum) + intrinsic(index) { if (horizontal) { + if (min) child.minIntrinsicHeight(size) else child.maxIntrinsicHeight(size) + } else { + if (min) child.minIntrinsicWidth(size) else child.maxIntrinsicWidth(size) + } } + } ?: 0 + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, true) else crossIntrinsic(measurables, height, true) + override fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, false) else crossIntrinsic(measurables, height, false) + override fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, true) else mainIntrinsic(measurables, width, true) + override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, false) else mainIntrinsic(measurables, width, false) + + private inline fun intrinsic(index: Int, query: () -> Int): Int { + if (!intrinsicSafe[index]) return 0 + return try { query() } catch (_: IllegalStateException) { 0 } + } +} + +private fun NodeTable.hasScrollingDescendant(id: Int): Boolean { + val node = node(id) ?: return false + if (node.widget in setOf(WidgetKind.LazyColumn, WidgetKind.LazyRow, WidgetKind.LazyGrid, WidgetKind.ScrollColumn, WidgetKind.ScrollRow)) return true + return node.children.any { hasScrollingDescendant(it) } +} + /** * Children of a Column: weight is applied here because `Modifier.weight` exists only inside * `ColumnScope`. Every layout that stacks its children vertically draws them through this, diff --git a/renderer/desktop/src/renderer/TextResolution.kt b/renderer/desktop/src/renderer/TextResolution.kt index c284b2b5..b4b5dffd 100644 --- a/renderer/desktop/src/renderer/TextResolution.kt +++ b/renderer/desktop/src/renderer/TextResolution.kt @@ -143,6 +143,7 @@ internal fun resolveText( * resolving a colour through the design system is the dearest thing done here. */ forMeasure: Boolean = false, + intrinsicWordBreak: WordBreak? = null, ): ResolvedText { val problems = mutableListOf() // Sizes are sp. Text in CSS pixels is not given smaller sp to undo the font scale: @@ -203,7 +204,7 @@ internal fun resolveText( val badRuns = if (input.runs.isEmpty()) null else runsProblem(input.runs, input.text.encodeToByteArray().size) val runs = if (badRuns == null) input.runs else emptyList() - val mode = when (input.wordBreak) { + val mode = when (intrinsicWordBreak ?: input.wordBreak) { WordBreak.KeepAll -> BreakMode.KeepAll WordBreak.BreakAll -> BreakMode.BreakAll WordBreak.Normal, null -> BreakMode.Normal diff --git a/renderer/desktop/test/RowContentSizeTest.kt b/renderer/desktop/test/RowContentSizeTest.kt new file mode 100644 index 00000000..e0a3f325 --- /dev/null +++ b/renderer/desktop/test/RowContentSizeTest.kt @@ -0,0 +1,132 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class RowContentSizeTest { + @Test + fun fr70_unweighted_containers_wrap_their_content() = runComposeUiTest { + val batch = buildList { + add(Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false))) + add(Mutation.Create(1, WidgetKind.Column)) + listOf(WidgetKind.Box, WidgetKind.Row, WidgetKind.Column).forEachIndexed { index, kind -> + val row = 10 + index * 10 + add(Mutation.Create(row, WidgetKind.Row)) + add(Mutation.SetModifier(row, 0, ProtocolModifier.Width(300f))) + add(Mutation.Create(row + 1, WidgetKind.Column)) + add(Mutation.SetModifier(row + 1, 0, ProtocolModifier.Weight(1f))) + add(Mutation.Create(row + 2, kind)) + add(Mutation.Create(row + 3, WidgetKind.Text)) + add(Mutation.SetProp(row + 3, PropertyKind.Text, PropertyValue.Text("Switch"))) + add(Mutation.Insert(row + 2, row + 3, 0)) + add(Mutation.Insert(row, row + 1, 0)) + add(Mutation.Insert(row, row + 2, 1)) + add(Mutation.Insert(1, row, index)) + } + } + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + for (row in listOf(10, 20, 30)) { + val container = onNodeWithTag(nodeTestTag(row + 2)).fetchSemanticsNode().size.width + val text = onNodeWithTag(nodeTestTag(row + 3)).fetchSemanticsNode().size.width + val width = onNodeWithTag(nodeTestTag(row)).fetchSemanticsNode().size.width + assertEquals(width, onNodeWithTag(nodeTestTag(row + 1)).fetchSemanticsNode().size.width + container) + assertEquals(text, container, "an unweighted container wraps its content") + assertTrue(container < width) + } + } + + @Test + fun fr70_weighted_text_keeps_room_for_the_longest_word() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + Mutation.Create(1, WidgetKind.Column), + Mutation.Create(2, WidgetKind.Row), + Mutation.SetModifier(2, 0, ProtocolModifier.Width(300f)), + Mutation.Create(3, WidgetKind.Column), + Mutation.SetModifier(3, 0, ProtocolModifier.Weight(1f)), + Mutation.Create(4, WidgetKind.Text), + Mutation.SetProp(4, PropertyKind.Text, PropertyValue.Text("가나다라마바사 다른 문장")), + Mutation.Create(5, WidgetKind.Box), + Mutation.SetModifier(5, 0, ProtocolModifier.Width(290f)), + Mutation.Create(6, WidgetKind.Text), + Mutation.SetProp(6, PropertyKind.Text, PropertyValue.Text("가나다라마바사")), + Mutation.Insert(3, 4, 0), + Mutation.Insert(2, 3, 0), + Mutation.Insert(2, 5, 1), + Mutation.Insert(1, 2, 0), + Mutation.Insert(1, 6, 1), + ) + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + val columnWidth = onNodeWithTag(nodeTestTag(3)).fetchSemanticsNode().size.width + val wordWidth = onNodeWithTag(nodeTestTag(6)).fetchSemanticsNode().size.width + assertTrue(columnWidth >= wordWidth, "weighted text has room for its longest word") + } + @Test + fun fr70_weighted_column_child_keeps_minimum_intrinsic_height() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + Mutation.Create(1, WidgetKind.Column), + Mutation.SetModifier(1, 0, ProtocolModifier.Height(300f)), + Mutation.Create(2, WidgetKind.Box), + Mutation.SetModifier(2, 0, ProtocolModifier.Weight(1f)), + Mutation.Create(3, WidgetKind.Box), + Mutation.SetModifier(3, 0, ProtocolModifier.Height(80f)), + Mutation.Create(4, WidgetKind.Box), + Mutation.SetModifier(4, 0, ProtocolModifier.Height(290f)), + Mutation.Insert(2, 3, 0), + Mutation.Insert(1, 2, 0), + Mutation.Insert(1, 4, 1), + ) + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + val weighted = onNodeWithTag(nodeTestTag(2)).fetchSemanticsNode().size.height + val content = onNodeWithTag(nodeTestTag(3)).fetchSemanticsNode().size.height + assertEquals(content, weighted, "weighted child retains its intrinsic height") + } + + @Test + fun fr70_weighted_children_share_remaining_space() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + Mutation.Create(1, WidgetKind.Row), + Mutation.SetModifier(1, 0, ProtocolModifier.Width(300f)), + Mutation.Create(2, WidgetKind.Box), + Mutation.SetModifier(2, 0, ProtocolModifier.Weight(1f)), + Mutation.Create(3, WidgetKind.Box), + Mutation.SetModifier(3, 0, ProtocolModifier.Weight(2f)), + Mutation.Create(4, WidgetKind.Box), + Mutation.SetModifier(4, 0, ProtocolModifier.Width(60f)), + Mutation.Insert(1, 2, 0), + Mutation.Insert(1, 3, 1), + Mutation.Insert(1, 4, 2), + ) + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + val first = onNodeWithTag(nodeTestTag(2)).fetchSemanticsNode().size.width + val second = onNodeWithTag(nodeTestTag(3)).fetchSemanticsNode().size.width + val fixed = onNodeWithTag(nodeTestTag(4)).fetchSemanticsNode().size.width + val row = onNodeWithTag(nodeTestTag(1)).fetchSemanticsNode().size.width + assertEquals(row - fixed, first + second) + assertTrue(kotlin.math.abs(second - 2 * first) <= 2) + } + +} From b0ab2258b4a66f310912b66682d9ebf3c7f9f2d4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:51:29 +0900 Subject: [PATCH 104/224] Feat: Apply per-window color scheme on Linux (FR-53.3, #401) --- renderer/linux/src/LinuxWindow.kt | 18 +++- renderer/linux/src/LinuxWindowBackend.kt | 7 ++ renderer/linux/test/LinuxMultiWindowTest.kt | 104 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/renderer/linux/src/LinuxWindow.kt b/renderer/linux/src/LinuxWindow.kt index c413470e..6c4f0a28 100644 --- a/renderer/linux/src/LinuxWindow.kt +++ b/renderer/linux/src/LinuxWindow.kt @@ -7,6 +7,7 @@ package dev.darkpyonix.composerust.ui.platform import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -18,9 +19,12 @@ import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.runtime.LocalSystemDarkObserver import java.lang.System import org.thisisthepy.compose.window.CaretRect import org.thisisthepy.compose.window.FrameClock +import org.thisisthepy.compose.window.SystemTheme import org.thisisthepy.compose.window.WindowConfig import org.thisisthepy.compose.window.WindowEvent import org.thisisthepy.compose.window.WindowFrames @@ -67,6 +71,14 @@ internal class LinuxWindow private constructor( private val reportFrames = System.getenv("DXC_REPORT_FRAMES") != null private val reportInput = System.getenv("DXC_REPORT_INPUT") != null + internal var colorScheme by mutableStateOf(ColorScheme.FollowSystem) + + internal fun resolvedDark(): Boolean = when (colorScheme) { + ColorScheme.Dark -> true + ColorScheme.Light -> false + ColorScheme.FollowSystem -> x11.systemTheme() == SystemTheme.Dark + } + /** * Whether this window has the keyboard, as the window last said. * @@ -279,7 +291,11 @@ internal class LinuxWindow private constructor( semantics.perform(id, action, argument) fun setContent(content: @Composable () -> Unit) { - scene.setContent(content) + scene.setContent { + CompositionLocalProvider(LocalSystemDarkObserver provides { resolvedDark() }) { + content() + } + } } /** diff --git a/renderer/linux/src/LinuxWindowBackend.kt b/renderer/linux/src/LinuxWindowBackend.kt index 25127452..6435170f 100644 --- a/renderer/linux/src/LinuxWindowBackend.kt +++ b/renderer/linux/src/LinuxWindowBackend.kt @@ -4,6 +4,7 @@ package dev.darkpyonix.composerust.ui.platform import androidx.compose.runtime.Composable import dev.darkpyonix.composerust.protocol.Chrome +import dev.darkpyonix.composerust.protocol.ColorScheme import dev.darkpyonix.composerust.protocol.TitleBar import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine @@ -60,6 +61,7 @@ internal class LinuxWindowBackend( override fun open(config: PlatformWindowConfig, listener: PlatformWindowListener): PlatformWindow? { val window = LinuxWindow.open(windowConfigOf(config), work) ?: return null + window.colorScheme = config.colorScheme window.platformListener = listener window.setContent(content(config.nodeId)) val platform = LinuxPlatformWindow(this, window) @@ -208,6 +210,11 @@ internal class LinuxPlatformWindow( override fun followPointer(anchorX: Float, anchorY: Float): Boolean = x11.followPointer(anchorX, anchorY) + override fun setColorScheme(scheme: ColorScheme) { + window.colorScheme = scheme + window.x11.requestFrame() + } + override suspend fun awaitFrame(onFrame: (Long) -> Unit) { var waiter: ((Long) -> Unit)? = null try { diff --git a/renderer/linux/test/LinuxMultiWindowTest.kt b/renderer/linux/test/LinuxMultiWindowTest.kt index 9845444f..e0cf56a3 100644 --- a/renderer/linux/test/LinuxMultiWindowTest.kt +++ b/renderer/linux/test/LinuxMultiWindowTest.kt @@ -2,6 +2,22 @@ package dev.darkpyonix.composerust.ui.platform +import dev.darkpyonix.composerust.protocol.Backdrop +import dev.darkpyonix.composerust.protocol.Chrome +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.LastWindowClosed +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.protocol.Window +import dev.darkpyonix.composerust.protocol.WindowMode +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.LocalSystemDarkObserver +import dev.darkpyonix.composerust.tooling.FakeHostConnection import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -190,4 +206,92 @@ class LinuxMultiWindowTest { backend.closeAll() } } + + @Test + fun fr53_linux_color_scheme_overrides_one_window_only() { + if (!hasDisplay()) return + val darkByWindow = mutableMapOf() + val work = mainForTests() + val backend = LinuxWindowBackend(work) { nodeId -> + { + darkByWindow[nodeId] = LocalSystemDarkObserver.current?.invoke() + } + } + val initial = listOf( + Mutation.SetWindow( + Window( + chrome = Chrome.Modern, + titleBar = TitleBar.Normal, + title = "", + width = 0, + height = 0, + minWidth = 0, + minHeight = 0, + resizable = true, + backdrop = Backdrop.None, + mode = WindowMode.Windows, + lastWindowClosed = LastWindowClosed.KeepRunning, + ), + ), + Mutation.Create(10, WidgetKind.Window), + Mutation.SetProp(10, PropertyKind.WindowTitle, PropertyValue.Text("Window A")), + Mutation.SetProp(10, PropertyKind.WindowSize, PropertyValue.Pair(300f, 200f)), + Mutation.Insert(0, 10, -1), + Mutation.SetWindowTheme(10, Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Dark, false)), + Mutation.Create(20, WidgetKind.Window), + Mutation.SetProp(20, PropertyKind.WindowTitle, PropertyValue.Text("Window B")), + Mutation.SetProp(20, PropertyKind.WindowSize, PropertyValue.Pair(300f, 200f)), + Mutation.Insert(0, 20, -1), + Mutation.SetWindowTheme(20, Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + ) + val conn = FakeHostConnection(initial) + val host = ComposeRustHost(conn) + host.start() + val manager = WindowManager(host, backend, onExit = {}) + try { + manager.attach() + assertTrue( + backend.turnsUntil { darkByWindow[10] != null && darkByWindow[20] != null }, + "both windows did not render", + ) + assertEquals(true, darkByWindow[10]) + assertEquals(false, darkByWindow[20]) + + conn.scheduleFrame( + listOf( + Mutation.SetWindowTheme( + 10, + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false), + ), + ), + ) + host.renderFrame(1L) + assertTrue( + backend.turnsUntil { darkByWindow[10] == false }, + "window A did not update to light", + ) + assertEquals(false, darkByWindow[10]) + assertEquals(false, darkByWindow[20]) + + conn.scheduleFrame( + listOf( + Mutation.SetWindowTheme( + 20, + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Dark, false), + ), + ), + ) + host.renderFrame(2L) + assertTrue( + backend.turnsUntil { darkByWindow[20] == true }, + "window B did not update to dark", + ) + assertEquals(false, darkByWindow[10]) + assertEquals(true, darkByWindow[20]) + } finally { + manager.shutdown() + backend.closeAll() + host.shutdown() + } + } } From 01adbc20c85da615d443d7731642e6ac01e46f2e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:51:34 +0900 Subject: [PATCH 105/224] Fix: Match the Window record in the Linux color test (FR-53.3, #401) --- renderer/linux/test/LinuxMultiWindowTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/renderer/linux/test/LinuxMultiWindowTest.kt b/renderer/linux/test/LinuxMultiWindowTest.kt index e0cf56a3..4d6c8427 100644 --- a/renderer/linux/test/LinuxMultiWindowTest.kt +++ b/renderer/linux/test/LinuxMultiWindowTest.kt @@ -2,7 +2,6 @@ package dev.darkpyonix.composerust.ui.platform -import dev.darkpyonix.composerust.protocol.Backdrop import dev.darkpyonix.composerust.protocol.Chrome import dev.darkpyonix.composerust.protocol.ColorScheme import dev.darkpyonix.composerust.protocol.DesignSystem @@ -208,7 +207,7 @@ class LinuxMultiWindowTest { } @Test - fun fr53_linux_color_scheme_overrides_one_window_only() { + fun fr53_53_3_linux_color_scheme_overrides_one_window_only() { if (!hasDisplay()) return val darkByWindow = mutableMapOf() val work = mainForTests() @@ -223,12 +222,13 @@ class LinuxMultiWindowTest { chrome = Chrome.Modern, titleBar = TitleBar.Normal, title = "", + icon = 0, width = 0, height = 0, minWidth = 0, minHeight = 0, resizable = true, - backdrop = Backdrop.None, + zoomLevel = null, mode = WindowMode.Windows, lastWindowClosed = LastWindowClosed.KeepRunning, ), From b99616b21dbe047679d031753dba58ad8384a35c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:51:51 +0900 Subject: [PATCH 106/224] Feat: Zoom each Linux window on its own (FR-53.3, #404) --- renderer/linux/src/LinuxWindow.kt | 49 ++++++++++++-- renderer/linux/src/LinuxWindowBackend.kt | 7 +- renderer/linux/test/LinuxMultiWindowTest.kt | 73 ++++++++++++++++++++- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/renderer/linux/src/LinuxWindow.kt b/renderer/linux/src/LinuxWindow.kt index c413470e..38bd9b19 100644 --- a/renderer/linux/src/LinuxWindow.kt +++ b/renderer/linux/src/LinuxWindow.kt @@ -7,6 +7,7 @@ package dev.darkpyonix.composerust.ui.platform import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -18,6 +19,8 @@ import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import dev.darkpyonix.composerust.runtime.LocalZoom +import dev.darkpyonix.composerust.runtime.Zoom import java.lang.System import org.thisisthepy.compose.window.CaretRect import org.thisisthepy.compose.window.FrameClock @@ -62,11 +65,34 @@ internal class LinuxWindow private constructor( * `Dispatchers.Main`, so the application's main-thread work runs here too, in the same queue. */ private val work: FrameDispatcher, + internal val zoom: Zoom, ) : WindowListener { private val reportFrames = System.getenv("DXC_REPORT_FRAMES") != null private val reportInput = System.getenv("DXC_REPORT_INPUT") != null + private var reportedZoomLevel: Int? = null + private var reportedOs: Float? = null + private var zoomDirty = true + + internal fun setZoomLevel(level: Int) { + if (closed) return + if (zoom.setLevel(level)) { + zoomDirty = true + x11.requestFrame() + } + reportZoom() + } + + private fun reportZoom() { + val listener = platformListener ?: return + if (reportedZoomLevel != zoom.level || reportedOs != zoom.os) { + reportedZoomLevel = zoom.level + reportedOs = zoom.os + listener.onZoomChanged(zoom.k, zoom.os, zoom.level) + } + } + /** * Whether this window has the keyboard, as the window last said. * @@ -189,7 +215,7 @@ internal class LinuxWindow private constructor( */ private val frames = WindowFrames({ x11.measure() }) { width, height, scale -> val size = IntSize(width, height) - val density = Density(scale) + val density = zoom.density(scale) // Told to the scene here, in the frame that is about to be drawn at that size, because // a framebuffer that fits and a scene that does not is a window drawing its old size // into a corner of its new one. @@ -233,6 +259,11 @@ internal class LinuxWindow private constructor( * Null for the one window of a single window application, which closes when asked. */ internal var platformListener: PlatformWindowListener? = null + set(value) { + field = value + reportedZoomLevel = null + reportedOs = null + } /** Whether [close] has run, so that a second call does nothing. */ private var closed = false @@ -279,7 +310,7 @@ internal class LinuxWindow private constructor( semantics.perform(id, action, argument) fun setContent(content: @Composable () -> Unit) { - scene.setContent(content) + scene.setContent { CompositionLocalProvider(LocalZoom provides zoom) { content() } } } /** @@ -357,6 +388,8 @@ internal class LinuxWindow private constructor( * arrived this turn, which is news to every window. */ internal fun processTurn(woken: Boolean) { + if (zoom.refresh()) zoomDirty = true + reportZoom() val turnHeard = heard.isNotEmpty() || woken for (event in heard) { if (reportInput && event.kind != WindowEvent.POINTER_MOVE) { @@ -377,8 +410,8 @@ internal class LinuxWindow private constructor( // Only when there is something to draw. A window that is being resized has // already had its frame drawn by the resize, and a window where nothing is // happening should leave the screen alone. - val drew = if (!painted || turnHeard || scene.hasInvalidations() || frameWaiters.isNotEmpty()) { - frames.draw() + val drew = if (!painted || zoomDirty || turnHeard || scene.hasInvalidations() || frameWaiters.isNotEmpty()) { + frames.draw().also { if (it) zoomDirty = false } } else { false } @@ -429,7 +462,11 @@ internal class LinuxWindow private constructor( * [work] is where the scene's work runs. It must have been made on this thread, which is * the one the window is run on. */ - fun open(config: WindowConfig, work: FrameDispatcher = FrameDispatcher()): LinuxWindow? { + fun open( + config: WindowConfig, + work: FrameDispatcher = FrameDispatcher(), + zoom: Zoom = Zoom(nativeLinuxTextScale(), nativeLinuxZoomLevelStore()), + ): LinuxWindow? { val x11 = X11Window() // The listener is the window being made, and the window cannot exist before the // platform does, so it is handed over once both are there. @@ -437,7 +474,7 @@ internal class LinuxWindow private constructor( if (!x11.open(config, handoff)) { return null } - val window = LinuxWindow(x11, config.title, work) + val window = LinuxWindow(x11, config.title, work, zoom) handoff.target = window return window } diff --git a/renderer/linux/src/LinuxWindowBackend.kt b/renderer/linux/src/LinuxWindowBackend.kt index 25127452..e9c805a7 100644 --- a/renderer/linux/src/LinuxWindowBackend.kt +++ b/renderer/linux/src/LinuxWindowBackend.kt @@ -5,6 +5,7 @@ package dev.darkpyonix.composerust.ui.platform import androidx.compose.runtime.Composable import dev.darkpyonix.composerust.protocol.Chrome import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.runtime.Zoom import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine import org.thisisthepy.compose.window.TitleBarStyle @@ -27,6 +28,7 @@ import platform.posix.usleep */ internal class LinuxWindowBackend( private val work: FrameDispatcher, + private val makeZoom: () -> Zoom = { Zoom(nativeLinuxTextScale(), nativeLinuxZoomLevelStore()) }, private val content: (nodeId: Int) -> @Composable () -> Unit, ) : WindowBackend { @@ -59,8 +61,9 @@ internal class LinuxWindowBackend( override val supportsMultipleWindows: Boolean get() = true override fun open(config: PlatformWindowConfig, listener: PlatformWindowListener): PlatformWindow? { - val window = LinuxWindow.open(windowConfigOf(config), work) ?: return null + val window = LinuxWindow.open(windowConfigOf(config), work, makeZoom()) ?: return null window.platformListener = listener + window.setZoomLevel(config.zoomLevel ?: window.zoom.level) window.setContent(content(config.nodeId)) val platform = LinuxPlatformWindow(this, window) windows += platform @@ -180,6 +183,8 @@ internal class LinuxPlatformWindow( override fun setTitle(title: String) = x11.setTitle(title) + override fun setZoomLevel(level: Int) = window.setZoomLevel(level) + override fun setMinimumSize(width: Float, height: Float) = x11.setMinimumSize(width.toInt(), height.toInt()) diff --git a/renderer/linux/test/LinuxMultiWindowTest.kt b/renderer/linux/test/LinuxMultiWindowTest.kt index 9845444f..a9d76897 100644 --- a/renderer/linux/test/LinuxMultiWindowTest.kt +++ b/renderer/linux/test/LinuxMultiWindowTest.kt @@ -2,6 +2,11 @@ package dev.darkpyonix.composerust.ui.platform +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalDensity +import dev.darkpyonix.composerust.runtime.Zoom +import dev.darkpyonix.composerust.runtime.ZoomLevelStore +import dev.darkpyonix.composerust.runtime.appZoomScale import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -26,9 +31,11 @@ class LinuxMultiWindowTest { val moves = ArrayList() override fun onCloseRequested() { closeRequests++ } val focusEvents = ArrayList() + val zoomEvents = ArrayList>() override fun onFocusChanged(focused: Boolean) { focusEvents += focused } override fun onMoved(bounds: ScreenBounds) { moves += bounds } override fun onFollowEnded(cancelled: Boolean) {} + override fun onZoomChanged(k: Float, os: Float, level: Int) { zoomEvents += Triple(k, os, level) } } private fun hasDisplay(): Boolean { @@ -37,7 +44,7 @@ class LinuxMultiWindowTest { return false } - private fun backend() = LinuxWindowBackend(mainForTests()) { { } } + private fun backend() = LinuxWindowBackend(mainForTests(), makeZoom = { Zoom() }) { { } } private fun config(id: Int, width: Float = 320f, height: Float = 240f) = PlatformWindowConfig(nodeId = id, title = "window $id", width = width, height = height) @@ -51,6 +58,70 @@ class LinuxMultiWindowTest { return done() } + @Test + fun fr53_53_3_linux_zoom_at_open_and_after_open_is_local_to_each_window() { + if (!hasDisplay()) return + val densities = mutableMapOf() + val backend = LinuxWindowBackend(mainForTests(), makeZoom = { Zoom() }) { id -> + { + val density = LocalDensity.current.density + SideEffect { densities[id] = density } + } + } + try { + val firstEvents = Recorder() + val secondEvents = Recorder() + val first = backend.open(config(1).copy(zoomLevel = 2), firstEvents)!! + val second = backend.open(config(2).copy(zoomLevel = 0), secondEvents)!! + assertEquals(2, firstEvents.zoomEvents.last().third) + assertEquals(0, secondEvents.zoomEvents.last().third) + assertTrue(backend.turnsUntil { densities.size == 2 }) + val base = densities.getValue(2) + assertEquals(base * appZoomScale(2), densities.getValue(1), 0.001f) + + first.setZoomLevel(-1) + assertEquals(-1, firstEvents.zoomEvents.last().third) + assertTrue(backend.turnsUntil { kotlin.math.abs(densities.getValue(1) - base * appZoomScale(-1)) < 0.001f }) + assertEquals(base, densities.getValue(2), 0.001f) + assertEquals(listOf(0), secondEvents.zoomEvents.map { it.third }) + + first.setZoomLevel(100) + assertEquals(8, firstEvents.zoomEvents.last().third) + val count = firstEvents.zoomEvents.size + first.setZoomLevel(9) + assertEquals(count, firstEvents.zoomEvents.size) + second.close() + first.close() + first.setZoomLevel(0) + assertEquals(count, firstEvents.zoomEvents.size) + } finally { + backend.closeAll() + } + } + + @Test + fun fr53_53_3_linux_new_window_reads_the_last_saved_zoom() { + if (!hasDisplay()) return + val store = object : ZoomLevelStore { + var saved: Int? = 3 + override fun load(): Int? = saved + override fun save(level: Int) { saved = level } + } + val backend = LinuxWindowBackend(mainForTests(), makeZoom = { Zoom(store = store) }) { { } } + try { + val firstEvents = Recorder() + val first = backend.open(config(1), firstEvents)!! + assertEquals(3, firstEvents.zoomEvents.last().third) + first.setZoomLevel(-2) + val secondEvents = Recorder() + backend.open(config(2), secondEvents)!! + assertEquals(-2, secondEvents.zoomEvents.last().third) + assertEquals(-2, store.saved) + } finally { + backend.closeAll() + } + } + @Test fun fr53_linux_two_window_nodes_open_two_x_windows_and_close_one() { if (!hasDisplay()) return From e39f327cb620e9ab79ef6677e74be0784e0be903 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:52:56 +0900 Subject: [PATCH 107/224] Feat: Deliver window drag over to registered Linux drop targets (FR-53.7, #409) --- renderer/desktop/src/WindowBackend.kt | 8 + renderer/desktop/src/renderer/RenderNode.kt | 25 ++- renderer/desktop/src/renderer/WindowSize.kt | 20 +++ renderer/linux/src/LinuxRenderer.kt | 2 +- renderer/linux/test/LinuxMultiWindowTest.kt | 163 ++++++++++++++++++++ 5 files changed, 216 insertions(+), 2 deletions(-) diff --git a/renderer/desktop/src/WindowBackend.kt b/renderer/desktop/src/WindowBackend.kt index 0dbd96e0..45f4943b 100644 --- a/renderer/desktop/src/WindowBackend.kt +++ b/renderer/desktop/src/WindowBackend.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.ui.platform import dev.darkpyonix.composerust.protocol.Chrome import dev.darkpyonix.composerust.protocol.ColorScheme import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.runtime.WindowDropTargets // What the operating system layers (macOS, Windows, Linux) implement so that one process can // hold several windows, and the only thing the window manager knows about them. @@ -207,4 +208,11 @@ fun interface DropTargets { companion object { val None = DropTargets { _, _, _ -> null } } + + object Registered : DropTargets { + override fun targetAt(windowId: Int, x: Float, y: Float): DropTarget? = + WindowDropTargets.find(windowId, x, y)?.let { (id, local) -> + DropTarget(id, local.first, local.second) + } + } } diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 81070ee9..6efa8995 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -27,6 +28,8 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable @@ -42,6 +45,8 @@ import kotlin.math.roundToInt import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.runtime.LocalWindowNodeId +import dev.darkpyonix.composerust.runtime.WindowDropTargets import dev.darkpyonix.composerust.runtime.LocalZoom import dev.darkpyonix.composerust.runtime.islandDensity import dev.darkpyonix.composerust.protocol.TypeRole @@ -195,7 +200,25 @@ fun RenderNode( } else { chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) } - val modifier = platformFileDrop(pressable, node, dispatcher) + val windowId = LocalWindowNodeId.current + val subscribed = windowId != 0 && node.handler(PropertyKind.OnWindowDragOver) != null + val density = LocalDensity.current.density + if (subscribed) { + DisposableEffect(windowId, nodeId) { + onDispose { WindowDropTargets.remove(windowId, nodeId) } + } + } + var depth = 0 + var ancestor = node.parentId + while (ancestor != NodeTable.ROOT_ID && depth <= 1024) { + depth++ + ancestor = table.node(ancestor)?.parentId ?: NodeTable.ROOT_ID + } + val dropModifier = if (subscribed) pressable.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + WindowDropTargets.update(windowId, nodeId, bounds.left, bounds.top, bounds.right, bounds.bottom, density, depth) + } else pressable + val modifier = platformFileDrop(dropModifier, node, dispatcher) // A layout made of glass tells what is inside it so. A field in a glass bar is part of // the bar in the systems that draw glass, and it can only take that shape if it knows // what it is sitting on. diff --git a/renderer/desktop/src/renderer/WindowSize.kt b/renderer/desktop/src/renderer/WindowSize.kt index b0da631a..362c7aaf 100644 --- a/renderer/desktop/src/renderer/WindowSize.kt +++ b/renderer/desktop/src/renderer/WindowSize.kt @@ -117,3 +117,23 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * zoom - carry this as their node id, and the theme and zoom level are looked up under it. */ val LocalWindowNodeId = staticCompositionLocalOf { 0 } + +/** Bounds are in window pixels, matching the Linux backend's screen coordinates. */ +internal object WindowDropTargets { + private data class Target(val left: Float, val top: Float, val right: Float, val bottom: Float, val density: Float, val depth: Int) + private val targets = LinkedHashMap, Target>() + + fun update(windowId: Int, nodeId: Int, left: Float, top: Float, right: Float, bottom: Float, density: Float, depth: Int) { + targets[windowId to nodeId] = Target(left, top, right, bottom, density, depth) + } + + fun remove(windowId: Int, nodeId: Int) { targets.remove(windowId to nodeId) } + + fun find(windowId: Int, x: Float, y: Float): Pair>? { + val target = targets.entries.toList().asReversed().filter { (key, rect) -> + key.first == windowId && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom + }.maxByOrNull { it.value.depth } ?: return null + val rect = target.value + return target.key.second to ((x - rect.left) / rect.density to (y - rect.top) / rect.density) + } +} diff --git a/renderer/linux/src/LinuxRenderer.kt b/renderer/linux/src/LinuxRenderer.kt index f4a3190c..20cf67ae 100644 --- a/renderer/linux/src/LinuxRenderer.kt +++ b/renderer/linux/src/LinuxRenderer.kt @@ -109,7 +109,7 @@ private fun runWindows( ): Int { val backend = LinuxWindowBackend(main) { nodeId -> { ComposeRustContent(host, windowNodeId = nodeId) } } opened(backend) - val manager = WindowManager(host, backend, onExit = backend::requestExit) + val manager = WindowManager(host, backend, onExit = backend::requestExit, dropTargets = DropTargets.Registered) val frames = CoroutineScope(main + Job()) try { manager.attach() diff --git a/renderer/linux/test/LinuxMultiWindowTest.kt b/renderer/linux/test/LinuxMultiWindowTest.kt index 9845444f..6c8fceca 100644 --- a/renderer/linux/test/LinuxMultiWindowTest.kt +++ b/renderer/linux/test/LinuxMultiWindowTest.kt @@ -2,6 +2,20 @@ package dev.darkpyonix.composerust.ui.platform +import dev.darkpyonix.composerust.protocol.Chrome +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.LastWindowClosed +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.protocol.Window +import dev.darkpyonix.composerust.protocol.WindowMode +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.WindowDropTargets +import dev.darkpyonix.composerust.tooling.FakeHostConnection import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -51,6 +65,155 @@ class LinuxMultiWindowTest { return done() } + private fun bytesOf(vararg values: Float): ByteArray { + val out = ByteArray(values.size * 4) + values.forEachIndexed { i, value -> + val bits = value.toRawBits() + for (b in 0 until 4) out[i * 4 + b] = (bits shr (8 * b)).toByte() + } + return out + } + + /** A window whose frame and pointer are scripted, so the test does not depend on a window manager. */ + private class ScriptedWindow( + private val inner: PlatformWindow, + private val frame: ScreenBounds, + private val pointer: () -> ScreenPointer, + ) : PlatformWindow by inner { + override fun bounds(): ScreenBounds = frame + override fun contentOrigin(): ScreenPoint = ScreenPoint(frame.x, frame.y) + override fun pointerOnScreen(): ScreenPointer = pointer() + override fun followPointer(anchorX: Float, anchorY: Float): Boolean = true + } + + private class ScriptedBackend( + private val inner: LinuxWindowBackend, + private val frames: Map, + private val pointer: () -> ScreenPointer, + ) : WindowBackend by inner { + override fun open(config: PlatformWindowConfig, listener: PlatformWindowListener): PlatformWindow? { + val opened = inner.open(config, listener) ?: return null + return ScriptedWindow(opened, frames.getValue(config.nodeId), pointer) + } + } + + @Test + fun fr53_53_7_linux_registered_drop_targets_follow_bounds_and_subscription() { + WindowDropTargets.update(1, 10, 20f, 30f, 120f, 130f, 2f, 1) + WindowDropTargets.update(1, 11, 40f, 50f, 80f, 90f, 2f, 2) + WindowDropTargets.update(2, 20, 20f, 30f, 120f, 130f, 1f, 1) + try { + assertEquals(DropTarget(11, 5f, 5f), DropTargets.Registered.targetAt(1, 50f, 60f)) + assertEquals(DropTarget(20, 30f, 30f), DropTargets.Registered.targetAt(2, 50f, 60f)) + WindowDropTargets.remove(1, 11) + assertEquals(DropTarget(10, 15f, 15f), DropTargets.Registered.targetAt(1, 50f, 60f)) + WindowDropTargets.update(1, 10, 200f, 200f, 300f, 300f, 2f, 1) + assertEquals(null, DropTargets.Registered.targetAt(1, 50f, 60f)) + } finally { + WindowDropTargets.remove(1, 10) + WindowDropTargets.remove(1, 11) + WindowDropTargets.remove(2, 20) + } + } + + /** + * The whole path of a dragged window: the Host's `DragFromPointer`, a real X window drawing a + * real `Text` node that subscribed with `OnWindowDragOver` (its bounds come from layout, not + * from the test), the manager polling the pointer, and the `WindowDragOver` events the Host + * receives. Only the pointer position and the window frames are scripted. + */ + @Test + fun fr53_53_7_linux_drag_over_reaches_the_host_through_a_real_window() { + if (!hasDisplay()) return + var pointer = ScreenPointer(0f, 0f, true) + val frames = mapOf( + 1 to ScreenBounds(0f, 600f, 300f, 200f), + 2 to ScreenBounds(1000f, 0f, 300f, 200f), + ) + val main = mainForTests() + lateinit var host: ComposeRustHost + val real = LinuxWindowBackend(main) { nodeId -> { ComposeRustContent(host, windowNodeId = nodeId) } } + val backend = ScriptedBackend(real, frames) { pointer } + fun window(id: Int, vararg props: Pair) = buildList { + add(Mutation.Create(id, WidgetKind.Window)) + add(Mutation.SetProp(id, PropertyKind.WindowSize, PropertyValue.Bytes(bytesOf(300f, 200f)))) + for ((kind, value) in props) add(Mutation.SetProp(id, kind, value)) + add(Mutation.Insert(0, id, -1)) + } + fun strip(parent: Int, id: Int, handler: Long) = listOf( + Mutation.Create(id, WidgetKind.Text), + Mutation.SetProp(id, PropertyKind.Text, PropertyValue.Text("tab strip")), + Mutation.SetProp(id, PropertyKind.OnWindowDragOver, PropertyValue.Integer(handler)), + Mutation.Insert(parent, id, -1), + ) + val initial = listOf( + Mutation.SetWindow( + Window( + chrome = Chrome.Modern, + titleBar = TitleBar.Normal, + title = "", + icon = 0, + width = 0, + height = 0, + minWidth = 0, + minHeight = 0, + resizable = true, + zoomLevel = null, + mode = WindowMode.Windows, + lastWindowClosed = LastWindowClosed.KeepRunning, + ), + ), + ) + window(2) + strip(2, 20, 33L) + + window( + 1, + PropertyKind.DragFromPointer to PropertyValue.Bytes( + ByteArray(12).also { + it[0] = 1 + bytesOf(0f, 0f).copyInto(it, 4) + }, + ), + PropertyKind.OnWindowDrag to PropertyValue.Integer(11L), + ) + strip(1, 10, 44L) + val conn = FakeHostConnection(initial) + host = ComposeRustHost(conn) + host.start() + val manager = WindowManager(host, backend, onExit = {}, dropTargets = DropTargets.Registered) + try { + manager.attach() + // The subscribed node of window 2 has been laid out and has reported its bounds. + assertTrue( + real.turnsUntil { WindowDropTargets.find(2, 2f, 4f) != null }, + "the subscribed node never registered its bounds from layout", + ) + fun overs() = conn.events.filterIsInstance() + + pointer = ScreenPointer(1002f, 4f, true) + manager.flushFrame() + assertEquals(listOf(1), overs().map { it.kind }, "no Enter reached the Host") + val enter = overs().first() + assertEquals(20, enter.nodeId) + assertEquals(33L, enter.handlerId) + assertEquals(1, enter.window) + + pointer = ScreenPointer(1004f, 5f, true) + manager.flushFrame() + assertEquals(listOf(1, 2), overs().map { it.kind }) + + pointer = ScreenPointer(1250f, 190f, true) + manager.flushFrame() + assertEquals(listOf(1, 2, 3), overs().map { it.kind }) + + pointer = ScreenPointer(1003f, 4f, true) + manager.flushFrame() + assertTrue(overs().all { it.nodeId == 20 }, "the dragged window's own strip was hit") + assertEquals(1, overs().last().kind, "re-entering the strip did not Enter again") + } finally { + manager.shutdown() + real.closeAll() + host.shutdown() + } + } + @Test fun fr53_linux_two_window_nodes_open_two_x_windows_and_close_one() { if (!hasDisplay()) return From c34637a086efff22453ef6e0cb967a8019fc9b75 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:06:38 +0900 Subject: [PATCH 108/224] Chore: Regenerate the staged Android Kotlin copy for the weighted layout (#427) --- .../android-kotlin/shared/HostRichText.kt | 28 ++- .../android-kotlin/shared/RenderNode.kt | 195 +++++++++++++++++- .../android-kotlin/shared/TextResolution.kt | 3 +- 3 files changed, 212 insertions(+), 14 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostRichText.kt b/compose-rust/android-kotlin/shared/HostRichText.kt index 3393b22e..4bd48f00 100644 --- a/compose-rust/android-kotlin/shared/HostRichText.kt +++ b/compose-rust/android-kotlin/shared/HostRichText.kt @@ -6,6 +6,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.LinkAnnotation @@ -14,11 +20,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.MultiParagraphIntrinsics import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.SpanRecords import dev.darkpyonix.composerust.protocol.TypeRole +import dev.darkpyonix.composerust.protocol.WordBreak +import dev.darkpyonix.composerust.protocol.OverflowWrap import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.ui.node.AssetCache import dev.darkpyonix.composerust.ui.node.Node @@ -126,12 +135,13 @@ internal fun HostRichText( ) { val input = node.textInput() val density = textDensity(input, LocalDensity.current) + val fontResolver = LocalFontFamilyResolver.current val resolved = resolveText( input, theme, assets, density, - LocalFontFamilyResolver.current, + fontResolver, link = { run -> // A link is a press on a range, and a press is the event the boundary already // has. No new event tag for a thing that is already a click. @@ -143,6 +153,10 @@ internal fun HostRichText( ) }, ) + val wordFloor = if (input.softWrap && (input.wordBreak == null || input.wordBreak == WordBreak.Normal) && input.overflowWrap != OverflowWrap.Anywhere) { + val words = resolveText(input, theme, assets, density, fontResolver, intrinsicWordBreak = WordBreak.KeepAll) + kotlin.math.ceil(MultiParagraphIntrinsics(words.text, words.style, words.placeholders, density, fontResolver).minIntrinsicWidth).toInt() + } else 0 val runs = node.runsDecodeProblem() ?: resolved.runsProblem val fonts = node.fontListProblem() ?: resolved.problems.firstOrNull() val problem = when { @@ -156,7 +170,7 @@ internal fun HostRichText( CompositionLocalProvider(LocalDensity provides density) { BasicText( text = resolved.text, - modifier = modifier, + modifier = modifier.then(WordFloor(wordFloor)), style = resolved.style, overflow = resolved.overflow, softWrap = resolved.softWrap, @@ -167,6 +181,16 @@ internal fun HostRichText( } } +private class WordFloor(private val width: Int) : LayoutModifier { + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = + measurable.measure(constraints).let { placeable -> + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int = + maxOf(width, measurable.minIntrinsicWidth(height)) +} + /** * Says a set of runs is wrong, and goes on drawing the string without them. * diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 81070ee9..799cc701 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -1,5 +1,6 @@ package dev.darkpyonix.composerust.ui.node +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -27,6 +28,10 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable @@ -36,6 +41,8 @@ import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.distinctUntilChanged import kotlin.math.roundToInt @@ -202,17 +209,29 @@ fun RenderNode( val glass = node.isMadeOfGlass(theme) ProvideInheritedColor(nodeId, node, table, theme) { when (node.widget) { - WidgetKind.Column -> Column( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.width(IntrinsicSize.Min) else modifier, - verticalArrangement = node.verticalArrangement(theme), - horizontalAlignment = node.horizontalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } - - WidgetKind.Row -> Row( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.height(IntrinsicSize.Min) else modifier, - horizontalArrangement = node.horizontalArrangement(theme), - verticalAlignment = node.verticalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } + WidgetKind.Column, WidgetKind.Row -> { + val horizontal = node.widget == WidgetKind.Row + val sizedModifier = if (node.usesIntrinsicCrossAxis(table)) { + if (horizontal) modifier.height(IntrinsicSize.Min) else modifier.width(IntrinsicSize.Min) + } else modifier + val weights = node.children.map { table.node(it)?.modifiers?.weightOf() } + val intrinsicSafe = node.children.map { child -> !table.hasScrollingDescendant(child) } + if (weights.any { it != null }) { + WeightedStack( + node, table, dispatcher, sizedModifier, horizontal, weights, intrinsicSafe, + node.horizontalArrangement(theme), node.verticalArrangement(theme), + node.horizontalAlignment(), node.verticalAlignment(), glass, + ) + } else if (horizontal) { + Row(sizedModifier, node.horizontalArrangement(theme), node.verticalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } else { + Column(sizedModifier, node.verticalArrangement(theme), node.horizontalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } + } // A place files may be dropped is laid out as a plain box. Being this widget is // the whole of the difference: the drop target is set up for it and for nothing @@ -725,6 +744,160 @@ internal fun OnGlass(glass: Boolean, content: @Composable () -> Unit) { CompositionLocalProvider(LocalGlassDepth provides LocalGlassDepth.current + 1, content = content) } +/** Measures weighted children at least as large as their main-axis minimum intrinsic size. */ +@Composable +private fun WeightedStack( + node: Node, + table: NodeTable, + dispatcher: EventDispatcher, + modifier: Modifier, + horizontal: Boolean, + weights: List, + intrinsicSafe: List, + horizontalArrangement: Arrangement.Horizontal, + verticalArrangement: Arrangement.Vertical, + horizontalAlignment: Alignment.Horizontal, + verticalAlignment: Alignment.Vertical, + glass: Boolean, +) { + val policy = remember(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) { + WeightedStackPolicy(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) + } + Layout( + content = { + OnGlass(glass) { + node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } + } + }, + modifier = modifier.clipToBounds(), + measurePolicy = policy, + ) +} + +private class WeightedStackPolicy( + private val horizontal: Boolean, + private val weights: List, + private val intrinsicSafe: List, + private val horizontalArrangement: Arrangement.Horizontal, + private val verticalArrangement: Arrangement.Vertical, + private val horizontalAlignment: Alignment.Horizontal, + private val verticalAlignment: Alignment.Vertical, +) : MeasurePolicy { + private fun spacing(scope: androidx.compose.ui.unit.Density): Int = with(scope) { + (if (horizontal) horizontalArrangement.spacing else verticalArrangement.spacing).roundToPx() + } + + override fun MeasureScope.measure(measurables: List, constraints: Constraints): MeasureResult { + val mainMax = if (horizontal) constraints.maxWidth else constraints.maxHeight + val mainMin = if (horizontal) constraints.minWidth else constraints.minHeight + val crossMax = if (horizontal) constraints.maxHeight else constraints.maxWidth + val gap = spacing(this) + val places = arrayOfNulls(measurables.size) + var fixed = 0 + val gaps = gap * (measurables.size - 1).coerceAtLeast(0) + fun childConstraints(min: Int, max: Int) = if (horizontal) { + Constraints(minWidth = min, maxWidth = max, maxHeight = crossMax) + } else { + Constraints(minHeight = min, maxHeight = max, maxWidth = crossMax) + } + measurables.forEachIndexed { index, measurable -> + if (weights[index] == null) { + val available = if (mainMax == Constraints.Infinity) mainMax else (mainMax - fixed - gaps).coerceAtLeast(0) + val place = measurable.measure(childConstraints(0, available)) + places[index] = place + fixed += if (horizontal) place.width else place.height + } + } + val target = if (mainMax == Constraints.Infinity) mainMin else mainMax + val remaining = (target - fixed - gaps).coerceAtLeast(0) + val totalWeight = weights.filterNotNull().sum() + var distributed = 0 + var remainingWeight = totalWeight + measurables.forEachIndexed { index, measurable -> + val weight = weights[index] ?: return@forEachIndexed + val share = if (remainingWeight == weight) remaining - distributed else (remaining * weight / totalWeight).roundToInt().coerceAtMost((remaining - distributed).coerceAtLeast(0)) + distributed += share + remainingWeight -= weight + val intrinsic = intrinsic(index) { if (horizontal) measurable.minIntrinsicWidth(crossMax) else measurable.minIntrinsicHeight(crossMax) } + val size = maxOf(share, intrinsic).coerceAtMost(Constraints.Infinity - 1) + places[index] = measurable.measure(childConstraints(size, size)) + } + val children = places.map { it!! } + val sizes = children.map { if (horizontal) it.width else it.height }.toIntArray() + val contentMain = (sizes.fold(0L) { sum, size -> sum + size } + gaps).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val main = if (horizontal) constraints.constrainWidth(contentMain) else constraints.constrainHeight(contentMain) + val contentCross = children.maxOfOrNull { if (horizontal) it.height else it.width } ?: 0 + val cross = if (horizontal) constraints.constrainHeight(contentCross) else constraints.constrainWidth(contentCross) + val positions = IntArray(children.size) + if (horizontal) with(horizontalArrangement) { arrange(main, sizes, layoutDirection, positions) } + else with(verticalArrangement) { arrange(main, sizes, positions) } + return layout(if (horizontal) main else cross, if (horizontal) cross else main) { + children.forEachIndexed { index, place -> + if (horizontal) place.place(positions[index], verticalAlignment.align(place.height, cross)) + else place.place(horizontalAlignment.align(place.width, cross, layoutDirection), positions[index]) + } + } + } + + private fun IntrinsicMeasureScope.mainIntrinsic(children: List, cross: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val sizes = children.mapIndexed { index, child -> intrinsic(index) { + if (horizontal) { + if (min) child.minIntrinsicWidth(cross) else child.maxIntrinsicWidth(cross) + } else { + if (min) child.minIntrinsicHeight(cross) else child.maxIntrinsicHeight(cross) + } + } } + val unit = sizes.indices.filter { weights[it] != null }.maxOfOrNull { sizes[it] / weights[it]!! } ?: 0f + val weighted = (unit * weights.filterNotNull().sum()).roundToInt() + return (sizes.indices.filter { weights[it] == null }.sumOf { sizes[it].toLong() } + weighted + gap) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + private fun IntrinsicMeasureScope.crossIntrinsic(children: List, main: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val fixed = children.indices.filter { weights[it] == null }.map { index -> + val child = children[index] + intrinsic(index) { if (horizontal) child.maxIntrinsicWidth(Constraints.Infinity) else child.maxIntrinsicHeight(Constraints.Infinity) } + }.fold(0L) { sum, value -> sum + value } + val remaining = if (main == Constraints.Infinity) Constraints.Infinity else (main.toLong() - fixed - gap).coerceAtLeast(0).toInt() + val total = weights.filterNotNull().sum() + return children.indices.maxOfOrNull { index -> + val child = children[index] + val weight = weights[index] + val available = if (weight == null || remaining == Constraints.Infinity) Constraints.Infinity + else (remaining * weight / total).roundToInt() + val minimum = intrinsic(index) { if (horizontal) child.minIntrinsicWidth(Constraints.Infinity) else child.minIntrinsicHeight(Constraints.Infinity) } + val size = if (weight == null) available else maxOf(available, minimum) + intrinsic(index) { if (horizontal) { + if (min) child.minIntrinsicHeight(size) else child.maxIntrinsicHeight(size) + } else { + if (min) child.minIntrinsicWidth(size) else child.maxIntrinsicWidth(size) + } } + } ?: 0 + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, true) else crossIntrinsic(measurables, height, true) + override fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, false) else crossIntrinsic(measurables, height, false) + override fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, true) else mainIntrinsic(measurables, width, true) + override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, false) else mainIntrinsic(measurables, width, false) + + private inline fun intrinsic(index: Int, query: () -> Int): Int { + if (!intrinsicSafe[index]) return 0 + return try { query() } catch (_: IllegalStateException) { 0 } + } +} + +private fun NodeTable.hasScrollingDescendant(id: Int): Boolean { + val node = node(id) ?: return false + if (node.widget in setOf(WidgetKind.LazyColumn, WidgetKind.LazyRow, WidgetKind.LazyGrid, WidgetKind.ScrollColumn, WidgetKind.ScrollRow)) return true + return node.children.any { hasScrollingDescendant(it) } +} + /** * Children of a Column: weight is applied here because `Modifier.weight` exists only inside * `ColumnScope`. Every layout that stacks its children vertically draws them through this, diff --git a/compose-rust/android-kotlin/shared/TextResolution.kt b/compose-rust/android-kotlin/shared/TextResolution.kt index c284b2b5..b4b5dffd 100644 --- a/compose-rust/android-kotlin/shared/TextResolution.kt +++ b/compose-rust/android-kotlin/shared/TextResolution.kt @@ -143,6 +143,7 @@ internal fun resolveText( * resolving a colour through the design system is the dearest thing done here. */ forMeasure: Boolean = false, + intrinsicWordBreak: WordBreak? = null, ): ResolvedText { val problems = mutableListOf() // Sizes are sp. Text in CSS pixels is not given smaller sp to undo the font scale: @@ -203,7 +204,7 @@ internal fun resolveText( val badRuns = if (input.runs.isEmpty()) null else runsProblem(input.runs, input.text.encodeToByteArray().size) val runs = if (badRuns == null) input.runs else emptyList() - val mode = when (input.wordBreak) { + val mode = when (intrinsicWordBreak ?: input.wordBreak) { WordBreak.KeepAll -> BreakMode.KeepAll WordBreak.BreakAll -> BreakMode.BreakAll WordBreak.Normal, null -> BreakMode.Normal From 2725ba053828526b08c3ef295117335126a5b98d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:46:53 +0900 Subject: [PATCH 109/224] Feat: Button text alignment, label size and a content slot (FR-66, #425) Button gets text_align and font_size, sent only when called, and takes children that are drawn as a vertically centred Row in place of the label. Covers FR-66 criteria 1 to 3: fr66_* tests in compose-rust/tests/ui_button_row.rs and renderer/desktop/test/ButtonRowTest.kt. The staged Android copy is regenerated. --- .../android-kotlin/shared/HostButton.kt | 17 ++- .../android-kotlin/shared/RenderNode.kt | 4 +- compose-rust/src/ui/widgets.rs | 27 +++- compose-rust/tests/ui_button_row.rs | 59 +++++++++ renderer/desktop/src/renderer/HostButton.kt | 17 ++- renderer/desktop/src/renderer/RenderNode.kt | 4 +- renderer/desktop/test/ButtonRowTest.kt | 124 ++++++++++++++++++ 7 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 compose-rust/tests/ui_button_row.rs create mode 100644 renderer/desktop/test/ButtonRowTest.kt diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 0b55a98e..19c04bac 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -22,6 +23,8 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp @@ -41,7 +44,10 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.ui.node.Children +import dev.darkpyonix.composerust.ui.node.LocalInheritedTextColor import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.groupAlpha import dev.darkpyonix.composerust.ui.intProp import dev.darkpyonix.composerust.ui.overflow @@ -61,6 +67,7 @@ import dev.darkpyonix.composerust.ui.variant internal fun HostButton( node: Node, modifier: Modifier, + table: NodeTable, dispatcher: EventDispatcher, theme: ResolvedTheme, ) { @@ -185,7 +192,15 @@ internal fun HostButton( named, contentAlignment = if (alignStart) Alignment.CenterStart else Alignment.Center, ) { - if (icon == null) { + if (node.children.isNotEmpty()) { + // Children replace the label. They are laid out as a Row centred vertically, so + // a weighted Spacer pushes whatever follows it to the button's inner edge. + CompositionLocalProvider(LocalInheritedTextColor provides ColorProducer { tint }) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Children(node, table, dispatcher) + } + } + } else if (icon == null) { ThemedText(text = text, style = label, maxLines = lines, softWrap = lines > 1, overflow = ellipsis) } else { Row( diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 81070ee9..9d1e7bb6 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -241,7 +241,7 @@ fun RenderNode( // The controls are drawn through `Unselectable`, which leaves them out of a // selectable region around them: a label is pressed, not copied. Outside a region // it adds nothing. - WidgetKind.Button -> Unselectable { HostButton(node, modifier, dispatcher, theme) } + WidgetKind.Button -> Unselectable { HostButton(node, modifier, table, dispatcher, theme) } WidgetKind.TextField -> Unselectable { HostTextField(node, modifier, dispatcher, table.assets) } WidgetKind.LazyColumn -> HostLazyColumn(node, modifier, table, dispatcher) @@ -750,7 +750,7 @@ private fun ColumnScope.WeightedChild(childId: Int, table: NodeTable, dispatcher } @Composable -private fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { +internal fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { node.children.forEach { childId -> key(childId) { WeightedChild(childId, table, dispatcher) } } } diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 964d9588..f942920b 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -1019,6 +1019,8 @@ pub struct ButtonScope { color: Option, max_lines: Option, overflow: Option, + text_align: Option, + font_size: Option, on_click: Option>, done: bool, } @@ -1039,12 +1041,28 @@ pub fn Button(text: impl Into) -> ButtonScope { color: None, max_lines: None, overflow: None, + text_align: None, + font_size: None, on_click: None, done: false, } } impl ButtonScope { + /// Where the label sits in the button. A button that never calls this sends nothing + /// and keeps its label centred. + pub fn text_align(mut self, align: TextAlign) -> Self { + self.text_align = Some(align); + self + } + + /// The label's size in sp. A button that never calls this sends nothing and takes the + /// design system's size for its variant. + pub fn font_size(mut self, sp: f32) -> Self { + self.font_size = Some(sp); + self + } + /// The meaning of the glyph on it, never a picture. pub fn icon(mut self, icon: IconRole) -> Self { self.icon = Some(icon); @@ -1104,6 +1122,7 @@ impl ButtonScope { let (icon, enabled, variant, kind, color) = (self.icon, self.enabled, self.variant, self.kind, self.color); let (max_lines, overflow) = (self.max_lines, self.overflow); + let (text_align, font_size) = (self.text_align, self.font_size); let listeners = vec![("onclick", unit(self.on_click.take()))]; emit( widget_key("compose_rust::ui::Button"), @@ -1117,6 +1136,12 @@ impl ButtonScope { attrs.push(("button_kind", role_or_zero(kind))); attrs.push(("color", super::paint(color))); lines_and_overflow(max_lines, overflow, attrs); + if text_align.is_some() { + attrs.push(("text_align", role_or_zero(text_align))); + } + if font_size.is_some() { + attrs.push(("font_size", dp_or_zero(font_size))); + } }, listeners, content, @@ -1124,7 +1149,7 @@ impl ButtonScope { } } -composed_on_drop!(ButtonScope); +container!(ButtonScope); /// A two-state control, for Checkbox, RadioButton and Switch alike: the Host owns the /// value, the control draws exactly what it is told and reports what the user asked for. diff --git a/compose-rust/tests/ui_button_row.rs b/compose-rust/tests/ui_button_row.rs new file mode 100644 index 00000000..490c1bcf --- /dev/null +++ b/compose-rust/tests/ui_button_row.rs @@ -0,0 +1,59 @@ +//! A Button can align and size its label, and can hold children in place of the label. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::schema::TextAlign; +use compose_rust::ui::*; +use support::Tree; + +fn dump(content: impl Fn() + Send + Sync + 'static) -> String { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree.dump() +} + +#[test] +fn fr66_a_button_that_asks_for_nothing_sends_no_alignment_or_size() { + let tree = dump(|| { + Button("변경 사항"); + }); + assert!(!tree.contains("TextAlign"), "{tree}"); + assert!(!tree.contains("FontSize"), "{tree}"); +} + +#[test] +fn fr66_a_button_writes_the_alignment_and_size_it_was_given() { + let tree = dump(|| { + Button("변경 사항") + .text_align(TextAlign::Start) + .font_size(13.0); + }); + assert!(tree.contains("TextAlign=Integer(1)"), "{tree}"); + assert!(tree.contains("FontSize=Float(13"), "{tree}"); +} + +#[test] +fn fr66_a_button_emits_its_children_under_itself() { + let tree = dump(|| { + Button("").content(|| { + Text("Files"); + Spacer().modifier(Modifier.weight(1.0)); + Text("12"); + }); + }); + let lines: Vec<&str> = tree.lines().collect(); + let button = lines + .iter() + .position(|l| l.trim_start().starts_with("Button")) + .unwrap_or_else(|| panic!("no Button in\n{tree}")); + let indent = |l: &str| l.len() - l.trim_start().len(); + let children: Vec<&&str> = lines[button + 1..] + .iter() + .take_while(|l| indent(l) > indent(lines[button])) + .collect(); + assert_eq!(children.len(), 3, "{tree}"); + assert!(children[0].contains("Text=\"Files\""), "{tree}"); + assert!(children[2].contains("Text=\"12\""), "{tree}"); +} diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 0b55a98e..19c04bac 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -22,6 +23,8 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp @@ -41,7 +44,10 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.ui.node.Children +import dev.darkpyonix.composerust.ui.node.LocalInheritedTextColor import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.groupAlpha import dev.darkpyonix.composerust.ui.intProp import dev.darkpyonix.composerust.ui.overflow @@ -61,6 +67,7 @@ import dev.darkpyonix.composerust.ui.variant internal fun HostButton( node: Node, modifier: Modifier, + table: NodeTable, dispatcher: EventDispatcher, theme: ResolvedTheme, ) { @@ -185,7 +192,15 @@ internal fun HostButton( named, contentAlignment = if (alignStart) Alignment.CenterStart else Alignment.Center, ) { - if (icon == null) { + if (node.children.isNotEmpty()) { + // Children replace the label. They are laid out as a Row centred vertically, so + // a weighted Spacer pushes whatever follows it to the button's inner edge. + CompositionLocalProvider(LocalInheritedTextColor provides ColorProducer { tint }) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Children(node, table, dispatcher) + } + } + } else if (icon == null) { ThemedText(text = text, style = label, maxLines = lines, softWrap = lines > 1, overflow = ellipsis) } else { Row( diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 81070ee9..9d1e7bb6 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -241,7 +241,7 @@ fun RenderNode( // The controls are drawn through `Unselectable`, which leaves them out of a // selectable region around them: a label is pressed, not copied. Outside a region // it adds nothing. - WidgetKind.Button -> Unselectable { HostButton(node, modifier, dispatcher, theme) } + WidgetKind.Button -> Unselectable { HostButton(node, modifier, table, dispatcher, theme) } WidgetKind.TextField -> Unselectable { HostTextField(node, modifier, dispatcher, table.assets) } WidgetKind.LazyColumn -> HostLazyColumn(node, modifier, table, dispatcher) @@ -750,7 +750,7 @@ private fun ColumnScope.WeightedChild(childId: Int, table: NodeTable, dispatcher } @Composable -private fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { +internal fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { node.children.forEach { childId -> key(childId) { WeightedChild(childId, table, dispatcher) } } } diff --git a/renderer/desktop/test/ButtonRowTest.kt b/renderer/desktop/test/ButtonRowTest.kt new file mode 100644 index 00000000..36d0d6f7 --- /dev/null +++ b/renderer/desktop/test/ButtonRowTest.kt @@ -0,0 +1,124 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.ButtonVariant +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.TextAlign +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A Button that is a row: a left-aligned label, its own label size, and children in place + * of the label. + */ +@OptIn(ExperimentalTestApi::class) +class ButtonRowTest { + private val root = 1 + private val button = 2 + private val label = 3 + private val spacer = 4 + private val count = 5 + private val handler = 77L + + private fun theme() = Mutation.SetTheme( + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false), + ) + + private fun buttonNode(width: Float?) = buildList { + add(Mutation.Create(button, WidgetKind.Button)) + if (width != null) add(Mutation.SetModifier(button, 0, ProtocolModifier.Width(width))) + add(Mutation.SetProp(button, PropertyKind.Variant, PropertyValue.Integer(ButtonVariant.Text.ordinal + 1L))) + add(Mutation.SetProp(button, PropertyKind.OnClick, PropertyValue.Integer(handler))) + } + + private fun text(id: Int, value: String) = listOf( + Mutation.Create(id, WidgetKind.Text), + Mutation.SetProp(id, PropertyKind.Text, PropertyValue.Text(value)), + Mutation.Insert(button, id, if (id == label) 0 else 2), + ) + + private fun childrenButton() = listOf(theme(), Mutation.Create(root, WidgetKind.Box)) + + buttonNode(300f) + + text(label, "Files") + + listOf( + Mutation.Create(spacer, WidgetKind.Spacer), + Mutation.SetModifier(spacer, 0, ProtocolModifier.Weight(1f)), + Mutation.Insert(button, spacer, 1), + ) + + text(count, "12") + + listOf(Mutation.Insert(root, button, 0)) + + @Test + fun fr66_children_lay_out_as_a_row_with_the_count_at_the_right_edge() { + runComposeUiTest { + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(childrenButton()))) } + waitForIdle() + val outer = onNodeWithTag(nodeTestTag(button)).getBoundsInRoot() + val first = onNodeWithTag(nodeTestTag(label), useUnmergedTree = true).getBoundsInRoot() + val last = onNodeWithTag(nodeTestTag(count), useUnmergedTree = true).getBoundsInRoot() + assertTrue(first.left - outer.left in 0.dp..32.dp, "label starts at the inner padding: ${first.left - outer.left}") + assertTrue(outer.right - last.right in 0.dp..32.dp, "count is at the inner right edge: ${outer.right - last.right}") + val labelMid = (first.top + first.bottom) / 2 + val countMid = (last.top + last.bottom) / 2 + assertTrue((labelMid - countMid).value in -1f..1f, "children share a vertical centre") + } + } + + @Test + fun fr66_a_click_on_the_whole_button_fires_once() { + runComposeUiTest { + val connection = FakeHostConnection(childrenButton()) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + onNodeWithTag(nodeTestTag(button)).performClick() + waitForIdle() + assertEquals( + listOf(HostEvent.Clicked(button, handler)), + connection.events.filterIsInstance(), + ) + } + } + + private fun widthOf(fontSize: Float?, align: TextAlign?): Float { + var width = 0f + runComposeUiTest { + val mutations = buildList { + add(theme()) + add(Mutation.Create(root, WidgetKind.Box)) + addAll(buttonNode(null)) + add(Mutation.SetProp(button, PropertyKind.Text, PropertyValue.Text("Changes"))) + if (fontSize != null) add(Mutation.SetProp(button, PropertyKind.FontSize, PropertyValue.Float(fontSize))) + if (align != null) add(Mutation.SetProp(button, PropertyKind.TextAlign, PropertyValue.Integer(align.ordinal + 1L))) + add(Mutation.Insert(root, button, 0)) + } + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) } + waitForIdle() + val bounds = onNodeWithTag(nodeTestTag(button)).getBoundsInRoot() + width = bounds.right.value - bounds.left.value + } + return width + } + + @Test + fun fr66_font_size_sizes_the_label_and_its_absence_changes_nothing() { + assertTrue(widthOf(30f, TextAlign.Start) > widthOf(null, TextAlign.Start), "a larger FontSize widens the label") + assertEquals(widthOf(null, null), widthOf(null, TextAlign.Start), "alignment alone does not resize an unconstrained button") + } +} From 1791e0da041549ae83b9d7a49bb2e283c82bc953 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:50:31 +0900 Subject: [PATCH 110/224] Test: Check the Start label position and FontSize in the FR-66 button test; tidy imports (#425) Adds fr66_start_alignment_puts_the_label_at_the_inner_padding_and_font_size_sets_its_height. The staged Android copy is regenerated. --- .../android-kotlin/shared/HostButton.kt | 2 +- renderer/desktop/src/renderer/HostButton.kt | 2 +- renderer/desktop/test/ButtonRowTest.kt | 33 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 19c04bac..05f7d951 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -24,7 +25,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 19c04bac..05f7d951 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -24,7 +25,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp diff --git a/renderer/desktop/test/ButtonRowTest.kt b/renderer/desktop/test/ButtonRowTest.kt index 36d0d6f7..0c7f669f 100644 --- a/renderer/desktop/test/ButtonRowTest.kt +++ b/renderer/desktop/test/ButtonRowTest.kt @@ -3,8 +3,10 @@ package dev.darkpyonix.composerust.test import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.protocol.ButtonVariant import dev.darkpyonix.composerust.protocol.ColorScheme @@ -121,4 +123,35 @@ class ButtonRowTest { assertTrue(widthOf(30f, TextAlign.Start) > widthOf(null, TextAlign.Start), "a larger FontSize widens the label") assertEquals(widthOf(null, null), widthOf(null, TextAlign.Start), "alignment alone does not resize an unconstrained button") } + + private fun labelBounds(width: Float, fontSize: Float?, align: TextAlign?): Pair { + lateinit var result: Pair + runComposeUiTest { + val mutations = buildList { + add(theme()) + add(Mutation.Create(root, WidgetKind.Box)) + addAll(buttonNode(width)) + add(Mutation.SetProp(button, PropertyKind.Text, PropertyValue.Text("Changes"))) + if (fontSize != null) add(Mutation.SetProp(button, PropertyKind.FontSize, PropertyValue.Float(fontSize))) + if (align != null) add(Mutation.SetProp(button, PropertyKind.TextAlign, PropertyValue.Integer(align.ordinal + 1L))) + add(Mutation.Insert(root, button, 0)) + } + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) } + waitForIdle() + result = onNodeWithTag(nodeTestTag(button)).getBoundsInRoot() to + onNodeWithText("Changes", useUnmergedTree = true).getBoundsInRoot() + } + return result + } + + @Test + fun fr66_start_alignment_puts_the_label_at_the_inner_padding_and_font_size_sets_its_height() { + val (outer, start) = labelBounds(300f, 13f, TextAlign.Start) + val (_, centred) = labelBounds(300f, 13f, null) + assertTrue(start.left - outer.left in 0.dp..32.dp, "a Start label sits at the inner padding: ${start.left - outer.left}") + assertTrue(centred.left - outer.left > 60.dp, "an unaligned label stays centred: ${centred.left - outer.left}") + val (_, small) = labelBounds(300f, 13f, TextAlign.Start) + val (_, large) = labelBounds(300f, 26f, TextAlign.Start) + assertTrue(large.bottom - large.top > small.bottom - small.top, "FontSize sets the label size") + } } From 4994e3edb638e7cffc68c846b05aa995e0ddb25e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:40:42 +0900 Subject: [PATCH 111/224] Feat: Accessibility modifier and accessible_name on any node (FR-68, #429, #426) Adds the Semantics modifier (wire tag 34: role plus tri-state checked and selected), the AccessibleName property (119), Modifier label/role/checked/selected/semantics and accessible_name on Button, Switch, Checkbox, RadioButton and Slider. Covers FR-68 criteria 1 to 4: fr68_* tests in compose-rust/tests/fr68_semantics.rs and renderer/desktop/test/AccessibilityModifierTest.kt. Generated files regenerated, the new Accessibility.kt is linked into every platform's shared directory. Known gap: the design system's Slider exposes no range semantics node, so the every-focusable-node check leaves sliders out until the Compose fork exposes one. --- CHANGELOG.md | 4 + .../android-kotlin/shared/Accessibility.kt | 60 +++++ .../android-kotlin/shared/HostButton.kt | 9 +- .../android-kotlin/shared/ModifierChain.kt | 2 + .../android-kotlin/shared/NodeTable.kt | 3 + .../android-kotlin/shared/Protocol.gen.kt | 22 +- .../android-kotlin/shared/RenderNode.kt | 2 + compose-rust/schema-hash.txt | 2 +- compose-rust/src/lib.rs | 6 +- compose-rust/src/protocol.rs | 38 ++- compose-rust/src/schema.rs | 60 ++++- compose-rust/src/ui/mod.rs | 127 +++++++++- compose-rust/src/ui/widgets.rs | 42 ++++ compose-rust/src/writer.rs | 15 +- compose-rust/tests/fr68_semantics.rs | 118 +++++++++ compose-rust/tests/html_elements.rs | 4 + compose-rust/tests/vectors/vectors.json | 2 +- renderer/android/src/shared/Accessibility.kt | 1 + renderer/desktop/src/protocol/Protocol.gen.kt | 22 +- .../desktop/src/renderer/Accessibility.kt | 60 +++++ renderer/desktop/src/renderer/HostButton.kt | 9 +- .../desktop/src/renderer/ModifierChain.kt | 2 + renderer/desktop/src/renderer/NodeTable.kt | 3 + renderer/desktop/src/renderer/RenderNode.kt | 2 + .../desktop/test/AccessibilityModifierTest.kt | 224 ++++++++++++++++++ renderer/desktop/test/PickerTest.kt | 2 + renderer/ios/src/shared/Accessibility.kt | 1 + renderer/linux/src/shared/Accessibility.kt | 1 + renderer/macos/src/shared/Accessibility.kt | 1 + renderer/web/src/shared/Accessibility.kt | 1 + renderer/windows/src/shared/Accessibility.kt | 1 + 31 files changed, 828 insertions(+), 18 deletions(-) create mode 100644 compose-rust/android-kotlin/shared/Accessibility.kt create mode 100644 compose-rust/tests/fr68_semantics.rs create mode 120000 renderer/android/src/shared/Accessibility.kt create mode 100644 renderer/desktop/src/renderer/Accessibility.kt create mode 100644 renderer/desktop/test/AccessibilityModifierTest.kt create mode 120000 renderer/ios/src/shared/Accessibility.kt create mode 120000 renderer/linux/src/shared/Accessibility.kt create mode 120000 renderer/macos/src/shared/Accessibility.kt create mode 120000 renderer/web/src/shared/Accessibility.kt create mode 120000 renderer/windows/src/shared/Accessibility.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a2e83e..4287afdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Accessibility modifier and `accessible_name` + +`Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. + ### Modifier `match_parent_size` `Modifier` has a new `MatchParentSize` (wire tag 31) for a child of a `Box`: it fills exactly the parent's bounds without enlarging the parent, so an overlay such as a press tint needs no size or radius restated ([#431](https://github.com/DarkPyonix/compose-rust/issues/431)). The schema hash changes, so a renderer and a crate from before this change no longer agree. diff --git a/compose-rust/android-kotlin/shared/Accessibility.kt b/compose-rust/android-kotlin/shared/Accessibility.kt new file mode 100644 index 00000000..b581e914 --- /dev/null +++ b/compose-rust/android-kotlin/shared/Accessibility.kt @@ -0,0 +1,60 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.ui.node.Node + +/** The two bits of one tri-state field in the `Semantics` state word. */ +private const val FIELD_MASK = 0b11 +private const val CHECKED_SHIFT = 0 +private const val SELECTED_SHIFT = 2 + +/** A field of the state word: unset, off or on. */ +private fun Int.field(shift: Int): Boolean? = when ((this shr shift) and FIELD_MASK) { + 1 -> false + 2 -> true + else -> null +} + +private fun SemanticsRole.toRole(): Role? = when (this) { + SemanticsRole.None -> null + SemanticsRole.Button -> Role.Button + SemanticsRole.Checkbox -> Role.Checkbox + SemanticsRole.Switch -> Role.Switch + SemanticsRole.RadioButton -> Role.RadioButton + SemanticsRole.Tab -> Role.Tab + SemanticsRole.Image -> Role.Image + SemanticsRole.DropdownList -> Role.DropdownList +} + +/** + * What the Host said this node is to assistive technology: the accessible name, and the + * role and state of a `Semantics` modifier. + * + * It is applied here, outside the widget, because every widget receives this modifier as its + * own, so the one place covers a container, a control and a node the Host only labelled. A + * node that said nothing gets the same modifier back, so nothing changes for it. + */ +internal fun Modifier.accessibility(node: Node): Modifier { + val name = node.text(PropertyKind.AccessibleName).takeIf { it.isNotEmpty() } + val declared = node.modifiers.lastOrNull { it is ProtocolModifier.Semantics } as? ProtocolModifier.Semantics + if (name == null && declared == null) return this + val role = declared?.role?.toRole() + val checked = declared?.state?.field(CHECKED_SHIFT) + val selected = declared?.state?.field(SELECTED_SHIFT) + return semantics { + if (name != null) contentDescription = name + if (role != null) this.role = role + if (checked != null) toggleableState = ToggleableState(checked) + if (selected != null) this.selected = selected + } +} diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 0756781f..f2565a0b 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -184,7 +184,14 @@ internal fun HostButton( // the glyph means, so it is what the name is made of, and assistive technology never // meets an unnamed control. val named = decorated - .let { if (icon != null && text.isEmpty()) it.semantics { contentDescription = icon.name } else it } + .let { + // The Host's own name for the button is read instead of the icon's meaning. + if (icon != null && text.isEmpty() && node.text(PropertyKind.AccessibleName).isEmpty()) { + it.semantics { contentDescription = icon.name } + } else { + it + } + } .let { if (node.property(PropertyKind.Checked) != null) it.semantics { selected = checked } else it } Box( named, diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 48f25601..4b6828c8 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -176,6 +176,8 @@ internal fun List.toComposeModifier( } } is ProtocolModifier.PressFeedback -> chain + // Applied with the node's accessible name in one place, `accessibility`. + is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) is ProtocolModifier.MatchParentSize -> chain diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 22296a8a..6ff6e7ef 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -694,6 +694,9 @@ class NodeTable { internal fun supportsProperty(widget: WidgetKind, property: PropertyKind): Boolean = when (property) { + // The name assistive technology reads can be given to any node. + PropertyKind.AccessibleName -> true + // Event properties carry handler ids and are valid on any widget. PropertyKind.OnClick, PropertyKind.OnValueChange, diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 88e2476c..b18604e6 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, Progress } enum class Key { Enter } @@ -28,6 +28,8 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList } + enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } enum class TextAlign { Start, Center, End, Justify } @@ -1024,6 +1026,7 @@ sealed interface Modifier { data object MatchParentSize : Modifier data class WindowDrag(val enabled: Boolean) : Modifier data class PressFeedback(val scale: kotlin.Float, val tintAlpha: kotlin.Float) : Modifier + data class Semantics(val role: dev.darkpyonix.composerust.protocol.SemanticsRole, val state: Int) : Modifier } /** How the segment from one keyframe to the next is timed. */ @@ -1298,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = -3628289596330618536L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2186,6 +2189,7 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 119 -> PropertyKind.AccessibleName 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } @@ -2417,6 +2421,18 @@ object Protocol { else -> throw ProtocolException("unknown MaterialRole tag $tag", offset) } + private fun semanticsRole(tag: Int, offset: Int): SemanticsRole = when (tag) { + 1 -> SemanticsRole.None + 2 -> SemanticsRole.Button + 3 -> SemanticsRole.Checkbox + 4 -> SemanticsRole.Switch + 5 -> SemanticsRole.RadioButton + 6 -> SemanticsRole.Tab + 7 -> SemanticsRole.Image + 8 -> SemanticsRole.DropdownList + else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) + } + private fun spaceRole(tag: Int, offset: Int): SpaceRole = when (tag) { 1 -> SpaceRole.None 2 -> SpaceRole.Xs @@ -2864,6 +2880,7 @@ object Protocol { 31 -> 0 32 -> 0 33 -> 0 + 34 -> 0 else -> throw ProtocolException("unknown modifier tag $tag", offset) } @@ -2901,6 +2918,7 @@ object Protocol { 31 -> Modifier.MatchParentSize 32 -> Modifier.WindowDrag(flag(first, offset)) 33 -> Modifier.PressFeedback(kotlin.Float.fromBits(first.toInt()), kotlin.Float.fromBits((first ushr 32).toInt())) + 34 -> Modifier.Semantics(semanticsRole(first.toInt(), offset), second.toInt()) else -> throw ProtocolException("unknown modifier tag $tag", offset) } } diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index a4893d68..fe524798 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -50,6 +50,7 @@ import dev.darkpyonix.composerust.protocol.WidgetKind import dev.darkpyonix.composerust.design.ContainerRole import androidx.compose.ui.graphics.Shape import dev.darkpyonix.composerust.foundation.containerShape +import dev.darkpyonix.composerust.ui.accessibility import dev.darkpyonix.composerust.ui.pressable import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ToggleRole @@ -190,6 +191,7 @@ fun RenderNode( ), ) .testTag(nodeTestTag(nodeId)) + .accessibility(node) // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 0c88702d..92e05329 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x07b2695bace58374 +0xcda5b9d5ec77d158 diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 4edd0475..c44d2599 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -84,9 +84,9 @@ pub use schema::{ ColorScheme, DecorationKind, DesignSystem, EventPayload, GenericFamily, GutterDecorationKind, HoverPhase, IconRole, Key, LoopMode, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, NotificationPermission, NotificationPresentation, OverflowWrap, Paint, - PropertyKind, RevealAlign, SCHEMA_HASH, Selection, Severity, ShapeRole, SpaceRole, TextAlign, - TextOverflow, Theme, TileMode, TypeRole, WidgetKind, WindowHeightClass, WindowSizeClass, - WordBreak, + PropertyKind, RevealAlign, SCHEMA_HASH, Selection, SemanticsRole, Severity, ShapeRole, + SpaceRole, TextAlign, TextOverflow, Theme, TileMode, TypeRole, WidgetKind, WindowHeightClass, + WindowSizeClass, WordBreak, }; pub use schema::{ AnimatedProperty, Animation, AnimationControl, AnimationEventKind, AnimationEvents, diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 5b55ed32..a5b393be 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -3,8 +3,8 @@ use crate::schema::{ AssetKind, ColorScheme, DesignSystem, Key, MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, NotificationPermission, NotificationPresentation, - Paint, PropertyKind, Selection, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, WidgetKind, - modifier_extra_words, + Paint, PropertyKind, SEMANTICS_STATE_MASK, Selection, SemanticsRole, ShapeRole, SpaceRole, + TYPE_ROLE_COUNT, Theme, WidgetKind, modifier_extra_words, }; use core::fmt; @@ -2290,6 +2290,7 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { Modifier::PressFeedback { scale, tint_alpha } => { (33, two_words(pack_floats(*scale, *tint_alpha), 0)) } + Modifier::Semantics { role, state } => (34, two_words(*role as u64, u64::from(*state))), Modifier::Transform { a, b, @@ -2440,6 +2441,16 @@ fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { + let state = u32::try_from(second) + .ok() + .filter(|state| state & !SEMANTICS_STATE_MASK == 0) + .ok_or(ProtocolError::InvalidModifier(34))?; + Ok(Modifier::Semantics { + role: decode_role::(first)?, + state, + }) + } other => Err(ProtocolError::InvalidModifier(other)), } } @@ -2606,6 +2617,29 @@ mod tests { ); } + #[test] + fn fr68_semantics_round_trips_and_refuses_unknown_state_bits() { + let expected = Modifier::Semantics { + role: SemanticsRole::Switch, + state: (2 << crate::schema::SEMANTICS_CHECKED_SHIFT) + | (1 << crate::schema::SEMANTICS_SELECTED_SHIFT), + }; + let mutation = Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: expected.clone(), + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&mutation).unwrap(); + assert_eq!(decode_batch(encoder.finish().unwrap()).unwrap(), [mutation]); + + let words = [SemanticsRole::Button as u64, 0x10, 0, 0, 0, 0]; + assert_eq!( + decode_modifier(34, &words), + Err(ProtocolError::InvalidModifier(34)) + ); + } + #[test] fn fr51_press_feedback_round_trips() { let expected = [Mutation::SetModifier { diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 954b160b..70d098a5 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -183,8 +183,8 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key;", - "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,accessible_name;", + "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback,Semantics;", "keys=Enter;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", "windowsizeclasses=Compact,Medium,Expanded;", @@ -822,6 +822,19 @@ define_wire_enum!(MATERIAL_ROLE_SCHEMA, MaterialRole { Chrome = 4, }); +// What a node is to assistive technology. Compose's own `Role` names, so the Renderer maps +// each one to the platform's. `None` leaves the role the widget already has. +define_wire_enum!(SEMANTICS_ROLE_SCHEMA, SemanticsRole { + None = 1, + Button = 2, + Checkbox = 3, + Switch = 4, + RadioButton = 5, + Tab = 6, + Image = 7, + DropdownList = 8, +}); + // Density roles, because dp density differs per design system. define_wire_enum!(SPACE_ROLE_SCHEMA, SpaceRole { None = 1, @@ -1253,6 +1266,10 @@ pub const ROLE_ENUM_SCHEMA: &[RoleEnumSchema] = &[ name: "MaterialRole", variants: MATERIAL_ROLE_SCHEMA, }, + RoleEnumSchema { + name: "SemanticsRole", + variants: SEMANTICS_ROLE_SCHEMA, + }, RoleEnumSchema { name: "SpaceRole", variants: SPACE_ROLE_SCHEMA, @@ -1943,8 +1960,24 @@ pub enum Modifier { scale: f32, tint_alpha: f32, }, + /// What this node is to assistive technology: its role, and whether it is checked or + /// selected. `state` holds two tri-state fields, `SEMANTICS_CHECKED_SHIFT` and + /// `SEMANTICS_SELECTED_SHIFT` bits up, each 0 for unset, 1 for off and 2 for on. The + /// accessible name is the `AccessibleName` property, because a string does not fit in + /// a modifier's words. + Semantics { + role: SemanticsRole, + state: u32, + }, } +/// Where the checked field sits in `Modifier::Semantics::state`. +pub const SEMANTICS_CHECKED_SHIFT: u32 = 0; +/// Where the selected field sits in `Modifier::Semantics::state`. +pub const SEMANTICS_SELECTED_SHIFT: u32 = 2; +/// Every bit `Modifier::Semantics::state` may set. +pub const SEMANTICS_STATE_MASK: u32 = 0b1111; + impl Modifier { /// A border with a width and a paint for each side, in CSS order: top, right, bottom, /// left. @@ -2420,6 +2453,19 @@ const PRESS_FEEDBACK_FIELDS: &[FieldSchema] = &[ }, ]; +const SEMANTICS_FIELDS: &[FieldSchema] = &[ + FieldSchema { + name: "role", + ty: FieldType::Role("SemanticsRole"), + slot: FieldSlot::FirstLow, + }, + FieldSchema { + name: "state", + ty: FieldType::U32, + slot: FieldSlot::SecondLow, + }, +]; + pub const MODIFIER_SCHEMA: &[VariantSchema] = &[ VariantSchema { name: "Empty", @@ -2606,6 +2652,12 @@ pub const MODIFIER_SCHEMA: &[VariantSchema] = &[ extra_words: 0, fields: PRESS_FEEDBACK_FIELDS, }, + VariantSchema { + name: "Semantics", + tag: 34, + extra_words: 0, + fields: SEMANTICS_FIELDS, + }, ]; #[derive(Clone, Debug, PartialEq)] @@ -2858,6 +2910,10 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // into a window of its own, named by it. OnWindowDragOver = 167, TearOffKey = 168, + // The name assistive technology reads for a node, a string. On a widget it overrides + // the visible label, and on an icon-only button it beats the name the icon's meaning + // would give. 119 is the first free number after the island block. + AccessibleName = 119, }); #[derive(Clone, Debug, PartialEq)] diff --git a/compose-rust/src/ui/mod.rs b/compose-rust/src/ui/mod.rs index 99d853d6..ebee7428 100644 --- a/compose-rust/src/ui/mod.rs +++ b/compose-rust/src/ui/mod.rs @@ -34,10 +34,11 @@ pub use crate::brush::{Brush, Stop, brush}; pub use crate::drawing::{DrawCommand, DrawList, DrawListBuilder}; pub use crate::message::{Message, show_message}; pub use crate::runtime::{application, launch}; +pub use crate::schema::SemanticsRole as Role; pub use crate::schema::{ Alignment, Arrangement, ButtonKind, ButtonVariant, Color, ColorRole, ColorScheme, DesignSystem, - IconRole, Key, MaterialRole, MessageDuration, MotionRole, Paint, ShapeRole, SpaceRole, - TextAlign, TextOverflow, Theme, TypeRole, WindowHeightClass, WindowSizeClass, + IconRole, Key, MaterialRole, MessageDuration, MotionRole, Paint, SemanticsRole, ShapeRole, + SpaceRole, TextAlign, TextOverflow, Theme, TypeRole, WindowHeightClass, WindowSizeClass, }; pub use crate::spans::TextSpans; pub use crate::theme::{ThemeHandle, current_theme}; @@ -85,6 +86,53 @@ impl Shadow { } } +/// Whether a node is checked or selected, for assistive technology. Each is unset until +/// it is given a value, and an unset one sends nothing. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SemanticsState { + pub checked: Option, + pub selected: Option, +} + +impl SemanticsState { + pub const fn checked(checked: bool) -> Self { + Self { + checked: Some(checked), + selected: None, + } + } + + pub const fn selected(selected: bool) -> Self { + Self { + checked: None, + selected: Some(selected), + } + } +} + +/// The text of an accessible name, kept for as long as the program runs so a `Modifier` +/// can stay `Copy`. Equal names share one allocation, so a name that is written on every +/// frame costs one lookup and no memory; only a name never seen before is kept. +fn keep_label(label: &str) -> Option<&'static str> { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + + if label.is_empty() { + return None; + } + static LABELS: OnceLock>> = OnceLock::new(); + let mut labels = LABELS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(kept) = labels.get(label) { + return Some(*kept); + } + let kept: &'static str = Box::leak(label.to_owned().into_boxed_str()); + labels.insert(kept); + Some(kept) +} + /// What sits around a widget: its size, its surface, its outline, how far its content /// is inset, and what the Renderer reports about it. Compose's `Modifier`. /// @@ -116,6 +164,9 @@ pub struct Modifier { elevation: Option, alpha: Option, press_feedback: Option<(f32, f32)>, + semantics_role: Option, + semantics_label: Option<&'static str>, + semantics_state: SemanticsState, transform: Option<[f32; 8]>, animated_alpha: Option>, animated_background: Option>, @@ -156,6 +207,12 @@ impl Modifier { elevation: None, alpha: None, press_feedback: None, + semantics_role: None, + semantics_label: None, + semantics_state: SemanticsState { + checked: None, + selected: None, + }, transform: None, animated_alpha: None, animated_background: None, @@ -358,6 +415,39 @@ impl Modifier { self } + /// The name assistive technology reads for this node. On a widget with a visible label + /// it replaces that label; an empty name is the same as none. + pub fn label(mut self, label: &str) -> Self { + self.semantics_label = keep_label(label); + self + } + + /// What this node is to assistive technology, for a node that is not a widget with + /// a role of its own: a `Box` that is pressed is a button. + pub const fn role(mut self, role: Role) -> Self { + self.semantics_role = Some(role); + self + } + + /// Whether the node is checked, as a toggle announces it. + pub const fn checked(mut self, checked: bool) -> Self { + self.semantics_state.checked = Some(checked); + self + } + + /// Whether the node is the selected one of its group. + pub const fn selected(mut self, selected: bool) -> Self { + self.semantics_state.selected = Some(selected); + self + } + + /// The role, the name and the state in one call. + pub fn semantics(self, role: Role, label: &str, state: SemanticsState) -> Self { + let mut named = self.role(role).label(label); + named.semantics_state = state; + named + } + /// Compose's `graphicsLayer`: a rotation, a scale and a translation about an origin, /// drawn without moving the layout, and an opacity for the whole group. A pointer is /// tested against the shape that is drawn. @@ -469,6 +559,25 @@ pub(crate) fn optional_text(value: Option) -> OwnedAttr { value.map_or(OwnedAttr::None, OwnedAttr::Text) } +/// The role and state of a Modifier as the one integer the `semantics` attribute carries, +/// or none when neither was given. +fn semantics_bits(modifier: &Modifier) -> Option { + use crate::schema::{SEMANTICS_CHECKED_SHIFT, SEMANTICS_SELECTED_SHIFT}; + + let state = modifier.semantics_state; + if modifier.semantics_role.is_none() && state.checked.is_none() && state.selected.is_none() { + return None; + } + let tri = |value: Option| value.map_or(0_i64, |on| if on { 2 } else { 1 }); + let role = i64::from(u16::from( + modifier.semantics_role.unwrap_or(SemanticsRole::None), + )); + Some( + role | (tri(state.checked) << (16 + SEMANTICS_CHECKED_SHIFT)) + | (tri(state.selected) << (16 + SEMANTICS_SELECTED_SHIFT)), + ) +} + /// Writes the Modifier attributes, in the order the `rsx!` widgets write them. pub(crate) fn push_modifiers(modifier: &Modifier, mods: Mods, out: &mut Attrs) { let container = mods == Mods::Container; @@ -556,6 +665,20 @@ pub(crate) fn push_modifiers(modifier: &Modifier, mods: Mods, out: &mut Attrs) { OwnedAttr::Bytes([scale.to_le_bytes(), alpha.to_le_bytes()].concat()) }), ); + // The role and state travel as one integer, the role in the low 16 bits; the name is a + // property of the node because a string does not fit in a modifier's words. + push( + false, + "semantics", + semantics_bits(modifier).map_or(OwnedAttr::None, OwnedAttr::Int), + ); + push( + false, + "accessible_name", + modifier + .semantics_label + .map_or(OwnedAttr::None, |label| OwnedAttr::Text(label.to_owned())), + ); push( false, "alpha", diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 964d9588..bc67cba9 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -1008,6 +1008,30 @@ fn lines_and_overflow(max_lines: Option, overflow: Option, at } } +/// Writes a widget's own accessible name. It replaces one the Modifier gave, so the name +/// said on the widget is the one that reaches assistive technology. +fn push_accessible_name(name: Option, attrs: &mut Attrs) { + if let Some(name) = name.filter(|name| !name.is_empty()) { + attrs.retain(|(key, _)| *key != "accessible_name"); + attrs.push(("accessible_name", OwnedAttr::Text(name))); + } +} + +/// Adds `accessible_name` to a builder that has an `accessible_name` field. +macro_rules! accessible_name { + ($name:ident) => { + impl $name { + /// The name assistive technology reads. With no visible label it is the + /// control's name; with one it replaces the label, and on an icon-only button + /// it is read instead of the icon's meaning. + pub fn accessible_name(mut self, name: impl Into) -> Self { + self.accessible_name = Some(name.into()); + self + } + } + }; +} + /// A `Button` and its builder. pub struct ButtonScope { modifier: Modifier, @@ -1019,6 +1043,7 @@ pub struct ButtonScope { color: Option, max_lines: Option, overflow: Option, + accessible_name: Option, on_click: Option>, done: bool, } @@ -1039,11 +1064,14 @@ pub fn Button(text: impl Into) -> ButtonScope { color: None, max_lines: None, overflow: None, + accessible_name: None, on_click: None, done: false, } } +accessible_name!(ButtonScope); + impl ButtonScope { /// The meaning of the glyph on it, never a picture. pub fn icon(mut self, icon: IconRole) -> Self { @@ -1104,6 +1132,7 @@ impl ButtonScope { let (icon, enabled, variant, kind, color) = (self.icon, self.enabled, self.variant, self.kind, self.color); let (max_lines, overflow) = (self.max_lines, self.overflow); + let accessible_name = self.accessible_name.take(); let listeners = vec![("onclick", unit(self.on_click.take()))]; emit( widget_key("compose_rust::ui::Button"), @@ -1117,6 +1146,7 @@ impl ButtonScope { attrs.push(("button_kind", role_or_zero(kind))); attrs.push(("color", super::paint(color))); lines_and_overflow(max_lines, overflow, attrs); + push_accessible_name(accessible_name, attrs); }, listeners, content, @@ -1135,6 +1165,7 @@ macro_rules! toggle { modifier: Modifier, checked: bool, enabled: bool, + accessible_name: Option, on_change: Option>, done: bool, } @@ -1145,11 +1176,14 @@ macro_rules! toggle { modifier: Modifier::new(), checked: $setter, enabled: true, + accessible_name: None, on_change: None, done: false, } } + accessible_name!($name); + impl $name { pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; @@ -1166,6 +1200,7 @@ macro_rules! toggle { fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let (checked, enabled) = (self.checked, self.enabled); + let accessible_name = self.accessible_name.take(); let handler: EventCallback = match self.on_change.take() { Some(mut handler) => EventCallback::Value(Rc::new(RefCell::new( // Off is 0.0 and on is anything else, so a Renderer that reports a @@ -1181,6 +1216,7 @@ macro_rules! toggle { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("checked", OwnedAttr::Bool(checked))); attrs.push(("enabled", OwnedAttr::Bool(enabled))); + push_accessible_name(accessible_name, attrs); }, vec![("onchange", handler)], content, @@ -1223,6 +1259,7 @@ pub struct SliderScope { max: f32, steps: u32, enabled: bool, + accessible_name: Option, on_change: Option>, done: bool, } @@ -1238,11 +1275,14 @@ pub fn Slider(value: f32) -> SliderScope { max: 1.0, steps: 0, enabled: true, + accessible_name: None, on_change: None, done: false, } } +accessible_name!(SliderScope); + impl SliderScope { /// The two ends. Compose's `valueRange`. pub fn range(mut self, min: f32, max: f32) -> Self { @@ -1283,6 +1323,7 @@ impl SliderScope { self.steps, self.enabled, ); + let accessible_name = self.accessible_name.take(); let handler = match self.on_change.take() { Some(mut handler) => EventCallback::Value(Rc::new(RefCell::new(move |value: f64| { handler(value as f32) @@ -1300,6 +1341,7 @@ impl SliderScope { attrs.push(("max", OwnedAttr::Float(f64::from(max)))); attrs.push(("steps", OwnedAttr::Int(i64::from(steps)))); attrs.push(("enabled", OwnedAttr::Bool(enabled))); + push_accessible_name(accessible_name, attrs); }, vec![("onchange", handler)], content, diff --git a/compose-rust/src/writer.rs b/compose-rust/src/writer.rs index d10f2d47..aa427165 100644 --- a/compose-rust/src/writer.rs +++ b/compose-rust/src/writer.rs @@ -28,7 +28,7 @@ const SPANS_BIT: u32 = 63; /// How many Modifier slots a node has. The slot numbers are assigned in `modifier_for`, /// and this is one past the last of them. -const MODIFIER_SLOTS: usize = 25; +const MODIFIER_SLOTS: usize = 26; /// The first of the slots a node's shadow layers take, and how many there are. const SHADOW_BASE: u16 = 8; @@ -345,6 +345,7 @@ impl NodeWriter { const MATCH_PARENT_SIZE: u16 = 22; const WINDOW_DRAG: u16 = 23; const PRESS_FEEDBACK: u16 = 24; + const SEMANTICS: u16 = 25; let float = |value: &AttrValue<'_>| match value { AttrValue::Float(number) => Some(*number as f32), @@ -379,6 +380,7 @@ impl NodeWriter { "motion" => Some(MOTION), "material" => Some(MATERIAL), "press_feedback" => Some(PRESS_FEEDBACK), + "semantics" => Some(SEMANTICS), _ => None, }; let slot = slot_of(name)?; @@ -558,6 +560,17 @@ impl NodeWriter { Modifier::PressFeedback { scale, tint_alpha }, ))) } + // The role in the low 16 bits and the state above them, as one integer. + "semantics" => { + let packed = u32::try_from(integer(value)?).ok()?; + let state = packed >> 16; + if state & !crate::schema::SEMANTICS_STATE_MASK != 0 { + return Some(None); + } + let role = + crate::SemanticsRole::try_from(u16::try_from(packed & 0xffff).ok()?).ok()?; + Some(Some((SEMANTICS, Modifier::Semantics { role, state }))) + } "offset" => { let AttrValue::Bytes(bytes) = value else { return Some(None); diff --git a/compose-rust/tests/fr68_semantics.rs b/compose-rust/tests/fr68_semantics.rs new file mode 100644 index 00000000..8c06b104 --- /dev/null +++ b/compose-rust/tests/fr68_semantics.rs @@ -0,0 +1,118 @@ +//! A node can say what it is and what it is called to assistive technology. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +#[composable] +fn Plain() { + Column().content(|| { + Box().modifier(Modifier.padding(4.0)).content(|| { + Text("plain"); + }); + Button("Save").on_click(|| {}); + Switch(true); + }); +} + +#[composable] +fn Named() { + Column().content(|| { + Box() + .modifier(Modifier.label("Refresh").role(Role::Button)) + .on_click(|| {}) + .content(|| { + Text("↻"); + }); + Button("") + .icon(IconRole::Add) + .accessible_name("Import") + .on_click(|| {}); + Button("Save") + .modifier(Modifier.label("From the modifier")) + .accessible_name("Save the draft") + .on_click(|| {}); + Switch(true).accessible_name("Notifications"); + Checkbox(false).accessible_name("Remember me"); + RadioButton(true).accessible_name("Small"); + Slider(0.5).accessible_name("Volume"); + }); +} + +#[composable] +fn Stated() { + Column().content(|| { + Box() + .modifier(Modifier.semantics(Role::Tab, "Files", SemanticsState::selected(true))) + .content(|| {}); + Box().modifier(Modifier.checked(false)).content(|| {}); + }); +} + +#[test] +fn fr68_a_node_that_asks_for_no_semantics_sends_none() { + let dump = start(Plain).dump(); + assert!(!dump.contains("AccessibleName"), "{dump}"); + assert!(!dump.contains("Semantics"), "{dump}"); +} + +#[test] +fn fr68_the_modifier_sends_the_name_and_the_role() { + let dump = start(Named).dump(); + assert!(dump.contains("AccessibleName=\"Refresh\""), "{dump}"); + assert!( + dump.contains("Semantics { role: Button, state: 0 }"), + "{dump}" + ); +} + +#[test] +fn fr68_the_modifier_sends_the_state_and_an_unset_role_stays_none() { + let dump = start(Stated).dump(); + // Selected on is 2 in the selected field, which starts two bits up. + assert!(dump.contains("Semantics { role: Tab, state: 8 }"), "{dump}"); + assert!(dump.contains("AccessibleName=\"Files\""), "{dump}"); + // Checked off is 1 in the checked field, with no role given. + assert!( + dump.contains("Semantics { role: None, state: 1 }"), + "{dump}" + ); +} + +#[test] +fn fr68_accessible_name_reaches_every_control_that_has_one() { + let dump = start(Named).dump(); + for (widget, name) in [ + ("Button", "Import"), + ("Switch", "Notifications"), + ("Checkbox", "Remember me"), + ("RadioButton", "Small"), + ("Slider", "Volume"), + ] { + let line = dump + .lines() + .find(|line| line.trim_start().starts_with(widget)) + .unwrap_or_else(|| panic!("no {widget} in\n{dump}")); + assert!( + line.contains(&format!("AccessibleName=\"{name}\"")), + "{widget}: {line}" + ); + } +} + +#[test] +fn fr68_the_widget_name_wins_over_the_modifier_label_and_the_icon() { + let dump = start(Named).dump(); + assert!(dump.contains("AccessibleName=\"Save the draft\""), "{dump}"); + assert!(!dump.contains("From the modifier"), "{dump}"); +} diff --git a/compose-rust/tests/html_elements.rs b/compose-rust/tests/html_elements.rs index 38d354a3..de7f9139 100644 --- a/compose-rust/tests/html_elements.rs +++ b/compose-rust/tests/html_elements.rs @@ -138,6 +138,10 @@ fn every_modifier() -> Vec { scale: 0.975, tint_alpha: 0.07, }, + Modifier::Semantics { + role: compose_rust::SemanticsRole::Button, + state: 0b0110, + }, ] } diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 29de9f06..b43eec3d 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "554621546583655284", + "schemaHashUnsigned": "14818454477378933080", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/android/src/shared/Accessibility.kt b/renderer/android/src/shared/Accessibility.kt new file mode 120000 index 00000000..f54ae078 --- /dev/null +++ b/renderer/android/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 88e2476c..b18604e6 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, Progress } enum class Key { Enter } @@ -28,6 +28,8 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList } + enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } enum class TextAlign { Start, Center, End, Justify } @@ -1024,6 +1026,7 @@ sealed interface Modifier { data object MatchParentSize : Modifier data class WindowDrag(val enabled: Boolean) : Modifier data class PressFeedback(val scale: kotlin.Float, val tintAlpha: kotlin.Float) : Modifier + data class Semantics(val role: dev.darkpyonix.composerust.protocol.SemanticsRole, val state: Int) : Modifier } /** How the segment from one keyframe to the next is timed. */ @@ -1298,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = -3628289596330618536L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2186,6 +2189,7 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 119 -> PropertyKind.AccessibleName 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } @@ -2417,6 +2421,18 @@ object Protocol { else -> throw ProtocolException("unknown MaterialRole tag $tag", offset) } + private fun semanticsRole(tag: Int, offset: Int): SemanticsRole = when (tag) { + 1 -> SemanticsRole.None + 2 -> SemanticsRole.Button + 3 -> SemanticsRole.Checkbox + 4 -> SemanticsRole.Switch + 5 -> SemanticsRole.RadioButton + 6 -> SemanticsRole.Tab + 7 -> SemanticsRole.Image + 8 -> SemanticsRole.DropdownList + else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) + } + private fun spaceRole(tag: Int, offset: Int): SpaceRole = when (tag) { 1 -> SpaceRole.None 2 -> SpaceRole.Xs @@ -2864,6 +2880,7 @@ object Protocol { 31 -> 0 32 -> 0 33 -> 0 + 34 -> 0 else -> throw ProtocolException("unknown modifier tag $tag", offset) } @@ -2901,6 +2918,7 @@ object Protocol { 31 -> Modifier.MatchParentSize 32 -> Modifier.WindowDrag(flag(first, offset)) 33 -> Modifier.PressFeedback(kotlin.Float.fromBits(first.toInt()), kotlin.Float.fromBits((first ushr 32).toInt())) + 34 -> Modifier.Semantics(semanticsRole(first.toInt(), offset), second.toInt()) else -> throw ProtocolException("unknown modifier tag $tag", offset) } } diff --git a/renderer/desktop/src/renderer/Accessibility.kt b/renderer/desktop/src/renderer/Accessibility.kt new file mode 100644 index 00000000..b581e914 --- /dev/null +++ b/renderer/desktop/src/renderer/Accessibility.kt @@ -0,0 +1,60 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.ui.node.Node + +/** The two bits of one tri-state field in the `Semantics` state word. */ +private const val FIELD_MASK = 0b11 +private const val CHECKED_SHIFT = 0 +private const val SELECTED_SHIFT = 2 + +/** A field of the state word: unset, off or on. */ +private fun Int.field(shift: Int): Boolean? = when ((this shr shift) and FIELD_MASK) { + 1 -> false + 2 -> true + else -> null +} + +private fun SemanticsRole.toRole(): Role? = when (this) { + SemanticsRole.None -> null + SemanticsRole.Button -> Role.Button + SemanticsRole.Checkbox -> Role.Checkbox + SemanticsRole.Switch -> Role.Switch + SemanticsRole.RadioButton -> Role.RadioButton + SemanticsRole.Tab -> Role.Tab + SemanticsRole.Image -> Role.Image + SemanticsRole.DropdownList -> Role.DropdownList +} + +/** + * What the Host said this node is to assistive technology: the accessible name, and the + * role and state of a `Semantics` modifier. + * + * It is applied here, outside the widget, because every widget receives this modifier as its + * own, so the one place covers a container, a control and a node the Host only labelled. A + * node that said nothing gets the same modifier back, so nothing changes for it. + */ +internal fun Modifier.accessibility(node: Node): Modifier { + val name = node.text(PropertyKind.AccessibleName).takeIf { it.isNotEmpty() } + val declared = node.modifiers.lastOrNull { it is ProtocolModifier.Semantics } as? ProtocolModifier.Semantics + if (name == null && declared == null) return this + val role = declared?.role?.toRole() + val checked = declared?.state?.field(CHECKED_SHIFT) + val selected = declared?.state?.field(SELECTED_SHIFT) + return semantics { + if (name != null) contentDescription = name + if (role != null) this.role = role + if (checked != null) toggleableState = ToggleableState(checked) + if (selected != null) this.selected = selected + } +} diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 0756781f..f2565a0b 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -184,7 +184,14 @@ internal fun HostButton( // the glyph means, so it is what the name is made of, and assistive technology never // meets an unnamed control. val named = decorated - .let { if (icon != null && text.isEmpty()) it.semantics { contentDescription = icon.name } else it } + .let { + // The Host's own name for the button is read instead of the icon's meaning. + if (icon != null && text.isEmpty() && node.text(PropertyKind.AccessibleName).isEmpty()) { + it.semantics { contentDescription = icon.name } + } else { + it + } + } .let { if (node.property(PropertyKind.Checked) != null) it.semantics { selected = checked } else it } Box( named, diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 48f25601..4b6828c8 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -176,6 +176,8 @@ internal fun List.toComposeModifier( } } is ProtocolModifier.PressFeedback -> chain + // Applied with the node's accessible name in one place, `accessibility`. + is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) is ProtocolModifier.MatchParentSize -> chain diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 22296a8a..6ff6e7ef 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -694,6 +694,9 @@ class NodeTable { internal fun supportsProperty(widget: WidgetKind, property: PropertyKind): Boolean = when (property) { + // The name assistive technology reads can be given to any node. + PropertyKind.AccessibleName -> true + // Event properties carry handler ids and are valid on any widget. PropertyKind.OnClick, PropertyKind.OnValueChange, diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index a4893d68..fe524798 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -50,6 +50,7 @@ import dev.darkpyonix.composerust.protocol.WidgetKind import dev.darkpyonix.composerust.design.ContainerRole import androidx.compose.ui.graphics.Shape import dev.darkpyonix.composerust.foundation.containerShape +import dev.darkpyonix.composerust.ui.accessibility import dev.darkpyonix.composerust.ui.pressable import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ToggleRole @@ -190,6 +191,7 @@ fun RenderNode( ), ) .testTag(nodeTestTag(nodeId)) + .accessibility(node) // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) diff --git a/renderer/desktop/test/AccessibilityModifierTest.kt b/renderer/desktop/test/AccessibilityModifierTest.kt new file mode 100644 index 00000000..3af7ff27 --- /dev/null +++ b/renderer/desktop/test/AccessibilityModifierTest.kt @@ -0,0 +1,224 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.runComposeUiTest +import dev.darkpyonix.composerust.protocol.IconRole +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val TREE_ROOT = 1 +private const val TAG_PREFIX = "compose-node-" +private const val SOME_HANDLER = 77L + +private fun name(text: String) = PropertyValue.Text(text) + +/** What a screen reader would read for a node: its description, then its text. */ +private fun SemanticsNode.spoken(): String { + val description = config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() + val text = config.getOrNull(SemanticsProperties.Text).orEmpty().map { it.text } + return (description + text).joinToString(" ").trim() +} + +private fun SemanticsNode.tag(): String? = config.getOrNull(SemanticsProperties.TestTag) + +/** Everything that can take focus or be operated, which is what must be named. */ +private val operable = SemanticsMatcher("can be operated") { + it.tag()?.startsWith(TAG_PREFIX) == true && ( + it.config.contains(SemanticsActions.OnClick) || + it.config.contains(SemanticsProperties.ToggleableState) || + it.config.contains(SemanticsProperties.Selected) || + it.config.contains(SemanticsProperties.ProgressBarRangeInfo) + ) +} + +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.show(mutations: List) { + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) } + waitForIdle() +} + +/** A column holding one of every kind of control, each given a name unless it names itself. */ +private fun everyOperable(named: Boolean) = buildList { + add(Mutation.Create(TREE_ROOT, WidgetKind.Column)) + var next = TREE_ROOT + 1 + fun child(widget: WidgetKind, setup: (Int) -> List): Int { + val id = next++ + add(Mutation.Create(id, widget)) + addAll(setup(id)) + add(Mutation.Insert(TREE_ROOT, id, id - TREE_ROOT - 1)) + return id + } + fun label(id: Int, text: String) = + if (named) listOf(Mutation.SetProp(id, PropertyKind.AccessibleName, name(text))) else emptyList() + + // A button with a word, and one with only a glyph, which is named by its meaning. + child(WidgetKind.Button) { + listOf( + Mutation.SetProp(it, PropertyKind.Text, name("Save")), + Mutation.SetProp(it, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + } + child(WidgetKind.Button) { + listOf( + Mutation.SetProp(it, PropertyKind.Text, name("")), + Mutation.SetProp(it, PropertyKind.Icon, PropertyValue.Integer(IconRole.Add.ordinal + 1L)), + Mutation.SetProp(it, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "Import") + } + // A pressable container with no text of its own. + child(WidgetKind.Box) { + listOf( + Mutation.SetProp(it, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "Refresh") + } + for (kind in listOf(WidgetKind.Switch, WidgetKind.Checkbox, WidgetKind.RadioButton)) { + child(kind) { + listOf( + Mutation.SetProp(it, PropertyKind.Checked, PropertyValue.Bool(true)), + Mutation.SetProp(it, PropertyKind.OnValueChange, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "$kind option") + } + } + child(WidgetKind.Slider) { + listOf( + Mutation.SetProp(it, PropertyKind.Value, PropertyValue.Float(0.5f)), + Mutation.SetProp(it, PropertyKind.Min, PropertyValue.Float(0f)), + Mutation.SetProp(it, PropertyKind.Max, PropertyValue.Float(1f)), + Mutation.SetProp(it, PropertyKind.OnValueChange, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "Volume") + } +} + +/** The nodes that can be operated and that a screen reader would find without a name. */ +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.unnamed(): List = + onAllNodes(operable).fetchSemanticsNodes().filter { it.spoken().isEmpty() }.map { it.tag().orEmpty() } + +@OptIn(ExperimentalTestApi::class) +class AccessibilityModifierTest { + @Test + fun fr68_every_operable_node_has_a_name() { + runComposeUiTest { + show(everyOperable(named = true)) + val operableCount = onAllNodes(operable).fetchSemanticsNodes().size + assertTrue(operableCount >= 6, "only $operableCount operable nodes were found, so the check proves nothing") + val missing = unnamed() + assertTrue(missing.isEmpty(), "these nodes can be operated and have no name: $missing") + } + } + + @Test + fun fr68_the_check_finds_a_control_that_has_no_name() { + runComposeUiTest { + show(everyOperable(named = false)) + val missing = unnamed() + // The pressable container and the three toggles say nothing by themselves. The slider + // is left out: the design system's slider publishes no range semantics yet, so it is + // not an operable node to this check. + assertTrue(missing.size >= 4, "an unnamed control went unnoticed: $missing") + assertTrue(missing.all { it.startsWith(TAG_PREFIX) }, "$missing") + } + } + + @Test + fun fr68_a_box_with_a_label_and_a_role_is_announced_as_both() { + val box = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetProp(box, PropertyKind.AccessibleName, name("Refresh")), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Button, 0)), + ), + ) + val node = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode() + assertEquals(listOf("Refresh"), node.config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(Role.Button, node.config.getOrNull(SemanticsProperties.Role)) + } + } + + @Test + fun fr68_a_node_that_asks_for_nothing_gets_no_semantics_of_its_own() { + val box = 1 + runComposeUiTest { + show(listOf(Mutation.Create(box, WidgetKind.Box))) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertEquals(null, config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(null, config.getOrNull(SemanticsProperties.Role)) + } + } + + @Test + fun fr68_the_state_of_a_modifier_reaches_the_semantics() { + val box = 1 + runComposeUiTest { + // Checked on is 2 in the low field, selected off is 1 in the field above it. + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Tab, 2 or (1 shl 2))), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertEquals(Role.Tab, config.getOrNull(SemanticsProperties.Role)) + assertEquals(ToggleableState.On, config.getOrNull(SemanticsProperties.ToggleableState)) + assertEquals(false, config.getOrNull(SemanticsProperties.Selected)) + } + } + + @Test + fun fr68_the_accessible_name_beats_the_name_of_the_icon() { + val button = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(button, WidgetKind.Button), + Mutation.SetProp(button, PropertyKind.Text, name("")), + Mutation.SetProp(button, PropertyKind.Icon, PropertyValue.Integer(IconRole.Search.ordinal + 1L)), + Mutation.SetProp(button, PropertyKind.AccessibleName, name("Import")), + Mutation.SetProp(button, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ), + ) + val node = onNodeWithTag(nodeTestTag(button)).fetchSemanticsNode() + assertEquals(listOf("Import"), node.config.getOrNull(SemanticsProperties.ContentDescription)) + } + } + + @Test + fun fr68_a_named_switch_says_its_name_and_that_it_is_on() { + val switch = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(switch, WidgetKind.Switch), + Mutation.SetProp(switch, PropertyKind.Checked, PropertyValue.Bool(true)), + Mutation.SetProp(switch, PropertyKind.AccessibleName, name("Notifications")), + Mutation.SetProp(switch, PropertyKind.OnValueChange, PropertyValue.Integer(SOME_HANDLER)), + ), + ) + val config = onNodeWithTag(nodeTestTag(switch)).fetchSemanticsNode().config + assertEquals(listOf("Notifications"), config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(ToggleableState.On, config.getOrNull(SemanticsProperties.ToggleableState)) + } + } +} diff --git a/renderer/desktop/test/PickerTest.kt b/renderer/desktop/test/PickerTest.kt index 44e17f62..98fd8641 100644 --- a/renderer/desktop/test/PickerTest.kt +++ b/renderer/desktop/test/PickerTest.kt @@ -212,6 +212,8 @@ class PickerTest { PropertyKind.OnKeyDown, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, + // The name assistive technology reads can be given to any node. + PropertyKind.AccessibleName, ) listOf(WidgetKind.DatePicker, WidgetKind.TimePicker).forEach { widget -> PropertyKind.entries.forEach { property -> diff --git a/renderer/ios/src/shared/Accessibility.kt b/renderer/ios/src/shared/Accessibility.kt new file mode 120000 index 00000000..f54ae078 --- /dev/null +++ b/renderer/ios/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/Accessibility.kt b/renderer/linux/src/shared/Accessibility.kt new file mode 120000 index 00000000..f54ae078 --- /dev/null +++ b/renderer/linux/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/Accessibility.kt b/renderer/macos/src/shared/Accessibility.kt new file mode 120000 index 00000000..f54ae078 --- /dev/null +++ b/renderer/macos/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/web/src/shared/Accessibility.kt b/renderer/web/src/shared/Accessibility.kt new file mode 120000 index 00000000..f54ae078 --- /dev/null +++ b/renderer/web/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/Accessibility.kt b/renderer/windows/src/shared/Accessibility.kt new file mode 120000 index 00000000..f54ae078 --- /dev/null +++ b/renderer/windows/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file From c75cf90913bdecc206a6f7d03f91b6a810e3d6de Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:09:45 +0900 Subject: [PATCH 112/224] Fix: Bound the label table, drop the per-frame label allocation, name the slider gap (FR-68, #429) Modifier::label keeps at most 4096 distinct labels and looks a repeated label up without allocating or locking; the accessible name is written from a static string. Adds fr68_repeated_labels_do_not_grow_the_table, fr68_labels_past_the_cap_are_dropped and fr68_a_box_with_a_label_and_a_role_is_one_semantics_node. The slider exclusion in the every-node check now names thisisthepy/compose-multiplatform-core-extended#180. --- CHANGELOG.md | 2 +- compose-rust/src/runtime/composer.rs | 3 + compose-rust/src/schema.rs | 3 +- compose-rust/src/ui/mod.rs | 132 +++++++++++++++--- .../desktop/test/AccessibilityModifierTest.kt | 36 ++++- 5 files changed, 150 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4287afdf..2ce39985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Accessibility modifier and `accessible_name` -`Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. +`Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. `Modifier::label` keeps each distinct name for the life of the program, at most 4096 of them; a name past that limit is left off, and debug builds print a warning once. ### Modifier `match_parent_size` diff --git a/compose-rust/src/runtime/composer.rs b/compose-rust/src/runtime/composer.rs index d6e21c6b..8145e565 100644 --- a/compose-rust/src/runtime/composer.rs +++ b/compose-rust/src/runtime/composer.rs @@ -119,6 +119,8 @@ pub(crate) enum Observer { pub(crate) enum OwnedAttr { None, Text(String), + /// A text kept for the life of the program, such as an interned accessible name. + Label(&'static str), Float(f64), Int(i64), Bool(bool), @@ -134,6 +136,7 @@ impl OwnedAttr { match self { Self::None => AttrValue::None, Self::Text(text) => AttrValue::Text(text), + Self::Label(text) => AttrValue::Text(text), Self::Float(value) => AttrValue::Float(*value), Self::Int(value) => AttrValue::Int(*value), Self::Bool(value) => AttrValue::Bool(*value), diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 70d098a5..fe35eb2f 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -2912,7 +2912,8 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO TearOffKey = 168, // The name assistive technology reads for a node, a string. On a widget it overrides // the visible label, and on an icon-only button it beats the name the icon's meaning - // would give. 119 is the first free number after the island block. + // would give. 117 and 118 are reserved for another pending change, and 119 is the next + // free number; 120 is `IslandZoom`. AccessibleName = 119, }); diff --git a/compose-rust/src/ui/mod.rs b/compose-rust/src/ui/mod.rs index ebee7428..f9202186 100644 --- a/compose-rust/src/ui/mod.rs +++ b/compose-rust/src/ui/mod.rs @@ -110,27 +110,86 @@ impl SemanticsState { } } -/// The text of an accessible name, kept for as long as the program runs so a `Modifier` -/// can stay `Copy`. Equal names share one allocation, so a name that is written on every -/// frame costs one lookup and no memory; only a name never seen before is kept. -fn keep_label(label: &str) -> Option<&'static str> { - use std::collections::HashSet; - use std::sync::{Mutex, OnceLock}; +/// The most distinct accessible names a program can keep. Names are kept for the life of +/// the program so a `Modifier` can stay `Copy`; the cap keeps a name built from data +/// (an id, a counter) from growing that memory without end. +pub(crate) const MAX_LABELS: usize = 4096; + +/// The distinct accessible names seen so far, each leaked once. +struct LabelTable { + kept: std::sync::RwLock>, + warned: std::sync::atomic::AtomicBool, +} - if label.is_empty() { - return None; +impl LabelTable { + fn new() -> Self { + Self { + kept: Default::default(), + warned: Default::default(), + } + } + + /// The kept copy of `label`. A name already kept is found under a shared lock with no + /// allocation. A new name is kept until the table holds `cap` names; past that it is + /// dropped (and reported in debug builds) so memory stays bounded. + fn intern(&self, label: &str, cap: usize) -> Option<&'static str> { + use std::sync::atomic::Ordering; + + if label.is_empty() { + return None; + } + { + let kept = self.kept.read().unwrap_or_else(|p| p.into_inner()); + if let Some(found) = kept.get(label) { + return Some(*found); + } + } + let mut kept = self.kept.write().unwrap_or_else(|p| p.into_inner()); + if let Some(found) = kept.get(label) { + return Some(*found); + } + if kept.len() >= cap { + if cfg!(debug_assertions) && !self.warned.swap(true, Ordering::Relaxed) { + eprintln!( + "compose-rust: more than {cap} distinct accessible names; \ + names past the limit are left off. Use a fixed set of names." + ); + } + return None; + } + let leaked: &'static str = Box::leak(label.to_owned().into_boxed_str()); + kept.insert(leaked); + Some(leaked) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.kept.read().unwrap_or_else(|p| p.into_inner()).len() } - static LABELS: OnceLock>> = OnceLock::new(); - let mut labels = LABELS - .get_or_init(Default::default) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(kept) = labels.get(label) { - return Some(*kept); - } - let kept: &'static str = Box::leak(label.to_owned().into_boxed_str()); - labels.insert(kept); - Some(kept) +} + +fn keep_label(label: &str) -> Option<&'static str> { + use std::cell::Cell; + use std::sync::OnceLock; + + static LABELS: OnceLock = OnceLock::new(); + thread_local! { + // The name this thread kept last. A name written on every frame is found here + // without taking any lock. + static LAST: Cell> = const { Cell::new(None) }; + } + if let Some(last) = LAST.with(Cell::get) { + if last == label { + return Some(last); + } + } + let kept = LABELS + .get_or_init(LabelTable::new) + .intern(label, MAX_LABELS); + if kept.is_some() { + LAST.with(|last| last.set(kept)); + } + kept } /// What sits around a widget: its size, its surface, its outline, how far its content @@ -417,6 +476,10 @@ impl Modifier { /// The name assistive technology reads for this node. On a widget with a visible label /// it replaces that label; an empty name is the same as none. + /// + /// Names are kept for the life of the program, and at most 4096 distinct names are + /// kept. A name past that limit is left off (debug builds print a warning once), so + /// use a fixed set of names rather than one built from changing data. pub fn label(mut self, label: &str) -> Self { self.semantics_label = keep_label(label); self @@ -677,7 +740,7 @@ pub(crate) fn push_modifiers(modifier: &Modifier, mods: Mods, out: &mut Attrs) { "accessible_name", modifier .semantics_label - .map_or(OwnedAttr::None, |label| OwnedAttr::Text(label.to_owned())), + .map_or(OwnedAttr::None, OwnedAttr::Label), ); push( false, @@ -853,3 +916,32 @@ mod fr19_9_tests { } } } + +#[cfg(test)] +mod fr68_label_tests { + use super::*; + + #[test] + fn fr68_repeated_labels_do_not_grow_the_table() { + let table = LabelTable::new(); + let first = table.intern("Refresh", MAX_LABELS).unwrap(); + for _ in 0..10_000 { + let again = table.intern("Refresh", MAX_LABELS).unwrap(); + assert!(std::ptr::eq(first, again)); + } + assert_eq!(table.len(), 1); + } + + #[test] + fn fr68_labels_past_the_cap_are_dropped() { + let table = LabelTable::new(); + for n in 0..MAX_LABELS { + assert!(table.intern(&format!("label {n}"), MAX_LABELS).is_some()); + } + assert_eq!(table.len(), MAX_LABELS); + assert_eq!(table.intern("one too many", MAX_LABELS), None); + assert_eq!(table.len(), MAX_LABELS); + // A name already kept is still found. + assert!(table.intern("label 0", MAX_LABELS).is_some()); + } +} diff --git a/renderer/desktop/test/AccessibilityModifierTest.kt b/renderer/desktop/test/AccessibilityModifierTest.kt index 3af7ff27..8acd1695 100644 --- a/renderer/desktop/test/AccessibilityModifierTest.kt +++ b/renderer/desktop/test/AccessibilityModifierTest.kt @@ -57,6 +57,12 @@ private fun ComposeUiTest.show(mutations: List) { waitForIdle() } +/** + * Widgets the every-operable-node-has-a-name check does not cover. + * Sliders are left out until the design system's Slider exposes a range semantics node: thisisthepy/compose-multiplatform-core-extended#180. + */ +private val LEFT_OUT_OF_THE_CHECK = setOf(WidgetKind.Slider) + /** A column holding one of every kind of control, each given a name unless it names itself. */ private fun everyOperable(named: Boolean) = buildList { add(Mutation.Create(TREE_ROOT, WidgetKind.Column)) @@ -99,7 +105,7 @@ private fun everyOperable(named: Boolean) = buildList { ) + label(it, "$kind option") } } - child(WidgetKind.Slider) { + if (WidgetKind.Slider !in LEFT_OUT_OF_THE_CHECK) child(WidgetKind.Slider) { listOf( Mutation.SetProp(it, PropertyKind.Value, PropertyValue.Float(0.5f)), Mutation.SetProp(it, PropertyKind.Min, PropertyValue.Float(0f)), @@ -132,9 +138,8 @@ class AccessibilityModifierTest { runComposeUiTest { show(everyOperable(named = false)) val missing = unnamed() - // The pressable container and the three toggles say nothing by themselves. The slider - // is left out: the design system's slider publishes no range semantics yet, so it is - // not an operable node to this check. + // The pressable container and the three toggles say nothing by themselves. Sliders + // are in LEFT_OUT_OF_THE_CHECK. assertTrue(missing.size >= 4, "an unnamed control went unnoticed: $missing") assertTrue(missing.all { it.startsWith(TAG_PREFIX) }, "$missing") } @@ -168,6 +173,29 @@ class AccessibilityModifierTest { } } + @Test + fun fr68_a_box_with_a_label_and_a_role_is_one_semantics_node() { + val box = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetProp(box, PropertyKind.AccessibleName, name("Refresh")), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Button, 0)), + ), + ) + val nodes = onAllNodes(SemanticsMatcher("carries the label or the role") { + it.tag() == nodeTestTag(box) || + it.config.getOrNull(SemanticsProperties.ContentDescription) == listOf("Refresh") || + it.config.getOrNull(SemanticsProperties.Role) == Role.Button + }, useUnmergedTree = true).fetchSemanticsNodes() + assertEquals(1, nodes.size, "the label and the role must sit on one node: $nodes") + val config = nodes.single().config + assertEquals(listOf("Refresh"), config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(Role.Button, config.getOrNull(SemanticsProperties.Role)) + } + } + @Test fun fr68_the_state_of_a_modifier_reaches_the_semantics() { val box = 1 From d6341fe9de7e1e6c4c7031587dea8450a652b75e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:10:25 +0900 Subject: [PATCH 113/224] Feat: Colour and size options for Switch, Slider and Checkbox (FR-67, #426, #428), draft before codegen --- CHANGELOG.md | 4 + compose-rust/src/schema.rs | 12 +- compose-rust/src/ui/widgets.rs | 98 ++++++++++++ compose-rust/tests/fr67_controls.rs | 98 ++++++++++++ renderer/android/src/shared/ControlLook.kt | 1 + renderer/desktop/src/renderer/ControlLook.kt | 75 +++++++++ .../desktop/src/renderer/ControlWidgets.kt | 94 +++++++---- renderer/desktop/src/renderer/HostSlider.kt | 2 + renderer/desktop/src/renderer/HostToggle.kt | 2 + .../src/renderer/Material3ControlWidgets.kt | 15 +- renderer/desktop/src/renderer/NodeTable.kt | 13 ++ renderer/desktop/test/ControlColoursTest.kt | 149 ++++++++++++++++++ renderer/ios/src/shared/ControlLook.kt | 1 + renderer/linux/src/shared/ControlLook.kt | 1 + renderer/macos/src/shared/ControlLook.kt | 1 + renderer/web/src/shared/ControlLook.kt | 1 + renderer/windows/src/shared/ControlLook.kt | 1 + 17 files changed, 532 insertions(+), 36 deletions(-) create mode 100644 compose-rust/tests/fr67_controls.rs create mode 120000 renderer/android/src/shared/ControlLook.kt create mode 100644 renderer/desktop/src/renderer/ControlLook.kt create mode 100644 renderer/desktop/test/ControlColoursTest.kt create mode 120000 renderer/ios/src/shared/ControlLook.kt create mode 120000 renderer/linux/src/shared/ControlLook.kt create mode 120000 renderer/macos/src/shared/ControlLook.kt create mode 120000 renderer/web/src/shared/ControlLook.kt create mode 120000 renderer/windows/src/shared/ControlLook.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4287afdf..2b16d9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Colours and size for `Switch`, `Slider` and `Checkbox` + +`Switch` has `track_color`, `thumb_color` and `size(width, height)`, `Slider` has `track_color`, `active_track_color` and `thumb_color`, and `Checkbox` has `container_color`, `check_color` and `size(side)` ([#426](https://github.com/DarkPyonix/compose-rust/issues/426), [#428](https://github.com/DarkPyonix/compose-rust/issues/428)). A value that is not set is not sent, and the design system draws that slot. On the wire these are the new `TrackColor` (121), `ThumbColor` (122), `ActiveTrackColor` (123), `ContainerColor` (124), `CheckColor` (125) and `ControlSize` (126) properties. The schema hash changes, so a renderer and a crate from before this change no longer agree. + ### Accessibility modifier and `accessible_name` `Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 70d098a5..ac079dc1 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -183,7 +183,7 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,accessible_name;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,accessible_name,track_color,thumb_color,active_track_color,container_color,check_color,control_size;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback,Semantics;", "keys=Enter;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", @@ -2914,6 +2914,16 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // the visible label, and on an icon-only button it beats the name the icon's meaning // would give. 119 is the first free number after the island block. AccessibleName = 119, + // Colours and size of the selection controls. A node that does not set one leaves + // the design system's value, so a control that sends none draws as it always has. + // The colours are `Paint` bits; the size is two f32 (width, height) in dp. + // 120 is the island zoom, so these start at 121. + TrackColor = 121, + ThumbColor = 122, + ActiveTrackColor = 123, + ContainerColor = 124, + CheckColor = 125, + ControlSize = 126, }); #[derive(Clone, Debug, PartialEq)] diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index bc67cba9..1c227a1a 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -1008,6 +1008,55 @@ fn lines_and_overflow(max_lines: Option, overflow: Option, at } } +/// The colours and size a Switch, Slider or Checkbox was asked to draw with. Each is sent +/// only when it was set, so a control that sets none sends the properties it always has +/// and the design system draws it. +#[derive(Clone, Copy, Default)] +struct ControlLook { + track: Option, + thumb: Option, + active_track: Option, + container: Option, + check: Option, + size: Option<(f32, f32)>, +} + +impl ControlLook { + fn push(&self, attrs: &mut Attrs) { + let paints = [ + ("track_color", self.track), + ("thumb_color", self.thumb), + ("active_track_color", self.active_track), + ("container_color", self.container), + ("check_color", self.check), + ]; + for (name, paint) in paints { + if let Some(paint) = paint { + attrs.push((name, super::paint(Some(paint)))); + } + } + if let Some((width, height)) = self.size { + let mut bytes = Vec::with_capacity(8); + bytes.extend_from_slice(&width.to_bits().to_le_bytes()); + bytes.extend_from_slice(&height.to_bits().to_le_bytes()); + attrs.push(("control_size", OwnedAttr::Bytes(bytes))); + } + } +} + +/// Adds a colour setter that fills one slot of `look`. +macro_rules! control_colour { + ($name:ident, $slot:ident, $setter:ident, $doc:literal) => { + impl $name { + #[doc = $doc] + pub fn $setter(mut self, paint: Paint) -> Self { + self.look.$slot = Some(paint); + self + } + } + }; +} + /// Writes a widget's own accessible name. It replaces one the Modifier gave, so the name /// said on the widget is the one that reaches assistive technology. fn push_accessible_name(name: Option, attrs: &mut Attrs) { @@ -1166,6 +1215,7 @@ macro_rules! toggle { checked: bool, enabled: bool, accessible_name: Option, + look: ControlLook, on_change: Option>, done: bool, } @@ -1177,6 +1227,7 @@ macro_rules! toggle { checked: $setter, enabled: true, accessible_name: None, + look: ControlLook::default(), on_change: None, done: false, } @@ -1201,6 +1252,7 @@ macro_rules! toggle { let modifier = self.modifier; let (checked, enabled) = (self.checked, self.enabled); let accessible_name = self.accessible_name.take(); + let look = self.look; let handler: EventCallback = match self.on_change.take() { Some(mut handler) => EventCallback::Value(Rc::new(RefCell::new( // Off is 0.0 and on is anything else, so a Renderer that reports a @@ -1217,6 +1269,7 @@ macro_rules! toggle { attrs.push(("checked", OwnedAttr::Bool(checked))); attrs.push(("enabled", OwnedAttr::Bool(enabled))); push_accessible_name(accessible_name, attrs); + look.push(attrs); }, vec![("onchange", handler)], content, @@ -1228,6 +1281,39 @@ macro_rules! toggle { }; } +control_colour!( + SwitchScope, + track, + track_color, + "The track, whichever state it is in. The Host owns the state, so it passes the colour \ + that goes with it." +); +control_colour!(SwitchScope, thumb, thumb_color, "The thumb that travels along the track."); + +impl SwitchScope { + /// The track's width and height in dp. The thumb scales with the height. + pub fn size(mut self, width: f32, height: f32) -> Self { + self.look.size = Some((width, height)); + self + } +} + +control_colour!( + CheckboxScope, + container, + container_color, + "The box when it is checked." +); +control_colour!(CheckboxScope, check, check_color, "The check mark."); + +impl CheckboxScope { + /// The length of the box's side in dp. + pub fn size(mut self, side: f32) -> Self { + self.look.size = Some((side, side)); + self + } +} + toggle!( CheckboxScope, Checkbox, @@ -1260,6 +1346,7 @@ pub struct SliderScope { steps: u32, enabled: bool, accessible_name: Option, + look: ControlLook, on_change: Option>, done: bool, } @@ -1276,12 +1363,21 @@ pub fn Slider(value: f32) -> SliderScope { steps: 0, enabled: true, accessible_name: None, + look: ControlLook::default(), on_change: None, done: false, } } accessible_name!(SliderScope); +control_colour!(SliderScope, track, track_color, "The part of the track that is not filled."); +control_colour!(SliderScope, thumb, thumb_color, "The thumb."); +control_colour!( + SliderScope, + active_track, + active_track_color, + "The part of the track up to the thumb." +); impl SliderScope { /// The two ends. Compose's `valueRange`. @@ -1324,6 +1420,7 @@ impl SliderScope { self.enabled, ); let accessible_name = self.accessible_name.take(); + let look = self.look; let handler = match self.on_change.take() { Some(mut handler) => EventCallback::Value(Rc::new(RefCell::new(move |value: f64| { handler(value as f32) @@ -1342,6 +1439,7 @@ impl SliderScope { attrs.push(("steps", OwnedAttr::Int(i64::from(steps)))); attrs.push(("enabled", OwnedAttr::Bool(enabled))); push_accessible_name(accessible_name, attrs); + look.push(attrs); }, vec![("onchange", handler)], content, diff --git a/compose-rust/tests/fr67_controls.rs b/compose-rust/tests/fr67_controls.rs new file mode 100644 index 00000000..564ae199 --- /dev/null +++ b/compose-rust/tests/fr67_controls.rs @@ -0,0 +1,98 @@ +//! Switch, Slider and Checkbox take colours and a size, and send them only when asked. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +const INK: Paint = Paint::Literal(Color::rgb(0x1c1b19)); +const SURFACE: Paint = Paint::Literal(Color::rgb(0xffffff)); + +#[composable] +fn Plain() { + Column().content(|| { + Switch(true); + Slider(0.5); + Checkbox(true); + }); +} + +#[composable] +fn Styled() { + Column().content(|| { + Switch(true) + .size(36.0, 20.0) + .track_color(INK) + .thumb_color(SURFACE); + Slider(0.5) + .active_track_color(INK) + .track_color(SURFACE) + .thumb_color(INK); + Checkbox(true) + .size(18.0) + .container_color(INK) + .check_color(SURFACE); + }); +} + +fn line<'a>(dump: &'a str, widget: &str) -> &'a str { + dump.lines() + .find(|line| line.trim_start().starts_with(widget)) + .unwrap_or_else(|| panic!("no {widget} in\n{dump}")) +} + +#[test] +fn fr67_controls_that_set_nothing_send_none_of_the_new_properties() { + let dump = start(Plain).dump(); + for name in [ + "TrackColor", + "ThumbColor", + "ActiveTrackColor", + "ContainerColor", + "CheckColor", + "ControlSize", + ] { + assert!(!dump.contains(name), "{name} was sent in\n{dump}"); + } +} + +#[test] +fn fr67_the_switch_sends_its_colours_and_its_size() { + let dump = start(Styled).dump(); + let switch = line(&dump, "Switch"); + for name in ["TrackColor", "ThumbColor", "ControlSize"] { + assert!(switch.contains(name), "{switch}"); + } + for name in ["ActiveTrackColor", "ContainerColor", "CheckColor"] { + assert!(!switch.contains(name), "{switch}"); + } +} + +#[test] +fn fr67_the_slider_sends_the_active_and_remaining_track_and_the_thumb() { + let dump = start(Styled).dump(); + let slider = line(&dump, "Slider"); + for name in ["ActiveTrackColor", "TrackColor", "ThumbColor"] { + assert!(slider.contains(name), "{slider}"); + } + assert!(!slider.contains("ControlSize"), "{slider}"); +} + +#[test] +fn fr67_the_checkbox_sends_its_container_check_and_side() { + let dump = start(Styled).dump(); + let checkbox = line(&dump, "Checkbox"); + for name in ["ContainerColor", "CheckColor", "ControlSize"] { + assert!(checkbox.contains(name), "{checkbox}"); + } + assert!(!checkbox.contains("TrackColor"), "{checkbox}"); +} diff --git a/renderer/android/src/shared/ControlLook.kt b/renderer/android/src/shared/ControlLook.kt new file mode 120000 index 00000000..45b78b1c --- /dev/null +++ b/renderer/android/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/desktop/src/renderer/ControlLook.kt b/renderer/desktop/src/renderer/ControlLook.kt new file mode 100644 index 00000000..83cc0763 --- /dev/null +++ b/renderer/desktop/src/renderer/ControlLook.kt @@ -0,0 +1,75 @@ +package dev.darkpyonix.composerust.design + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.paintProp +import dev.darkpyonix.composerust.ui.resolvedShape + +/** + * The colours and size the application asked a Switch, Slider or Checkbox to draw with. + * + * Every slot is null until the node sent it, and a slot that is null keeps what the design + * system draws. A look with nothing in it changes nothing, which is how a control that + * sends none of these properties stays exactly as it was. + */ +class ControlLook( + /** The track of a Switch, or the part of a Slider's track that is not filled. */ + val track: Color? = null, + /** The travelling thumb of a Switch or a Slider. */ + val thumb: Color? = null, + /** The filled part of a Slider's track. */ + val activeTrack: Color? = null, + /** The box of a checked Checkbox. */ + val container: Color? = null, + /** The check mark. */ + val check: Color? = null, + /** A Switch's width, or a Checkbox's side. */ + val width: Dp? = null, + /** A Switch's height. */ + val height: Dp? = null, + /** The corner shape the node asked for, or null where the design system's applies. */ + val shape: Shape? = null, +) { + /** Whether a Switch or a Checkbox has to be drawn from this look rather than by default. */ + fun appliesTo(role: ToggleRole): Boolean = when (role) { + ToggleRole.Switch -> track != null || thumb != null || width != null || height != null + ToggleRole.Checkbox -> container != null || check != null || width != null + ToggleRole.RadioButton -> false + } + + companion object { + val None = ControlLook() + } +} + +/** Reads the look a node asked for. Colours go through the theme like every other paint. */ +internal fun Node.controlLook(theme: ResolvedTheme): ControlLook { + fun colour(kind: PropertyKind): Color? = paintProp(kind)?.let { theme.color(it) } + val size = bytes(PropertyKind.ControlSize)?.takeIf { it.size >= 8 } + val width = size?.let { littleEndianFloat(it, 0) }?.takeIf { it > 0f }?.dp + val height = size?.let { littleEndianFloat(it, 4) }?.takeIf { it > 0f }?.dp + val named = modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.Shape || + it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + } + return ControlLook( + track = colour(PropertyKind.TrackColor), + thumb = colour(PropertyKind.ThumbColor), + activeTrack = colour(PropertyKind.ActiveTrackColor), + container = colour(PropertyKind.ContainerColor), + check = colour(PropertyKind.CheckColor), + width = width, + height = height, + shape = if (named) modifiers.resolvedShape(theme) else null, + ) +} + +private fun littleEndianFloat(bytes: ByteArray, at: Int): Float { + var bits = 0 + for (index in 3 downTo 0) bits = (bits shl 8) or (bytes[at + index].toInt() and 0xff) + return Float.fromBits(bits) +} diff --git a/renderer/desktop/src/renderer/ControlWidgets.kt b/renderer/desktop/src/renderer/ControlWidgets.kt index b0d554ea..aaf8f8d1 100644 --- a/renderer/desktop/src/renderer/ControlWidgets.kt +++ b/renderer/desktop/src/renderer/ControlWidgets.kt @@ -82,6 +82,7 @@ interface ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -97,6 +98,9 @@ interface ControlWidgets { * [named] colours the part that has been filled in and the thumb with it, where the * application said what those should look like. It is null in the ordinary case and * the design system answers instead, which is what an adaptive application wants. + * + * [look] holds the colours the application named for the two parts of the track and + * the thumb, each of which wins over [named] and over the design system where it is set. */ @Composable fun Slider( @@ -108,6 +112,7 @@ interface ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -152,6 +157,7 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).toggle(role) val motion = theme.rules.motion @@ -163,7 +169,7 @@ internal object DrawnControlWidgets : ControlWidgets { // animates without a frame of it crossing the boundary. val progress by animateFloatAsState(if (checked) 1f else 0f, spec) val container by animateColorAsState( - targetValue = if (checked) style.containerChecked else style.container, + targetValue = if (checked) look.container ?: style.containerChecked else style.container, animationSpec = tween(spec.durationMillis, easing = motion.easing), ) val alpha = if (enabled) 1f else style.disabledAlpha @@ -208,14 +214,19 @@ internal object DrawnControlWidgets : ControlWidgets { } when (role) { - ToggleRole.Checkbox -> Box( - clickable - .size(style.size) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { drawTick(style.mark.copy(alpha = alpha), progress) }, - ) + ToggleRole.Checkbox -> { + val shape = look.shape ?: style.shape + // A box the application filled has no outline of its own to show. + val outline = if (checked && look.container != null) 0.dp else style.borderWidth + Box( + clickable + .size(look.width ?: style.size) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { drawTick((look.check ?: style.mark).copy(alpha = alpha), progress) }, + ) + } ToggleRole.RadioButton -> Box( clickable @@ -231,27 +242,41 @@ internal object DrawnControlWidgets : ControlWidgets { }, ) - ToggleRole.Switch -> Box( - clickable - .size(width = style.trackWidth, height = style.trackHeight) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { - val diameter = style.thumbSize.toPx() - val margin = (size.height - diameter) / 2f - val travel = size.width - diameter - margin * 2f - val thumb = if (checked) style.mark else style.markUnchecked - drawCircle( - color = thumb.copy(alpha = alpha), - radius = diameter / 2f, - center = Offset( - x = margin + diameter / 2f + travel * progress, - y = size.height / 2f, - ), + ToggleRole.Switch -> { + val shape = look.shape ?: style.shape + val outline = if (look.track != null) 0.dp else style.borderWidth + Box( + clickable + .size( + width = look.width ?: style.trackWidth, + height = look.height ?: style.trackHeight, ) - }, - ) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { + // A track of the application's own height has a thumb that + // scales with it, the same 4 to 5 proportion as a 16 dp thumb on + // a 20 dp track. + val diameter = if (look.height != null) { + size.height * SWITCH_THUMB_RATIO + } else { + style.thumbSize.toPx() + } + val margin = (size.height - diameter) / 2f + val travel = size.width - diameter - margin * 2f + val thumb = look.thumb ?: if (checked) style.mark else style.markUnchecked + drawCircle( + color = thumb.copy(alpha = alpha), + radius = diameter / 2f, + center = Offset( + x = margin + diameter / 2f + travel * progress, + y = size.height / 2f, + ), + ) + }, + ) + } } } @@ -265,10 +290,12 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).slider - val active = named ?: style.activeTrack - val knob = named ?: style.thumb + val active = look.activeTrack ?: named ?: style.activeTrack + val knob = look.thumb ?: named ?: style.thumb + val rest = look.track ?: style.track val min = range.start val max = range.endInclusive val span = (max - min).takeIf { it > 0f } @@ -312,7 +339,7 @@ internal object DrawnControlWidgets : ControlWidgets { val usable = size.width - thumb val radius = CornerRadius(trackHeight / 2f, trackHeight / 2f) drawRoundRect( - color = style.track, + color = rest, topLeft = Offset(left, trackTop), size = Size(usable, trackHeight), cornerRadius = radius, @@ -502,6 +529,9 @@ private fun valueAt(x: Float, width: Float, thumb: Float, min: Float, max: Float return min + (max - min) * ((x - thumb / 2f) / usable).coerceIn(0f, 1f) } +/** A switch thumb's diameter as a share of the track's height, when the height is the application's. */ +private const val SWITCH_THUMB_RATIO = 0.8f + /** Narrow enough to sit in a row, wide enough that the track is worth dragging. */ private val SLIDER_MIN_WIDTH = 120.dp diff --git a/renderer/desktop/src/renderer/HostSlider.kt b/renderer/desktop/src/renderer/HostSlider.kt index 5f886f69..11ccfa63 100644 --- a/renderer/desktop/src/renderer/HostSlider.kt +++ b/renderer/desktop/src/renderer/HostSlider.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.controlLook import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -71,6 +72,7 @@ internal fun HostSlider( onChange = report, modifier = modifier, theme = theme, + look = node.controlLook(theme), ) } diff --git a/renderer/desktop/src/renderer/HostToggle.kt b/renderer/desktop/src/renderer/HostToggle.kt index 42355f36..ec59674e 100644 --- a/renderer/desktop/src/renderer/HostToggle.kt +++ b/renderer/desktop/src/renderer/HostToggle.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.controlLook import dev.darkpyonix.composerust.design.ToggleRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind @@ -47,5 +48,6 @@ internal fun HostToggle( }, modifier = modifier, theme = theme, + look = node.controlLook(theme), ) } diff --git a/renderer/desktop/src/renderer/Material3ControlWidgets.kt b/renderer/desktop/src/renderer/Material3ControlWidgets.kt index 1c1dbfec..e642cc06 100644 --- a/renderer/desktop/src/renderer/Material3ControlWidgets.kt +++ b/renderer/desktop/src/renderer/Material3ControlWidgets.kt @@ -45,7 +45,15 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { + // The library's Switch and Checkbox have fixed metrics, so a size or a colour the + // application named for them is drawn by the shared control drawing instead, which + // also owns the transition and the toggle semantics. + if (look.appliesTo(role)) { + DrawnControlWidgets.Toggle(role, checked, enabled, onChange, modifier, theme, look) + return + } val primary = theme.color(ColorRole.Primary) val onPrimary = theme.color(ColorRole.OnPrimary) val outline = theme.color(ColorRole.Outline) @@ -101,6 +109,7 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val primary = named ?: theme.color(ColorRole.Primary) Slider( @@ -111,9 +120,9 @@ internal object Material3ControlWidgets : ControlWidgets { valueRange = range, steps = steps, colors = SliderDefaults.colors( - thumbColor = primary, - activeTrackColor = primary, - inactiveTrackColor = theme.color(ColorRole.SurfaceVariant), + thumbColor = look.thumb ?: primary, + activeTrackColor = look.activeTrack ?: primary, + inactiveTrackColor = look.track ?: theme.color(ColorRole.SurfaceVariant), ), ) } diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 6ff6e7ef..a67a5821 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -697,6 +697,19 @@ class NodeTable { // The name assistive technology reads can be given to any node. PropertyKind.AccessibleName -> true + // Colours and size of the selection controls. + PropertyKind.TrackColor, + PropertyKind.ThumbColor, + -> widget == WidgetKind.Switch || widget == WidgetKind.Slider + + PropertyKind.ActiveTrackColor -> widget == WidgetKind.Slider + + PropertyKind.ContainerColor, + PropertyKind.CheckColor, + -> widget == WidgetKind.Checkbox + + PropertyKind.ControlSize -> widget == WidgetKind.Switch || widget == WidgetKind.Checkbox + // Event properties carry handler ids and are valid on any widget. PropertyKind.OnClick, PropertyKind.OnValueChange, diff --git a/renderer/desktop/test/ControlColoursTest.kt b/renderer/desktop/test/ControlColoursTest.kt new file mode 100644 index 00000000..18dcd2a6 --- /dev/null +++ b/renderer/desktop/test/ControlColoursTest.kt @@ -0,0 +1,149 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.ShapeRole +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A Switch, Slider and Checkbox draw with the colours and the size the Host named, and + * keep the design system's look for every slot it did not name. + */ +@OptIn(ExperimentalTestApi::class) +class ControlColoursTest { + private val root = 1 + private val control = 2 + + private val ink = 0xFF1C1B19.toInt() + private val white = 0xFFFFFFFF.toInt() + private val red = 0xFFFF0000.toInt() + private val green = 0xFF00FF00.toInt() + + private fun literal(argb: Int) = PropertyValue.Integer((2L shl 32) or (argb.toLong() and 0xffffffffL)) + + private fun size(width: Float, height: Float) = PropertyValue.Bytes( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putFloat(width).putFloat(height).array(), + ) + + /** One control on a white 240x80 page, with the properties the test names. */ + private fun render( + system: DesignSystem, + widget: WidgetKind, + properties: List>, + extra: List = emptyList(), + check: androidx.compose.ui.test.ComposeUiTest.(java.awt.image.BufferedImage) -> Unit, + ) { + runComposeUiTest { + val mutations = buildList { + add(Mutation.SetTheme(Theme(system, system, ColorScheme.Light, false))) + add(Mutation.Create(root, WidgetKind.Box)) + add(Mutation.SetModifier(root, 0, ProtocolModifier.Size(240f, 80f))) + add(Mutation.SetModifier(root, 1, ProtocolModifier.Background(Paint.Literal(white)))) + add(Mutation.Create(control, widget)) + properties.forEach { (kind, value) -> add(Mutation.SetProp(control, kind, value)) } + addAll(extra) + add(Mutation.Insert(root, control, 0)) + } + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) + } + waitForIdle() + check(onNodeWithTag(nodeTestTag(root)).captureToImage().toAwtImage()) + } + } + + @Test + fun fr67_a_switch_with_a_size_and_colours_measures_that_size_and_draws_them() { + DesignSystem.entries.forEach { system -> + render( + system, + WidgetKind.Switch, + listOf( + PropertyKind.Checked to PropertyValue.Bool(true), + PropertyKind.ControlSize to size(36f, 20f), + PropertyKind.TrackColor to literal(ink), + PropertyKind.ThumbColor to literal(red), + ), + ) { image -> + onNodeWithTag(nodeTestTag(control)).assertWidthIsEqualTo(36.dp) + onNodeWithTag(nodeTestTag(control)).assertHeightIsEqualTo(20.dp) + // The track, left of the thumb, and the thumb, 16 dp across at the right end. + assertEquals(ink, image.getRGB(5, 10), "$system: the checked track") + assertEquals(red, image.getRGB(26, 10), "$system: the thumb") + // Outside the 36x20 box is the page. + assertEquals(white, image.getRGB(40, 10), "$system: beyond the track") + } + } + } + + @Test + fun fr67_a_slider_draws_the_filled_and_the_remaining_track_in_the_colours_named() { + listOf(DesignSystem.Material3, DesignSystem.Fluent).forEach { system -> + render( + system, + WidgetKind.Slider, + listOf( + PropertyKind.Value to PropertyValue.Float(0.5f), + PropertyKind.Min to PropertyValue.Float(0f), + PropertyKind.Max to PropertyValue.Float(1f), + PropertyKind.ActiveTrackColor to literal(ink), + PropertyKind.TrackColor to literal(green), + PropertyKind.ThumbColor to literal(red), + ), + extra = listOf(Mutation.SetModifier(control, 0, ProtocolModifier.Size(200f, 48f))), + ) { image -> + assertEquals(ink, image.getRGB(30, 24), "$system: the filled part") + assertEquals(green, image.getRGB(170, 24), "$system: the remaining part") + } + } + } + + @Test + fun fr67_a_checked_checkbox_is_a_filled_square_with_a_check_in_the_colour_named() { + DesignSystem.entries.forEach { system -> + render( + system, + WidgetKind.Checkbox, + listOf( + PropertyKind.Checked to PropertyValue.Bool(true), + PropertyKind.ControlSize to size(18f, 18f), + PropertyKind.ContainerColor to literal(ink), + PropertyKind.CheckColor to literal(white), + ), + extra = listOf(Mutation.SetModifier(control, 0, ProtocolModifier.ShapeRole(ShapeRole.None))), + ) { image -> + onNodeWithTag(nodeTestTag(control)).assertWidthIsEqualTo(18.dp) + onNodeWithTag(nodeTestTag(control)).assertHeightIsEqualTo(18.dp) + // The corner of a square filled box is the container colour. + assertEquals(ink, image.getRGB(1, 1), "$system: the box") + assertEquals(ink, image.getRGB(16, 2), "$system: the box") + // The check passes through the middle of the box in white. + var whitePixels = 0 + for (y in 2 until 16) for (x in 2 until 16) if (image.getRGB(x, y) == white) whitePixels++ + check(whitePixels > 8) { "$system: no white check inside the box ($whitePixels)" } + } + } + } +} diff --git a/renderer/ios/src/shared/ControlLook.kt b/renderer/ios/src/shared/ControlLook.kt new file mode 120000 index 00000000..45b78b1c --- /dev/null +++ b/renderer/ios/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/ControlLook.kt b/renderer/linux/src/shared/ControlLook.kt new file mode 120000 index 00000000..45b78b1c --- /dev/null +++ b/renderer/linux/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/ControlLook.kt b/renderer/macos/src/shared/ControlLook.kt new file mode 120000 index 00000000..45b78b1c --- /dev/null +++ b/renderer/macos/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/web/src/shared/ControlLook.kt b/renderer/web/src/shared/ControlLook.kt new file mode 120000 index 00000000..45b78b1c --- /dev/null +++ b/renderer/web/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/ControlLook.kt b/renderer/windows/src/shared/ControlLook.kt new file mode 120000 index 00000000..45b78b1c --- /dev/null +++ b/renderer/windows/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file From 49eb3c475f112bd2abf28578a6854db264aabb61 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:18:08 +0900 Subject: [PATCH 114/224] Feat: OnFocusGained and OnKeyUp node events (FR-45, #98, #100) Covers the FR-45 acceptance criteria for the two node events: handler attributes 117 and 118, event tags 48 and 49 (FR-53 took 39 to 47, so the SPEC numbers move up). The schema hash changes. Tests: KeyEventTest, ProtocolVectorsTest, and the Rust protocol tests. --- .../android-kotlin/shared/HostTextField.kt | 39 +-- .../android-kotlin/shared/KeyEvents.kt | 69 ++++- .../android-kotlin/shared/NodeTable.kt | 2 + .../android-kotlin/shared/Protocol.gen.kt | 25 +- .../android-kotlin/shared/RenderNode.kt | 36 ++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/protocol.rs | 240 +++++++++++++++++- compose-rust/src/runtime/host.rs | 12 +- compose-rust/src/schema.rs | 26 +- compose-rust/src/ui/widgets.rs | 96 +++++++ compose-rust/src/writer.rs | 4 + compose-rust/tests/vectors/vectors.json | 2 +- docs/SPEC.md | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 25 +- .../desktop/src/renderer/HostTextField.kt | 39 +-- renderer/desktop/src/renderer/KeyEvents.kt | 69 ++++- renderer/desktop/src/renderer/NodeTable.kt | 2 + renderer/desktop/src/renderer/RenderNode.kt | 36 ++- renderer/desktop/test/KeyEventTest.kt | 122 +++++++++ 19 files changed, 795 insertions(+), 53 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 0306bfdc..53051397 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -98,6 +98,8 @@ internal fun HostTextField( val changeHandler = node.handler(PropertyKind.OnValueChange) val submitHandler = node.handler(PropertyKind.OnSubmit) val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) val focusLostHandler = node.handler(PropertyKind.OnFocusLost) val compositionHandler = node.handler(PropertyKind.OnCompositionChange) val placeholder = node.text(PropertyKind.Placeholder) @@ -193,6 +195,9 @@ internal fun HostTextField( val inputModifier = modifier .focusRequester(focusRequester) .onFocusChanged { state -> + if (!focused && state.isFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) } @@ -206,22 +211,26 @@ internal fun HostTextField( // guard that keeps Enter away from Rust during a Korean composition. .onPreviewKeyEvent { event -> val composingNow = value.composition != null - val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) - if (consumedByHandler) { - true - } else if (!isDispatchableKeyDown(event, composingNow)) { - false - } else if (!shouldSubmitOnEnter( - composing = composingNow, - multiline = multiline, - shiftPressed = event.isShiftPressed, - hasSubmitHandler = submitHandler != null, - ) - ) { - false + if (keyUpHandler != null && isDispatchableKeyUp(event, composingNow)) { + dispatchKeyUp(nodeId, keyUpHandler, event, composingNow, dispatcher) } else { - dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + val consumedByHandler = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) + if (consumedByHandler) { + true + } else if (!isDispatchableKeyDown(event, composingNow)) { + false + } else if (!shouldSubmitOnEnter( + composing = composingNow, + multiline = multiline, + shiftPressed = event.isShiftPressed, + hasSubmitHandler = submitHandler != null, + ) + ) { + false + } else { + dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + } } } diff --git a/compose-rust/android-kotlin/shared/KeyEvents.kt b/compose-rust/android-kotlin/shared/KeyEvents.kt index b5d64e2d..620acd6d 100644 --- a/compose-rust/android-kotlin/shared/KeyEvents.kt +++ b/compose-rust/android-kotlin/shared/KeyEvents.kt @@ -84,6 +84,53 @@ internal fun dispatchKeyDown( ) } +/** + * Whether a key release may be offered to the Host at all. + * + * Key-up only, a key the schema can name, and never while an IME composition is in progress. + */ +internal fun shouldDispatchKeyUp( + composing: Boolean, + isKeyUp: Boolean, + key: ProtocolKey?, +): Boolean = !composing && isKeyUp && key != null + +/** [shouldDispatchKeyUp] applied to a Compose key event. */ +internal fun isDispatchableKeyUp(event: ComposeKeyEvent, composing: Boolean): Boolean = + shouldDispatchKeyUp( + composing = composing, + isKeyUp = event.type == KeyEventType.KeyUp, + key = protocolKey(event.key), + ) + +/** + * Sends one key release to the Host and returns its consumption result. + * + * Returns false without calling the Host whenever [isDispatchableKeyUp] says no, so a + * composition keystroke never reaches Rust. + */ +internal fun dispatchKeyUp( + nodeId: Int, + handlerId: Long, + event: ComposeKeyEvent, + composing: Boolean, + dispatcher: EventDispatcher, +): Boolean { + if (!isDispatchableKeyUp(event, composing)) return false + val key = protocolKey(event.key) ?: return false + return dispatcher.dispatch( + HostEvent.KeyUp( + nodeId = nodeId, + handlerId = handlerId, + key = key, + shiftKey = event.isShiftPressed, + ctrlKey = event.isCtrlPressed, + altKey = event.isAltPressed, + metaKey = event.isMetaPressed, + ), + ) +} + /** * `Modifier.onKeyEvent` for a widget that owns no text composition. * @@ -93,8 +140,26 @@ internal fun dispatchKeyDown( */ internal fun Modifier.hostKeyEvents( nodeId: Int, - handlerId: Long, + keyDownHandler: Long?, + keyUpHandler: Long?, dispatcher: EventDispatcher, ): Modifier = onKeyEvent { event -> - dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher) + when { + keyDownHandler != null && isDispatchableKeyDown(event, composing = false) -> + dispatchKeyDown(nodeId, keyDownHandler, event, composing = false, dispatcher = dispatcher) + keyUpHandler != null && isDispatchableKeyUp(event, composing = false) -> + dispatchKeyUp(nodeId, keyUpHandler, event, composing = false, dispatcher = dispatcher) + else -> false + } } + +internal fun Modifier.hostKeyEvents( + nodeId: Int, + handlerId: Long, + dispatcher: EventDispatcher, +): Modifier = hostKeyEvents( + nodeId = nodeId, + keyDownHandler = handlerId, + keyUpHandler = null, + dispatcher = dispatcher, +) diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 29ccc2a8..77ba22bd 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -720,7 +720,9 @@ class NodeTable { PropertyKind.OnValueChange, PropertyKind.OnSubmit, PropertyKind.OnFocusLost, + PropertyKind.OnFocusGained, PropertyKind.OnKeyDown, + PropertyKind.OnKeyUp, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, -> true diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 5dc613ff..2caaecdf 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, Progress } enum class Key { Enter } @@ -1292,13 +1292,15 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FocusGained(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class KeyUp(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 2165073138301532475L + const val SCHEMA_HASH: Long = 2266959730736018618L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1753,6 +1755,8 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FocusGained -> null + is HostEvent.KeyUp -> null } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1795,6 +1799,8 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FocusGained -> 16 + is HostEvent.KeyUp -> 20 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1841,6 +1847,8 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FocusGained -> 48 + is HostEvent.KeyUp -> 49 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -2005,6 +2013,17 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FocusGained -> Unit + is HostEvent.KeyUp -> { + out.putShort(keyTag(event.key).toShort()) + var modifiers = 0 + if (event.shiftKey) modifiers = modifiers or 0x01 + if (event.ctrlKey) modifiers = modifiers or 0x02 + if (event.altKey) modifiers = modifiers or 0x04 + if (event.metaKey) modifiers = modifiers or 0x08 + out.put(modifiers.toByte()) + out.put(0.toByte()) + } } if (text != null) out.put(text) return out.position() - start @@ -2188,6 +2207,8 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 117 -> PropertyKind.OnFocusGained + 118 -> PropertyKind.OnKeyUp 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 69495546..c9ba8a60 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -22,9 +22,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key @@ -173,10 +177,34 @@ fun RenderNode( // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) - val withKeys = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val withKeys = if ((keyDownHandler == null && keyUpHandler == null) || + node.widget == WidgetKind.TextField + ) { chain } else { - chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + chain.hostKeyEvents(nodeId, keyDownHandler, keyUpHandler, dispatcher) + } + // Focus-gained and focus-lost for non-TextField nodes: `hasFocus` covers both the node + // itself and any focusable descendant inside it. + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) + val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + var hasFocusWithin by remember(nodeId) { mutableStateOf(false) } + val withFocusEvents = if ((focusGainedHandler == null && focusLostHandler == null) || + node.widget == WidgetKind.TextField + ) { + withKeys + } else { + withKeys.onFocusChanged { state -> + val nowFocused = state.hasFocus + if (!hasFocusWithin && nowFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } + if (hasFocusWithin && !nowFocused && focusLostHandler != null) { + dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) + } + hasFocusWithin = nowFocused + } } // Only where the node said it is a place files may be dropped. Everywhere else this // adds nothing to the chain and the platform shows no drop cursor. @@ -191,9 +219,9 @@ fun RenderNode( null } val pressable = if (clickHandler == null) { - withKeys + withFocusEvents } else { - withKeys.pressable( + withFocusEvents.pressable( nodeId, clickHandler, node.flag(PropertyKind.Enabled, default = true), diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 68b40b6a..ed066238 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x1e0be28c5933f93b +0x1f75dbdd3ea2b4ba diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 1d3b03b5..bb3c8c33 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -332,6 +332,9 @@ const EVENT_WINDOW_UNAVAILABLE: u16 = 44; const EVENT_WINDOW_CLOSED: u16 = 45; const EVENT_WINDOW_REQUESTED: u16 = 46; const EVENT_APPLICATION_REOPEN: u16 = 47; +// 48 and 49 are the node focus-gained and key-up events. +const EVENT_FOCUS_GAINED: u16 = 48; +const EVENT_KEY_UP: u16 = 49; /// Where `SetWindow` keeps the mode and the last window policy: in the bits of the /// `resizable` word above the flag. @@ -408,6 +411,7 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { crate::schema::EventPayload::TextSubmitted(read_string(bytes, 16, record_len)?) } EVENT_FOCUS_LOST if record_len == 16 => crate::schema::EventPayload::FocusLost, + EVENT_FOCUS_GAINED if record_len == 16 => crate::schema::EventPayload::FocusGained, EVENT_PROTOCOL_ERROR if record_len == 28 => crate::schema::EventPayload::ProtocolError { code: read_u32(bytes, 16)?, message: read_string(bytes, 20, record_len)?, @@ -424,6 +428,18 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { meta_key: modifiers & MODIFIER_META != 0, } } + EVENT_KEY_UP if record_len == 20 => { + let raw_key = read_u16(bytes, 16)?; + let modifiers = *bytes.get(18).ok_or(ProtocolError::Truncated)?; + crate::schema::EventPayload::KeyUp { + key: Key::try_from(raw_key) + .map_err(|()| ProtocolError::InvalidValueKind(raw_key))?, + shift_key: modifiers & MODIFIER_SHIFT != 0, + ctrl_key: modifiers & MODIFIER_CTRL != 0, + alt_key: modifiers & MODIFIER_ALT != 0, + meta_key: modifiers & MODIFIER_META != 0, + } + } EVENT_RANGE_REQUESTED if record_len == 24 => crate::schema::EventPayload::RangeRequested { start: read_u32(bytes, 16)?, count: read_u32(bytes, 20)?, @@ -668,7 +684,9 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { | EVENT_ANIMATION | EVENT_POINTER | EVENT_REDUCED_MOTION_CHANGED - | EVENT_ZOOM_CHANGED => { + | EVENT_ZOOM_CHANGED + | EVENT_FOCUS_GAINED + | EVENT_KEY_UP => { return Err(ProtocolError::InvalidRecordLength); } other => return Err(ProtocolError::InvalidTag(other)), @@ -704,6 +722,27 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P output.push(0); return Ok(()); } + if let crate::schema::EventPayload::KeyUp { + key, + shift_key, + ctrl_key, + alt_key, + meta_key, + } = event.payload + { + output.extend_from_slice(&EVENT_KEY_UP.to_le_bytes()); + output.extend_from_slice(&20_u16.to_le_bytes()); + output.extend_from_slice(&event.node_id.to_le_bytes()); + output.extend_from_slice(&event.handler_id.to_le_bytes()); + output.extend_from_slice(&(key as u16).to_le_bytes()); + let modifiers = (u8::from(shift_key) * MODIFIER_SHIFT) + | (u8::from(ctrl_key) * MODIFIER_CTRL) + | (u8::from(alt_key) * MODIFIER_ALT) + | (u8::from(meta_key) * MODIFIER_META); + output.push(modifiers); + output.push(0); + return Ok(()); + } if let crate::schema::EventPayload::ValueChanged(value) = event.payload { output.extend_from_slice(&EVENT_VALUE_CHANGED.to_le_bytes()); output.extend_from_slice(&24_u16.to_le_bytes()); @@ -1003,6 +1042,7 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P (EVENT_TEXT_SUBMITTED, 24, Some(*value), None) } crate::schema::EventPayload::FocusLost => (EVENT_FOCUS_LOST, 16, None, None), + crate::schema::EventPayload::FocusGained => (EVENT_FOCUS_GAINED, 16, None, None), crate::schema::EventPayload::FilesEntered => (EVENT_FILES_ENTERED, 16, None, None), crate::schema::EventPayload::FilesDropped(paths) => { (EVENT_FILES_DROPPED, 24, Some(*paths), None) @@ -1032,6 +1072,7 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P (EVENT_NOTIFICATION_ACTIVATED, 28, Some(*key), Some(*action)) } crate::schema::EventPayload::KeyDown { .. } + | crate::schema::EventPayload::KeyUp { .. } | crate::schema::EventPayload::RangeRequested { .. } | crate::schema::EventPayload::ValueChanged(_) | crate::schema::EventPayload::WindowSizeChanged { .. } @@ -3245,4 +3286,201 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 11); drop(seen); } + + #[test] + fn fr45_focus_gained_and_lost_pair() { + use std::sync::Arc; + use std::sync::Mutex; + + let trace: Arc>> = Arc::new(Mutex::new(Vec::new())); + let trace_for_root = Arc::clone(&trace); + let mut host = crate::ComposeHost::with_content(move || { + let gained_trace = Arc::clone(&trace_for_root); + let lost_trace = Arc::clone(&trace_for_root); + crate::ui::TextField() + .on_focus_gained(move || { + gained_trace.lock().unwrap().push("gained"); + }) + .on_focus_lost(move || { + lost_trace.lock().unwrap().push("lost"); + }); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + let gained_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnFocusGained, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_focus_gained must emit OnFocusGained"); + let lost_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnFocusLost, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_focus_lost must emit OnFocusLost"); + assert_eq!(gained_prop.0, lost_prop.0); + assert_eq!(PropertyKind::OnFocusGained as u16, 117); + + let gained_event = HostEvent { + node_id: gained_prop.0, + handler_id: gained_prop.1, + payload: crate::EventPayload::FocusGained, + }; + let mut wire = Vec::new(); + encode_event(&gained_event, &mut wire).unwrap(); + assert_eq!(wire.len(), 16); + assert_eq!(read_u16(&wire, 0).unwrap(), EVENT_FOCUS_GAINED); + assert_eq!(EVENT_FOCUS_GAINED, 48); + assert_eq!(decode_event(&wire).unwrap(), gained_event); + host.dispatch_event(&wire).unwrap(); + + let lost_event = HostEvent { + node_id: lost_prop.0, + handler_id: lost_prop.1, + payload: crate::EventPayload::FocusLost, + }; + encode_event(&lost_event, &mut wire).unwrap(); + assert_eq!(decode_event(&wire).unwrap(), lost_event); + host.dispatch_event(&wire).unwrap(); + + assert_eq!(*trace.lock().unwrap(), vec!["gained", "lost"]); + } + + #[test] + fn fr45_key_up_follows_key_down() { + use std::sync::Arc; + use std::sync::Mutex; + + let trace: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let trace_for_root = Arc::clone(&trace); + let mut host = crate::ComposeHost::with_content(move || { + let down_trace = Arc::clone(&trace_for_root); + let up_trace = Arc::clone(&trace_for_root); + crate::ui::TextField() + .on_key_down(move |event| { + down_trace.lock().unwrap().push(( + "down", + event.key(), + event.shift_key(), + event.ctrl_key(), + event.alt_key(), + event.meta_key(), + )); + }) + .on_key_up(move |event| { + up_trace.lock().unwrap().push(( + "up", + event.key(), + event.shift_key(), + event.ctrl_key(), + event.alt_key(), + event.meta_key(), + )); + }); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + let down_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnKeyDown, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_key_down must emit OnKeyDown"); + let up_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnKeyUp, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_key_up must emit OnKeyUp"); + assert_eq!(down_prop.0, up_prop.0); + assert_eq!(PropertyKind::OnKeyUp as u16, 118); + + let down_event = HostEvent { + node_id: down_prop.0, + handler_id: down_prop.1, + payload: crate::EventPayload::KeyDown { + key: Key::Enter, + shift_key: true, + ctrl_key: false, + alt_key: true, + meta_key: false, + }, + }; + let mut wire = Vec::new(); + encode_event(&down_event, &mut wire).unwrap(); + assert_eq!(decode_event(&wire).unwrap(), down_event); + host.dispatch_event(&wire).unwrap(); + + let up_event = HostEvent { + node_id: up_prop.0, + handler_id: up_prop.1, + payload: crate::EventPayload::KeyUp { + key: Key::Enter, + shift_key: true, + ctrl_key: false, + alt_key: true, + meta_key: false, + }, + }; + encode_event(&up_event, &mut wire).unwrap(); + assert_eq!(wire.len(), 20); + assert_eq!(read_u16(&wire, 0).unwrap(), EVENT_KEY_UP); + assert_eq!(EVENT_KEY_UP, 49); + assert_eq!(decode_event(&wire).unwrap(), up_event); + host.dispatch_event(&wire).unwrap(); + + assert_eq!( + *trace.lock().unwrap(), + vec![ + ("down", Key::Enter, true, false, true, false), + ("up", Key::Enter, true, false, true, false), + ] + ); + } + + #[test] + fn fr45_unregistered_nodes_get_no_events() { + let mut host = crate::ComposeHost::with_content(|| { + crate::ui::TextField(); + crate::ui::Box().content(|| {}); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + for mutation in &mutations { + if let Mutation::SetProp { property, .. } = mutation { + assert_ne!( + *property, + PropertyKind::OnFocusGained, + "unregistered node must not emit OnFocusGained" + ); + assert_ne!( + *property, + PropertyKind::OnKeyUp, + "unregistered node must not emit OnKeyUp" + ); + } + } + } } diff --git a/compose-rust/src/runtime/host.rs b/compose-rust/src/runtime/host.rs index 293ab9f0..e58a0e16 100644 --- a/compose-rust/src/runtime/host.rs +++ b/compose-rust/src/runtime/host.rs @@ -323,7 +323,10 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result (*handler.borrow_mut())(), (EventCallback::Text(handler), EventPayload::TextChanged(text)) | (EventCallback::Text(handler), EventPayload::TextSubmitted(text)) => { @@ -337,6 +340,13 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result { let event = KeyEvent::new(*key, *shift_key, *ctrl_key, *alt_key, *meta_key); diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index c43f153c..23f9ad11 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -182,10 +182,10 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,on_focus_gained,on_key_up;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform;", "keys=Enter;", - "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", + "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen,FocusGained,KeyUp;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus" @@ -2812,6 +2812,8 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // into a window of its own, named by it. OnWindowDragOver = 167, TearOffKey = 168, + OnFocusGained = 117, + OnKeyUp = 118, }); #[derive(Clone, Debug, PartialEq)] @@ -3045,6 +3047,16 @@ pub enum EventPayload<'a> { /// The user opened the application again while it had no window. Delivered to the /// root. ApplicationReopen, + /// The node or a descendant inside it gained keyboard focus. + FocusGained, + /// A key was released while the node held focus. + KeyUp { + key: Key, + shift_key: bool, + ctrl_key: bool, + alt_key: bool, + meta_key: bool, + }, } pub const EVENT_SCHEMA: &[EventSchema] = &[ @@ -3261,6 +3273,16 @@ pub const EVENT_SCHEMA: &[EventSchema] = &[ tag: 47, payload: EventPayloadType::None, }, + EventSchema { + name: "FocusGained", + tag: 48, + payload: EventPayloadType::None, + }, + EventSchema { + name: "KeyUp", + tag: 49, + payload: EventPayloadType::KeyDown, + }, ]; /// One argument of a boundary operation. diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 870f2c4a..ee89e918 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -200,6 +200,65 @@ macro_rules! click_handler { }; } +/// Optional focus and key handlers on a node. A node that registers none of these +/// emits no focus or key listener property on the wire. +#[derive(Default)] +struct FocusAndKeyHandlers { + on_focus_gained: Option>, + on_focus_lost: Option>, + on_key_down: Option>, + on_key_up: Option>, +} + +fn focus_and_key_input( + mut handlers: FocusAndKeyHandlers, + listeners: &mut Vec<(&'static str, EventCallback)>, +) { + if let Some(handler) = handlers.on_focus_gained.take() { + listeners.push(("onfocusgained", unit(Some(handler)))); + } + if let Some(handler) = handlers.on_focus_lost.take() { + listeners.push(("onfocuslost", unit(Some(handler)))); + } + if let Some(handler) = handlers.on_key_down.take() { + listeners.push(("onkeydown", EventCallback::Key(Rc::new(RefCell::new(handler))))); + } + if let Some(handler) = handlers.on_key_up.take() { + listeners.push(("onkeyup", EventCallback::Key(Rc::new(RefCell::new(handler))))); + } +} + +macro_rules! focus_and_key_handlers { + ($name:ident) => { + impl $name { + /// Runs when this node or a descendant inside it gains keyboard focus. + pub fn on_focus_gained(mut self, handler: impl FnMut() + 'static) -> Self { + self.focus_and_keys.on_focus_gained = Some(std::boxed::Box::new(handler)); + self + } + + /// Runs when this node and its descendants lose keyboard focus. + pub fn on_focus_lost(mut self, handler: impl FnMut() + 'static) -> Self { + self.focus_and_keys.on_focus_lost = Some(std::boxed::Box::new(handler)); + self + } + + /// Sees each key pressed while this node or a descendant inside it holds focus; + /// calling `consume` on the event keeps the Renderer from acting on it. + pub fn on_key_down(mut self, handler: impl FnMut(KeyEvent) + 'static) -> Self { + self.focus_and_keys.on_key_down = Some(std::boxed::Box::new(handler)); + self + } + + /// Sees each key released while this node or a descendant inside it holds focus. + pub fn on_key_up(mut self, handler: impl FnMut(KeyEvent) + 'static) -> Self { + self.focus_and_keys.on_key_up = Some(std::boxed::Box::new(handler)); + self + } + } + }; +} + /// Composes the description when the statement it was written in ends. macro_rules! composed_on_drop { ($name:ident $(<$lifetime:lifetime>)?) => { @@ -271,6 +330,7 @@ macro_rules! linear { linear: Linear, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, tear_off: Option, done: bool, } @@ -282,6 +342,7 @@ macro_rules! linear { linear: Linear::default(), pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), tear_off: None, done: false, } @@ -329,6 +390,7 @@ macro_rules! linear { let mut subscription = Attrs::new(); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); tear_off_input(tear_off, &mut subscription, &mut listeners); emit( widget_key($key), @@ -347,6 +409,7 @@ macro_rules! linear { container!($name); pointer_subscription!($name); click_handler!($name); + focus_and_key_handlers!($name); tear_off!($name); }; } @@ -377,6 +440,7 @@ pub struct ComposeBox { alignment: Option, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, tear_off: Option, done: bool, } @@ -388,6 +452,7 @@ pub fn Box() -> ComposeBox { alignment: None, pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), tear_off: None, done: false, } @@ -408,6 +473,7 @@ impl ComposeBox { let mut subscription = Attrs::new(); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); tear_off_input(tear_off, &mut subscription, &mut listeners); emit( widget_key("compose_rust::ui::Box"), @@ -426,6 +492,7 @@ impl ComposeBox { container!(ComposeBox); pointer_subscription!(ComposeBox); click_handler!(ComposeBox); +focus_and_key_handlers!(ComposeBox); tear_off!(ComposeBox); /// A plain container with the standard Modifiers and nothing of its own. @@ -436,6 +503,7 @@ macro_rules! plain_container { modifier: Modifier, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, done: bool, } @@ -445,6 +513,7 @@ macro_rules! plain_container { modifier: Modifier::new(), pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), done: false, } } @@ -457,6 +526,7 @@ macro_rules! plain_container { let mut subscription = Attrs::new(); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); emit( widget_key($key), WidgetKind::$widget, @@ -473,6 +543,7 @@ macro_rules! plain_container { container!($name); pointer_subscription!($name); click_handler!($name); + focus_and_key_handlers!($name); }; } @@ -575,6 +646,7 @@ macro_rules! scroll_container { scroll_state: Option, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, done: bool, } @@ -585,6 +657,7 @@ macro_rules! scroll_container { scroll_state: None, pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), done: false, } } @@ -606,6 +679,7 @@ macro_rules! scroll_container { scroll_state_input(scroll_state, &mut subscription, &mut listeners); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); emit( widget_key($key), WidgetKind::$widget, @@ -622,6 +696,7 @@ macro_rules! scroll_container { container!($name); pointer_subscription!($name); click_handler!($name); + focus_and_key_handlers!($name); }; } @@ -871,8 +946,10 @@ pub struct TextFieldScope { font: FontRefs, on_value_change: Option>, on_submit: Option>, + on_focus_gained: Option>, on_focus_lost: Option>, on_key_down: Option>, + on_key_up: Option>, on_composition_change: Option>, done: bool, } @@ -891,8 +968,10 @@ pub fn TextField() -> TextFieldScope { font: FontRefs::default(), on_value_change: None, on_submit: None, + on_focus_gained: None, on_focus_lost: None, on_key_down: None, + on_key_up: None, on_composition_change: None, done: false, } @@ -945,6 +1024,11 @@ impl TextFieldScope { self } + pub fn on_focus_gained(mut self, handler: impl FnMut() + 'static) -> Self { + self.on_focus_gained = Some(std::boxed::Box::new(handler)); + self + } + pub fn on_focus_lost(mut self, handler: impl FnMut() + 'static) -> Self { self.on_focus_lost = Some(std::boxed::Box::new(handler)); self @@ -957,6 +1041,12 @@ impl TextFieldScope { self } + /// Sees each key released while the field holds focus. + pub fn on_key_up(mut self, handler: impl FnMut(KeyEvent) + 'static) -> Self { + self.on_key_up = Some(std::boxed::Box::new(handler)); + self + } + /// Reports when an input method begins or ends an active composition in the field. pub fn on_composition_change(mut self, handler: impl FnMut(bool) + 'static) -> Self { self.on_composition_change = Some(std::boxed::Box::new(handler)); @@ -979,6 +1069,12 @@ impl TextFieldScope { ("onfocuslost", unit(self.on_focus_lost.take())), ("onkeydown", key_down), ]; + if let Some(handler) = self.on_focus_gained.take() { + listeners.push(("onfocusgained", unit(Some(handler)))); + } + if let Some(handler) = self.on_key_up.take() { + listeners.push(("onkeyup", EventCallback::Key(Rc::new(RefCell::new(handler))))); + } if let Some(mut handler) = self.on_composition_change.take() { listeners.push(( "oncompositionchange", diff --git a/compose-rust/src/writer.rs b/compose-rust/src/writer.rs index 59685b5a..5ed67994 100644 --- a/compose-rust/src/writer.rs +++ b/compose-rust/src/writer.rs @@ -836,7 +836,11 @@ pub(crate) fn event_property(name: &str) -> Option { "valuechange" | "onvaluechange" => Some(PropertyKind::OnValueChange), "submit" | "onsubmit" => Some(PropertyKind::OnSubmit), "focuslost" | "onfocuslost" => Some(PropertyKind::OnFocusLost), + "focusgained" | "onfocusgained" | "focusin" | "onfocusin" => { + Some(PropertyKind::OnFocusGained) + } "keydown" | "onkeydown" => Some(PropertyKind::OnKeyDown), + "keyup" | "onkeyup" => Some(PropertyKind::OnKeyUp), "rangerequest" | "onrangerequest" => Some(PropertyKind::OnRangeRequested), "dismiss" | "ondismiss" => Some(PropertyKind::OnDismiss), "filesentered" | "onfilesentered" => Some(PropertyKind::OnFilesEntered), diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 86a40e7b..ee8c036b 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "2165073138301532475", + "schemaHashUnsigned": "2266959730736018618", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/docs/SPEC.md b/docs/SPEC.md index a2a25244..2dbadfe3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3124,7 +3124,7 @@ Host는 디스플레이 배율(밀도)을 모르고 바뀐 것도 모릅니다. - 새 노드 이벤트 `OnFocusGained`. 그 노드나 그 하위가 포커스를 얻을 때 옵니다. 페이로드는 없습니다. - 새 노드 이벤트 `OnKeyUp`. FR-12.1의 `KeyDown`과 같은 페이로드(`code`, `text`, 수정자, `repeat`)를 싣고, 키를 뗄 때 옵니다. - 둘 다 핸들러가 등록된 노드에만 보냅니다. 등록이 없으면 경계를 건너지 않습니다. -- 번호. 핸들러 속성은 `OnFocusGained` 117, `OnKeyUp` 118입니다(15.2.2의 규칙대로 FR-40의 110-116 뒤입니다). 이벤트 태그는 FocusGained 39, KeyUp 40입니다. 33은 FR-43의 `ZoomChanged`가, 34-38은 FR-38.9가 가집니다. +- 번호. 핸들러 속성은 `OnFocusGained` 117, `OnKeyUp` 118입니다(15.2.2의 규칙대로 FR-40의 110-116 뒤입니다). 이벤트 태그는 FocusGained 48, KeyUp 49입니다. 33은 FR-43의 `ZoomChanged`가, 34-38은 FR-38.9가, 39-47은 FR-53의 창 이벤트가 가집니다. - 스키마 해시가 바뀝니다. 수용 기준: diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 5dc613ff..2caaecdf 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, Progress } enum class Key { Enter } @@ -1292,13 +1292,15 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FocusGained(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class KeyUp(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 2165073138301532475L + const val SCHEMA_HASH: Long = 2266959730736018618L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1753,6 +1755,8 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FocusGained -> null + is HostEvent.KeyUp -> null } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1795,6 +1799,8 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FocusGained -> 16 + is HostEvent.KeyUp -> 20 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1841,6 +1847,8 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FocusGained -> 48 + is HostEvent.KeyUp -> 49 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -2005,6 +2013,17 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FocusGained -> Unit + is HostEvent.KeyUp -> { + out.putShort(keyTag(event.key).toShort()) + var modifiers = 0 + if (event.shiftKey) modifiers = modifiers or 0x01 + if (event.ctrlKey) modifiers = modifiers or 0x02 + if (event.altKey) modifiers = modifiers or 0x04 + if (event.metaKey) modifiers = modifiers or 0x08 + out.put(modifiers.toByte()) + out.put(0.toByte()) + } } if (text != null) out.put(text) return out.position() - start @@ -2188,6 +2207,8 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 117 -> PropertyKind.OnFocusGained + 118 -> PropertyKind.OnKeyUp 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 0306bfdc..53051397 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -98,6 +98,8 @@ internal fun HostTextField( val changeHandler = node.handler(PropertyKind.OnValueChange) val submitHandler = node.handler(PropertyKind.OnSubmit) val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) val focusLostHandler = node.handler(PropertyKind.OnFocusLost) val compositionHandler = node.handler(PropertyKind.OnCompositionChange) val placeholder = node.text(PropertyKind.Placeholder) @@ -193,6 +195,9 @@ internal fun HostTextField( val inputModifier = modifier .focusRequester(focusRequester) .onFocusChanged { state -> + if (!focused && state.isFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) } @@ -206,22 +211,26 @@ internal fun HostTextField( // guard that keeps Enter away from Rust during a Korean composition. .onPreviewKeyEvent { event -> val composingNow = value.composition != null - val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) - if (consumedByHandler) { - true - } else if (!isDispatchableKeyDown(event, composingNow)) { - false - } else if (!shouldSubmitOnEnter( - composing = composingNow, - multiline = multiline, - shiftPressed = event.isShiftPressed, - hasSubmitHandler = submitHandler != null, - ) - ) { - false + if (keyUpHandler != null && isDispatchableKeyUp(event, composingNow)) { + dispatchKeyUp(nodeId, keyUpHandler, event, composingNow, dispatcher) } else { - dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + val consumedByHandler = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) + if (consumedByHandler) { + true + } else if (!isDispatchableKeyDown(event, composingNow)) { + false + } else if (!shouldSubmitOnEnter( + composing = composingNow, + multiline = multiline, + shiftPressed = event.isShiftPressed, + hasSubmitHandler = submitHandler != null, + ) + ) { + false + } else { + dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + } } } diff --git a/renderer/desktop/src/renderer/KeyEvents.kt b/renderer/desktop/src/renderer/KeyEvents.kt index b5d64e2d..620acd6d 100644 --- a/renderer/desktop/src/renderer/KeyEvents.kt +++ b/renderer/desktop/src/renderer/KeyEvents.kt @@ -84,6 +84,53 @@ internal fun dispatchKeyDown( ) } +/** + * Whether a key release may be offered to the Host at all. + * + * Key-up only, a key the schema can name, and never while an IME composition is in progress. + */ +internal fun shouldDispatchKeyUp( + composing: Boolean, + isKeyUp: Boolean, + key: ProtocolKey?, +): Boolean = !composing && isKeyUp && key != null + +/** [shouldDispatchKeyUp] applied to a Compose key event. */ +internal fun isDispatchableKeyUp(event: ComposeKeyEvent, composing: Boolean): Boolean = + shouldDispatchKeyUp( + composing = composing, + isKeyUp = event.type == KeyEventType.KeyUp, + key = protocolKey(event.key), + ) + +/** + * Sends one key release to the Host and returns its consumption result. + * + * Returns false without calling the Host whenever [isDispatchableKeyUp] says no, so a + * composition keystroke never reaches Rust. + */ +internal fun dispatchKeyUp( + nodeId: Int, + handlerId: Long, + event: ComposeKeyEvent, + composing: Boolean, + dispatcher: EventDispatcher, +): Boolean { + if (!isDispatchableKeyUp(event, composing)) return false + val key = protocolKey(event.key) ?: return false + return dispatcher.dispatch( + HostEvent.KeyUp( + nodeId = nodeId, + handlerId = handlerId, + key = key, + shiftKey = event.isShiftPressed, + ctrlKey = event.isCtrlPressed, + altKey = event.isAltPressed, + metaKey = event.isMetaPressed, + ), + ) +} + /** * `Modifier.onKeyEvent` for a widget that owns no text composition. * @@ -93,8 +140,26 @@ internal fun dispatchKeyDown( */ internal fun Modifier.hostKeyEvents( nodeId: Int, - handlerId: Long, + keyDownHandler: Long?, + keyUpHandler: Long?, dispatcher: EventDispatcher, ): Modifier = onKeyEvent { event -> - dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher) + when { + keyDownHandler != null && isDispatchableKeyDown(event, composing = false) -> + dispatchKeyDown(nodeId, keyDownHandler, event, composing = false, dispatcher = dispatcher) + keyUpHandler != null && isDispatchableKeyUp(event, composing = false) -> + dispatchKeyUp(nodeId, keyUpHandler, event, composing = false, dispatcher = dispatcher) + else -> false + } } + +internal fun Modifier.hostKeyEvents( + nodeId: Int, + handlerId: Long, + dispatcher: EventDispatcher, +): Modifier = hostKeyEvents( + nodeId = nodeId, + keyDownHandler = handlerId, + keyUpHandler = null, + dispatcher = dispatcher, +) diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 29ccc2a8..77ba22bd 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -720,7 +720,9 @@ class NodeTable { PropertyKind.OnValueChange, PropertyKind.OnSubmit, PropertyKind.OnFocusLost, + PropertyKind.OnFocusGained, PropertyKind.OnKeyDown, + PropertyKind.OnKeyUp, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, -> true diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 69495546..c9ba8a60 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -22,9 +22,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key @@ -173,10 +177,34 @@ fun RenderNode( // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) - val withKeys = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val withKeys = if ((keyDownHandler == null && keyUpHandler == null) || + node.widget == WidgetKind.TextField + ) { chain } else { - chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + chain.hostKeyEvents(nodeId, keyDownHandler, keyUpHandler, dispatcher) + } + // Focus-gained and focus-lost for non-TextField nodes: `hasFocus` covers both the node + // itself and any focusable descendant inside it. + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) + val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + var hasFocusWithin by remember(nodeId) { mutableStateOf(false) } + val withFocusEvents = if ((focusGainedHandler == null && focusLostHandler == null) || + node.widget == WidgetKind.TextField + ) { + withKeys + } else { + withKeys.onFocusChanged { state -> + val nowFocused = state.hasFocus + if (!hasFocusWithin && nowFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } + if (hasFocusWithin && !nowFocused && focusLostHandler != null) { + dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) + } + hasFocusWithin = nowFocused + } } // Only where the node said it is a place files may be dropped. Everywhere else this // adds nothing to the chain and the platform shows no drop cursor. @@ -191,9 +219,9 @@ fun RenderNode( null } val pressable = if (clickHandler == null) { - withKeys + withFocusEvents } else { - withKeys.pressable( + withFocusEvents.pressable( nodeId, clickHandler, node.flag(PropertyKind.Enabled, default = true), diff --git a/renderer/desktop/test/KeyEventTest.kt b/renderer/desktop/test/KeyEventTest.kt index 26169464..34cff157 100644 --- a/renderer/desktop/test/KeyEventTest.kt +++ b/renderer/desktop/test/KeyEventTest.kt @@ -20,6 +20,7 @@ import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.WidgetKind import dev.darkpyonix.composerust.foundation.protocolKey import dev.darkpyonix.composerust.foundation.shouldDispatchKeyDown +import dev.darkpyonix.composerust.foundation.shouldDispatchKeyUp import dev.darkpyonix.composerust.runtime.ComposeRustContent import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.tooling.FakeHostConnection @@ -140,4 +141,125 @@ class KeyEventTest { connection.nodeEvents, ) } + + /** + * A focused `TextField` sends `FocusGained` once when focus arrives and `FocusLost` + * when focus moves to another control. + */ + @Test + fun fr45_focus_gained_and_lost_pair() = runComposeUiTest { + val otherField = 2 + val gainedHandler = 51L + val lostHandler = 52L + val connection = FakeHostConnection( + listOf( + Mutation.Create(10, WidgetKind.Column), + Mutation.Create(FIELD, WidgetKind.TextField), + Mutation.SetProp(FIELD, PropertyKind.OnFocusGained, PropertyValue.Integer(gainedHandler)), + Mutation.SetProp(FIELD, PropertyKind.OnFocusLost, PropertyValue.Integer(lostHandler)), + Mutation.Create(otherField, WidgetKind.TextField), + Mutation.Insert(10, FIELD, 0), + Mutation.Insert(10, otherField, 1), + Mutation.Insert(0, 10, 0), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + waitForIdle() + assertEquals( + listOf(HostEvent.FocusGained(FIELD, gainedHandler)), + connection.nodeEvents, + ) + + onNodeWithTag(nodeTestTag(otherField)).requestFocus() + waitForIdle() + assertEquals( + listOf( + HostEvent.FocusGained(FIELD, gainedHandler), + HostEvent.FocusLost(FIELD, lostHandler), + ), + connection.nodeEvents, + ) + } + + /** + * Pressing and releasing a key sends `KeyDown` followed by `KeyUp` with the same key + * and modifiers, and never while an IME composition is in progress. + */ + @Test + fun fr45_key_up_follows_key_down() = runComposeUiTest { + val keyUpHandler = 43L + assertFalse( + shouldDispatchKeyUp(composing = true, isKeyUp = true, key = ProtocolKey.Enter), + "Key-up during a composition must not be offered to the Host", + ) + assertTrue( + shouldDispatchKeyUp(composing = false, isKeyUp = true, key = ProtocolKey.Enter), + ) + + val connection = FakeHostConnection( + keyField( + Mutation.SetProp(FIELD, PropertyKind.OnKeyUp, PropertyValue.Integer(keyUpHandler)), + ), + ) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { pressKey(Key.Enter) } + waitForIdle() + + assertEquals( + listOf( + HostEvent.KeyDown( + nodeId = FIELD, + handlerId = KEY_HANDLER, + key = ProtocolKey.Enter, + shiftKey = false, + ctrlKey = false, + altKey = false, + metaKey = false, + ), + HostEvent.KeyUp( + nodeId = FIELD, + handlerId = keyUpHandler, + key = ProtocolKey.Enter, + shiftKey = false, + ctrlKey = false, + altKey = false, + metaKey = false, + ), + ), + connection.nodeEvents, + ) + } + + /** + * A node with neither focus nor key handlers registered sends nothing across the boundary + * when focused, blurred, or typed into. + */ + @Test + fun fr45_unregistered_nodes_get_no_events() = runComposeUiTest { + val otherField = 2 + val connection = FakeHostConnection( + listOf( + Mutation.Create(10, WidgetKind.Column), + Mutation.Create(FIELD, WidgetKind.TextField), + Mutation.SetProp(FIELD, PropertyKind.Multiline, PropertyValue.Bool(true)), + Mutation.Create(otherField, WidgetKind.TextField), + Mutation.Insert(10, FIELD, 0), + Mutation.Insert(10, otherField, 1), + Mutation.Insert(0, 10, 0), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { pressKey(Key.Enter) } + onNodeWithTag(nodeTestTag(otherField)).requestFocus() + waitForIdle() + + assertEquals(emptyList(), connection.nodeEvents) + } } From e41e48d3148251e0333fc5ed04b42631f5cd138a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:20:37 +0900 Subject: [PATCH 115/224] Feat: Colour and size options for Switch, Slider and Checkbox, verified (FR-67, #426, #428) Regenerates the protocol files for PropertyKind 121 to 126 and restages the Android copy. Fixes the drawn Switch ignoring track_color when on. Covers FR-67 criteria 1 to 4: fr67_* tests in compose-rust/tests/fr67_controls.rs and renderer/desktop/test/ControlColoursTest.kt. --- .../android-kotlin/shared/ControlLook.kt | 75 ++++++++++++++ .../android-kotlin/shared/ControlWidgets.kt | 98 +++++++++++++------ .../android-kotlin/shared/HostSlider.kt | 2 + .../android-kotlin/shared/HostToggle.kt | 2 + .../shared/Material3ControlWidgets.kt | 15 ++- .../android-kotlin/shared/NodeTable.kt | 13 +++ .../android-kotlin/shared/Protocol.gen.kt | 10 +- compose-rust/schema-hash.txt | 2 +- compose-rust/src/ui/widgets.rs | 14 ++- compose-rust/tests/vectors/vectors.json | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 10 +- .../desktop/src/renderer/ControlWidgets.kt | 6 +- 12 files changed, 205 insertions(+), 44 deletions(-) create mode 100644 compose-rust/android-kotlin/shared/ControlLook.kt diff --git a/compose-rust/android-kotlin/shared/ControlLook.kt b/compose-rust/android-kotlin/shared/ControlLook.kt new file mode 100644 index 00000000..83cc0763 --- /dev/null +++ b/compose-rust/android-kotlin/shared/ControlLook.kt @@ -0,0 +1,75 @@ +package dev.darkpyonix.composerust.design + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.paintProp +import dev.darkpyonix.composerust.ui.resolvedShape + +/** + * The colours and size the application asked a Switch, Slider or Checkbox to draw with. + * + * Every slot is null until the node sent it, and a slot that is null keeps what the design + * system draws. A look with nothing in it changes nothing, which is how a control that + * sends none of these properties stays exactly as it was. + */ +class ControlLook( + /** The track of a Switch, or the part of a Slider's track that is not filled. */ + val track: Color? = null, + /** The travelling thumb of a Switch or a Slider. */ + val thumb: Color? = null, + /** The filled part of a Slider's track. */ + val activeTrack: Color? = null, + /** The box of a checked Checkbox. */ + val container: Color? = null, + /** The check mark. */ + val check: Color? = null, + /** A Switch's width, or a Checkbox's side. */ + val width: Dp? = null, + /** A Switch's height. */ + val height: Dp? = null, + /** The corner shape the node asked for, or null where the design system's applies. */ + val shape: Shape? = null, +) { + /** Whether a Switch or a Checkbox has to be drawn from this look rather than by default. */ + fun appliesTo(role: ToggleRole): Boolean = when (role) { + ToggleRole.Switch -> track != null || thumb != null || width != null || height != null + ToggleRole.Checkbox -> container != null || check != null || width != null + ToggleRole.RadioButton -> false + } + + companion object { + val None = ControlLook() + } +} + +/** Reads the look a node asked for. Colours go through the theme like every other paint. */ +internal fun Node.controlLook(theme: ResolvedTheme): ControlLook { + fun colour(kind: PropertyKind): Color? = paintProp(kind)?.let { theme.color(it) } + val size = bytes(PropertyKind.ControlSize)?.takeIf { it.size >= 8 } + val width = size?.let { littleEndianFloat(it, 0) }?.takeIf { it > 0f }?.dp + val height = size?.let { littleEndianFloat(it, 4) }?.takeIf { it > 0f }?.dp + val named = modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.Shape || + it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + } + return ControlLook( + track = colour(PropertyKind.TrackColor), + thumb = colour(PropertyKind.ThumbColor), + activeTrack = colour(PropertyKind.ActiveTrackColor), + container = colour(PropertyKind.ContainerColor), + check = colour(PropertyKind.CheckColor), + width = width, + height = height, + shape = if (named) modifiers.resolvedShape(theme) else null, + ) +} + +private fun littleEndianFloat(bytes: ByteArray, at: Int): Float { + var bits = 0 + for (index in 3 downTo 0) bits = (bits shl 8) or (bytes[at + index].toInt() and 0xff) + return Float.fromBits(bits) +} diff --git a/compose-rust/android-kotlin/shared/ControlWidgets.kt b/compose-rust/android-kotlin/shared/ControlWidgets.kt index b0d554ea..896c05ea 100644 --- a/compose-rust/android-kotlin/shared/ControlWidgets.kt +++ b/compose-rust/android-kotlin/shared/ControlWidgets.kt @@ -82,6 +82,7 @@ interface ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -97,6 +98,9 @@ interface ControlWidgets { * [named] colours the part that has been filled in and the thumb with it, where the * application said what those should look like. It is null in the ordinary case and * the design system answers instead, which is what an adaptive application wants. + * + * [look] holds the colours the application named for the two parts of the track and + * the thumb, each of which wins over [named] and over the design system where it is set. */ @Composable fun Slider( @@ -108,6 +112,7 @@ interface ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -152,6 +157,7 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).toggle(role) val motion = theme.rules.motion @@ -163,7 +169,11 @@ internal object DrawnControlWidgets : ControlWidgets { // animates without a frame of it crossing the boundary. val progress by animateFloatAsState(if (checked) 1f else 0f, spec) val container by animateColorAsState( - targetValue = if (checked) style.containerChecked else style.container, + targetValue = when { + role == ToggleRole.Switch && look.track != null -> look.track + checked -> look.container ?: style.containerChecked + else -> style.container + }, animationSpec = tween(spec.durationMillis, easing = motion.easing), ) val alpha = if (enabled) 1f else style.disabledAlpha @@ -208,14 +218,19 @@ internal object DrawnControlWidgets : ControlWidgets { } when (role) { - ToggleRole.Checkbox -> Box( - clickable - .size(style.size) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { drawTick(style.mark.copy(alpha = alpha), progress) }, - ) + ToggleRole.Checkbox -> { + val shape = look.shape ?: style.shape + // A box the application filled has no outline of its own to show. + val outline = if (checked && look.container != null) 0.dp else style.borderWidth + Box( + clickable + .size(look.width ?: style.size) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { drawTick((look.check ?: style.mark).copy(alpha = alpha), progress) }, + ) + } ToggleRole.RadioButton -> Box( clickable @@ -231,27 +246,41 @@ internal object DrawnControlWidgets : ControlWidgets { }, ) - ToggleRole.Switch -> Box( - clickable - .size(width = style.trackWidth, height = style.trackHeight) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { - val diameter = style.thumbSize.toPx() - val margin = (size.height - diameter) / 2f - val travel = size.width - diameter - margin * 2f - val thumb = if (checked) style.mark else style.markUnchecked - drawCircle( - color = thumb.copy(alpha = alpha), - radius = diameter / 2f, - center = Offset( - x = margin + diameter / 2f + travel * progress, - y = size.height / 2f, - ), + ToggleRole.Switch -> { + val shape = look.shape ?: style.shape + val outline = if (look.track != null) 0.dp else style.borderWidth + Box( + clickable + .size( + width = look.width ?: style.trackWidth, + height = look.height ?: style.trackHeight, ) - }, - ) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { + // A track of the application's own height has a thumb that + // scales with it, the same 4 to 5 proportion as a 16 dp thumb on + // a 20 dp track. + val diameter = if (look.height != null) { + size.height * SWITCH_THUMB_RATIO + } else { + style.thumbSize.toPx() + } + val margin = (size.height - diameter) / 2f + val travel = size.width - diameter - margin * 2f + val thumb = look.thumb ?: if (checked) style.mark else style.markUnchecked + drawCircle( + color = thumb.copy(alpha = alpha), + radius = diameter / 2f, + center = Offset( + x = margin + diameter / 2f + travel * progress, + y = size.height / 2f, + ), + ) + }, + ) + } } } @@ -265,10 +294,12 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).slider - val active = named ?: style.activeTrack - val knob = named ?: style.thumb + val active = look.activeTrack ?: named ?: style.activeTrack + val knob = look.thumb ?: named ?: style.thumb + val rest = look.track ?: style.track val min = range.start val max = range.endInclusive val span = (max - min).takeIf { it > 0f } @@ -312,7 +343,7 @@ internal object DrawnControlWidgets : ControlWidgets { val usable = size.width - thumb val radius = CornerRadius(trackHeight / 2f, trackHeight / 2f) drawRoundRect( - color = style.track, + color = rest, topLeft = Offset(left, trackTop), size = Size(usable, trackHeight), cornerRadius = radius, @@ -502,6 +533,9 @@ private fun valueAt(x: Float, width: Float, thumb: Float, min: Float, max: Float return min + (max - min) * ((x - thumb / 2f) / usable).coerceIn(0f, 1f) } +/** A switch thumb's diameter as a share of the track's height, when the height is the application's. */ +private const val SWITCH_THUMB_RATIO = 0.8f + /** Narrow enough to sit in a row, wide enough that the track is worth dragging. */ private val SLIDER_MIN_WIDTH = 120.dp diff --git a/compose-rust/android-kotlin/shared/HostSlider.kt b/compose-rust/android-kotlin/shared/HostSlider.kt index 5f886f69..11ccfa63 100644 --- a/compose-rust/android-kotlin/shared/HostSlider.kt +++ b/compose-rust/android-kotlin/shared/HostSlider.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.controlLook import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -71,6 +72,7 @@ internal fun HostSlider( onChange = report, modifier = modifier, theme = theme, + look = node.controlLook(theme), ) } diff --git a/compose-rust/android-kotlin/shared/HostToggle.kt b/compose-rust/android-kotlin/shared/HostToggle.kt index 42355f36..ec59674e 100644 --- a/compose-rust/android-kotlin/shared/HostToggle.kt +++ b/compose-rust/android-kotlin/shared/HostToggle.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.controlLook import dev.darkpyonix.composerust.design.ToggleRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind @@ -47,5 +48,6 @@ internal fun HostToggle( }, modifier = modifier, theme = theme, + look = node.controlLook(theme), ) } diff --git a/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt b/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt index 1c1dbfec..e642cc06 100644 --- a/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt +++ b/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt @@ -45,7 +45,15 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { + // The library's Switch and Checkbox have fixed metrics, so a size or a colour the + // application named for them is drawn by the shared control drawing instead, which + // also owns the transition and the toggle semantics. + if (look.appliesTo(role)) { + DrawnControlWidgets.Toggle(role, checked, enabled, onChange, modifier, theme, look) + return + } val primary = theme.color(ColorRole.Primary) val onPrimary = theme.color(ColorRole.OnPrimary) val outline = theme.color(ColorRole.Outline) @@ -101,6 +109,7 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val primary = named ?: theme.color(ColorRole.Primary) Slider( @@ -111,9 +120,9 @@ internal object Material3ControlWidgets : ControlWidgets { valueRange = range, steps = steps, colors = SliderDefaults.colors( - thumbColor = primary, - activeTrackColor = primary, - inactiveTrackColor = theme.color(ColorRole.SurfaceVariant), + thumbColor = look.thumb ?: primary, + activeTrackColor = look.activeTrack ?: primary, + inactiveTrackColor = look.track ?: theme.color(ColorRole.SurfaceVariant), ), ) } diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 6ff6e7ef..a67a5821 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -697,6 +697,19 @@ class NodeTable { // The name assistive technology reads can be given to any node. PropertyKind.AccessibleName -> true + // Colours and size of the selection controls. + PropertyKind.TrackColor, + PropertyKind.ThumbColor, + -> widget == WidgetKind.Switch || widget == WidgetKind.Slider + + PropertyKind.ActiveTrackColor -> widget == WidgetKind.Slider + + PropertyKind.ContainerColor, + PropertyKind.CheckColor, + -> widget == WidgetKind.Checkbox + + PropertyKind.ControlSize -> widget == WidgetKind.Switch || widget == WidgetKind.Checkbox + // Event properties carry handler ids and are valid on any widget. PropertyKind.OnClick, PropertyKind.OnValueChange, diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index b18604e6..bd8a57d8 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, Progress } enum class Key { Enter } @@ -1301,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -3628289596330618536L + const val SCHEMA_HASH: Long = -9126323327933126420L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2190,6 +2190,12 @@ object Protocol { 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey 119 -> PropertyKind.AccessibleName + 121 -> PropertyKind.TrackColor + 122 -> PropertyKind.ThumbColor + 123 -> PropertyKind.ActiveTrackColor + 124 -> PropertyKind.ContainerColor + 125 -> PropertyKind.CheckColor + 126 -> PropertyKind.ControlSize 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 92e05329..78d39629 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0xcda5b9d5ec77d158 +0x8158c9495d2f68ec diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 1c227a1a..876a59f5 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -1288,7 +1288,12 @@ control_colour!( "The track, whichever state it is in. The Host owns the state, so it passes the colour \ that goes with it." ); -control_colour!(SwitchScope, thumb, thumb_color, "The thumb that travels along the track."); +control_colour!( + SwitchScope, + thumb, + thumb_color, + "The thumb that travels along the track." +); impl SwitchScope { /// The track's width and height in dp. The thumb scales with the height. @@ -1370,7 +1375,12 @@ pub fn Slider(value: f32) -> SliderScope { } accessible_name!(SliderScope); -control_colour!(SliderScope, track, track_color, "The part of the track that is not filled."); +control_colour!( + SliderScope, + track, + track_color, + "The part of the track that is not filled." +); control_colour!(SliderScope, thumb, thumb_color, "The thumb."); control_colour!( SliderScope, diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index b43eec3d..e90039ed 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "14818454477378933080", + "schemaHashUnsigned": "9320420745776425196", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index b18604e6..bd8a57d8 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, Progress } enum class Key { Enter } @@ -1301,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -3628289596330618536L + const val SCHEMA_HASH: Long = -9126323327933126420L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2190,6 +2190,12 @@ object Protocol { 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey 119 -> PropertyKind.AccessibleName + 121 -> PropertyKind.TrackColor + 122 -> PropertyKind.ThumbColor + 123 -> PropertyKind.ActiveTrackColor + 124 -> PropertyKind.ContainerColor + 125 -> PropertyKind.CheckColor + 126 -> PropertyKind.ControlSize 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/renderer/desktop/src/renderer/ControlWidgets.kt b/renderer/desktop/src/renderer/ControlWidgets.kt index aaf8f8d1..896c05ea 100644 --- a/renderer/desktop/src/renderer/ControlWidgets.kt +++ b/renderer/desktop/src/renderer/ControlWidgets.kt @@ -169,7 +169,11 @@ internal object DrawnControlWidgets : ControlWidgets { // animates without a frame of it crossing the boundary. val progress by animateFloatAsState(if (checked) 1f else 0f, spec) val container by animateColorAsState( - targetValue = if (checked) look.container ?: style.containerChecked else style.container, + targetValue = when { + role == ToggleRole.Switch && look.track != null -> look.track + checked -> look.container ?: style.containerChecked + else -> style.container + }, animationSpec = tween(spec.durationMillis, easing = motion.easing), ) val alpha = if (enabled) 1f else style.disabledAlpha From 455d917038bce893c94379bff0af6df327264774 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:28:56 +0900 Subject: [PATCH 116/224] Chore: Refresh the staged Android Protocol.gen.kt --- compose-rust/android-kotlin/shared/Protocol.gen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 88e2476c..817e2253 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -1246,6 +1246,9 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation } sealed interface HostEvent { @@ -1298,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = -1297132866241875313L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1326,6 +1329,7 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1475,6 +1479,10 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the From 311a0ab4efec47dad68af3e9c7e2d272b857cbbf Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:31:59 +0900 Subject: [PATCH 117/224] Docs: FR-67 state-dependent track colour and removal back to defaults; FR-68 name removal (#426, #428, #429) Approved by the supervisor (delegated), reason: Dioxus sends a conditional attribute that goes away as a removal, and the Renderer must return to the design system default. --- docs/SPEC.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index fa3961b4..a7a5c880 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3755,12 +3755,15 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 - 세 위젯 빌더에 색 역할 또는 `Paint`로 주는 `track_color`, `thumb_color`(Switch, Slider), `active_track_color`(Slider), `container_color`, `check_color`(Checkbox)와 크기 `size(Dp)`(Switch는 `size(width, height)`)가 있습니다. 부르지 않으면 속성이 나가지 않고 디자인 시스템 기본 모양입니다. - Renderer는 이 값이 하나라도 오면 Compose의 `SwitchDefaults.colors`, `SliderDefaults.colors`, `CheckboxDefaults.colors`와 크기 수정자로 그립니다. 값이 오지 않은 칸은 기본값을 씁니다. - 선택된 `Checkbox`는 `container_color`로 채운 사각형(모서리 반지름은 `Shape` 수정자를 따름)에 `check_color` 체크를 그립니다. +- `track_color`는 켜짐과 꺼짐에 같은 색으로 적용됩니다. 상태마다 다른 트랙 색이 필요하면 앱이 상태에 맞는 색을 보내고, 상태가 바뀌면 바뀐 색만 다시 나갑니다(새 API는 없습니다). +- 앱이 이 속성을 더는 쓰지 않으면(예: 비활성일 때만 색을 주던 컨트롤이 활성이 됨) 제거가 전달되고, Renderer는 그 칸을 디자인 시스템 기본값으로 되돌립니다. 마지막으로 받은 값이 남지 않습니다. 수용 기준. 1. `Switch(true).size(36.0, 20.0).track_color(ink).thumb_color(surface)`의 측정 크기가 36x20dp이고 켜진 트랙 픽셀이 `ink`입니다. 2. `Slider`에 `active_track_color`와 `track_color`를 주면 채워진 구간과 남은 구간이 그 색으로 그려집니다. 3. 체크된 `Checkbox.size(18.0).container_color(ink).check_color(white)`는 18x18dp 채워진 사각형과 흰 체크를 그립니다. 4. 색과 크기를 하나도 주지 않은 세 위젯은 지금과 같은 속성을 보냅니다. +5. 색과 크기를 준 컨트롤에서 그 속성을 제거하면(제거 전달 뒤) 측정 크기와 색이 디자인 시스템 기본값으로 돌아옵니다. 시험 이름은 `fr67_`로 시작합니다. ### FR-68 어느 노드에나 붙는 접근성 수정자 (`Draft`, 이슈 #429, #426) @@ -3775,6 +3778,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 2. `Box.modifier(label("새로고침").role(Button))`의 접근성 노드가 그 이름과 역할을 가집니다. 3. `Button("").icon(Refresh).accessible_name("가져오기")`의 접근성 이름이 "가져오기"입니다. 4. 이름을 준 `Switch(true)`의 접근성 노드가 이름과 켜짐 상태를 가집니다. +5. 이름을 준 노드에서 `accessible_name`을 제거하면(제거 전달 뒤) 접근성 이름이 위젯의 기본(보이는 라벨이나 아이콘의 뜻)으로 돌아옵니다. ### FR-69 비활성 Button의 라벨 대비 (`Draft`, 이슈 #432) From 5a434d8daee47409c2c328506dcbb4a9fdc36b1a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:52:42 +0900 Subject: [PATCH 118/224] Test: Removing the accessible name returns the default name (FR-68, #429) Covers FR-68 criterion 5: fr68_a_widget_that_stops_naming_itself_sends_a_removal and fr68_removing_the_accessible_name_returns_the_default_name. --- compose-rust/tests/fr68_semantics.rs | 60 +++++++++++++++++++ .../desktop/test/AccessibilityModifierTest.kt | 43 +++++++++++++ 2 files changed, 103 insertions(+) diff --git a/compose-rust/tests/fr68_semantics.rs b/compose-rust/tests/fr68_semantics.rs index 8c06b104..ab7649cd 100644 --- a/compose-rust/tests/fr68_semantics.rs +++ b/compose-rust/tests/fr68_semantics.rs @@ -116,3 +116,63 @@ fn fr68_the_widget_name_wins_over_the_modifier_label_and_the_icon() { assert!(dump.contains("AccessibleName=\"Save the draft\""), "{dump}"); assert!(!dump.contains("From the modifier"), "{dump}"); } + +#[composable] +fn Renaming() { + let named = remember(|| mutable_state_of(true)); + Column().content(|| { + let flip = named.clone(); + Row() + .on_click(move || flip.update(|n| *n = !*n)) + .content(|| { + Text("flip"); + }); + let button = Button("").icon(IconRole::Add).on_click(|| {}); + if named.get() { + button.accessible_name("Import") + } else { + button + }; + }); +} + +#[test] +fn fr68_a_widget_that_stops_naming_itself_sends_a_removal() { + use compose_rust::protocol::{HostEvent, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, PropertyKind}; + + let mut host = ComposeHost::with_content(Renaming); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + assert!(tree.dump().contains("AccessibleName=\"Import\"")); + + let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; + let mut batches = Vec::new(); + let (batch, _) = host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the click is accepted"); + batches.push(batch.to_vec()); + batches.push(host.render_frame(0).expect("the frame encodes").to_vec()); + + let mut removed = 0; + for batch in &batches { + for mutation in decode_batch(batch).expect("the batch decodes") { + if let Mutation::SetProp { + property: PropertyKind::AccessibleName, + value, + .. + } = mutation + { + assert_eq!(value, PropertyValue::None); + removed += 1; + } + } + tree.apply(batch); + } + assert_eq!(removed, 1, "one removal of the name was expected"); + assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); +} diff --git a/renderer/desktop/test/AccessibilityModifierTest.kt b/renderer/desktop/test/AccessibilityModifierTest.kt index 8acd1695..5071b5ec 100644 --- a/renderer/desktop/test/AccessibilityModifierTest.kt +++ b/renderer/desktop/test/AccessibilityModifierTest.kt @@ -1,5 +1,6 @@ package dev.darkpyonix.composerust.test +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsActions @@ -22,6 +23,8 @@ import dev.darkpyonix.composerust.runtime.ComposeRustContent import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.tooling.FakeHostConnection import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -249,4 +252,44 @@ class AccessibilityModifierTest { assertEquals(ToggleableState.On, config.getOrNull(SemanticsProperties.ToggleableState)) } } + + @Test + fun fr68_removing_the_accessible_name_returns_the_default_name() { + val button = 1 + fun iconButton(vararg extra: Mutation) = listOf( + Mutation.Create(button, WidgetKind.Button), + Mutation.SetProp(button, PropertyKind.Text, name("")), + Mutation.SetProp(button, PropertyKind.Icon, PropertyValue.Integer(IconRole.Search.ordinal + 1L)), + Mutation.SetProp(button, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + extra + fun ComposeUiTest.description() = onNodeWithTag(nodeTestTag(button)).fetchSemanticsNode() + .config.getOrNull(SemanticsProperties.ContentDescription) + + // The name of the same button when it never had one of its own. + var defaultName: List? = null + runComposeUiTest { + show(iconButton()) + defaultName = description() + } + + runComposeUiTest { + val frames = FrameRequestSource() + val connection = FakeHostConnection( + iconButton(Mutation.SetProp(button, PropertyKind.AccessibleName, name("가져오기"))), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + assertEquals(listOf("가져오기"), description()) + + connection.scheduleFrame(listOf(Mutation.SetProp(button, PropertyKind.AccessibleName, PropertyValue.None))) + frames.request() + waitForIdle() + assertEquals(defaultName, description(), "the name after the removal is not the default one") + assertTrue(description() != listOf("가져오기"), "the removed name is still spoken") + } + } } From ba50fdb379b4c6513fe5e4f8d048c5fd9482d6cb Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:52:45 +0900 Subject: [PATCH 119/224] Test: Removing the colours and size returns the defaults (FR-67, #426, #428) Covers FR-67 criterion 5: fr67_dropping_a_colour_or_size_sends_a_removal, fr67_removing_the_colours_and_size_returns_the_defaults and fr67_removing_the_checkbox_colours_and_size_returns_the_defaults. --- compose-rust/tests/fr67_controls.rs | 69 ++++++++++++++ renderer/desktop/test/ControlColoursTest.kt | 100 ++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/compose-rust/tests/fr67_controls.rs b/compose-rust/tests/fr67_controls.rs index 564ae199..c0dd3e72 100644 --- a/compose-rust/tests/fr67_controls.rs +++ b/compose-rust/tests/fr67_controls.rs @@ -96,3 +96,72 @@ fn fr67_the_checkbox_sends_its_container_check_and_side() { } assert!(!checkbox.contains("TrackColor"), "{checkbox}"); } + +#[composable] +fn Dropping() { + let styled = remember(|| mutable_state_of(true)); + Column().content(|| { + let flip = styled.clone(); + Row() + .on_click(move || flip.update(|n| *n = !*n)) + .content(|| { + Text("flip"); + }); + // Each widget is finished by its own statement, so they compose in source order. + let switch = Switch(true).thumb_color(SURFACE); + if styled.get() { + switch.size(36.0, 20.0).track_color(INK) + } else { + switch + }; + let checkbox = Checkbox(true).container_color(INK); + if styled.get() { + checkbox.size(18.0).check_color(SURFACE) + } else { + checkbox + }; + }); +} + +#[test] +fn fr67_dropping_a_colour_or_size_sends_a_removal() { + use compose_rust::protocol::{HostEvent, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, PropertyKind}; + + let mut host = ComposeHost::with_content(Dropping); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + + let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; + let (batch, _) = host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the click is accepted"); + let mut batches = vec![batch.to_vec()]; + batches.push(host.render_frame(0).expect("the frame encodes").to_vec()); + + let mut removed = Vec::new(); + for batch in &batches { + for mutation in decode_batch(batch).expect("the batch decodes") { + if let Mutation::SetProp { + property, value, .. + } = mutation + { + if value == PropertyValue::None { + removed.push(format!("{property:?}")); + } + } + } + } + removed.sort(); + // The Switch drops its track colour and size, the Checkbox its check colour and side. + // Colours and sizes that are still set are not removed. + assert_eq!( + removed, + ["CheckColor", "ControlSize", "ControlSize", "TrackColor"], + "{removed:?}" + ); +} diff --git a/renderer/desktop/test/ControlColoursTest.kt b/renderer/desktop/test/ControlColoursTest.kt index 18dcd2a6..68951460 100644 --- a/renderer/desktop/test/ControlColoursTest.kt +++ b/renderer/desktop/test/ControlColoursTest.kt @@ -1,13 +1,17 @@ package dev.darkpyonix.composerust.test +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertWidthIsEqualTo import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width import dev.darkpyonix.composerust.protocol.ColorScheme import dev.darkpyonix.composerust.protocol.DesignSystem import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier @@ -22,10 +26,13 @@ import dev.darkpyonix.composerust.runtime.ComposeRustContent import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.tooling.FakeHostConnection import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals /** * A Switch, Slider and Checkbox draw with the colours and the size the Host named, and @@ -146,4 +153,97 @@ class ControlColoursTest { } } } + + /** The measured size of the control and the page around it, after the Host sent `afterwards`, if it did. */ + private class Snapshot(val width: Float, val height: Float, val pixels: List) + + private fun snapshot( + system: DesignSystem, + widget: WidgetKind, + properties: List>, + afterwards: List = emptyList(), + extra: List = emptyList(), + ): Snapshot { + lateinit var result: Snapshot + runComposeUiTest { + val frames = FrameRequestSource() + val mutations = buildList { + add(Mutation.SetTheme(Theme(system, system, ColorScheme.Light, false))) + add(Mutation.Create(root, WidgetKind.Box)) + add(Mutation.SetModifier(root, 0, ProtocolModifier.Size(240f, 80f))) + add(Mutation.SetModifier(root, 1, ProtocolModifier.Background(Paint.Literal(white)))) + add(Mutation.Create(control, widget)) + properties.forEach { (kind, value) -> add(Mutation.SetProp(control, kind, value)) } + addAll(extra) + add(Mutation.Insert(root, control, 0)) + } + val connection = FakeHostConnection(mutations) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + if (afterwards.isNotEmpty()) { + connection.scheduleFrame(afterwards) + frames.request() + waitForIdle() + } + val bounds = onNodeWithTag(nodeTestTag(control)).getUnclippedBoundsInRoot() + val image = onNodeWithTag(nodeTestTag(root)).captureToImage().toAwtImage() + val pixels = buildList { for (y in 0 until image.height) for (x in 0 until image.width) add(image.getRGB(x, y)) } + result = Snapshot(bounds.width.value, bounds.height.value, pixels) + } + return result + } + + @Test + fun fr67_removing_the_colours_and_size_returns_the_defaults() { + val named = listOf( + PropertyKind.ControlSize to size(36f, 20f), + PropertyKind.TrackColor to literal(ink), + PropertyKind.ThumbColor to literal(red), + ) + DesignSystem.entries.forEach { system -> + val checked = PropertyKind.Checked to PropertyValue.Bool(true) + val reference = snapshot(system, WidgetKind.Switch, listOf(checked)) + val styled = snapshot(system, WidgetKind.Switch, listOf(checked) + named) + assertEquals(36f, styled.width, "$system: the size was applied") + assertNotEquals(reference.pixels, styled.pixels, "$system: the colours were applied") + val removed = snapshot( + system, + WidgetKind.Switch, + listOf(checked) + named, + afterwards = named.map { (kind, _) -> Mutation.SetProp(control, kind, PropertyValue.None) }, + ) + assertEquals(reference.width, removed.width, "$system: the width after the removal") + assertEquals(reference.height, removed.height, "$system: the height after the removal") + assertEquals(reference.pixels, removed.pixels, "$system: the pixels after the removal") + } + } + + @Test + fun fr67_removing_the_checkbox_colours_and_size_returns_the_defaults() { + val named = listOf( + PropertyKind.ControlSize to size(30f, 30f), + PropertyKind.ContainerColor to literal(ink), + PropertyKind.CheckColor to literal(red), + ) + DesignSystem.entries.forEach { system -> + val checked = PropertyKind.Checked to PropertyValue.Bool(true) + val reference = snapshot(system, WidgetKind.Checkbox, listOf(checked)) + val styled = snapshot(system, WidgetKind.Checkbox, listOf(checked) + named) + assertEquals(30f, styled.width, "$system: the size was applied") + assertNotEquals(reference.pixels, styled.pixels, "$system: the colours were applied") + val removed = snapshot( + system, + WidgetKind.Checkbox, + listOf(checked) + named, + afterwards = named.map { (kind, _) -> Mutation.SetProp(control, kind, PropertyValue.None) }, + ) + assertEquals(reference.width, removed.width, "$system: the width after the removal") + assertEquals(reference.height, removed.height, "$system: the height after the removal") + assertEquals(reference.pixels, removed.pixels, "$system: the pixels after the removal") + } + } } From e52fc60c10ff473d0e7a560d5ef8639fb028702e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:17:40 +0900 Subject: [PATCH 120/224] Feat: Send every key to the Host with code, text and repeat (FR-12.1, #100) KeyDown becomes a header plus a length-prefixed text tail: code (HID usage), text (DOM key string) and repeat. Key gains an Other variant for keys the enum does not name. The schema hash changes. Covers the five FR-12.1 criteria (fr12_* and nfr7_malformed_key_down_text_is_a_protocol_error). --- .../android-kotlin/shared/HostTextField.kt | 5 +- .../android-kotlin/shared/KeyEvents.kt | 156 ++++++++++++++++-- .../android-kotlin/shared/Protocol.gen.kt | 15 +- compose-rust/schema-hash.txt | 2 +- compose-rust/src/codegen.rs | 74 +++++---- compose-rust/src/input.rs | 30 ++++ compose-rust/src/protocol.rs | 57 ++++++- compose-rust/src/runtime/host.rs | 6 +- compose-rust/src/schema.rs | 10 +- compose-rust/tests/boundary_calls.rs | 3 + compose-rust/tests/generated_protocol.rs | 17 +- compose-rust/tests/vectors/events.bin | Bin 952 -> 969 bytes compose-rust/tests/vectors/vectors.json | 58 +++---- renderer/desktop/src/protocol/Protocol.gen.kt | 15 +- .../desktop/src/renderer/HostTextField.kt | 5 +- renderer/desktop/src/renderer/KeyEvents.kt | 156 ++++++++++++++++-- renderer/desktop/test/KeyEventTest.kt | 95 ++++++++++- renderer/desktop/test/ProtocolVectorsTest.kt | 32 ++-- 18 files changed, 598 insertions(+), 138 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 0408e2a4..94242ea5 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -166,6 +166,7 @@ internal fun HostTextField( } } + val heldKeys = remember { HeldKeys() } val inputModifier = modifier .onFocusChanged { state -> if (focused && !state.isFocused && focusLostHandler != null) { @@ -182,10 +183,10 @@ internal fun HostTextField( .onPreviewKeyEvent { event -> val composingNow = value.composition != null val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher, heldKeys) if (consumedByHandler) { true - } else if (!isDispatchableKeyDown(event, composingNow)) { + } else if (!isEnterKeyDown(event, composingNow)) { false } else if (!shouldSubmitOnEnter( composing = composingNow, diff --git a/compose-rust/android-kotlin/shared/KeyEvents.kt b/compose-rust/android-kotlin/shared/KeyEvents.kt index b5d64e2d..e55f691b 100644 --- a/compose-rust/android-kotlin/shared/KeyEvents.kt +++ b/compose-rust/android-kotlin/shared/KeyEvents.kt @@ -1,6 +1,8 @@ package dev.darkpyonix.composerust.foundation +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed @@ -10,6 +12,7 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Key as ProtocolKey import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent @@ -26,21 +29,133 @@ import dev.darkpyonix.composerust.runtime.EventDispatcher * a Host handler clear the field and the syllable being composed would vanish. */ -/** The protocol key for a Compose key, or null when the schema cannot name it. */ -internal fun protocolKey(key: Key): ProtocolKey? = when (key) { +/** + * The protocol key for a Compose key. The schema names Enter; every other key is `Other`, + * and the event's `code` and `text` say which one it was. + */ +internal fun protocolKey(key: Key): ProtocolKey = when (key) { Key.Enter, Key.NumPadEnter -> ProtocolKey.Enter - // The schema's `Key` enum has only `Enter`, so no other key can cross the boundary. - // Every additional key needs a schema variant, which changes the schema hash and so - // requires both sides to be rebuilt together. - else -> null + else -> ProtocolKey.Other +} + +/** What a physical key is called on the wire: its USB HID usage and its DOM `key` string. */ +internal class KeyName(val code: Int, val text: String) + +private val namedKeys: Map = buildMap { + // USB HID usage IDs of the keyboard page (0x07); the DOM `key` value is the second word. + fun name(key: Key, code: Int, text: String) { + put(key, KeyName(code, text)) + } + val letters = listOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, + Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, + ) + letters.forEachIndexed { index, key -> name(key, 0x04 + index, ('a' + index).toString()) } + val digits = listOf( + Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, + Key.Zero, + ) + digits.forEachIndexed { index, key -> name(key, 0x1E + index, ((index + 1) % 10).toString()) } + val functions = listOf( + Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, + Key.F12, + ) + functions.forEachIndexed { index, key -> name(key, 0x3A + index, "F${index + 1}") } + name(Key.Enter, 0x28, "Enter") + name(Key.Escape, 0x29, "Escape") + name(Key.Backspace, 0x2A, "Backspace") + name(Key.Tab, 0x2B, "Tab") + name(Key.Spacebar, 0x2C, " ") + name(Key.Minus, 0x2D, "-") + name(Key.Equals, 0x2E, "=") + name(Key.LeftBracket, 0x2F, "[") + name(Key.RightBracket, 0x30, "]") + name(Key.Backslash, 0x31, "\\") + name(Key.Semicolon, 0x33, ";") + name(Key.Apostrophe, 0x34, "'") + name(Key.Grave, 0x35, "`") + name(Key.Comma, 0x36, ",") + name(Key.Period, 0x37, ".") + name(Key.Slash, 0x38, "/") + name(Key.CapsLock, 0x39, "CapsLock") + name(Key.Insert, 0x49, "Insert") + name(Key.MoveHome, 0x4A, "Home") + name(Key.PageUp, 0x4B, "PageUp") + name(Key.Delete, 0x4C, "Delete") + name(Key.MoveEnd, 0x4D, "End") + name(Key.PageDown, 0x4E, "PageDown") + name(Key.DirectionRight, 0x4F, "ArrowRight") + name(Key.DirectionLeft, 0x50, "ArrowLeft") + name(Key.DirectionDown, 0x51, "ArrowDown") + name(Key.DirectionUp, 0x52, "ArrowUp") + name(Key.NumPadEnter, 0x58, "Enter") + name(Key.CtrlLeft, 0xE0, "Control") + name(Key.ShiftLeft, 0xE1, "Shift") + name(Key.AltLeft, 0xE2, "Alt") + name(Key.MetaLeft, 0xE3, "Meta") + name(Key.CtrlRight, 0xE4, "Control") + name(Key.ShiftRight, 0xE5, "Shift") + name(Key.AltRight, 0xE6, "Alt") + name(Key.MetaRight, 0xE7, "Meta") +} + +/** + * The HID usage and DOM `key` string of a key press. A letter is upper case while Shift is + * held, as the DOM reports it. A key the table does not know keeps a zero usage and the + * character it typed, or `"Unidentified"` when it typed none. + */ +internal fun keyName(key: Key, shift: Boolean, codePoint: Int = 0): KeyName { + val known = namedKeys[key] + if (known != null) { + val isLetter = known.text.length == 1 && known.text[0] in 'a'..'z' + return if (isLetter && shift) KeyName(known.code, known.text.uppercase()) else known + } + val typed = if (codePoint > 0x1F && codePoint != 0x7F) { + codePointToString(codePoint) + } else { + "Unidentified" + } + return KeyName(0, typed) +} + +private fun codePointToString(codePoint: Int): String = + if (codePoint < 0x10000) { + codePoint.toChar().toString() + } else { + val offset = codePoint - 0x10000 + charArrayOf((0xD800 + (offset shr 10)).toChar(), (0xDC00 + (offset and 0x3FF)).toChar()) + .concatToString() + } + +/** + * Which keys are held down at one place that listens for keys. + * + * A key-down for a key that has not been released is the platform repeating it. Compose's + * key event carries no repeat flag, so the renderer tells repeats from first presses by + * watching the releases. + */ +internal class HeldKeys { + private val held = HashSet() + + /** Notes one event and says whether it is a key-down repeating a held key. */ + fun observe(type: KeyEventType, key: Key): Boolean = when (type) { + KeyEventType.KeyDown -> !held.add(key.keyCode) + KeyEventType.KeyUp -> { + held.remove(key.keyCode) + false + } + else -> false + } + + fun clear() = held.clear() } /** * Whether a key press may be offered to the Host at all. * - * Key-down only (a Host handler is `on_key_down`), a key the schema can name, and **never - * while an IME composition is in progress**. Kept free of Compose types so the rule that - * protects a Korean composition can be asserted directly, the way [shouldSubmitOnEnter] is. + * Key-down only (a Host handler is `on_key_down`) and **never while an IME composition is + * in progress**. Kept free of Compose types so the rule that protects a Korean composition + * can be asserted directly, the way [shouldSubmitOnEnter] is. */ internal fun shouldDispatchKeyDown( composing: Boolean, @@ -56,11 +171,16 @@ internal fun isDispatchableKeyDown(event: ComposeKeyEvent, composing: Boolean): key = protocolKey(event.key), ) +/** Whether the press is an Enter key-down outside a composition, the one that can submit. */ +internal fun isEnterKeyDown(event: ComposeKeyEvent, composing: Boolean): Boolean = + isDispatchableKeyDown(event, composing) && protocolKey(event.key) == ProtocolKey.Enter + /** * Sends one key press to the Host and returns its consumption result. * * Returns false without calling the Host whenever [isDispatchableKeyDown] says no, so a - * composition keystroke never reaches Rust. + * composition keystroke never reaches Rust. [held] sees every event, key-ups included, so + * a repeated key-down is marked as one. */ internal fun dispatchKeyDown( nodeId: Int, @@ -68,18 +188,23 @@ internal fun dispatchKeyDown( event: ComposeKeyEvent, composing: Boolean, dispatcher: EventDispatcher, + held: HeldKeys = HeldKeys(), ): Boolean { + val repeat = held.observe(event.type, event.key) if (!isDispatchableKeyDown(event, composing)) return false - val key = protocolKey(event.key) ?: return false + val name = keyName(event.key, event.isShiftPressed, event.utf16CodePoint) return dispatcher.dispatch( HostEvent.KeyDown( nodeId = nodeId, handlerId = handlerId, - key = key, + key = protocolKey(event.key), shiftKey = event.isShiftPressed, ctrlKey = event.isCtrlPressed, altKey = event.isAltPressed, metaKey = event.isMetaPressed, + repeat = repeat, + code = name.code, + text = name.text, ), ) } @@ -95,6 +220,9 @@ internal fun Modifier.hostKeyEvents( nodeId: Int, handlerId: Long, dispatcher: EventDispatcher, -): Modifier = onKeyEvent { event -> - dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher) +): Modifier = composed { + val held = remember { HeldKeys() } + onKeyEvent { event -> + dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher, held = held) + } } diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 88e2476c..eedc7f95 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -10,7 +10,7 @@ enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyC enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } -enum class Key { Enter } +enum class Key { Enter, Other } enum class WindowSizeClass { Compact, Medium, Expanded } @@ -1257,7 +1257,7 @@ sealed interface HostEvent { data class TextSubmitted(override val nodeId: Int, override val handlerId: Long, val text: String) : HostEvent data class FocusLost(override val nodeId: Int, override val handlerId: Long) : HostEvent data class ProtocolError(override val nodeId: Int, override val handlerId: Long, val code: Int, val message: String) : HostEvent - data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent + data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent data class RangeRequested(override val nodeId: Int, override val handlerId: Long, val start: Int, val count: Int) : HostEvent data class ValueChanged(override val nodeId: Int, override val handlerId: Long, val value: Double) : HostEvent data class WindowSizeChanged(override val nodeId: Int, override val handlerId: Long, val widthDp: kotlin.Float, val heightDp: kotlin.Float, val sizeClass: WindowSizeClass, val heightClass: WindowHeightClass, val safeAreaTop: kotlin.Float, val safeAreaLeading: kotlin.Float, val safeAreaTrailing: kotlin.Float) : HostEvent @@ -1298,7 +1298,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = -1976155757607335122L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1713,7 +1713,7 @@ object Protocol { is HostEvent.TextSubmitted -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.FocusLost -> null is HostEvent.ProtocolError -> event.message.toByteArray(StandardCharsets.UTF_8) - is HostEvent.KeyDown -> null + is HostEvent.KeyDown -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.RangeRequested -> null is HostEvent.ValueChanged -> null is HostEvent.WindowSizeChanged -> null @@ -1755,7 +1755,7 @@ object Protocol { is HostEvent.TextSubmitted -> 24 is HostEvent.FocusLost -> 16 is HostEvent.ProtocolError -> 28 - is HostEvent.KeyDown -> 20 + is HostEvent.KeyDown -> 32 is HostEvent.RangeRequested -> 24 is HostEvent.ValueChanged -> 24 is HostEvent.WindowSizeChanged -> 44 @@ -1858,7 +1858,9 @@ object Protocol { if (event.altKey) modifiers = modifiers or 0x04 if (event.metaKey) modifiers = modifiers or 0x08 out.put(modifiers.toByte()) - out.put(0.toByte()) + out.put((if (event.repeat) 1 else 0).toByte()) + out.putInt(event.code) + writeStringReference(out, recordLength, text!!) } is HostEvent.RangeRequested -> { out.putInt(event.start) @@ -2192,6 +2194,7 @@ object Protocol { private fun keyTag(key: Key): Int = when (key) { Key.Enter -> 1 + Key.Other -> 2 } private fun windowSizeClassTag(sizeClass: WindowSizeClass): Int = when (sizeClass) { diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 0c88702d..8651fdd8 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x07b2695bace58374 +0xe49348cefef8cb2e diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 9f5fb157..374e9936 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -484,7 +484,7 @@ sealed interface Mutation { output.push_str(", val code: Int, val message: String"); } EventPayloadType::KeyDown => { - output.push_str(", val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean"); + output.push_str(", val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String"); } EventPayloadType::Range => output.push_str(", val start: Int, val count: Int"), EventPayloadType::Double => output.push_str(", val value: Double"), @@ -979,7 +979,7 @@ object Protocol { ); for event in EVENT_SCHEMA { match event.payload { - EventPayloadType::Text => { + EventPayloadType::Text | EventPayloadType::KeyDown => { writeln!( output, " is HostEvent.{} -> event.text.toByteArray(StandardCharsets.UTF_8)", @@ -1019,8 +1019,7 @@ object Protocol { ) .unwrap(); } - EventPayloadType::KeyDown - | EventPayloadType::Range + EventPayloadType::Range | EventPayloadType::Double | EventPayloadType::WindowSize | EventPayloadType::DesignSystem @@ -1061,7 +1060,7 @@ object Protocol { EventPayloadType::None => 16, EventPayloadType::Text => 24, EventPayloadType::ProtocolError => 28, - EventPayloadType::KeyDown => 20, + EventPayloadType::KeyDown => 32, EventPayloadType::Range => 24, EventPayloadType::Double => 24, EventPayloadType::WindowSize => 44, @@ -1171,7 +1170,11 @@ object Protocol { " if (event.metaKey) modifiers = modifiers or 0x08\n", ); output.push_str(" out.put(modifiers.toByte())\n"); - output.push_str(" out.put(0.toByte())\n"); + output.push_str(" out.put((if (event.repeat) 1 else 0).toByte())\n"); + output.push_str(" out.putInt(event.code)\n"); + output.push_str( + " writeStringReference(out, recordLength, text!!)\n", + ); output.push_str(" }\n"); } EventPayloadType::Range => { @@ -3033,6 +3036,9 @@ pub fn generate_event_vector() -> Result, ProtocolError> { ctrl_key: true, alt_key: true, meta_key: true, + repeat: true, + code: 0x28, + text: "Enter", }, }, HostEvent { @@ -3296,34 +3302,34 @@ pub fn generate_vector_description() -> String { {{ "type": "TextSubmitted", "offset": 46, "length": 28, "nodeId": 8, "handlerId": 13, "text": "done" }}, {{ "type": "FocusLost", "offset": 74, "length": 16, "nodeId": 8, "handlerId": 14 }}, {{ "type": "ProtocolError", "offset": 90, "length": 35, "nodeId": 0, "handlerId": 0, "code": 9, "message": "bad tag" }}, - {{ "type": "KeyDown", "offset": 125, "length": 20, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true }}, - {{ "type": "RangeRequested", "offset": 145, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }}, - {{ "type": "ValueChanged", "offset": 169, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }}, - {{ "type": "WindowSizeChanged", "offset": 193, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }}, - {{ "type": "NotificationActivated", "offset": 237, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }}, - {{ "type": "NotificationPermissionChanged", "offset": 274, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }}, - {{ "type": "CodeChanged", "offset": 294, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }}, - {{ "type": "CodeEditRejected", "offset": 342, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }}, - {{ "type": "CodeHovered", "offset": 386, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }}, - {{ "type": "CodeSaveRequested", "offset": 422, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }}, - {{ "type": "DecorationActivated", "offset": 446, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }}, - {{ "type": "AnimationEvent", "offset": 470, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }}, - {{ "type": "ReducedMotionChanged", "offset": 510, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }}, - {{ "type": "Pointer", "offset": 530, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }}, - {{ "type": "SelectionChanged", "offset": 602, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }}, - {{ "type": "VisibleRangeChanged", "offset": 638, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }}, - {{ "type": "EditorFocusChanged", "offset": 666, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }}, - {{ "type": "FoldChanged", "offset": 690, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }}, - {{ "type": "CompositionChanged", "offset": 714, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }}, - {{ "type": "WindowCloseRequested", "offset": 738, "length": 16, "nodeId": 20, "handlerId": 201 }}, - {{ "type": "WindowFocusChanged", "offset": 754, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }}, - {{ "type": "WindowMoved", "offset": 774, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }}, - {{ "type": "WindowDrag", "offset": 806, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }}, - {{ "type": "WindowDragOver", "offset": 838, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }}, - {{ "type": "WindowUnavailable", "offset": 870, "length": 16, "nodeId": 20, "handlerId": 205 }}, - {{ "type": "WindowClosed", "offset": 886, "length": 16, "nodeId": 20, "handlerId": 206 }}, - {{ "type": "WindowRequested", "offset": 902, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }}, - {{ "type": "ApplicationReopen", "offset": 936, "length": 16, "nodeId": 0, "handlerId": 0 }} + {{ "type": "KeyDown", "offset": 125, "length": 37, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true, "repeat": true, "code": 40, "text": "Enter" }}, + {{ "type": "RangeRequested", "offset": 162, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }}, + {{ "type": "ValueChanged", "offset": 186, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }}, + {{ "type": "WindowSizeChanged", "offset": 210, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }}, + {{ "type": "NotificationActivated", "offset": 254, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }}, + {{ "type": "NotificationPermissionChanged", "offset": 291, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }}, + {{ "type": "CodeChanged", "offset": 311, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }}, + {{ "type": "CodeEditRejected", "offset": 359, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }}, + {{ "type": "CodeHovered", "offset": 403, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }}, + {{ "type": "CodeSaveRequested", "offset": 439, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }}, + {{ "type": "DecorationActivated", "offset": 463, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }}, + {{ "type": "AnimationEvent", "offset": 487, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }}, + {{ "type": "ReducedMotionChanged", "offset": 527, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }}, + {{ "type": "Pointer", "offset": 547, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }}, + {{ "type": "SelectionChanged", "offset": 619, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }}, + {{ "type": "VisibleRangeChanged", "offset": 655, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }}, + {{ "type": "EditorFocusChanged", "offset": 683, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }}, + {{ "type": "FoldChanged", "offset": 707, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }}, + {{ "type": "CompositionChanged", "offset": 731, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }}, + {{ "type": "WindowCloseRequested", "offset": 755, "length": 16, "nodeId": 20, "handlerId": 201 }}, + {{ "type": "WindowFocusChanged", "offset": 771, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }}, + {{ "type": "WindowMoved", "offset": 791, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }}, + {{ "type": "WindowDrag", "offset": 823, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }}, + {{ "type": "WindowDragOver", "offset": 855, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }}, + {{ "type": "WindowUnavailable", "offset": 887, "length": 16, "nodeId": 20, "handlerId": 205 }}, + {{ "type": "WindowClosed", "offset": 903, "length": 16, "nodeId": 20, "handlerId": 206 }}, + {{ "type": "WindowRequested", "offset": 919, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }}, + {{ "type": "ApplicationReopen", "offset": 953, "length": 16, "nodeId": 0, "handlerId": 0 }} ] }} }} diff --git a/compose-rust/src/input.rs b/compose-rust/src/input.rs index 3e12ea0f..1d50a3a3 100644 --- a/compose-rust/src/input.rs +++ b/compose-rust/src/input.rs @@ -16,6 +16,9 @@ pub struct KeyEvent { ctrl_key: bool, alt_key: bool, meta_key: bool, + repeat: bool, + code: u32, + text: String, consumed: Rc>, } @@ -27,10 +30,37 @@ impl KeyEvent { ctrl_key, alt_key, meta_key, + repeat: false, + code: 0, + text: String::new(), consumed: Rc::new(Cell::new(false)), } } + /// Adds what the wire carries beyond the modifiers: whether a held key is repeating, + /// the physical key as a USB HID usage value, and the DOM `key` string. + pub fn with_details(mut self, repeat: bool, code: u32, text: &str) -> Self { + self.repeat = repeat; + self.code = code; + self.text = text.to_owned(); + self + } + + /// True when the event comes from a held key repeating. + pub fn repeat(&self) -> bool { + self.repeat + } + + /// The physical key as a USB HID usage value, the same on every layout. + pub fn code(&self) -> u32 { + self.code + } + + /// The DOM `key` string, such as `"ArrowDown"` or `"a"`. + pub fn text(&self) -> &str { + &self.text + } + pub fn key(&self) -> Key { self.key } diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 5b55ed32..fb36048c 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -407,9 +407,10 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { code: read_u32(bytes, 16)?, message: read_string(bytes, 20, record_len)?, }, - EVENT_KEY_DOWN if record_len == 20 => { + EVENT_KEY_DOWN if record_len == 32 => { let raw_key = read_u16(bytes, 16)?; let modifiers = *bytes.get(18).ok_or(ProtocolError::Truncated)?; + let repeat = *bytes.get(19).ok_or(ProtocolError::Truncated)? != 0; crate::schema::EventPayload::KeyDown { key: Key::try_from(raw_key) .map_err(|()| ProtocolError::InvalidValueKind(raw_key))?, @@ -417,6 +418,9 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { ctrl_key: modifiers & MODIFIER_CTRL != 0, alt_key: modifiers & MODIFIER_ALT != 0, meta_key: modifiers & MODIFIER_META != 0, + repeat, + code: read_u32(bytes, 20)?, + text: read_string(bytes, 24, record_len)?, } } EVENT_RANGE_REQUESTED if record_len == 24 => crate::schema::EventPayload::RangeRequested { @@ -687,10 +691,13 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P ctrl_key, alt_key, meta_key, + repeat, + code, + text, } = event.payload { output.extend_from_slice(&EVENT_KEY_DOWN.to_le_bytes()); - output.extend_from_slice(&20_u16.to_le_bytes()); + output.extend_from_slice(&32_u16.to_le_bytes()); output.extend_from_slice(&event.node_id.to_le_bytes()); output.extend_from_slice(&event.handler_id.to_le_bytes()); output.extend_from_slice(&(key as u16).to_le_bytes()); @@ -699,7 +706,12 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P | (u8::from(alt_key) * MODIFIER_ALT) | (u8::from(meta_key) * MODIFIER_META); output.push(modifiers); - output.push(0); + output.push(u8::from(repeat)); + output.extend_from_slice(&code.to_le_bytes()); + let len = u32::try_from(text.len()).map_err(|_| ProtocolError::LengthOverflow)?; + output.extend_from_slice(&32_u32.to_le_bytes()); + output.extend_from_slice(&len.to_le_bytes()); + output.extend_from_slice(text.as_bytes()); return Ok(()); } if let crate::schema::EventPayload::ValueChanged(value) = event.payload { @@ -2843,6 +2855,9 @@ mod tests { ctrl_key: false, alt_key: true, meta_key: false, + repeat: true, + code: 0x28, + text: "Enter", }, }, ]; @@ -2874,6 +2889,42 @@ mod tests { assert_eq!(decode_event(&bytes).unwrap(), event); } + #[test] + fn nfr7_malformed_key_down_text_is_a_protocol_error() { + let event = HostEvent { + node_id: 1, + handler_id: 2, + payload: crate::EventPayload::KeyDown { + key: Key::Enter, + shift_key: false, + ctrl_key: false, + alt_key: false, + meta_key: false, + repeat: false, + code: 0x51, + text: "ArrowDown", + }, + }; + let mut bytes = Vec::new(); + encode_event(&event, &mut bytes).unwrap(); + assert_eq!(decode_event(&bytes).unwrap(), event); + // The text points past the end of the record. + let mut past_end = bytes.clone(); + past_end[28..32].copy_from_slice(&200_u32.to_le_bytes()); + assert_eq!(decode_event(&past_end), Err(ProtocolError::InvalidStringRange)); + // The text starts inside the header, before the string area. + let mut inside_header = bytes.clone(); + inside_header[24..28].copy_from_slice(&8_u32.to_le_bytes()); + assert_eq!( + decode_event(&inside_header), + Err(ProtocolError::InvalidStringRange) + ); + // The text is not UTF-8. + let mut not_utf8 = bytes; + not_utf8[32] = 0xff; + assert_eq!(decode_event(¬_utf8), Err(ProtocolError::InvalidUtf8)); + } + #[test] fn fr19_9_short_window_size_record_is_a_protocol_error() { let mut bytes = vec![0; 32]; diff --git a/compose-rust/src/runtime/host.rs b/compose-rust/src/runtime/host.rs index b859c2a5..68567fc6 100644 --- a/compose-rust/src/runtime/host.rs +++ b/compose-rust/src/runtime/host.rs @@ -337,9 +337,13 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result { - let event = KeyEvent::new(*key, *shift_key, *ctrl_key, *alt_key, *meta_key); + let event = KeyEvent::new(*key, *shift_key, *ctrl_key, *alt_key, *meta_key) + .with_details(*repeat, *code, text); (*handler.borrow_mut())(event.clone()); return Ok(i64::from(event.consumed())); } diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 954b160b..fbd3697c 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -185,7 +185,7 @@ pub const SCHEMA_DESCRIPTOR: &str = concat!( "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback;", - "keys=Enter;", + "keys=Enter,Other;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", @@ -557,6 +557,8 @@ pub struct Selection { // Compose key identities supported by the M0 schema. define_wire_enum!(KEY_SCHEMA, Key { Enter = 1, + // Any key the enum does not name. The event's `code` and `text` say which one. + Other = 2, }); define_wire_enum!(WINDOW_SIZE_CLASS_SCHEMA, WindowSizeClass { @@ -2876,6 +2878,12 @@ pub enum EventPayload<'a> { ctrl_key: bool, alt_key: bool, meta_key: bool, + /// True when the event comes from a held key repeating. + repeat: bool, + /// The physical key as a USB HID usage value, the same on every layout. + code: u32, + /// The DOM `key` string, such as `"ArrowDown"` or `"a"`. + text: &'a str, }, /// The Renderer asks the Host to materialise the visible item range. RangeRequested { diff --git a/compose-rust/tests/boundary_calls.rs b/compose-rust/tests/boundary_calls.rs index 84c84843..2640b633 100644 --- a/compose-rust/tests/boundary_calls.rs +++ b/compose-rust/tests/boundary_calls.rs @@ -315,6 +315,9 @@ fn fr12_key_consumption_is_returned_and_does_not_leak() { ctrl_key: false, alt_key: false, meta_key: false, + repeat: false, + code: 0x28, + text: "Enter", }, }); // SAFETY: The encoded event and output storage remain live for the call. diff --git a/compose-rust/tests/generated_protocol.rs b/compose-rust/tests/generated_protocol.rs index 9f921958..0feca650 100644 --- a/compose-rust/tests/generated_protocol.rs +++ b/compose-rust/tests/generated_protocol.rs @@ -18,11 +18,11 @@ fn fr7_generated_kotlin_matches_schema() { assert!(generated.contains("27 -> PropertyKind.Progress")); assert!(generated.contains("else -> throw ProtocolException(\"unknown widget tag $tag\"")); assert!(generated.contains("else -> throw ProtocolException(\"unknown property tag $tag\"")); - assert!(generated.contains("enum class Key { Enter }")); + assert!(generated.contains("enum class Key { Enter, Other }")); assert!(generated.contains( - "data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent" + "data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent" )); - assert!(generated.contains("is HostEvent.KeyDown -> 20")); + assert!(generated.contains("is HostEvent.KeyDown -> 32")); for (field, mask) in [ ("shiftKey", "0x01"), ("ctrlKey", "0x02"), @@ -89,12 +89,12 @@ fn fr7_generated_vectors_match_schema() { #[test] fn fr12_keydown_roundtrips_through_vectors() { let bytes = include_bytes!("vectors/events.bin"); - for (offset, length) in [(0, 16), (16, 30), (46, 28), (74, 16), (90, 35), (125, 20)] { + for (offset, length) in [(0, 16), (16, 30), (46, 28), (74, 16), (90, 35), (125, 37)] { decode_event(&bytes[offset..offset + length]).unwrap(); } - let key_down = &bytes[125..145]; - assert_eq!(&key_down[0..4], &[6, 0, 20, 0]); + let key_down = &bytes[125..162]; + assert_eq!(&key_down[0..4], &[6, 0, 32, 0]); assert_eq!(key_down[18], 0x0f); let expected = HostEvent { @@ -106,6 +106,9 @@ fn fr12_keydown_roundtrips_through_vectors() { ctrl_key: true, alt_key: true, meta_key: true, + repeat: true, + code: 0x28, + text: "Enter", }, }; assert_eq!(decode_event(key_down).unwrap(), expected); @@ -118,7 +121,7 @@ fn fr12_keydown_roundtrips_through_vectors() { #[test] fn fr8_range_requested_roundtrips_through_vectors() { let bytes = include_bytes!("vectors/events.bin"); - let range_requested = &bytes[145..169]; + let range_requested = &bytes[162..186]; assert_eq!(&range_requested[0..4], &[7, 0, 24, 0]); let expected = HostEvent { diff --git a/compose-rust/tests/vectors/events.bin b/compose-rust/tests/vectors/events.bin index c9f4e3fc7565c2da59f4cda7e30c1eb8dafb4263..d68076361ae8534e42d88c0a4a0ed94f8310e650 100644 GIT binary patch delta 46 tcmdnNev*Acy|MxWCj$cmKM;ceBLhF929Q<&Vpbq_%_~VQ+Bi|28337A2EqUU delta 29 fcmX@fzJq;2y^sh4Cj$cmKM;ceBapRmkvcN~P!j}d diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 29de9f06..1904fc2f 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "554621546583655284", + "schemaHashUnsigned": "16470588316102216494", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", @@ -24,34 +24,34 @@ { "type": "TextSubmitted", "offset": 46, "length": 28, "nodeId": 8, "handlerId": 13, "text": "done" }, { "type": "FocusLost", "offset": 74, "length": 16, "nodeId": 8, "handlerId": 14 }, { "type": "ProtocolError", "offset": 90, "length": 35, "nodeId": 0, "handlerId": 0, "code": 9, "message": "bad tag" }, - { "type": "KeyDown", "offset": 125, "length": 20, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true }, - { "type": "RangeRequested", "offset": 145, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }, - { "type": "ValueChanged", "offset": 169, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }, - { "type": "WindowSizeChanged", "offset": 193, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }, - { "type": "NotificationActivated", "offset": 237, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }, - { "type": "NotificationPermissionChanged", "offset": 274, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }, - { "type": "CodeChanged", "offset": 294, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }, - { "type": "CodeEditRejected", "offset": 342, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }, - { "type": "CodeHovered", "offset": 386, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }, - { "type": "CodeSaveRequested", "offset": 422, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }, - { "type": "DecorationActivated", "offset": 446, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }, - { "type": "AnimationEvent", "offset": 470, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }, - { "type": "ReducedMotionChanged", "offset": 510, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }, - { "type": "Pointer", "offset": 530, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }, - { "type": "SelectionChanged", "offset": 602, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }, - { "type": "VisibleRangeChanged", "offset": 638, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }, - { "type": "EditorFocusChanged", "offset": 666, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }, - { "type": "FoldChanged", "offset": 690, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }, - { "type": "CompositionChanged", "offset": 714, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }, - { "type": "WindowCloseRequested", "offset": 738, "length": 16, "nodeId": 20, "handlerId": 201 }, - { "type": "WindowFocusChanged", "offset": 754, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }, - { "type": "WindowMoved", "offset": 774, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }, - { "type": "WindowDrag", "offset": 806, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }, - { "type": "WindowDragOver", "offset": 838, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }, - { "type": "WindowUnavailable", "offset": 870, "length": 16, "nodeId": 20, "handlerId": 205 }, - { "type": "WindowClosed", "offset": 886, "length": 16, "nodeId": 20, "handlerId": 206 }, - { "type": "WindowRequested", "offset": 902, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }, - { "type": "ApplicationReopen", "offset": 936, "length": 16, "nodeId": 0, "handlerId": 0 } + { "type": "KeyDown", "offset": 125, "length": 37, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true, "repeat": true, "code": 40, "text": "Enter" }, + { "type": "RangeRequested", "offset": 162, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }, + { "type": "ValueChanged", "offset": 186, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }, + { "type": "WindowSizeChanged", "offset": 210, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }, + { "type": "NotificationActivated", "offset": 254, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }, + { "type": "NotificationPermissionChanged", "offset": 291, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }, + { "type": "CodeChanged", "offset": 311, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }, + { "type": "CodeEditRejected", "offset": 359, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }, + { "type": "CodeHovered", "offset": 403, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }, + { "type": "CodeSaveRequested", "offset": 439, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }, + { "type": "DecorationActivated", "offset": 463, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }, + { "type": "AnimationEvent", "offset": 487, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }, + { "type": "ReducedMotionChanged", "offset": 527, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }, + { "type": "Pointer", "offset": 547, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }, + { "type": "SelectionChanged", "offset": 619, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }, + { "type": "VisibleRangeChanged", "offset": 655, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }, + { "type": "EditorFocusChanged", "offset": 683, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }, + { "type": "FoldChanged", "offset": 707, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }, + { "type": "CompositionChanged", "offset": 731, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }, + { "type": "WindowCloseRequested", "offset": 755, "length": 16, "nodeId": 20, "handlerId": 201 }, + { "type": "WindowFocusChanged", "offset": 771, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }, + { "type": "WindowMoved", "offset": 791, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }, + { "type": "WindowDrag", "offset": 823, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }, + { "type": "WindowDragOver", "offset": 855, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }, + { "type": "WindowUnavailable", "offset": 887, "length": 16, "nodeId": 20, "handlerId": 205 }, + { "type": "WindowClosed", "offset": 903, "length": 16, "nodeId": 20, "handlerId": 206 }, + { "type": "WindowRequested", "offset": 919, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }, + { "type": "ApplicationReopen", "offset": 953, "length": 16, "nodeId": 0, "handlerId": 0 } ] } } diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 88e2476c..eedc7f95 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -10,7 +10,7 @@ enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyC enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } -enum class Key { Enter } +enum class Key { Enter, Other } enum class WindowSizeClass { Compact, Medium, Expanded } @@ -1257,7 +1257,7 @@ sealed interface HostEvent { data class TextSubmitted(override val nodeId: Int, override val handlerId: Long, val text: String) : HostEvent data class FocusLost(override val nodeId: Int, override val handlerId: Long) : HostEvent data class ProtocolError(override val nodeId: Int, override val handlerId: Long, val code: Int, val message: String) : HostEvent - data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent + data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent data class RangeRequested(override val nodeId: Int, override val handlerId: Long, val start: Int, val count: Int) : HostEvent data class ValueChanged(override val nodeId: Int, override val handlerId: Long, val value: Double) : HostEvent data class WindowSizeChanged(override val nodeId: Int, override val handlerId: Long, val widthDp: kotlin.Float, val heightDp: kotlin.Float, val sizeClass: WindowSizeClass, val heightClass: WindowHeightClass, val safeAreaTop: kotlin.Float, val safeAreaLeading: kotlin.Float, val safeAreaTrailing: kotlin.Float) : HostEvent @@ -1298,7 +1298,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = -1976155757607335122L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1713,7 +1713,7 @@ object Protocol { is HostEvent.TextSubmitted -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.FocusLost -> null is HostEvent.ProtocolError -> event.message.toByteArray(StandardCharsets.UTF_8) - is HostEvent.KeyDown -> null + is HostEvent.KeyDown -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.RangeRequested -> null is HostEvent.ValueChanged -> null is HostEvent.WindowSizeChanged -> null @@ -1755,7 +1755,7 @@ object Protocol { is HostEvent.TextSubmitted -> 24 is HostEvent.FocusLost -> 16 is HostEvent.ProtocolError -> 28 - is HostEvent.KeyDown -> 20 + is HostEvent.KeyDown -> 32 is HostEvent.RangeRequested -> 24 is HostEvent.ValueChanged -> 24 is HostEvent.WindowSizeChanged -> 44 @@ -1858,7 +1858,9 @@ object Protocol { if (event.altKey) modifiers = modifiers or 0x04 if (event.metaKey) modifiers = modifiers or 0x08 out.put(modifiers.toByte()) - out.put(0.toByte()) + out.put((if (event.repeat) 1 else 0).toByte()) + out.putInt(event.code) + writeStringReference(out, recordLength, text!!) } is HostEvent.RangeRequested -> { out.putInt(event.start) @@ -2192,6 +2194,7 @@ object Protocol { private fun keyTag(key: Key): Int = when (key) { Key.Enter -> 1 + Key.Other -> 2 } private fun windowSizeClassTag(sizeClass: WindowSizeClass): Int = when (sizeClass) { diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 0408e2a4..94242ea5 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -166,6 +166,7 @@ internal fun HostTextField( } } + val heldKeys = remember { HeldKeys() } val inputModifier = modifier .onFocusChanged { state -> if (focused && !state.isFocused && focusLostHandler != null) { @@ -182,10 +183,10 @@ internal fun HostTextField( .onPreviewKeyEvent { event -> val composingNow = value.composition != null val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher, heldKeys) if (consumedByHandler) { true - } else if (!isDispatchableKeyDown(event, composingNow)) { + } else if (!isEnterKeyDown(event, composingNow)) { false } else if (!shouldSubmitOnEnter( composing = composingNow, diff --git a/renderer/desktop/src/renderer/KeyEvents.kt b/renderer/desktop/src/renderer/KeyEvents.kt index b5d64e2d..e55f691b 100644 --- a/renderer/desktop/src/renderer/KeyEvents.kt +++ b/renderer/desktop/src/renderer/KeyEvents.kt @@ -1,6 +1,8 @@ package dev.darkpyonix.composerust.foundation +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed @@ -10,6 +12,7 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Key as ProtocolKey import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent @@ -26,21 +29,133 @@ import dev.darkpyonix.composerust.runtime.EventDispatcher * a Host handler clear the field and the syllable being composed would vanish. */ -/** The protocol key for a Compose key, or null when the schema cannot name it. */ -internal fun protocolKey(key: Key): ProtocolKey? = when (key) { +/** + * The protocol key for a Compose key. The schema names Enter; every other key is `Other`, + * and the event's `code` and `text` say which one it was. + */ +internal fun protocolKey(key: Key): ProtocolKey = when (key) { Key.Enter, Key.NumPadEnter -> ProtocolKey.Enter - // The schema's `Key` enum has only `Enter`, so no other key can cross the boundary. - // Every additional key needs a schema variant, which changes the schema hash and so - // requires both sides to be rebuilt together. - else -> null + else -> ProtocolKey.Other +} + +/** What a physical key is called on the wire: its USB HID usage and its DOM `key` string. */ +internal class KeyName(val code: Int, val text: String) + +private val namedKeys: Map = buildMap { + // USB HID usage IDs of the keyboard page (0x07); the DOM `key` value is the second word. + fun name(key: Key, code: Int, text: String) { + put(key, KeyName(code, text)) + } + val letters = listOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, + Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, + ) + letters.forEachIndexed { index, key -> name(key, 0x04 + index, ('a' + index).toString()) } + val digits = listOf( + Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, + Key.Zero, + ) + digits.forEachIndexed { index, key -> name(key, 0x1E + index, ((index + 1) % 10).toString()) } + val functions = listOf( + Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, + Key.F12, + ) + functions.forEachIndexed { index, key -> name(key, 0x3A + index, "F${index + 1}") } + name(Key.Enter, 0x28, "Enter") + name(Key.Escape, 0x29, "Escape") + name(Key.Backspace, 0x2A, "Backspace") + name(Key.Tab, 0x2B, "Tab") + name(Key.Spacebar, 0x2C, " ") + name(Key.Minus, 0x2D, "-") + name(Key.Equals, 0x2E, "=") + name(Key.LeftBracket, 0x2F, "[") + name(Key.RightBracket, 0x30, "]") + name(Key.Backslash, 0x31, "\\") + name(Key.Semicolon, 0x33, ";") + name(Key.Apostrophe, 0x34, "'") + name(Key.Grave, 0x35, "`") + name(Key.Comma, 0x36, ",") + name(Key.Period, 0x37, ".") + name(Key.Slash, 0x38, "/") + name(Key.CapsLock, 0x39, "CapsLock") + name(Key.Insert, 0x49, "Insert") + name(Key.MoveHome, 0x4A, "Home") + name(Key.PageUp, 0x4B, "PageUp") + name(Key.Delete, 0x4C, "Delete") + name(Key.MoveEnd, 0x4D, "End") + name(Key.PageDown, 0x4E, "PageDown") + name(Key.DirectionRight, 0x4F, "ArrowRight") + name(Key.DirectionLeft, 0x50, "ArrowLeft") + name(Key.DirectionDown, 0x51, "ArrowDown") + name(Key.DirectionUp, 0x52, "ArrowUp") + name(Key.NumPadEnter, 0x58, "Enter") + name(Key.CtrlLeft, 0xE0, "Control") + name(Key.ShiftLeft, 0xE1, "Shift") + name(Key.AltLeft, 0xE2, "Alt") + name(Key.MetaLeft, 0xE3, "Meta") + name(Key.CtrlRight, 0xE4, "Control") + name(Key.ShiftRight, 0xE5, "Shift") + name(Key.AltRight, 0xE6, "Alt") + name(Key.MetaRight, 0xE7, "Meta") +} + +/** + * The HID usage and DOM `key` string of a key press. A letter is upper case while Shift is + * held, as the DOM reports it. A key the table does not know keeps a zero usage and the + * character it typed, or `"Unidentified"` when it typed none. + */ +internal fun keyName(key: Key, shift: Boolean, codePoint: Int = 0): KeyName { + val known = namedKeys[key] + if (known != null) { + val isLetter = known.text.length == 1 && known.text[0] in 'a'..'z' + return if (isLetter && shift) KeyName(known.code, known.text.uppercase()) else known + } + val typed = if (codePoint > 0x1F && codePoint != 0x7F) { + codePointToString(codePoint) + } else { + "Unidentified" + } + return KeyName(0, typed) +} + +private fun codePointToString(codePoint: Int): String = + if (codePoint < 0x10000) { + codePoint.toChar().toString() + } else { + val offset = codePoint - 0x10000 + charArrayOf((0xD800 + (offset shr 10)).toChar(), (0xDC00 + (offset and 0x3FF)).toChar()) + .concatToString() + } + +/** + * Which keys are held down at one place that listens for keys. + * + * A key-down for a key that has not been released is the platform repeating it. Compose's + * key event carries no repeat flag, so the renderer tells repeats from first presses by + * watching the releases. + */ +internal class HeldKeys { + private val held = HashSet() + + /** Notes one event and says whether it is a key-down repeating a held key. */ + fun observe(type: KeyEventType, key: Key): Boolean = when (type) { + KeyEventType.KeyDown -> !held.add(key.keyCode) + KeyEventType.KeyUp -> { + held.remove(key.keyCode) + false + } + else -> false + } + + fun clear() = held.clear() } /** * Whether a key press may be offered to the Host at all. * - * Key-down only (a Host handler is `on_key_down`), a key the schema can name, and **never - * while an IME composition is in progress**. Kept free of Compose types so the rule that - * protects a Korean composition can be asserted directly, the way [shouldSubmitOnEnter] is. + * Key-down only (a Host handler is `on_key_down`) and **never while an IME composition is + * in progress**. Kept free of Compose types so the rule that protects a Korean composition + * can be asserted directly, the way [shouldSubmitOnEnter] is. */ internal fun shouldDispatchKeyDown( composing: Boolean, @@ -56,11 +171,16 @@ internal fun isDispatchableKeyDown(event: ComposeKeyEvent, composing: Boolean): key = protocolKey(event.key), ) +/** Whether the press is an Enter key-down outside a composition, the one that can submit. */ +internal fun isEnterKeyDown(event: ComposeKeyEvent, composing: Boolean): Boolean = + isDispatchableKeyDown(event, composing) && protocolKey(event.key) == ProtocolKey.Enter + /** * Sends one key press to the Host and returns its consumption result. * * Returns false without calling the Host whenever [isDispatchableKeyDown] says no, so a - * composition keystroke never reaches Rust. + * composition keystroke never reaches Rust. [held] sees every event, key-ups included, so + * a repeated key-down is marked as one. */ internal fun dispatchKeyDown( nodeId: Int, @@ -68,18 +188,23 @@ internal fun dispatchKeyDown( event: ComposeKeyEvent, composing: Boolean, dispatcher: EventDispatcher, + held: HeldKeys = HeldKeys(), ): Boolean { + val repeat = held.observe(event.type, event.key) if (!isDispatchableKeyDown(event, composing)) return false - val key = protocolKey(event.key) ?: return false + val name = keyName(event.key, event.isShiftPressed, event.utf16CodePoint) return dispatcher.dispatch( HostEvent.KeyDown( nodeId = nodeId, handlerId = handlerId, - key = key, + key = protocolKey(event.key), shiftKey = event.isShiftPressed, ctrlKey = event.isCtrlPressed, altKey = event.isAltPressed, metaKey = event.isMetaPressed, + repeat = repeat, + code = name.code, + text = name.text, ), ) } @@ -95,6 +220,9 @@ internal fun Modifier.hostKeyEvents( nodeId: Int, handlerId: Long, dispatcher: EventDispatcher, -): Modifier = onKeyEvent { event -> - dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher) +): Modifier = composed { + val held = remember { HeldKeys() } + onKeyEvent { event -> + dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher, held = held) + } } diff --git a/renderer/desktop/test/KeyEventTest.kt b/renderer/desktop/test/KeyEventTest.kt index 26169464..10a106bd 100644 --- a/renderer/desktop/test/KeyEventTest.kt +++ b/renderer/desktop/test/KeyEventTest.kt @@ -1,6 +1,7 @@ package dev.darkpyonix.composerust.test import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag @@ -18,6 +19,8 @@ import dev.darkpyonix.composerust.protocol.Mutation import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.foundation.HeldKeys +import dev.darkpyonix.composerust.foundation.keyName import dev.darkpyonix.composerust.foundation.protocolKey import dev.darkpyonix.composerust.foundation.shouldDispatchKeyDown import dev.darkpyonix.composerust.runtime.ComposeRustContent @@ -63,6 +66,9 @@ class KeyEventTest { ctrlKey = false, altKey = false, metaKey = false, + repeat = false, + code = 0x28, + text = "Enter", ), ), connection.nodeEvents, @@ -88,7 +94,7 @@ class KeyEventTest { ) } - /** Key-up is not a key-down, and an unnameable key cannot cross the schema. */ + /** Key-up is not a key-down, and a key the enum does not name travels as `Other`. */ @Test fun fr12_only_schema_key_downs_cross_the_boundary() { assertFalse( @@ -97,7 +103,7 @@ class KeyEventTest { assertFalse(shouldDispatchKeyDown(composing = false, isKeyDown = true, key = null)) assertEquals(ProtocolKey.Enter, protocolKey(Key.Enter)) assertEquals(ProtocolKey.Enter, protocolKey(Key.NumPadEnter)) - assertEquals(null, protocolKey(Key.A)) + assertEquals(ProtocolKey.Other, protocolKey(Key.A)) } /** A Host that does not consume leaves the key to the editor. */ @@ -140,4 +146,89 @@ class KeyEventTest { connection.nodeEvents, ) } + + /** Arrows, Escape, Tab and letters each arrive with their HID usage and DOM key string. */ + @Test + fun fr12_every_key_reaches_the_host_with_code_and_text() = runComposeUiTest { + val connection = FakeHostConnection(keyField()) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + val pressed = listOf( + Key.DirectionDown to (0x51 to "ArrowDown"), + Key.DirectionUp to (0x52 to "ArrowUp"), + Key.Escape to (0x29 to "Escape"), + Key.Tab to (0x2B to "Tab"), + Key.A to (0x04 to "a"), + ) + for ((key, _) in pressed) { + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { pressKey(key) } + } + waitForIdle() + + val events = connection.nodeEvents.filterIsInstance() + assertEquals(pressed.map { it.second }, events.map { it.code to it.text }) + assertTrue(events.all { !it.repeat }) + assertEquals(ProtocolKey.Other, events.first().key) + assertEquals("A", keyName(Key.A, shift = true).text) + } + + /** Only the first down of a held key is a first press; the rest are repeats. */ + @Test + fun fr12_held_key_marks_repeat() = runComposeUiTest { + val held = HeldKeys() + assertFalse(held.observe(KeyEventType.KeyDown, Key.DirectionDown)) + assertTrue(held.observe(KeyEventType.KeyDown, Key.DirectionDown)) + assertTrue(held.observe(KeyEventType.KeyDown, Key.DirectionDown)) + assertFalse(held.observe(KeyEventType.KeyUp, Key.DirectionDown)) + assertFalse(held.observe(KeyEventType.KeyDown, Key.DirectionDown), "a new press after release") + + val connection = FakeHostConnection(keyField()) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { + keyDown(Key.DirectionDown) + advanceEventTime(1_000) + keyUp(Key.DirectionDown) + } + waitForIdle() + + val events = connection.nodeEvents.filterIsInstance() + assertEquals(false, events.first().repeat) + assertTrue(events.drop(1).isNotEmpty() && events.drop(1).all { it.repeat }, "held key produced no repeats: ${events.map { it.repeat }}") + } + + /** No key of any kind is offered to the Host while a composition is in progress. */ + @Test + fun fr12_no_key_reaches_the_host_during_composition() { + for (key in listOf(Key.Enter, Key.DirectionDown, Key.Escape, Key.Tab, Key.A)) { + assertFalse( + shouldDispatchKeyDown(composing = true, isKeyDown = true, key = protocolKey(key)), + "$key reached the Host during a composition", + ) + assertTrue( + shouldDispatchKeyDown(composing = false, isKeyDown = true, key = protocolKey(key)), + ) + } + } + + /** A key the Host consumes is not handled by the editor, whatever key it is. */ + @Test + fun fr12_consumed_key_is_not_handled_by_the_renderer() = runComposeUiTest { + val connection = FakeHostConnection(keyField()) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { + pressKey(Key.A) + pressKey(Key.Enter) + } + waitForIdle() + + assertEquals(2, connection.nodeEvents.filterIsInstance().size) + onNodeWithTag(nodeTestTag(FIELD)).assertTextEquals("") + } } diff --git a/renderer/desktop/test/ProtocolVectorsTest.kt b/renderer/desktop/test/ProtocolVectorsTest.kt index fc9f5342..45ba375b 100644 --- a/renderer/desktop/test/ProtocolVectorsTest.kt +++ b/renderer/desktop/test/ProtocolVectorsTest.kt @@ -191,7 +191,7 @@ class ProtocolVectorsTest { activated, ) assertEquals(37, length) - assertEquals(bytes.copyOfRange(237, 274).toList(), activated.array().copyOf(length).toList()) + assertEquals(bytes.copyOfRange(254, 291).toList(), activated.array().copyOf(length).toList()) val changed = ByteBuffer.allocate(32) val changedLength = Protocol.encodeEvent( @@ -203,7 +203,7 @@ class ProtocolVectorsTest { changed, ) assertEquals(20, changedLength) - assertEquals(bytes.copyOfRange(274, 294).toList(), changed.array().copyOf(changedLength).toList()) + assertEquals(bytes.copyOfRange(291, 311).toList(), changed.array().copyOf(changedLength).toList()) } @Test @@ -400,14 +400,14 @@ class ProtocolVectorsTest { out, ) assertEquals(40, length) - assertEquals(bytes.copyOfRange(470, 510).toList(), out.array().copyOf(length).toList()) + assertEquals(bytes.copyOfRange(487, 527).toList(), out.array().copyOf(length).toList()) val motion = ByteBuffer.allocate(32) val motionLength = Protocol.encodeEvent( HostEvent.ReducedMotionChanged(0, 0, dev.darkpyonix.composerust.protocol.ReducedMotion.On), motion, ) assertEquals(20, motionLength) - assertEquals(bytes.copyOfRange(510, 530).toList(), motion.array().copyOf(motionLength).toList()) + assertEquals(bytes.copyOfRange(527, 547).toList(), motion.array().copyOf(motionLength).toList()) } /** The pointer event encodes to the 72 bytes the Host decodes. */ @@ -436,7 +436,7 @@ class ProtocolVectorsTest { out, ) assertEquals(72, length) - assertEquals(bytes.copyOfRange(530, 602).toList(), out.array().copyOf(length).toList()) + assertEquals(bytes.copyOfRange(547, 619).toList(), out.array().copyOf(length).toList()) } /** The theme record carries the palette behind it, and both sides read the same entries. */ @@ -553,16 +553,16 @@ class ProtocolVectorsTest { return buffer.array().copyOf(length) } val expected = listOf( - 294 to HostEvent.CodeChanged(12, 18L, 1, 1, 8, 1, 8, "mut "), - 342 to HostEvent.CodeEditRejected(12, 19L, 7, 0, 2, 1, 8, 1, 9), - 386 to HostEvent.CodeHovered(12, 20L, 42L, 0, 4, HoverPhase.Rest), - 422 to HostEvent.CodeSaveRequested(12, 21L, 2), - 446 to HostEvent.DecorationActivated(12, 22L, 41L), - 602 to HostEvent.SelectionChanged(12, 23L, 2, 1, 12, 0, 3), - 638 to HostEvent.VisibleRangeChanged(12, 24L, 2, 0, 3), - 666 to HostEvent.EditorFocusChanged(12, 25L, true), - 690 to HostEvent.FoldChanged(12, 26L, 0, true), - 714 to HostEvent.CompositionChanged(12, 27L, true), + 311 to HostEvent.CodeChanged(12, 18L, 1, 1, 8, 1, 8, "mut "), + 359 to HostEvent.CodeEditRejected(12, 19L, 7, 0, 2, 1, 8, 1, 9), + 403 to HostEvent.CodeHovered(12, 20L, 42L, 0, 4, HoverPhase.Rest), + 439 to HostEvent.CodeSaveRequested(12, 21L, 2), + 463 to HostEvent.DecorationActivated(12, 22L, 41L), + 619 to HostEvent.SelectionChanged(12, 23L, 2, 1, 12, 0, 3), + 655 to HostEvent.VisibleRangeChanged(12, 24L, 2, 0, 3), + 683 to HostEvent.EditorFocusChanged(12, 25L, true), + 707 to HostEvent.FoldChanged(12, 26L, 0, true), + 731 to HostEvent.CompositionChanged(12, 27L, true), ) for ((offset, event) in expected) { val wire = encoded(event) @@ -571,7 +571,7 @@ class ProtocolVectorsTest { "$event at $offset", ) } - assertEquals(952, bytes.size) + assertEquals(969, bytes.size) } /** From 5626b9870a962b7c82410889b9d3d82a6b6bbf48 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:35:35 +0900 Subject: [PATCH 121/224] Docs: Add the disabled Button contrast fix to the v0.0.6 changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 430009a4..f4e745a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ A `Row` with a weight in a `Column` (or a `Column` with a weight in a `Row`) is Every pressable surface (`on_click` containers, `Clickable` nodes and the toolkit `Button`) scales to 0.975 and shows a faint tint while pressed, inside the renderer with no Host round trip, and `Modifier::PressFeedback { scale, tint_alpha }` (wire tag 33) changes or turns it off ([#305](https://github.com/DarkPyonix/compose-rust/issues/305)). The schema hash changes. +### Disabled `Button` label keeps its contrast + +A disabled `Button` keeps its label at 3:1 or better against its faded fill on the page, a card and a raised card, and an active `Button` is unchanged ([#432](https://github.com/DarkPyonix/compose-rust/issues/432), [#447](https://github.com/DarkPyonix/compose-rust/pull/447)). + ### Schema hash and known test issue The schema hash changes in this release: a `Modifier` gains `MatchParentSize` (tag 31), `WindowDrag` (tag 32) and `PressFeedback` (tag 33), and the `WindowSizeChanged` event record grows from 32 to 44 bytes. A renderer and a crate from different versions no longer agree, so upgrade both together. Three `fr33_1_*` paste and menu tests fail on a Mac with a non-English locale. They fail the same way on `develop` without these changes, so this is not a regression ([#436](https://github.com/DarkPyonix/compose-rust/issues/436)). From 7e565789b8d6b762c3ea2d26ddeed3f7d70a4430 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:19:57 +0900 Subject: [PATCH 122/224] Fix: A disabled Button with children draws and exposes them once (FR-66, #425) After the disabled-label change, a disabled button composed its label twice, so children were drawn and exposed twice. A button with children is now faded as one group. Test: fr66_a_disabled_button_with_children_draws_and_exposes_them_once. Staged Android copy regenerated. --- .../android-kotlin/shared/HostButton.kt | 5 +++- renderer/desktop/src/renderer/HostButton.kt | 5 +++- renderer/desktop/test/ButtonRowTest.kt | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 1bcba83f..301ff46b 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -237,7 +237,10 @@ internal fun HostButton( } val alignment = if (alignStart) Alignment.CenterStart else Alignment.Center - if (enabled) { + // A button with children is faded as one group, children included: its content is the + // Host's to colour, so there is no label tint to keep readable, and composing the + // children twice would draw and expose each of them twice. + if (enabled || node.children.isNotEmpty()) { Box(named, contentAlignment = alignment) { ButtonLabel() } } else { Box { diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 1bcba83f..301ff46b 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -237,7 +237,10 @@ internal fun HostButton( } val alignment = if (alignStart) Alignment.CenterStart else Alignment.Center - if (enabled) { + // A button with children is faded as one group, children included: its content is the + // Host's to colour, so there is no label tint to keep readable, and composing the + // children twice would draw and expose each of them twice. + if (enabled || node.children.isNotEmpty()) { Box(named, contentAlignment = alignment) { ButtonLabel() } } else { Box { diff --git a/renderer/desktop/test/ButtonRowTest.kt b/renderer/desktop/test/ButtonRowTest.kt index 0c7f669f..8153ee8d 100644 --- a/renderer/desktop/test/ButtonRowTest.kt +++ b/renderer/desktop/test/ButtonRowTest.kt @@ -2,6 +2,7 @@ package dev.darkpyonix.composerust.test import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -84,6 +85,30 @@ class ButtonRowTest { } } + @Test + fun fr66_a_disabled_button_with_children_draws_and_exposes_them_once() { + runComposeUiTest { + val connection = FakeHostConnection( + childrenButton() + Mutation.SetProp(button, PropertyKind.Enabled, PropertyValue.Bool(false)), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + for (child in listOf(label, count)) { + assertEquals( + 1, + onAllNodesWithTag(nodeTestTag(child), useUnmergedTree = true).fetchSemanticsNodes().size, + "child $child appears once in the unmerged tree", + ) + } + onNodeWithTag(nodeTestTag(button)).performClick() + waitForIdle() + assertTrue( + connection.events.filterIsInstance().isEmpty(), + "a disabled button delivers no click", + ) + } + } + @Test fun fr66_a_click_on_the_whole_button_fires_once() { runComposeUiTest { From 630234ded8d3acb99e2fe953482c277dd91e4ddc Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:51:03 +0900 Subject: [PATCH 123/224] Feat: Package and check the Windows static renderer (NFR-15) Windows joins macos-aarch64 and linux in package-static-renderer.sh, build-windows.sh writes schema-hash.txt, and check-single-executable.sh checks a Windows executable with dumpbin. windows-static.yml uploads the packaged kit; a tag keeps the GraalVM tarball until the default moves. Content taken from #338, rebased onto develop, without windows-dlls.sh. --- .github/scripts/check-single-executable.sh | 129 ++++++++++++++++-- .github/scripts/package-static-renderer.sh | 54 +++++++- .github/workflows/test-native-renderer.yml | 28 ++-- .github/workflows/windows-static.yml | 22 +++ renderer/desktop/scripts/build-windows.sh | 11 ++ scripts/tests/package-static-renderer.test.sh | 77 +++++++++++ 6 files changed, 292 insertions(+), 29 deletions(-) create mode 100755 scripts/tests/package-static-renderer.test.sh diff --git a/.github/scripts/check-single-executable.sh b/.github/scripts/check-single-executable.sh index ae5b13ce..30b5d31e 100755 --- a/.github/scripts/check-single-executable.sh +++ b/.github/scripts/check-single-executable.sh @@ -21,6 +21,13 @@ # # Needs a display. On Linux run it under `xvfb-run -a`; a macOS runner has one. # +# On Windows (Git Bash, with the Visual Studio C++ tools installed) the tarball is the static +# kit build-windows.sh writes, packaged by package-static-renderer.sh. The build script has no +# download route for it yet, so the kit is unpacked into the scratch directory and handed to +# the build as DXC_WINDOWS_NATIVE_LIB, which links exactly what a consumer's build would. +# dumpbin (or llvm-readobj, or objdump) must then show Windows' own DLLs only; a hosted +# runner has no graphics card, so the window draws with Windows' software adapter. +# # What it prints is the evidence: the executable's size, before and after stripping, and # every library it names. set -euo pipefail @@ -44,9 +51,11 @@ fail() { } os="$(uname -s)" +exe="" case "$os" in Darwin|Linux) ;; - *) fail "this checks macOS and Linux executables; $os is not one" ;; + MINGW*|MSYS*|CYGWIN*) os=Windows exe=.exe ;; + *) fail "this checks macOS, Linux and Windows executables; $os is not one" ;; esac if [[ "$os" == Linux && -z "${DISPLAY:-}" ]]; then fail "DISPLAY is not set" "Run it under xvfb-run -a." @@ -69,13 +78,15 @@ esac # Listed into a variable first: grep -q stops reading early, and under pipefail the tar it # cut off would fail the check. contents="$(tar -tzf "$artifact")" -grep -qE '^(\./)?lib/libcompose_rust_renderer\.a$' <<< "$contents" || - fail "$(basename "$artifact") does not carry lib/libcompose_rust_renderer.a" \ +archive_pattern='^(\./)?lib/libcompose_rust_renderer\.a$' +[[ "$os" != Windows ]] || archive_pattern='^(\./)?libcompose_rust_renderer\.a$' +grep -qE "$archive_pattern" <<< "$contents" || + fail "$(basename "$artifact") does not carry the static renderer archive" \ "Only the static renderer makes an application one executable." # A workspace build in this checkout would be found before the cache, and then this would # test that instead of the artifact. -for built in renderer/build/macos renderer/build/linux \ +for built in renderer/build/macos renderer/build/linux renderer/build/windows \ renderer/build/linux-arm64 renderer/build/native-image/dist/lib; do [[ ! -e "$repo_root/$built" ]] || fail "$built exists in this checkout" \ "The build script would link that instead of the artifact. Run this in a clean checkout." @@ -93,6 +104,7 @@ cp "$artifact" "$artifact.sha256" "$scratch/cache/downloads/" # Nothing may point the build anywhere else. unset COMPOSE_RUST_RENDERER_DIR DIOXUS_COMPOSE_RENDERER_DIR DXC_MACOS_NATIVE_LIB DXC_LINUX_NATIVE_LIB +unset DXC_WINDOWS_NATIVE_LIB DXC_WINDOWS_CRT RUSTFLAGS CARGO_ENCODED_RUSTFLAGS CARGO_BUILD_RUSTFLAGS # The Kotlin/Native renderer cannot close its own window, so the self-check ends the # process once the frames are in. unset COMPOSE_RUST_AUTOEXIT_MS @@ -106,9 +118,40 @@ export COMPOSE_RUST_TEXT_SELF_CHECK=1 # No ICU data file anywhere an application could be pointed at one. The executable must find # words and line breaks with what is linked into it. A runner that ships one (a browser # carries icudtl.dat) has to have it removed before this runs. -icu_files="$(find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true)" +if [[ "$os" == Windows ]]; then + # Git Bash sees the drives as /c, /d. The system and program directories are where a + # browser or a runtime keeps one; the checkout and the scratch directory are not searched, + # because the kit's own skia/icudtl.dat is not in the tarball and must not be. + icu_roots=() + for root in "/c/Program Files" "/c/Program Files (x86)" "/c/ProgramData" "/c/Users" \ + "/c/Windows" "/c/hostedtoolcache" "/c/msys64" "/d/a/_tool" "/d/a/_temp"; do + [[ ! -d "$root" ]] || icu_roots+=("$root") + done + icu_files="$(find "${icu_roots[@]}" -name 'icudtl.dat' 2>/dev/null || true)" +else + icu_files="$(find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true)" +fi [[ -z "$icu_files" ]] || fail "an ICU data file is on this machine, so the text check would not prove anything" \ - $icu_files + "$icu_files" + +if [[ "$os" == Windows ]]; then + # The tarball root is the kit root, as build-windows.sh writes it and build.rs reads it. + kit="$scratch/kit" + mkdir -p "$kit" + tar -xzf "$artifact" -C "$kit" + [[ -f "$kit/libcompose_rust_renderer.a" ]] || fail "the unpacked kit has no libcompose_rust_renderer.a at its root" + for piece in gcc/libstdc++.a native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib \ + skiko/skiko-bridges.lib skiko/skia.lib; do + [[ -f "$kit/$piece" ]] || fail "the kit has no $piece" \ + "package-static-renderer.sh packs the whole kit build-windows.sh writes for windows-x64." + done + find "$kit" -name 'icudtl.dat' | grep . && fail "the kit carries an icudtl.dat" \ + "The ICU data is to be inside the renderer's object, not beside it." + DXC_WINDOWS_NATIVE_LIB="$(cygpath -w "$kit")" + export DXC_WINDOWS_NATIVE_LIB + # A hosted runner has no card: Windows' software adapter draws instead. + export DXC_D3D12_WARP="${DXC_D3D12_WARP:-1}" +fi # What every system the application may name lives under. Anything else is a file that # would have to travel with it. @@ -119,9 +162,59 @@ system_library() { esac } +# Visual Studio's tools, on PATH inside a developer prompt and found through vswhere otherwise. +# Prints nothing when there is none. +vs_tool() { + local name="$1" found vswhere + found="$(command -v "$name" || true)" + if [[ -z "$found" ]]; then + vswhere="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" + [[ -f "$vswhere" ]] || return 0 + found="$("$vswhere" -latest -products '*' -find "VC/Tools/MSVC/**/bin/Hostx64/x64/$name" | tr -d '\r' | head -1)" + [[ -z "$found" ]] || found="$(cygpath -u "$found")" + fi + echo "$found" +} + +# The DLLs a Windows executable asks the loader for, listed by dumpbin. The rules are those of +# scripts/check-windows-consumer.sh: Windows' own DLLs only. +check_windows_libraries() { + local binary="$1" dumpbin dlls dll lower system not_ours=() + dumpbin="$(vs_tool dumpbin.exe)" + [[ -n "$dumpbin" ]] || fail "no dumpbin to list the executable's DLLs with" \ + "Install the Visual Studio C++ build tools." + echo "-- dumpbin -dependents $(basename "$binary")" + "$dumpbin" -nologo -dependents "$(cygpath -w "$binary")" | tr -d '\r' | + sed -n '/Image has the following dependencies/,/Summary/p' + dlls="$("$dumpbin" -nologo -dependents "$(cygpath -w "$binary")" | tr -d '\r' | + sed -n 's/^ *\([A-Za-z0-9_.-]*\.[dD][lL][lL]\)$/\1/p' | sort -fu)" + [[ -n "$dlls" ]] || fail "dumpbin named no DLL at all, which is not a Windows executable that opens a window" + system="$(cygpath -u "${SystemRoot:-C:\\Windows}")/System32" + for dll in $dlls; do + lower="$(echo "$dll" | tr '[:upper:]' '[:lower:]')" + case "$lower" in + vcruntime*|msvcp*|concrt*|libgcc*|libstdc++*|libwinpthread*|*mingw*|\ + libcompose_rust_renderer*|*jvm*|*java*|awt*|skiko*|icu*) + fail "$(basename "$binary") needs $dll, which is not part of Windows" \ + "The runtime and the renderer are to be linked into the executable." ;; + api-ms-win-*|ext-ms-win-*) ;; + *) [[ -f "$system/$dll" || -f "$system/$lower" ]] || not_ours+=("$dll") ;; + esac + done + [[ "${#not_ours[@]}" -eq 0 ]] || fail "$(basename "$binary") needs DLLs Windows does not ship: ${not_ours[*]}" + # A statically linked JNI library announces itself with JNI_OnLoad_. + ! grep -aq 'JNI_OnLoad' "$binary" || + fail "the executable carries a JNI library, so a Java runtime is linked into it" + echo "-- $(wc -w <<< "$dlls" | tr -d ' ') DLLs, every one part of Windows: $(echo $dlls)" +} + # Prints what the executable names and fails on anything that is not the system's. check_libraries() { local binary="$1" failures=0 library resolved + if [[ "$os" == Windows ]]; then + check_windows_libraries "$binary" + return + fi if [[ "$os" == Darwin ]]; then echo "-- otool -L $(basename "$binary")" otool -L "$binary" | tail -n +2 @@ -185,7 +278,7 @@ check_libraries() { } size_of() { - if [[ "$os" == Darwin ]]; then stat -f %z "$1"; else stat -c %s "$1"; fi + if [[ "$os" == Darwin ]]; then stat -f %z "$1"; else wc -c < "$1" | tr -d ' '; fi } megabytes() { @@ -207,7 +300,7 @@ build() { grep -q "unpacked the renderer for v$crate_version" "$log" || grep -q "$scratch/cache/renderer" "$log" || echo "note the build log does not say it unpacked the artifact (a cached build replays nothing)" >&2 - echo "$repo_root/target/single-executable/$name/release/consumer" + echo "$repo_root/target/single-executable/$name/release/consumer$exe" } run_alone() { @@ -215,13 +308,13 @@ run_alone() { local directory="$scratch/run/$name" rm -rf "$directory" mkdir -p "$directory" - cp "$binary" "$directory/consumer" + cp "$binary" "$directory/consumer$exe" echo "-- ls -la $directory" ls -la "$directory" - [[ "$(ls -A "$directory")" == "consumer" ]] || fail "something other than the executable is in $directory" + [[ "$(ls -A "$directory")" == "consumer$exe" ]] || fail "something other than the executable is in $directory" echo "== running it alone in $directory" local log="$scratch/run-$name.log" status=0 - ( cd "$directory" && ./consumer --self-check ) > "$log" 2>&1 || status=$? + ( cd "$directory" && "./consumer$exe" --self-check ) > "$log" 2>&1 || status=$? cat "$log" grep -q "compose-rust text self-check: ok" "$log" || fail "the renderer did not lay Korean text out correctly with no ICU data file" \ @@ -229,6 +322,12 @@ run_alone() { [[ "$status" -eq 0 ]] || fail "the executable did not draw on its own (exit $status)" # And the renderer said so itself: its window opened, its scene was made on the main # dispatcher this platform has, and a frame reached the window. + if [[ "$os" == Windows ]]; then + # The Windows renderer's start lines are not the ones the other two print (it has no + # main dispatcher of the platform's), so what proves it drew is the exit status of the + # self-check, which the executable returns only once frames went through. + return 0 + fi local dispatcher="$smoke_main_dispatcher_kn_linux" [[ "$os" != Darwin ]] || dispatcher="$smoke_main_dispatcher_kn_macos" check_window_start "$(cat "$log")" "$status" "$dispatcher" || @@ -237,6 +336,12 @@ run_alone() { report() { local binary="$1" stripped="$scratch/consumer.stripped" + if [[ "$os" == Windows ]]; then + # A release MSVC executable keeps its symbols in a .pdb beside it, so it is as small + # as it gets. + echo "-- size: $(megabytes "$(size_of "$binary")")" + return + fi cp "$binary" "$stripped" if [[ "$os" == Darwin ]]; then strip -x "$stripped"; else strip "$stripped"; fi echo "-- size: $(megabytes "$(size_of "$binary")"), stripped $(megabytes "$(size_of "$stripped")")" @@ -245,7 +350,7 @@ report() { binary="$(build default "")" [[ -x "$binary" ]] || fail "the build produced no $binary" -ls "$scratch/cache/renderer"/v"$crate_version"/* >/dev/null 2>&1 || +[[ "$os" == Windows ]] || ls "$scratch/cache/renderer"/v"$crate_version"/* >/dev/null 2>&1 || fail "the build did not unpack the artifact into $scratch/cache/renderer" \ "It found a renderer somewhere else, so this did not test the artifact." check_libraries "$binary" diff --git a/.github/scripts/package-static-renderer.sh b/.github/scripts/package-static-renderer.sh index 8f19c41d..5c779b97 100755 --- a/.github/scripts/package-static-renderer.sh +++ b/.github/scripts/package-static-renderer.sh @@ -14,9 +14,19 @@ # schema-hash.txt the schema the renderer was generated from # renderer.version the crate version it is for # +# windows-x64 is the kit root as build-windows.sh writes renderer/build/windows, which the +# Host's build script reads (compose-rust/build.rs), so nothing is under lib/ or include/: +# +# libcompose_rust_renderer.a, libcompose_rust_renderer_api.h, schema-hash.txt, +# renderer.version, and +# gcc/*.a the GCC runtime the renderer object carries +# native/*.lib the Win32 window, the MinGW bridge, the embedded +# ICU loader, and the bridge for a static UCRT +# skiko/*.lib skiko's C++ half and Skia, MSVC +# # what build-macos.sh or build-linux.sh wrote # (renderer/build/macos, build/linux, ...) -# macos-aarch64, linux-x64 or linux-arm64 +# macos-aarch64, linux-x64, linux-arm64 or windows-x64 # the crate version, without a leading v # # The name, the checksum file and the version file are the ones build/renderer_dir.rs reads, @@ -41,8 +51,8 @@ version="${3#v}" out="$4" case "$target" in - macos-aarch64|linux-x64|linux-arm64) ;; - *) fail "no static renderer is packaged for '$target'" "known: macos-aarch64, linux-x64, linux-arm64" ;; + macos-aarch64|linux-x64|linux-arm64|windows-x64) ;; + *) fail "no static renderer is packaged for '$target'" "known: macos-aarch64, linux-x64, linux-arm64, windows-x64" ;; esac [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+].*)?$ ]] || fail "'$3' is not a crate version" @@ -54,16 +64,48 @@ for file in "$archive" "$header" "$hash"; do "Build the renderer first: renderer/desktop/scripts/build-macos.sh or build-linux.sh." done +# The pieces of the Windows kit beyond the archive, header and schema hash. Listed by +# name, so a kit that lost one fails here and not in an application's link. +windows_kit=( + gcc/libstdc++.a gcc/libgcc.a gcc/libgcc_eh.a gcc/libwinpthread.a + native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib + skiko/skiko-bridges.lib skiko/skia.lib +) +if [[ "$target" == windows-x64 ]]; then + for file in "${windows_kit[@]}"; do + [[ -f "$build_dir/$file" ]] || fail "no $file in $build_dir" \ + "Build the renderer first: renderer/desktop/scripts/build-windows.sh writes the whole kit." + done +fi + name="compose-rust-renderer-v$version-$target" mkdir -p "$out" out="$(cd "$out" && pwd)" staging="$out/$name.staging" rm -rf "$staging" -mkdir -p "$staging/lib" "$staging/include" -cp "$archive" "$staging/lib/" -cp "$header" "$staging/include/" +if [[ "$target" == windows-x64 ]]; then + mkdir -p "$staging" + cp "$archive" "$header" "$staging/" +else + mkdir -p "$staging/lib" "$staging/include" + cp "$archive" "$staging/lib/" + cp "$header" "$staging/include/" +fi cp "$hash" "$staging/schema-hash.txt" echo "$version" > "$staging/renderer.version" +if [[ "$target" == windows-x64 ]]; then + for file in "${windows_kit[@]}"; do + mkdir -p "$staging/$(dirname "$file")" + cp "$build_dir/$file" "$staging/$file" + done + # Any further library the kit holds in those directories (skia ships several .lib files). + for dir in gcc native skiko; do + for file in "$build_dir/$dir"/*.a "$build_dir/$dir"/*.lib; do + [[ -f "$file" && ! -e "$staging/$dir/$(basename "$file")" ]] || continue + cp "$file" "$staging/$dir/" + done + done +fi # COPYFILE_DISABLE keeps macOS tar from adding ._ resource fork entries to the archive. COPYFILE_DISABLE=1 tar -czf "$out/$name.tar.gz" -C "$staging" . diff --git a/.github/workflows/test-native-renderer.yml b/.github/workflows/test-native-renderer.yml index e07d6c1b..88a2a681 100644 --- a/.github/workflows/test-native-renderer.yml +++ b/.github/workflows/test-native-renderer.yml @@ -63,6 +63,7 @@ on: - scripts/parity/** - .github/workflows/test-native-renderer.yml - .github/workflows/static-renderer.yml + - .github/workflows/windows-static.yml - .github/workflows/fork-desktop-modules.yml # Pushes to main as well: the release branch is rebuilt from develop, so its head is a # different commit from any that was checked as a pull request. The release workflow @@ -309,21 +310,17 @@ jobs: single-executable: # Each platform's check needs only its own artifact, so one platform failing to build # does not hide whether the others are one executable. - needs: [changed, macos-static, linux-static, linux-arm64-static] + needs: [changed, macos-static, linux-static, linux-arm64-static, windows-static] if: ${{ !cancelled() && needs.changed.outputs.renderer == 'true' }} name: One executable on ${{ matrix.target }} runs-on: ${{ matrix.runs-on }} timeout-minutes: 60 strategy: fail-fast: false - matrix: - include: - - target: macos-aarch64 - runs-on: macos-latest - - target: linux-x64 - runs-on: ubuntu-24.04 - - target: linux-arm64 - runs-on: ubuntu-24.04-arm + # Windows is left out of a tag's run for now: the tag's windows-x64 artifact is still the + # GraalVM one (the `windows` job names it the same way), and windows-static does not + # upload its own on a tag until the release takes the static kit instead. + matrix: ${{ fromJSON(startsWith(github.ref, 'refs/tags/v') && '{"include":[{"target":"macos-aarch64","runs-on":"macos-latest"},{"target":"linux-x64","runs-on":"ubuntu-24.04"},{"target":"linux-arm64","runs-on":"ubuntu-24.04-arm"}]}' || '{"include":[{"target":"macos-aarch64","runs-on":"macos-latest"},{"target":"linux-x64","runs-on":"ubuntu-24.04"},{"target":"linux-arm64","runs-on":"ubuntu-24.04-arm"},{"target":"windows-x64","runs-on":"windows-latest"}]}') }} env: # Mesa draws with the processor; a hosted runner has no card. LIBGL_ALWAYS_SOFTWARE: '1' @@ -332,6 +329,7 @@ jobs: - uses: actions/checkout@v4 - name: Install the Rust toolchain + shell: bash run: rustup toolchain install stable --profile minimal && rustup default stable - name: Install the libraries an application links on Linux, and a virtual display @@ -359,8 +357,16 @@ jobs: run: | # find reports the directories it may not read and exits non-zero for them, # which is not a failure to find anything; what is left is checked afterwards. - { find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true; } | - while IFS= read -r file; do echo "removing $file"; sudo rm -f "$file"; done + if [[ "$RUNNER_OS" == Windows ]]; then + # Git Bash's view of the drives; no sudo, the runner is an administrator. + { find "/c/Program Files" "/c/Program Files (x86)" "/c/ProgramData" "/c/Users" \ + "/c/Windows" "/c/hostedtoolcache" "/c/msys64" "/d/a/_tool" "/d/a/_temp" \ + -name 'icudtl.dat' 2>/dev/null || true; } | + while IFS= read -r file; do echo "removing $file"; rm -f "$file"; done + else + { find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true; } | + while IFS= read -r file; do echo "removing $file"; sudo rm -f "$file"; done + fi - name: Build it, check what it needs, and run it alone shell: bash diff --git a/.github/workflows/windows-static.yml b/.github/workflows/windows-static.yml index 2327a469..984a3fb6 100644 --- a/.github/workflows/windows-static.yml +++ b/.github/workflows/windows-static.yml @@ -290,3 +290,25 @@ jobs: path: ${{ runner.temp }}/consumer-windows/consumer.exe if-no-files-found: ignore retention-days: 7 + + - name: Package the kit the way the release carries it + id: package + # The same tarball the other platforms ship: the static kit under lib/ and include/, + # with the rest of what an MSVC link needs beside it, and its checksum. A tag keeps + # the GraalVM tarball of the same name until the release takes this one instead. + if: steps.renderer.outcome == 'success' && !startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' compose-rust/Cargo.toml | head -1)" + ./.github/scripts/package-static-renderer.sh renderer/build/windows windows-x64 "$version" "$RUNNER_TEMP/artifact" + echo "name=compose-rust-renderer-v$version-windows-x64" >> "$GITHUB_OUTPUT" + + - name: Keep the artifact + if: steps.package.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.name }} + path: | + ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz + ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz.sha256 + retention-days: 7 diff --git a/renderer/desktop/scripts/build-windows.sh b/renderer/desktop/scripts/build-windows.sh index 9e50b517..3d0b761c 100755 --- a/renderer/desktop/scripts/build-windows.sh +++ b/renderer/desktop/scripts/build-windows.sh @@ -317,6 +317,17 @@ for symbol in compose_rust_renderer_run compose_rust_renderer_request_frame; do "Check the @CName annotations in staticlib/src/IosEntryPoints.kt." done +# The schema this renderer was generated from, written beside it, so the Host's build script +# can see the two disagree, and so the release can package the kit as it does the others. +schema_hash_decimal="$( + grep -o 'const val SCHEMA_HASH: Long = -\?[0-9]*' \ + "$PROJECT_DIR/desktop/src/protocol/Protocol.gen.kt" | + grep -o -- '-\?[0-9]*$' +)" +[[ -n "$schema_hash_decimal" ]] || die "could not read SCHEMA_HASH from desktop/src/protocol/Protocol.gen.kt" +# printf rather than awk: the hash fills all 64 bits and awk works in doubles. +printf '0x%016x\n' "$schema_hash_decimal" > "$OUT_DIR/schema-hash.txt" + echo echo "$OUT_DIR" du -sh "$OUT_DIR"/* | sed 's|'"$OUT_DIR"'/||' diff --git a/scripts/tests/package-static-renderer.test.sh b/scripts/tests/package-static-renderer.test.sh new file mode 100755 index 00000000..40d5ac9f --- /dev/null +++ b/scripts/tests/package-static-renderer.test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# .github/scripts/package-static-renderer.sh: what a static kit tarball holds per target. +# +# The windows-x64 kit is the Kotlin/Native renderer plus the pieces an MSVC link needs beside +# it. A kit that loses one of them links nowhere, so the packager has to refuse it, and the +# tarball has to carry all of them with the same lib/ and include/ layout as the others. +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +packager="$repo_root/.github/scripts/package-static-renderer.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/package-static.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +failures=0 +pass() { echo "ok - $1"; } +fail() { echo "FAIL - $1"; failures=$((failures + 1)); } + +make_unix_kit() { + mkdir -p "$1" + echo a > "$1/libcompose_rust_renderer.a" + echo h > "$1/libcompose_rust_renderer_api.h" + echo 0x0000000000000001 > "$1/schema-hash.txt" +} +make_windows_kit() { + make_unix_kit "$1" + mkdir -p "$1/gcc" "$1/native" "$1/skiko" + for f in gcc/libstdc++.a gcc/libgcc.a gcc/libgcc_eh.a gcc/libwinpthread.a \ + native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib \ + skiko/skiko-bridges.lib skiko/skia.lib skiko/skparagraph.lib; do + echo x > "$1/$f" + done + echo obj > "$1/native/win32_window.obj" +} + +# windows-x64 packages the whole kit +make_windows_kit "$work/win" +if out="$("$packager" "$work/win" windows-x64 1.2.3 "$work/out" 2>&1)"; then + tarball="$work/out/compose-rust-renderer-v1.2.3-windows-x64.tar.gz" + list="$(tar -tzf "$tarball")" + [[ -f "$tarball.sha256" ]] && pass "windows-x64 writes the checksum" || fail "no checksum" + for entry in libcompose_rust_renderer.a libcompose_rust_renderer_api.h \ + schema-hash.txt renderer.version gcc/libstdc++.a gcc/libwinpthread.a \ + native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib \ + skiko/skiko-bridges.lib skiko/skia.lib skiko/skparagraph.lib; do + grep -qxF -e "$entry" -e "./$entry" <<< "$list" && pass "windows-x64 carries $entry" || fail "windows-x64 lacks $entry" + done + ! grep -qE '(^|/)(lib|include)/' <<< "$list" && pass "windows-x64 keeps the kit root layout" || fail "windows-x64 uses lib/ or include/" + ! grep -q '\.obj$' <<< "$list" && pass "windows-x64 carries no loose objects" || fail "windows-x64 carries an .obj" +else + fail "windows-x64 packaging failed: $out" +fi + +# a Windows kit missing the static-UCRT bridge is refused +rm "$work/win/native/dxc-windows-static-ucrt.lib" +if "$packager" "$work/win" windows-x64 1.2.3 "$work/out2" >/dev/null 2>&1; then + fail "a Windows kit with no dxc-windows-static-ucrt.lib was packaged" +else + pass "a Windows kit with no dxc-windows-static-ucrt.lib is refused" +fi + +# the other targets need none of it +make_unix_kit "$work/linux" +if "$packager" "$work/linux" linux-x64 1.2.3 "$work/out3" >/dev/null 2>&1; then + pass "linux-x64 still packages the archive alone" +else + fail "linux-x64 packaging broke" +fi + +# an unknown target is still refused +if "$packager" "$work/linux" windows-arm64 1.2.3 "$work/out4" >/dev/null 2>&1; then + fail "windows-arm64 was packaged" +else + pass "an unknown target is refused" +fi + +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" From 9c135e22b15f596f66424a3cf1d0ec87a8b6ff52 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:53:05 +0900 Subject: [PATCH 124/224] Feat: Samples build against the released renderer (NFR-15) A resolve job looks for the crate version's renderer assets in its release; when all are there the samples download and check them instead of rebuilding the renderer, otherwise the build is as before. A static Windows renderer is packaged as the executable alone. Content taken from #323 without its skip_android input. --- .github/workflows/test-samples.yml | 126 +++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index e33c99ca..16f09834 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -18,9 +18,13 @@ # two should be versioned apart so a sample can be rebuilt without implying a library # release, and a library release is not held up by a sample that does not build. # -# Each sample links the renderer for its platform. On macOS and Linux that is the static -# archive, built by static-renderer.yml, so each sample is one executable; on Windows the -# renderer library still ships beside it. +# Each sample links the renderer for its platform. The renderer is the one the release for +# the checked-out crate version carries (compose-rust-renderer-v-.tar.gz, +# checked against its .sha256), so a sample build does not rebuild it. On macOS and Linux +# that is the static archive, so each sample is one executable; on Windows the renderer +# library still ships beside it. When the release has no such asset yet (a version still +# in a pull request), the resolve job says so and the renderer is built here as before, +# by static-renderer.yml and the Windows build script. name: Samples test @@ -68,11 +72,47 @@ jobs: steps: - run: echo "Heavy checks requested by ${{ github.event_name }} ${{ github.event.action }}" - # The renderers macOS and Linux ship, from the same reusable workflow the release uses, - # so that every sample there is one executable, built the way an application is. + # Does the release for this commit's crate version carry the renderer assets? Decides + # whether the build downloads them or builds the renderer itself. + resolve: + name: Find the released renderer + needs: requested + runs-on: ubuntu-latest + outputs: + released: ${{ steps.find.outputs.released }} + version: ${{ steps.find.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Read the crate version and look for the release asset + id: find + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' compose-rust/Cargo.toml | head -1)" + echo "version=$version" >> "$GITHUB_OUTPUT" + # All four desktop assets and their checksums must be there; a release that is + # half uploaded is treated as not released. + names="$(gh release view "v$version" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' 2>/dev/null || true)" + released=true + for target in macos-aarch64 linux-x64 linux-arm64 windows-x64; do + asset="compose-rust-renderer-v$version-$target.tar.gz" + for n in "$asset" "$asset.sha256"; do + grep -qxF "$n" <<<"$names" || { echo "missing $n"; released=false; } + done + done + echo "released=$released" >> "$GITHUB_OUTPUT" + echo "crate $version, released renderer: $released" + + # The renderers macOS and Linux ship, from the same reusable workflow the release uses. + # Only when the release does not carry them yet. static-renderer: name: renderer - needs: requested + needs: resolve + if: needs.resolve.outputs.released != 'true' strategy: fail-fast: false matrix: @@ -84,16 +124,18 @@ jobs: fork-desktop: # The Windows renderer's desktop module asks for the fork's Compose and skiko-awt by version. - needs: requested + # Only building that renderer needs them, so a released one skips this. + needs: resolve + if: needs.resolve.outputs.released != 'true' name: Build the Compose fork's desktop modules uses: ./.github/workflows/fork-desktop-modules.yml build: name: Build the sample (${{ matrix.target }}) - needs: [requested, static-renderer, fork-desktop] - # Windows builds its own renderer and does not need the static ones, and one platform's - # renderer failing does not stop the others' samples. - if: ${{ !cancelled() && needs.requested.result == 'success' }} + needs: [requested, resolve, static-renderer, fork-desktop] + # Skipped renderer jobs (a released renderer) are fine; one platform's renderer + # failing does not stop the others' samples. + if: ${{ !cancelled() && needs.requested.result == 'success' && needs.resolve.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: @@ -149,7 +191,7 @@ jobs: echo "link.exe now resolves to: $(command -v link || echo 'nothing on PATH')" - name: Install upstream GraalVM 25 - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.resolve.outputs.released != 'true' uses: graalvm/setup-graalvm@v1 with: java-version: '25' @@ -166,7 +208,7 @@ jobs: libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev - name: Cache the Kotlin toolchain and Maven dependencies - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.resolve.outputs.released != 'true' uses: actions/cache@v4 with: path: ~/.m2/repository @@ -174,48 +216,72 @@ jobs: restore-keys: kotlin-${{ matrix.target }}- - name: Take the Compose fork's desktop modules - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.resolve.outputs.released != 'true' uses: actions/download-artifact@v4 with: name: compose-fork-desktop path: ${{ runner.temp }}/compose-fork-desktop - name: Put them where the renderer's build looks - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.resolve.outputs.released != 'true' shell: bash run: | mkdir -p ~/.m2/repository/org/jetbrains cp -R "$RUNNER_TEMP/compose-fork-desktop/." ~/.m2/repository/org/ - name: Build the Windows renderer - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.resolve.outputs.released != 'true' run: ${{ matrix.renderer }} - name: Download the static renderer - if: matrix.static + if: matrix.static && needs.resolve.outputs.released != 'true' uses: actions/download-artifact@v4 with: pattern: compose-rust-renderer-v*-${{ matrix.target }} path: renderer-artifact merge-multiple: true + - name: Download the released renderer + if: needs.resolve.outputs.released == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + mkdir -p renderer-artifact + asset="compose-rust-renderer-v$VERSION-${{ matrix.target }}.tar.gz" + gh release download "v$VERSION" -R "$GITHUB_REPOSITORY" \ + --pattern "$asset" --pattern "$asset.sha256" --dir renderer-artifact + cd renderer-artifact + # The .sha256 holds " "; check it against the file just fetched. + if command -v sha256sum >/dev/null; then sha256sum -c "$asset.sha256" + else shasum -a 256 -c "$asset.sha256"; fi + - name: Unpack it where the build will be pointed - if: matrix.static + if: matrix.static || needs.resolve.outputs.released == 'true' shell: bash run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/renderer" - tar -xzf renderer-artifact/*.tar.gz -C "$RUNNER_TEMP/renderer" - ls -la "$RUNNER_TEMP/renderer/lib" - echo "RENDERER=$RUNNER_TEMP/renderer" >> "$GITHUB_ENV" + # Forward slashes on Windows too: GNU tar reads "D:" as a remote host, and an + # absolute path in RENDERER has to survive the build script's working directory. + dir="$RUNNER_TEMP/renderer" + if command -v cygpath >/dev/null; then dir="$(cygpath -m "$dir")"; fi + mkdir -p "$dir" + tar -xzf renderer-artifact/*.tar.gz -C "$dir" + ls -la "$dir" + echo "RENDERER=$dir" >> "$GITHUB_ENV" + echo "DIST=$dir" >> "$GITHUB_ENV" - name: Point the build at the Windows renderer - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.resolve.outputs.released != 'true' shell: bash # Absolute. A build script runs with its crate directory as the working # directory, not the workspace root, so a relative path here points at # compose-rust/renderer/... and finds nothing. - run: echo "RENDERER=${{ github.workspace }}/${{ matrix.dist }}" >> "$GITHUB_ENV" + run: | + echo "RENDERER=${{ github.workspace }}/${{ matrix.dist }}" >> "$GITHUB_ENV" + echo "DIST=${{ github.workspace }}/${{ matrix.dist }}" >> "$GITHUB_ENV" - name: Build every sample against it # A sample that does not build is skipped rather than allowed to end the run. The @@ -260,7 +326,8 @@ jobs: - name: Package each sample # On macOS and Linux a sample is one executable with the renderer inside it, and # that file is the whole archive. On Windows it still runs beside the renderer - # library it was linked against, so the two are shipped together. + # library it was linked against while the release carries one; with the static + # renderer the executable is alone. shell: bash run: | set -euo pipefail @@ -270,7 +337,14 @@ jobs: stage="staging/$app" mkdir -p "$stage" if [[ "${{ runner.os }}" == "Windows" ]]; then - cp -R "${{ matrix.dist }}/." "$stage/" + if [[ -d "$DIST/bin" ]]; then + cp -R "$DIST/." "$stage/" + else + # The static renderer is inside the executable, so nothing else travels. + cp "target/release/sample-$app.exe" "$stage/" + tar -czf "artifacts/$app-${{ matrix.target }}.tar.gz" -C staging "$app" + continue + fi # The executable goes in beside the DLL rather than above it. Windows # searches the directory the executable is in, and the renderer's DLL lives # in `bin` because AWT reads its font configuration from the `lib` next to From f4c814406f9a5b570b581afa946edbbd91827883 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:56:00 +0900 Subject: [PATCH 125/224] Fix: Say when the release lookup fails, and test it The lookup moves to find-released-renderer.sh with a shell test. A missing release or asset still falls back to building the renderer; a gh failure is reported as a warning. The workflow notes that a renderer change without a version bump is tested against the old release, and that the Windows static branches wait for a static windows-x64 tarball. --- .github/scripts/find-released-renderer.sh | 38 ++++++++++++++++++++ .github/workflows/test-samples.yml | 23 ++++++------ scripts/tests/find-released-renderer.test.sh | 36 +++++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) create mode 100755 .github/scripts/find-released-renderer.sh create mode 100755 scripts/tests/find-released-renderer.test.sh diff --git a/.github/scripts/find-released-renderer.sh b/.github/scripts/find-released-renderer.sh new file mode 100755 index 00000000..1e209020 --- /dev/null +++ b/.github/scripts/find-released-renderer.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Usage: find-released-renderer.sh +# +# Prints "released=true" or "released=false" for GITHUB_OUTPUT. true means the release +# v carries the renderer tarball and its checksum for every desktop target. +# +# "The release has no such asset" and "gh failed" are different answers. A missing release +# or missing asset is false, which sends the build to building the renderer itself. Any +# other gh failure (authentication, rate limit, network) is reported as a warning and also +# false, but it is said, because otherwise a broken token turns into a silent rebuild that +# takes hours. +set -uo pipefail + +version="${1:?usage: $0 }" +repo="${GITHUB_REPOSITORY:-}" + +err="$(mktemp)" +trap 'rm -f "$err"' EXIT +if names="$(gh release view "v$version" ${repo:+-R "$repo"} --json assets -q '.assets[].name' 2>"$err")"; then + : +else + if grep -qiE 'release not found|not found' "$err"; then + echo "no release v$version yet" >&2 + else + echo "::warning title=Could not read the release::gh failed: $(tr '\n' ' ' < "$err")" + fi + echo "released=false" + exit 0 +fi + +released=true +for target in macos-aarch64 linux-x64 linux-arm64 windows-x64; do + asset="compose-rust-renderer-v$version-$target.tar.gz" + for n in "$asset" "$asset.sha256"; do + grep -qxF "$n" <<<"$names" || { echo "missing $n" >&2; released=false; } + done +done +echo "released=$released" diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 16f09834..3a465195 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -74,6 +74,12 @@ jobs: # Does the release for this commit's crate version carry the renderer assets? Decides # whether the build downloads them or builds the renderer itself. + # + # The renderer used is the one released for the crate version, so a pull request that + # changes the renderer's sources without raising the version is tested against the old + # release. Raise the version, or the assets are not found, for the new sources to be built. + # The Windows static branches below take effect once the release carries a static + # windows-x64 tarball; until then the Windows build still uses the DLL kit. resolve: name: Find the released renderer needs: requested @@ -94,17 +100,12 @@ jobs: set -euo pipefail version="$(sed -n 's/^version = "\(.*\)"$/\1/p' compose-rust/Cargo.toml | head -1)" echo "version=$version" >> "$GITHUB_OUTPUT" - # All four desktop assets and their checksums must be there; a release that is - # half uploaded is treated as not released. - names="$(gh release view "v$version" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' 2>/dev/null || true)" - released=true - for target in macos-aarch64 linux-x64 linux-arm64 windows-x64; do - asset="compose-rust-renderer-v$version-$target.tar.gz" - for n in "$asset" "$asset.sha256"; do - grep -qxF "$n" <<<"$names" || { echo "missing $n"; released=false; } - done - done - echo "released=$released" >> "$GITHUB_OUTPUT" + # Needs all four desktop assets and their checksums; a half-uploaded release + # counts as not released. A gh failure is warned about, not hidden. + result="$(.github/scripts/find-released-renderer.sh "$version")" + echo "$result" + echo "$result" | grep '^released=' >> "$GITHUB_OUTPUT" + released="$(sed -n 's/^released=//p' <<<"$result")" echo "crate $version, released renderer: $released" # The renderers macOS and Linux ship, from the same reusable workflow the release uses. diff --git a/scripts/tests/find-released-renderer.test.sh b/scripts/tests/find-released-renderer.test.sh new file mode 100755 index 00000000..388c6e9b --- /dev/null +++ b/scripts/tests/find-released-renderer.test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# .github/scripts/find-released-renderer.sh: when the samples use the released renderer. +# A fake gh stands in for GitHub. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/find-released-renderer.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/find-released.XXXXXX")" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +cat > "$work/bin/gh" <<'GH' +#!/usr/bin/env bash +case "$FAKE_MODE" in + all) for t in macos-aarch64 linux-x64 linux-arm64 windows-x64; do + echo "compose-rust-renderer-v1.0.0-$t.tar.gz"; echo "compose-rust-renderer-v1.0.0-$t.tar.gz.sha256"; done ;; + partial) echo "compose-rust-renderer-v1.0.0-linux-x64.tar.gz" ;; + missing) echo "release not found" >&2; exit 1 ;; + broken) echo "HTTP 401: Bad credentials" >&2; exit 1 ;; +esac +GH +chmod +x "$work/bin/gh" +failures=0 +check() { # mode expected_output expected_warning(yes|no) + out="$(PATH="$work/bin:$PATH" FAKE_MODE="$1" "$script" 1.0.0 2>/dev/null)" + if [[ "$out" == *"released=$2"* ]]; then echo "ok - $1 -> $2"; else echo "FAIL - $1 gave: $out"; failures=$((failures + 1)); fi + if [[ "$3" == yes ]]; then + [[ "$out" == *"::warning"* ]] && echo "ok - $1 warns" || { echo "FAIL - $1 did not warn"; failures=$((failures + 1)); } + else + [[ "$out" != *"::warning"* ]] && echo "ok - $1 is quiet" || { echo "FAIL - $1 warned"; failures=$((failures + 1)); } + fi +} +check all true no +check partial false no +check missing false no +check broken false yes +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" From 535ff6252761369fe64f9323a7a40c6e08126beb Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:08:43 +0900 Subject: [PATCH 126/224] Docs: Add FR-20.8, an opt-in for window dp size changes (#467) --- docs/SPEC.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index 8565e65f..abc33a4a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1541,6 +1541,23 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 5. `use_window_size()`를 쓴 컴포넌트가 클래스 전환에서 다시 렌더링되고, 그렇지 않은 형제는 다시 렌더링되지 않습니다. 6. 알 수 없는 `size_class` 값은 `ProtocolError`가 되고 프로세스가 죽지 않습니다. +#### 20.8 크기 변화도 받는 창 (`Agreed`, 이슈 #467, 감독자 승인) + +20.4의 규칙은 클래스(와 19.9의 안전 영역)가 바뀔 때만 `WindowSizeChanged`를 보냅니다. 창 크기로 페이지를 짜는 Host(HTML과 CSS 호스트)는 클래스 안에서 1280x820이 1000x620으로 줄어도 다시 배치할 수 없습니다. `window_size()`가 마지막 클래스 변경 때의 크기를 계속 돌려주기 때문입니다. + +- **옵트인**: `Window` 노드의 불리언 속성 `report_size`(AttrKey 127, 기본 `false`). Rust에서는 `Window::new().report_size(true)`입니다. +- **켠 창**: 클래스와 안전 영역이 그대로여도, 창의 폭 또는 높이가 **반올림한 dp**로 바뀌면 같은 `WindowSizeChanged`를 보냅니다. 레코드와 이벤트 태그는 바뀌지 않고 이미 실린 `width_dp`, `height_dp`가 새 크기입니다. +- **반올림 규칙**: 폭과 높이를 각각 가장 가까운 정수 dp로 반올림합니다(0.5는 올림). 직전에 보낸 값과 반올림한 값이 같으면 보내지 않으므로 1dp 미만의 흔들림은 무시합니다. `Float`를 그대로 비교하지 않습니다. 보내는 `width_dp`와 `height_dp`는 반올림하지 않은 측정값입니다. +- **끈 창**: 지금과 한 바이트도 다르지 않습니다. 기본 동작, 호출 수, 할당이 그대로입니다. +- **비용**: 켠 창은 드래그로 크기를 바꾸는 동안 정수 dp가 바뀔 때마다 Host 호출이 한 번 늘 수 있습니다. 창 크기로 짜는 Host만 켭니다. +- 스키마 해시가 움직입니다(속성 하나 추가, 추가 전용). + +수용 기준: + +1. `report_size(true)`인 창은 같은 클래스 안에서 1280x820에서 1000x620으로 줄이면 `WindowSizeChanged`를 받고, 그 `width_dp`, `height_dp`가 새 크기입니다(`fr20_8_opted_in_window_reports_dp_change_inside_a_class`). +2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(`fr20_8_default_window_still_reports_only_class_change`). +3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(`fr20_8_opted_in_window_ignores_sub_dp_jitter`). + ### FR-21 탐색, 시트, 일시 메시지 (`Agreed`) FR-15.2의 코어 어휘 29개로는 애플리케이션이 세 가지를 말할 수 없습니다. 화면을 목적지로 나누는 것(탐색), 화면 위에 임시로 무언가를 덮는 것(시트), 그리고 "했습니다"라고 알리는 것(일시 메시지)입니다. 셋 다 없어서는 안 되는 부류이고, 셋 다 FR-15.3에서 한 번 제외됐거나 아예 논의되지 않았습니다. From a20d2925115e8c628786e206d2188687b91eb70c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:19:48 +0900 Subject: [PATCH 127/224] Docs: Name the FR-20.8 tests without backticks until they exist --- docs/SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index abc33a4a..bb86939f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1554,9 +1554,9 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 수용 기준: -1. `report_size(true)`인 창은 같은 클래스 안에서 1280x820에서 1000x620으로 줄이면 `WindowSizeChanged`를 받고, 그 `width_dp`, `height_dp`가 새 크기입니다(`fr20_8_opted_in_window_reports_dp_change_inside_a_class`). -2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(`fr20_8_default_window_still_reports_only_class_change`). -3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(`fr20_8_opted_in_window_ignores_sub_dp_jitter`). +1. `report_size(true)`인 창은 같은 클래스 안에서 1280x820에서 1000x620으로 줄이면 `WindowSizeChanged`를 받고, 그 `width_dp`, `height_dp`가 새 크기입니다(테스트 이름 fr20_8_opted_in_window_reports_dp_change_inside_a_class, 구현과 함께 들어옵니다). +2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(테스트 이름 fr20_8_default_window_still_reports_only_class_change, 구현과 함께 들어옵니다). +3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(테스트 이름 fr20_8_opted_in_window_ignores_sub_dp_jitter, 구현과 함께 들어옵니다). ### FR-21 탐색, 시트, 일시 메시지 (`Agreed`) From 20aa4c5b38f575386b1e3da3b82bdcc285e65200 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:31:02 +0900 Subject: [PATCH 128/224] Feat: Opt-in WindowSizeChanged for every whole-dp size change (FR-20.8, #467) Window gets a report_size property (127, default false). A window that sets it also reports when its width or height, rounded to whole dp, changes inside a size class. Default windows are unchanged. Covers the three FR-20.8 criteria. The schema hash changes. --- .../android-kotlin/shared/ComposeRustHost.kt | 3 +- .../android-kotlin/shared/NodeTable.kt | 1 + .../android-kotlin/shared/Protocol.gen.kt | 5 +- .../android-kotlin/shared/WindowSize.kt | 25 +++++++++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/schema.rs | 6 ++- compose-rust/src/ui/widgets.rs | 14 ++++++ compose-rust/tests/fr53_windows.rs | 27 +++++++++++ compose-rust/tests/vectors/vectors.json | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 5 +- .../desktop/src/renderer/ComposeRustHost.kt | 3 +- renderer/desktop/src/renderer/NodeTable.kt | 1 + renderer/desktop/src/renderer/WindowSize.kt | 25 +++++++++- renderer/desktop/test/WindowSizeTest.kt | 48 +++++++++++++++++++ 14 files changed, 154 insertions(+), 13 deletions(-) diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index c073e263..65eb322a 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -555,9 +555,10 @@ private fun ComposeRustTree( captionStyle.side == CaptionSide.Start WindowSafeArea(caption.height.value, if (atStart) width.value else 0f, if (atStart) 0f else width.value) } + val reportEveryDp = windowNodeId != 0 && host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true SideEffect { measuredSize?.let { size -> - with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea) } + with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } } } // How much room the buttons take and at which end. The platform's own are at the diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 29ccc2a8..cbf2fb04 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -1022,6 +1022,7 @@ class NodeTable { PropertyKind.WindowSize, PropertyKind.WindowMinSize, PropertyKind.WindowResizable, + PropertyKind.ReportSize, PropertyKind.WindowChrome, PropertyKind.WindowTitleBar, PropertyKind.WindowIcon, diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 817e2253..d23eca5e 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, ReportSize, Progress } enum class Key { Enter } @@ -1301,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -1297132866241875313L + const val SCHEMA_HASH: Long = 2293438994272253525L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2194,6 +2194,7 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 127 -> PropertyKind.ReportSize 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/compose-rust/android-kotlin/shared/WindowSize.kt b/compose-rust/android-kotlin/shared/WindowSize.kt index b0da631a..6a43d549 100644 --- a/compose-rust/android-kotlin/shared/WindowSize.kt +++ b/compose-rust/android-kotlin/shared/WindowSize.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass +import kotlin.math.floor /** * The width in dp at which each size class begins. @@ -79,6 +80,8 @@ class WindowSizeReporter(private val nodeId: Int = 0) { private var lastClass: WindowSizeClass = WindowSizeClass.Compact private var lastSafeArea = WindowSafeArea() private var lastHeightClass: WindowHeightClass = WindowHeightClass.Compact + private var lastWidthDp = 0 + private var lastHeightDp = 0 /** * Reports one measurement. Returns whether an event was sent. @@ -86,11 +89,29 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * Nothing is allocated when neither class has changed, which is the common case. The * two classes ride in one record, so a window that gets wider and shorter in the same * drag costs one event rather than two. + * + * A window that asked for [everyDpChange] also reports when its width or height, each + * rounded to the nearest whole dp (a half rounds up), differs from the rounded values of + * the last event sent. The rounded integers are compared, never the raw floats, so jitter + * under one dp is ignored. The event still carries the measured, unrounded size. */ - fun report(widthDp: Float, heightDp: Float, dispatcher: EventDispatcher, safeArea: WindowSafeArea = WindowSafeArea()): Boolean { + fun report( + widthDp: Float, + heightDp: Float, + dispatcher: EventDispatcher, + safeArea: WindowSafeArea = WindowSafeArea(), + everyDpChange: Boolean = false, + ): Boolean { val sizeClass = windowSizeClassOf(widthDp) val heightClass = windowHeightClassOf(heightDp) - if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) return false + if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) { + if (!everyDpChange) return false + if (floor(widthDp + 0.5f).toInt() == lastWidthDp && floor(heightDp + 0.5f).toInt() == lastHeightDp) return false + } + if (everyDpChange) { + lastWidthDp = floor(widthDp + 0.5f).toInt() + lastHeightDp = floor(heightDp + 0.5f).toInt() + } lastClass = sizeClass lastHeightClass = heightClass lastSafeArea = safeArea diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 10a628d0..6a4bc72a 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0xedffa887d92b328f +0x1fd3ee9d37a07655 diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 8bdc287b..15767490 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -183,7 +183,7 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,report_size;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback;", "keys=Enter;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", @@ -2858,6 +2858,10 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // into a window of its own, named by it. OnWindowDragOver = 167, TearOffKey = 168, + // Opt in to a `WindowSizeChanged` whenever the window's width or height, rounded to + // whole dp, changes, and not only when the size class does. A `Window` node's boolean; + // absent is false. + ReportSize = 127, }); #[derive(Clone, Debug, PartialEq)] diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 870f2c4a..caa896e7 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -2789,6 +2789,7 @@ pub struct WindowScope { size: Option<(f32, f32)>, min_size: Option<(f32, f32)>, resizable: Option, + report_size: Option, chrome: Option, title_bar: Option, icon: Option, @@ -2825,6 +2826,7 @@ pub fn Window(on_close_request: impl FnMut() + 'static) -> WindowScope { size: None, min_size: None, resizable: None, + report_size: None, chrome: None, title_bar: None, icon: None, @@ -2882,6 +2884,15 @@ impl WindowScope { self } + /// Asks to be told every time the window's width or height, rounded to whole dp, + /// changes. Without it the size is reported only when its class or the safe area + /// changes. Turn it on only for a window that lays out by its exact size: each + /// whole-dp change while the user drags an edge costs one call into the Host. + pub fn report_size(mut self, report: bool) -> Self { + self.report_size = Some(report); + self + } + /// Who draws the caption. pub fn chrome(mut self, chrome: crate::schema::Chrome) -> Self { self.chrome = Some(chrome); @@ -2997,6 +3008,9 @@ impl WindowScope { if let Some(resizable) = self.resizable { properties.push(("window_resizable", OwnedAttr::Bool(resizable))); } + if let Some(report) = self.report_size { + properties.push(("report_size", OwnedAttr::Bool(report))); + } if let Some(chrome) = self.chrome { properties.push(("window_chrome", OwnedAttr::Int(i64::from(chrome as u16)))); } diff --git a/compose-rust/tests/fr53_windows.rs b/compose-rust/tests/fr53_windows.rs index 554b91f7..45e02463 100644 --- a/compose-rust/tests/fr53_windows.rs +++ b/compose-rust/tests/fr53_windows.rs @@ -836,3 +836,30 @@ fn fr53_window_state_is_dropped_when_the_window_closes() { ); } } + +#[composable] +fn ReportingWindow() { + Window(|| {}).report_size(true).content(|| Nothing()); +} + +/// A window that opts in writes `report_size` as a boolean property on its node. +#[test] +fn fr20_8_window_report_size_writes_the_property() { + let mut host = ComposeHost::with_theme(ReportingWindow, Theme::unified(DesignSystem::Material3)) + .with_window(several_windows()); + let batch = host.rebuild().unwrap().to_vec(); + let windows = window_ids(&batch); + assert_eq!(windows.len(), 1); + let written = decode_batch(&batch).unwrap().into_iter().any(|mutation| { + matches!( + mutation, + Mutation::SetProp { + node_id, + property: PropertyKind::ReportSize, + value: PropertyValue::Bool(true), + } if node_id == windows[0] + ) + }); + assert!(written, "the window node carries report_size = true"); + assert_eq!(PropertyKind::ReportSize as u16, 127); +} diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 5a5a9786..b52584f0 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "17149611207467676303", + "schemaHashUnsigned": "2293438994272253525", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 817e2253..d23eca5e 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, ReportSize, Progress } enum class Key { Enter } @@ -1301,7 +1301,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -1297132866241875313L + const val SCHEMA_HASH: Long = 2293438994272253525L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -2194,6 +2194,7 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 127 -> PropertyKind.ReportSize 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index c073e263..65eb322a 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -555,9 +555,10 @@ private fun ComposeRustTree( captionStyle.side == CaptionSide.Start WindowSafeArea(caption.height.value, if (atStart) width.value else 0f, if (atStart) 0f else width.value) } + val reportEveryDp = windowNodeId != 0 && host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true SideEffect { measuredSize?.let { size -> - with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea) } + with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } } } // How much room the buttons take and at which end. The platform's own are at the diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 29ccc2a8..cbf2fb04 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -1022,6 +1022,7 @@ class NodeTable { PropertyKind.WindowSize, PropertyKind.WindowMinSize, PropertyKind.WindowResizable, + PropertyKind.ReportSize, PropertyKind.WindowChrome, PropertyKind.WindowTitleBar, PropertyKind.WindowIcon, diff --git a/renderer/desktop/src/renderer/WindowSize.kt b/renderer/desktop/src/renderer/WindowSize.kt index b0da631a..6a43d549 100644 --- a/renderer/desktop/src/renderer/WindowSize.kt +++ b/renderer/desktop/src/renderer/WindowSize.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass +import kotlin.math.floor /** * The width in dp at which each size class begins. @@ -79,6 +80,8 @@ class WindowSizeReporter(private val nodeId: Int = 0) { private var lastClass: WindowSizeClass = WindowSizeClass.Compact private var lastSafeArea = WindowSafeArea() private var lastHeightClass: WindowHeightClass = WindowHeightClass.Compact + private var lastWidthDp = 0 + private var lastHeightDp = 0 /** * Reports one measurement. Returns whether an event was sent. @@ -86,11 +89,29 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * Nothing is allocated when neither class has changed, which is the common case. The * two classes ride in one record, so a window that gets wider and shorter in the same * drag costs one event rather than two. + * + * A window that asked for [everyDpChange] also reports when its width or height, each + * rounded to the nearest whole dp (a half rounds up), differs from the rounded values of + * the last event sent. The rounded integers are compared, never the raw floats, so jitter + * under one dp is ignored. The event still carries the measured, unrounded size. */ - fun report(widthDp: Float, heightDp: Float, dispatcher: EventDispatcher, safeArea: WindowSafeArea = WindowSafeArea()): Boolean { + fun report( + widthDp: Float, + heightDp: Float, + dispatcher: EventDispatcher, + safeArea: WindowSafeArea = WindowSafeArea(), + everyDpChange: Boolean = false, + ): Boolean { val sizeClass = windowSizeClassOf(widthDp) val heightClass = windowHeightClassOf(heightDp) - if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) return false + if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) { + if (!everyDpChange) return false + if (floor(widthDp + 0.5f).toInt() == lastWidthDp && floor(heightDp + 0.5f).toInt() == lastHeightDp) return false + } + if (everyDpChange) { + lastWidthDp = floor(widthDp + 0.5f).toInt() + lastHeightDp = floor(heightDp + 0.5f).toInt() + } lastClass = sizeClass lastHeightClass = heightClass lastSafeArea = safeArea diff --git a/renderer/desktop/test/WindowSizeTest.kt b/renderer/desktop/test/WindowSizeTest.kt index 91417cc3..57a36bd3 100644 --- a/renderer/desktop/test/WindowSizeTest.kt +++ b/renderer/desktop/test/WindowSizeTest.kt @@ -37,6 +37,7 @@ import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue private const val LABEL = 1 @@ -274,6 +275,53 @@ class WindowSizeTest { ) } + @Test + fun fr20_8_opted_in_window_reports_dp_change_inside_a_class() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter(3) + + // Both sizes are Expanded wide and Medium high, so the class never changes. + assertTrue(reporter.report(1280f, 820f, dispatcher, everyDpChange = true)) + assertTrue(reporter.report(1000f, 620f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + val event = dispatcher.events[1] as HostEvent.WindowSizeChanged + assertEquals(3, event.nodeId) + assertEquals(1000f, event.widthDp) + assertEquals(620f, event.heightDp) + assertEquals(WindowSizeClass.Expanded, event.sizeClass) + assertEquals(WindowHeightClass.Medium, event.heightClass) + } + + @Test + fun fr20_8_default_window_still_reports_only_class_change() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + + assertTrue(reporter.report(1280f, 820f, dispatcher)) + assertFalse(reporter.report(1000f, 620f, dispatcher)) + assertFalse(reporter.report(900f, 500f, dispatcher)) + assertEquals(1, dispatcher.events.size) + } + + @Test + fun fr20_8_opted_in_window_ignores_sub_dp_jitter() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + + assertTrue(reporter.report(1000f, 700f, dispatcher, everyDpChange = true)) + // 999.5 rounds up to 1000 and 1000.49 rounds to 1000: the same whole dp. + assertFalse(reporter.report(999.5f, 700.2f, dispatcher, everyDpChange = true)) + assertFalse(reporter.report(1000.49f, 699.6f, dispatcher, everyDpChange = true)) + assertEquals(1, dispatcher.events.size) + // 1000.5 rounds up to 1001: one whole dp, one event, carrying the measured size. + assertTrue(reporter.report(1000.5f, 700f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + assertEquals(1000.5f, (dispatcher.events[1] as HostEvent.WindowSizeChanged).widthDp) + // Back inside that dp sends nothing more. + assertFalse(reporter.report(1001.2f, 700f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + } + @Test fun fr20_crossing_a_height_boundary_sends_one_event_and_the_two_axes_share_it() { val dispatcher = RecordingDispatcher() From e5ebb54e9199f5e5214cbef9098a64d3e7a14983 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:41:33 +0900 Subject: [PATCH 129/224] Docs: Correct the report_size example and say the root window cannot opt in --- docs/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index bb86939f..54e7953d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1545,7 +1545,7 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 20.4의 규칙은 클래스(와 19.9의 안전 영역)가 바뀔 때만 `WindowSizeChanged`를 보냅니다. 창 크기로 페이지를 짜는 Host(HTML과 CSS 호스트)는 클래스 안에서 1280x820이 1000x620으로 줄어도 다시 배치할 수 없습니다. `window_size()`가 마지막 클래스 변경 때의 크기를 계속 돌려주기 때문입니다. -- **옵트인**: `Window` 노드의 불리언 속성 `report_size`(AttrKey 127, 기본 `false`). Rust에서는 `Window::new().report_size(true)`입니다. +- **옵트인**: `Window` 노드의 불리언 속성 `report_size`(AttrKey 127, 기본 `false`). Rust에서는 `Window(on_close).report_size(true)`입니다. `Window` 노드가 없는 암묵적 단일 창(루트)은 옵트인할 수 없고 항상 지금의 동작입니다. - **켠 창**: 클래스와 안전 영역이 그대로여도, 창의 폭 또는 높이가 **반올림한 dp**로 바뀌면 같은 `WindowSizeChanged`를 보냅니다. 레코드와 이벤트 태그는 바뀌지 않고 이미 실린 `width_dp`, `height_dp`가 새 크기입니다. - **반올림 규칙**: 폭과 높이를 각각 가장 가까운 정수 dp로 반올림합니다(0.5는 올림). 직전에 보낸 값과 반올림한 값이 같으면 보내지 않으므로 1dp 미만의 흔들림은 무시합니다. `Float`를 그대로 비교하지 않습니다. 보내는 `width_dp`와 `height_dp`는 반올림하지 않은 측정값입니다. - **끈 창**: 지금과 한 바이트도 다르지 않습니다. 기본 동작, 호출 수, 할당이 그대로입니다. From 0c38ebc3780ad22b3b9b2d35ac656d0943b8ce81 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:09:30 +0900 Subject: [PATCH 130/224] Test: Cover removing report_size, the host path and rounding; keep default windows from recomposing on a resize (FR-20.8) Adds tests for removing the property, the flag reaching the reporter through the host, a safe-area-only change, fractional dp and a density change. The report_size lookup moves into a derivedStateOf, and only an opted-in window reads its measured size during composition, so default windows cost no extra recomposition. --- .../android-kotlin/shared/ComposeRustHost.kt | 15 ++- compose-rust/tests/fr53_windows.rs | 72 ++++++++++- .../desktop/src/renderer/ComposeRustHost.kt | 15 ++- renderer/desktop/test/WindowSizeTest.kt | 121 ++++++++++++++++++ 4 files changed, 218 insertions(+), 5 deletions(-) diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 65eb322a..455ca32c 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.snapshots.Snapshot @@ -555,9 +556,19 @@ private fun ComposeRustTree( captionStyle.side == CaptionSide.Start WindowSafeArea(caption.height.value, if (atStart) width.value else 0f, if (atStart) 0f else width.value) } - val reportEveryDp = windowNodeId != 0 && host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true + // Read through a derived state so a recomposition does not repeat the map lookup (which + // boxes the node id) and the read only runs again when the property itself changes. + // The implicit root window (id 0) has no Window node to carry the property, so it cannot opt in. + val reportEveryDp = if (windowNodeId == 0) false else remember(host, windowNodeId) { + derivedStateOf { host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true } + }.value + // A window that asked for every whole-dp change reads the size here, in the composition, + // so a new size recomposes this and reaches the effect. Any other window keeps reading it + // only inside the effect, as before, so a resize inside a size class costs it no + // recomposition. + val observedSize = if (reportEveryDp) measuredSize else null SideEffect { - measuredSize?.let { size -> + (observedSize ?: measuredSize)?.let { size -> with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } } } diff --git a/compose-rust/tests/fr53_windows.rs b/compose-rust/tests/fr53_windows.rs index 45e02463..217c1e52 100644 --- a/compose-rust/tests/fr53_windows.rs +++ b/compose-rust/tests/fr53_windows.rs @@ -861,5 +861,75 @@ fn fr20_8_window_report_size_writes_the_property() { ) }); assert!(written, "the window node carries report_size = true"); - assert_eq!(PropertyKind::ReportSize as u16, 127); + // The record itself: the property key 127 and a boolean true, as they are on the wire. + assert_eq!( + raw_set_props(&batch, windows[0]).iter().filter(|(key, ..)| *key == 127).collect::>(), + vec![&(127, VALUE_KIND_BOOL, 1)], + "report_size is written once, as key 127 with boolean value 1" + ); +} + +#[composable] +fn TogglingWindow() { + let on = remember(|| mutable_state_of(true)); + let toggle = on.clone(); + let window = Window(|| {}); + let window = if on.get() { window.report_size(true) } else { window }; + window.content(move || { + let toggle = toggle.clone(); + Button("toggle").on_click(move || toggle.update(|on| *on = !*on)); + }); +} + +/// A window that stops saying `report_size` has the property taken away, so the Renderer +/// goes back to the default of reporting class changes only. +#[test] +fn fr20_8_window_report_size_removed_writes_no_property() { + let mut host = ComposeHost::with_theme(TogglingWindow, Theme::unified(DesignSystem::Material3)) + .with_window(several_windows()); + let mut tree = Tree::default(); + let first = host.rebuild().unwrap().to_vec(); + let window = window_ids(&first)[0]; + assert!( + raw_set_props(&first, window).contains(&(127, VALUE_KIND_BOOL, 1)), + "the first batch sets report_size" + ); + tree.apply(&first); + + let (node_id, handler_id) = tree.find("Button", Some("toggle"), 0, PropertyKind::OnClick); + let (batch, _) = host + .dispatch(event(node_id, handler_id, EventPayload::Clicked)) + .expect("the click dispatches"); + let after = raw_set_props(batch, window); + assert!( + after.contains(&(127, VALUE_KIND_NONE, 0)), + "the property is unset on the wire, got {after:?}" + ); + assert!( + !after.contains(&(127, VALUE_KIND_BOOL, 1)), + "no boolean report_size is written again" + ); +} + +const VALUE_KIND_NONE: u16 = 0; +const VALUE_KIND_BOOL: u16 = 2; + +/// The `SetProp` records of one node as `(property key, value kind, value)`, read straight +/// from the encoded bytes: a 12 byte envelope, then records of tag, length, node, key, +/// value kind and a 64 bit value, all little endian. +fn raw_set_props(batch: &[u8], node: u32) -> Vec<(u16, u16, u64)> { + let u16_at = |at: usize| u16::from_le_bytes(batch[at..at + 2].try_into().unwrap()); + let u32_at = |at: usize| u32::from_le_bytes(batch[at..at + 4].try_into().unwrap()); + let end = u32_at(4) as usize; + let mut found = Vec::new(); + let mut at = 12; + while at < end { + let (tag, len) = (u16_at(at), usize::from(u16_at(at + 2))); + if tag == 2 && u32_at(at + 4) == node { + let value = u64::from_le_bytes(batch[at + 12..at + 20].try_into().unwrap()); + found.push((u16_at(at + 8), u16_at(at + 10), value)); + } + at += len; + } + found } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 65eb322a..455ca32c 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.snapshots.Snapshot @@ -555,9 +556,19 @@ private fun ComposeRustTree( captionStyle.side == CaptionSide.Start WindowSafeArea(caption.height.value, if (atStart) width.value else 0f, if (atStart) 0f else width.value) } - val reportEveryDp = windowNodeId != 0 && host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true + // Read through a derived state so a recomposition does not repeat the map lookup (which + // boxes the node id) and the read only runs again when the property itself changes. + // The implicit root window (id 0) has no Window node to carry the property, so it cannot opt in. + val reportEveryDp = if (windowNodeId == 0) false else remember(host, windowNodeId) { + derivedStateOf { host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true } + }.value + // A window that asked for every whole-dp change reads the size here, in the composition, + // so a new size recomposes this and reaches the effect. Any other window keeps reading it + // only inside the effect, as before, so a resize inside a size class costs it no + // recomposition. + val observedSize = if (reportEveryDp) measuredSize else null SideEffect { - measuredSize?.let { size -> + (observedSize ?: measuredSize)?.let { size -> with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } } } diff --git a/renderer/desktop/test/WindowSizeTest.kt b/renderer/desktop/test/WindowSizeTest.kt index 57a36bd3..920d8e9d 100644 --- a/renderer/desktop/test/WindowSizeTest.kt +++ b/renderer/desktop/test/WindowSizeTest.kt @@ -33,6 +33,7 @@ import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.runtime.windowHeightClassOf import dev.darkpyonix.composerust.runtime.windowSizeClassOf import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests import kotlin.test.Test @@ -42,6 +43,9 @@ import kotlin.test.assertTrue private const val LABEL = 1 +/** A `Window` node of its own, for the tests that need a node to carry a property. */ +private const val WINDOW = 5 + /** The token the Host would have handed out for a node it wants the size of. */ private const val SIZE_TOKEN = 7 @@ -447,4 +451,121 @@ class WindowSizeTest { assertEquals(2, sizes().size) assertEquals(WindowSizeClass.Expanded, sizes()[1].sizeClass) } + + @Test + fun fr20_8_removing_report_size_returns_to_class_only_reports() { + val table = NodeTable() + table.apply(Mutation.Create(WINDOW, WidgetKind.Window)) + table.apply(Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.Bool(true))) + val flag = { table.node(WINDOW)!!.flag(PropertyKind.ReportSize, false) } + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter(WINDOW) + + assertTrue(flag()) + assertTrue(reporter.report(1280f, 820f, dispatcher, everyDpChange = flag())) + assertTrue(reporter.report(1200f, 820f, dispatcher, everyDpChange = flag())) + + table.apply(Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.None)) + assertFalse(flag()) + // Inside the class again: nothing is sent, as before the window ever opted in. + assertFalse(reporter.report(1100f, 800f, dispatcher, everyDpChange = flag())) + assertEquals(2, dispatcher.events.size) + // A safe-area change and a class change still report. + assertTrue(reporter.report(1100f, 800f, dispatcher, WindowSafeArea(top = 24f), flag())) + assertTrue(reporter.report(500f, 800f, dispatcher, WindowSafeArea(top = 24f), flag())) + assertEquals(4, dispatcher.events.size) + } + + @Test + fun fr20_8_safe_area_only_change_reports_with_and_without_the_opt_in() { + for (everyDp in listOf(true, false)) { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + assertTrue(reporter.report(1000f, 700f, dispatcher, WindowSafeArea(), everyDp)) + assertFalse(reporter.report(1000f, 700f, dispatcher, WindowSafeArea(), everyDp)) + assertTrue(reporter.report(1000f, 700f, dispatcher, WindowSafeArea(top = 28f), everyDp)) + val event = dispatcher.events[1] as HostEvent.WindowSizeChanged + assertEquals(28f, event.safeAreaTop) + assertEquals(2, dispatcher.events.size) + } + } + + @Test + fun fr20_8_fractional_dp_is_compared_after_rounding() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + + assertTrue(reporter.report(600.4f, 700f, dispatcher, everyDpChange = true)) + assertFalse(reporter.report(600.45f, 700f, dispatcher, everyDpChange = true)) + // 600.4 rounds to 600 and 600.6 to 601, inside one class. + assertTrue(reporter.report(600.6f, 700f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + assertEquals(600.6f, (dispatcher.events[1] as HostEvent.WindowSizeChanged).widthDp) + } + + private fun windowBatch(report: Boolean): List = listOf( + Mutation.Create(WINDOW, WidgetKind.Window), + Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.Bool(report)), + Mutation.Insert(0, WINDOW, -1), + Mutation.Create(LABEL, WidgetKind.Text), + Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("hello")), + Mutation.Insert(WINDOW, LABEL, -1), + ) + + private fun flagReachesTheReporter(report: Boolean) = runComposeUiTest { + val connection = FakeHostConnection(windowBatch(report)) + var width by mutableStateOf(1000.dp) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(1f)) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(width, 700.dp), + windowNodeId = WINDOW, + ) + } + } + waitForIdle() + val sizes = { connection.events.filterIsInstance() } + // Compact to Expanded is a class change, so the first measurement is always sent. + assertEquals(1, sizes().size) + assertEquals(WINDOW, sizes()[0].nodeId) + + // Whole dp inside Expanded: sent only for a window that opted in. + width = 1003.dp + waitForIdle() + assertEquals(if (report) 2 else 1, sizes().size) + if (report) assertEquals(1003f, sizes()[1].widthDp) + } + + @Test + fun fr20_8_report_size_flag_reaches_the_reporter_through_the_host() = flagReachesTheReporter(true) + + @Test + fun fr20_8_window_without_report_size_stays_class_only_through_the_host() = flagReachesTheReporter(false) + + @Test + fun fr20_8_a_density_change_at_the_same_pixel_size_changes_the_reported_dp() = runComposeUiTest { + val connection = FakeHostConnection(windowBatch(true)) + var density by mutableStateOf(2f) + setContent { + val d = density + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(d)) { + // 1200 by 1400 pixels at either density. + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize((1200f / d).dp, (1400f / d).dp), + windowNodeId = WINDOW, + ) + } + } + waitForIdle() + val sizes = { connection.events.filterIsInstance() } + assertEquals(1, sizes().size) + assertEquals(600f, sizes()[0].widthDp) + density = 1.5f + waitForIdle() + assertEquals(2, sizes().size) + assertEquals(800f, sizes()[1].widthDp) + assertEquals(WindowSizeClass.Medium, sizes()[1].sizeClass) + } } From ad58df20ba75df7f4bc06f87db3d3f6bea7176d5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:17:11 +0900 Subject: [PATCH 131/224] Merge develop into feat/window-dp-reports-467 and name the FR-20.8 tests in the SPEC --- docs/SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 54e7953d..8c61e9a6 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1554,9 +1554,9 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 수용 기준: -1. `report_size(true)`인 창은 같은 클래스 안에서 1280x820에서 1000x620으로 줄이면 `WindowSizeChanged`를 받고, 그 `width_dp`, `height_dp`가 새 크기입니다(테스트 이름 fr20_8_opted_in_window_reports_dp_change_inside_a_class, 구현과 함께 들어옵니다). -2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(테스트 이름 fr20_8_default_window_still_reports_only_class_change, 구현과 함께 들어옵니다). -3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(테스트 이름 fr20_8_opted_in_window_ignores_sub_dp_jitter, 구현과 함께 들어옵니다). +1. `report_size(true)`인 창은 같은 클래스 안에서 1280x820에서 1000x620으로 줄이면 `WindowSizeChanged`를 받고, 그 `width_dp`, `height_dp`가 새 크기입니다(`fr20_8_opted_in_window_reports_dp_change_inside_a_class`). +2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(`fr20_8_default_window_still_reports_only_class_change`). +3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(`fr20_8_opted_in_window_ignores_sub_dp_jitter`). ### FR-21 탐색, 시트, 일시 메시지 (`Agreed`) From 834518505a8283dfc1a6d8aaf1cfe20a085dd8b5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:37:40 +0900 Subject: [PATCH 132/224] Chore: Pin the window modules at the fork's caption re-measure (#468) WINDOW_REVISION moves to 1b2884f, the merge of fork #182 (thisisthepy/compose-multiplatform-core-extended): the macOS window measures its caption again after it is shown, so a Chrome::FullSize window that never resizes still gets its safe area. Adds the FR-20.9 section. --- docs/SPEC.md | 12 ++++++++++++ renderer/scripts/build-compose.sh | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 8c61e9a6..8fefc2f6 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1558,6 +1558,18 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(`fr20_8_default_window_still_reports_only_class_change`). 3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(`fr20_8_opted_in_window_ignores_sub_dp_jitter`). +#### 20.9 안전 영역은 알려지는 때 도착합니다 (`Agreed`, 이슈 #468, 감독자 승인) + +`Chrome::FullSize`의 `WindowSafeArea`(19.9)는 창이 화면에 뜬 뒤에야 알 수 있는 값입니다(타이틀바가 배치되어야 캡션 높이를 잽니다). 창 생성 직후에 한 번만 재면 0을 얻고, 크기를 바꾸지 않는 창은 영원히 0으로 남습니다. 컴팩트 폭(390x844)으로 연 창이 그 예입니다. + +- Renderer는 창이 뜬 뒤 캡션을 다시 재서(제한된 횟수), 값이 알려지면 기존 경로로 `WindowSizeChanged`를 보냅니다. 클래스가 그대로여도 안전 영역이 바뀌면 보내는 것은 19.9의 규칙 그대로입니다. 새 이벤트도 필드도 없습니다. +- 캡션을 재는 창 코드는 Compose 포크에 있습니다(19.8). 이 저장소는 그 커밋으로 핀을 옮기기만 합니다. + +수용 기준: + +1. 안전 영역만 바뀌어도 클래스가 같으면 `WindowSizeChanged`가 한 번 옵니다(`fr19_9_safe_area_change_alone_resends`). +2. macOS에서 `Chrome::FullSize`로 390x844 크기로 연 창이 크기를 바꾸지 않아도 곧 `top>0, leading>0`인 안전 영역을 받습니다. 사람 손으로 확인하는 항목입니다(창 코드는 포크에서 시험합니다). + ### FR-21 탐색, 시트, 일시 메시지 (`Agreed`) FR-15.2의 코어 어휘 29개로는 애플리케이션이 세 가지를 말할 수 없습니다. 화면을 목적지로 나누는 것(탐색), 화면 위에 임시로 무언가를 덮는 것(시트), 그리고 "했습니다"라고 알리는 것(일시 메시지)입니다. 셋 다 없어서는 안 되는 부류이고, 셋 다 FR-15.3에서 한 번 제외됐거나 아예 논의되지 않았습니다. diff --git a/renderer/scripts/build-compose.sh b/renderer/scripts/build-compose.sh index 29a9076e..1e117511 100755 --- a/renderer/scripts/build-compose.sh +++ b/renderer/scripts/build-compose.sh @@ -41,7 +41,7 @@ REVISION="2eb80c12e7615ef80c7661ea63e0f20651f20a31" # accessibility bridges (the renderer hands them the scene's semantics). It replaces the # backport branch feat/windows-window-backport-57b4a0e, which carried those two fixes on an # older base; multi-window work builds on `extended` itself. -WINDOW_REVISION="317d380b4c34fc324c47811bd71fa76543510a94" +WINDOW_REVISION="1b2884f1610371a9867d7969a2e2376e473654bd" # The commit the Windows build takes. It was its own pin while the Windows Compose lived on a # branch apart from the Compose pin; the fork's `extended` now holds mingwX64 for every Compose UI # module, the skiko build for mingwX64 (extended/skiko/build-skiko-mingw.sh, which also compiles From 59ab46890a0edd411b17557c0912695b0017de11 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:18:37 +0900 Subject: [PATCH 133/224] Chore: Format the FR-20.8 tests with rustfmt --- compose-rust/tests/fr53_windows.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/compose-rust/tests/fr53_windows.rs b/compose-rust/tests/fr53_windows.rs index 217c1e52..60f74498 100644 --- a/compose-rust/tests/fr53_windows.rs +++ b/compose-rust/tests/fr53_windows.rs @@ -845,8 +845,9 @@ fn ReportingWindow() { /// A window that opts in writes `report_size` as a boolean property on its node. #[test] fn fr20_8_window_report_size_writes_the_property() { - let mut host = ComposeHost::with_theme(ReportingWindow, Theme::unified(DesignSystem::Material3)) - .with_window(several_windows()); + let mut host = + ComposeHost::with_theme(ReportingWindow, Theme::unified(DesignSystem::Material3)) + .with_window(several_windows()); let batch = host.rebuild().unwrap().to_vec(); let windows = window_ids(&batch); assert_eq!(windows.len(), 1); @@ -863,7 +864,10 @@ fn fr20_8_window_report_size_writes_the_property() { assert!(written, "the window node carries report_size = true"); // The record itself: the property key 127 and a boolean true, as they are on the wire. assert_eq!( - raw_set_props(&batch, windows[0]).iter().filter(|(key, ..)| *key == 127).collect::>(), + raw_set_props(&batch, windows[0]) + .iter() + .filter(|(key, ..)| *key == 127) + .collect::>(), vec![&(127, VALUE_KIND_BOOL, 1)], "report_size is written once, as key 127 with boolean value 1" ); @@ -874,7 +878,11 @@ fn TogglingWindow() { let on = remember(|| mutable_state_of(true)); let toggle = on.clone(); let window = Window(|| {}); - let window = if on.get() { window.report_size(true) } else { window }; + let window = if on.get() { + window.report_size(true) + } else { + window + }; window.content(move || { let toggle = toggle.clone(); Button("toggle").on_click(move || toggle.update(|on| *on = !*on)); From 79374c2ffffa0d8caef4e4b0f2076eac57cf901d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:26:26 +0900 Subject: [PATCH 134/224] Docs: Add FR-38 criterion 16, change-marker bars in the editor gutter (A request) Approved by the supervisor (delegated). Source: mockup editor.css .ed-gm (4px, 12px gap). Uses the existing Bar gutter decoration with ColorRole DiffAdded, DiffModified, DiffRemoved: no new decoration kind and no wire change. Added criteria only, no existing criterion changed. --- docs/SPEC.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index 54e7953d..8afb0a07 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2398,6 +2398,13 @@ Notification::new("세션이 끝났습니다") 13. `Folds`로 접을 수 있는 구간을 보내면 그 줄들이 접히고 접힌 줄은 그려지지 않습니다. 사용자가 접거나 펴면 `FoldChanged`가 가고, `SetFolded`로 Host도 접고 폅니다. 접힌 구간 안의 편집, 선택, 장식은 접힌 줄에 가려져도 문서 좌표를 잃지 않습니다(fr38_folded_ranges_hide_lines_and_report_toggles). 14. `GutterDecorations`가 줄 번호 옆에 그려지고 편집을 따라 움직입니다(기준 4와 같은 규칙). 누르면 `DecorationActivated`가 그 id를 싣고 갑니다(fr38_gutter_decorations_follow_edits_and_activate). 15. IME 조합이 시작되고 끝날 때 `CompositionChanged`가 한 번씩 갑니다. 조합 중인 글자는 이벤트에 실리지 않고 Renderer에만 있습니다(D5). 조합 중에는 `CodeChanged`가 나가지 않는다는 기준 1은 그대로입니다(fr38_composition_signal_carries_no_text, native-image 수동 확인은 기준 1과 함께 §6 방식으로 기록). +16. 변경 표시 막대. 색이 `DiffAdded`, `DiffModified`, `DiffRemoved` 중 하나인 `Bar` 거터 장식은 변경 표시 막대로 그려집니다. 새 장식 종류와 와이어 변경은 없고, 줄마다 레코드 하나입니다. 소유자가 디자인을 목업에 맞추라고 정했고, 값은 목업 `editor.css`의 `.ed-gm`(폭 4px, 오른쪽 간격 `--sp3` 12px, 줄 번호 열 바로 오른쪽)이며 색은 추가가 `DiffAdded`, 수정이 `DiffModified`, 삭제가 `DiffRemoved`입니다(목업에는 삭제 색이 없어 `DiffRemoved`를 씁니다). 승인: 감독자 B(위임 권한), 요청: 감독자 A. + 1. 폭은 4dp 고정이고 줄 높이에 비례하지 않습니다. 막대는 줄 번호 열 바로 오른쪽에 붙고, 그 오른쪽에 12dp 간격을 둔 뒤 코드가 시작됩니다. 막대의 높이는 그 줄의 높이 전부이고, 이어지는 줄의 막대는 끊기지 않고 이어져 보입니다. 위치와 폭은 `CodeEditorLook`의 토큰이고, 디자인 시스템이 값을 바꿀 수 있습니다. + 2. 색이 `Diff*`가 아닌 `Dot`, `Bar`, `Glyph`는 지금과 같은 모양과 자리에 그려집니다. 변경 표시 막대가 있어도 그 장식들은 사라지지 않습니다. + 3. 한 줄에 변경 표시 막대가 둘 이상이면 하나만 그리고 우선순위는 `DiffRemoved`, `DiffModified`, `DiffAdded` 순입니다. 그리지 않은 장식도 문서에서는 지워지지 않고 편집을 따라 움직입니다. + 4. 편집을 따라 움직이는 규칙은 기준 14와 같습니다. 앞에 줄을 끼우면 막대가 아래로 따라가고, 그 줄을 지우면 사라집니다. Host로 가는 이벤트는 없습니다. + 5. 접근성. 변경 표시 막대가 있는 줄의 접근성 설명에 "추가됨", "수정됨", "삭제됨" 중 하나가 붙고(플랫폼 로케일의 낱말), 색만으로 구분하지 않습니다. + 6. 시험 이름은 `fr38_`로 시작합니다. 폭 4dp와 위치는 픽셀 시험으로, 우선순위와 이동은 모델 시험으로, 기존 `Bar` 회귀는 기준 14의 시험이 그대로 통과하는 것으로 확인합니다. #### 38.9 추가 기능의 와이어 (소유자 승인 2026-10-25, 이슈 #30) From cd502ec14be7dc315701b863e3b184d526af4722 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:35:08 +0900 Subject: [PATCH 135/224] Chore: Skip the macOS jobs of the Test workflow for changes they cannot be affected by A changed job runs needs-macos.sh on the pull request's file list; docs, the other workflows and the Windows renderer's own files leave rust, kotlin and shell on macOS skipped. It fails open: any other path, an empty list, an error or a manual run runs them. needs-macos.test.sh covers the rule. --- .github/scripts/needs-macos.sh | 46 +++++++++++++++++++++++++++++++ .github/workflows/test.yml | 33 +++++++++++++++++++++- scripts/tests/needs-macos.test.sh | 28 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/needs-macos.sh create mode 100755 scripts/tests/needs-macos.test.sh diff --git a/.github/scripts/needs-macos.sh b/.github/scripts/needs-macos.sh new file mode 100755 index 00000000..8da27cd8 --- /dev/null +++ b/.github/scripts/needs-macos.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Usage: git diff --name-only | needs-macos.sh +# +# Reads the changed file names on stdin and prints "macos=true" or "macos=false" for +# GITHUB_OUTPUT. false means every changed file is one that no macOS job can be affected +# by, so test.yml leaves its macOS jobs out. It fails open: an empty list, an unreadable +# list or any file not named below prints true, so a doubt always runs the macOS jobs. +# +# Never affecting macOS, by path: +# docs/ documentation and the planning documents +# *.md at the root, LICENSE, .gitignore +# text that nothing builds +# .github/workflows/ workflows other than test.yml and fork-desktop-modules.yml, +# which test.yml itself runs +# renderer/windows/, renderer/staticlib-windows/, renderer/desktop/scripts/*.ps1, +# renderer/desktop/scripts/build-windows.sh, +# renderer/desktop/scripts/windows-build-contract.test.sh, +# renderer/desktop/scripts/windows-metadata/ +# the Windows renderer's own sources and build +# scripts/check-windows-consumer.sh +# the Windows consumer check +# +# Everything else counts as macOS-relevant, including .github/scripts/ (the shell suite +# runs on macOS and tests them), samples/, compose-rust/ and the rest of renderer/. +set -uo pipefail + +files="$(cat 2>/dev/null)" || { echo "macos=true"; exit 0; } +[[ -n "$files" ]] || { echo "macos=true"; exit 0; } + +while IFS= read -r file; do + [[ -n "$file" ]] || continue + case "$file" in + .github/workflows/test.yml|.github/workflows/fork-desktop-modules.yml) echo "macos=true"; exit 0 ;; + docs/*|.github/workflows/*) ;; + */*) case "$file" in + renderer/windows/*|renderer/staticlib-windows/*|renderer/desktop/scripts/windows-metadata/*) ;; + renderer/desktop/scripts/*.ps1|renderer/desktop/scripts/build-windows.sh) ;; + renderer/desktop/scripts/windows-build-contract.test.sh) ;; + scripts/check-windows-consumer.sh) ;; + *) echo "macos=true"; exit 0 ;; + esac ;; + *.md|LICENSE|.gitignore) ;; + *) echo "macos=true"; exit 0 ;; + esac +done <<< "$files" +echo "macos=false" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fba0b31d..8091a4e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,35 @@ env: CARGO_PROFILE_TEST_DEBUG: "0" jobs: + changed: + # Decides whether the macOS jobs run. Hosted macOS runners are the scarce ones (five at a + # time), so a change that no macOS job can be affected by, such as documents or the + # Windows renderer's own files, leaves them out. The rule is .github/scripts/needs-macos.sh + # and it fails open: any doubt, an error here, or an event other than a pull request + # (a manual run) runs all of them. A job left out is reported as skipped, which a required + # check accepts, and the Linux and Windows jobs always run. + name: Decide whether macOS jobs run + runs-on: ubuntu-latest + outputs: + macos: ${{ steps.decide.outputs.macos }} + steps: + - uses: actions/checkout@v4 + with: + # The merge commit and its first parent, the base, so the diff is the pull request. + fetch-depth: 2 + - id: decide + run: | + if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then + echo "macos=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + { git diff --name-only HEAD^1 HEAD | ./.github/scripts/needs-macos.sh; } >> "$GITHUB_OUTPUT" || + echo "macos=true" >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + rust: + needs: changed + if: ${{ !cancelled() && (matrix.os != 'macos-latest' || needs.changed.outputs.macos != 'false') }} name: Check Rust formatting, lints and tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -165,7 +193,8 @@ jobs: uses: ./.github/workflows/fork-desktop-modules.yml kotlin: - needs: fork-desktop + needs: [fork-desktop, changed] + if: ${{ !cancelled() && needs.fork-desktop.result == 'success' && (matrix.runs-on != 'macos-latest' || needs.changed.outputs.macos != 'false') }} # One job per platform, not one job for all of them. # # `./kotlin build` with no argument compiles every module for every platform it @@ -255,6 +284,8 @@ jobs: run: ./kotlin test -p ${{ matrix.platform }} shell: + needs: changed + if: ${{ !cancelled() && (matrix.os != 'macos-latest' || needs.changed.outputs.macos != 'false') }} name: Run the shell script tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: diff --git a/scripts/tests/needs-macos.test.sh b/scripts/tests/needs-macos.test.sh new file mode 100755 index 00000000..335fae2b --- /dev/null +++ b/scripts/tests/needs-macos.test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# .github/scripts/needs-macos.sh: which changes let test.yml leave its macOS jobs out. +# Anything unknown, or nothing at all, must run them. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/needs-macos.sh" +failures=0 +check() { # expected description files... + local expected="$1" what="$2"; shift 2 + local got; got="$(printf '%s\n' "$@" | "$script")" + if [[ "$got" == "macos=$expected" ]]; then echo "ok - $what"; else echo "FAIL - $what: $got"; failures=$((failures + 1)); fi +} +check false "docs only" docs/SPEC.md docs/guide/index.md +check false "root markdown and licence" README.md CHANGELOG.md PROJECT.md LICENSE +check false "other workflows" .github/workflows/pages.yml .github/workflows/windows-static.yml +check false "Windows renderer files" renderer/windows/src/Main.kt renderer/desktop/scripts/build-windows.sh scripts/check-windows-consumer.sh +check true "test.yml itself" .github/workflows/test.yml +check true "the reusable fork workflow" .github/workflows/fork-desktop-modules.yml +check true "a CI script (the shell suite tests it on macOS)" .github/scripts/package-static-renderer.sh +check true "Rust source" compose-rust/src/lib.rs +check true "a docs change beside Rust source" docs/SPEC.md compose-rust/src/lib.rs +check true "the macOS renderer" renderer/macos/src/Main.kt +check true "a root file nobody listed" Cargo.toml +check true "a script under scripts/" scripts/check.sh +check true "Windows desktop sources the JVM job compiles" renderer/desktop/src/NativeWindowShell.kt +out="$("$script" < /dev/null)"; [[ "$out" == "macos=true" ]] && echo "ok - no file list runs macOS" || { echo "FAIL - empty: $out"; failures=$((failures + 1)); } +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" From 5e03fd6708c3559f42ff5609df43e1764ec6d400 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:37:28 +0900 Subject: [PATCH 136/224] Fix: Build the Test matrices from the changed job instead of reading matrix in a job if GitHub rejects a job-level if that reads the matrix, so the macOS legs now leave the matrices themselves (fromJSON of the changed job's outputs). The diff lists renames at both ends, workflows that shell tests read stay on the macOS side, and needs-macos.test.sh checks the wiring, a rename and a path with spaces. --- .github/scripts/needs-macos.sh | 14 +++-- .github/workflows/test.yml | 92 +++++++++++++++++-------------- scripts/tests/needs-macos.test.sh | 35 +++++++++++- 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/.github/scripts/needs-macos.sh b/.github/scripts/needs-macos.sh index 8da27cd8..ebd61d14 100755 --- a/.github/scripts/needs-macos.sh +++ b/.github/scripts/needs-macos.sh @@ -10,8 +10,12 @@ # docs/ documentation and the planning documents # *.md at the root, LICENSE, .gitignore # text that nothing builds -# .github/workflows/ workflows other than test.yml and fork-desktop-modules.yml, -# which test.yml itself runs +# .github/workflows/{pages,publish-crates,release-sync,windows-static,test-windows-c, +# test-windows-resize-metrics,test-windows-resize-probe}.yml +# workflows no shell test reads. The others stay on the macOS +# side: install-nik.test.sh, kn-start-report.test.sh and +# others read them, and install-nik only means anything on +# macOS. # renderer/windows/, renderer/staticlib-windows/, renderer/desktop/scripts/*.ps1, # renderer/desktop/scripts/build-windows.sh, # renderer/desktop/scripts/windows-build-contract.test.sh, @@ -30,8 +34,10 @@ files="$(cat 2>/dev/null)" || { echo "macos=true"; exit 0; } while IFS= read -r file; do [[ -n "$file" ]] || continue case "$file" in - .github/workflows/test.yml|.github/workflows/fork-desktop-modules.yml) echo "macos=true"; exit 0 ;; - docs/*|.github/workflows/*) ;; + docs/*) ;; + .github/workflows/pages.yml|.github/workflows/publish-crates.yml|.github/workflows/release-sync.yml) ;; + .github/workflows/windows-static.yml|.github/workflows/test-windows-c.yml) ;; + .github/workflows/test-windows-resize-metrics.yml|.github/workflows/test-windows-resize-probe.yml) ;; */*) case "$file" in renderer/windows/*|renderer/staticlib-windows/*|renderer/desktop/scripts/windows-metadata/*) ;; renderer/desktop/scripts/*.ps1|renderer/desktop/scripts/build-windows.sh) ;; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8091a4e4..8a3e7fde 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,24 +53,54 @@ jobs: runs-on: ubuntu-latest outputs: macos: ${{ steps.decide.outputs.macos }} + os: ${{ steps.matrix.outputs.os }} + kotlin: ${{ steps.matrix.outputs.kotlin }} steps: - uses: actions/checkout@v4 with: # The merge commit and its first parent, the base, so the diff is the pull request. fetch-depth: 2 - id: decide + continue-on-error: true run: | if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then echo "macos=true" >> "$GITHUB_OUTPUT" exit 0 fi - { git diff --name-only HEAD^1 HEAD | ./.github/scripts/needs-macos.sh; } >> "$GITHUB_OUTPUT" || - echo "macos=true" >> "$GITHUB_OUTPUT" + # --no-renames lists both ends of a rename, so a file moved out of renderer/ into + # docs/ still names renderer/. core.quotepath keeps non-ASCII names readable; a + # name git still has to quote is not on the skip list and so runs macOS. + decision="$(git -c core.quotepath=off diff --no-renames --name-only HEAD^1 HEAD | + ./.github/scripts/needs-macos.sh)" || decision="macos=true" + [[ "$decision" == "macos=true" || "$decision" == "macos=false" ]] || decision="macos=true" + echo "$decision" >> "$GITHUB_OUTPUT" + echo "$decision" + - id: matrix + if: always() + # A job-level condition cannot read the matrix, so the macOS legs are left out of + # the matrices themselves. Anything but an explicit macos=false keeps them. + env: + MACOS: ${{ steps.decide.outputs.macos }} + run: | + kotlin='{"include":[ + {"platform":"jvm","runs-on":"macos-latest","test":true}, + {"platform":"iosSimulatorArm64","runs-on":"macos-latest","test":true}, + {"platform":"iosArm64","runs-on":"macos-latest","test":false}, + {"platform":"wasmJs","runs-on":"ubuntu-latest","test":false}, + {"platform":"android","runs-on":"ubuntu-latest","test":true}]}' + if [[ "$MACOS" == "false" ]]; then + os='["ubuntu-latest"]' + kotlin="$(jq -c '.include |= map(select(.["runs-on"] != "macos-latest"))' <<< "$kotlin")" + else + os='["macos-latest","ubuntu-latest"]' + kotlin="$(jq -c . <<< "$kotlin")" + fi + echo "os=$os" >> "$GITHUB_OUTPUT" + echo "kotlin=$kotlin" >> "$GITHUB_OUTPUT" cat "$GITHUB_OUTPUT" rust: needs: changed - if: ${{ !cancelled() && (matrix.os != 'macos-latest' || needs.changed.outputs.macos != 'false') }} name: Check Rust formatting, lints and tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -78,7 +108,7 @@ jobs: matrix: # macos-latest is Apple silicon, which is the platform SPEC PR-8 # describes and the one the benchmark baseline was measured on. - os: [macos-latest, ubuntu-latest] + os: ${{ fromJSON(needs.changed.outputs.os) }} steps: - uses: actions/checkout@v4 @@ -194,7 +224,6 @@ jobs: kotlin: needs: [fork-desktop, changed] - if: ${{ !cancelled() && needs.fork-desktop.result == 'success' && (matrix.runs-on != 'macos-latest' || needs.changed.outputs.macos != 'false') }} # One job per platform, not one job for all of them. # # `./kotlin build` with no argument compiles every module for every platform it @@ -206,44 +235,26 @@ jobs: # The split also keeps each cache honest. The jvm and android jobs never download # konan at all, and a Kotlin/Native toolchain change no longer invalidates the cache # of the job that finishes first. + # + # The matrix is built by the changed job, which leaves the macOS legs out when no macOS job + # can be affected. What each leg is, and why: + # jvm the desktop module and every UI test; macOS because it is the primary + # platform and the one the Compose desktop tests are written against. + # iosSimulatorArm64 Kotlin/Native; compiles the iOS renderer, which shares its + # interpreter sources with the desktop one through symlinks, and runs + # the protocol codec tests on it. + # iosArm64 the device slice, linked and never run: running it needs a signed + # app on real hardware. It proves the arm64 slice still compiles. + # wasmJs compiled, not run: a browser test loads skiko's wasm module, and on + # this runner it arrives truncated, which aborts before any test + # starts. WebBoundaryTest would need that fixed and + # web/scripts/build-host.sh run first. + # android built and tested on Linux. name: Build and test the renderer (${{ matrix.platform }}) runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false - matrix: - include: - # The renderer's desktop module and every UI test live here. macOS because it is - # the primary platform and the one the Compose desktop tests are written against. - - platform: jvm - runs-on: macos-latest - test: true - # Kotlin/Native. Compiles the iOS renderer, which shares its interpreter sources - # with the desktop one through symlinks, and runs the protocol codec tests on it. - - platform: iosSimulatorArm64 - runs-on: macos-latest - test: true - # The device slice is linked, never run: running it needs a signed app on real - # hardware. Building it is what proves the arm64 slice still compiles. - - platform: iosArm64 - runs-on: macos-latest - test: false - # Compiled, not run, and now for one reason rather than two. A browser test - # loads skiko's wasm module, and on this runner it arrives truncated, which - # aborts before any test starts (`section (code 10, "Code") extends past end of - # the module`). It does not reproduce locally, where the same command passes, so - # this is the toolchain or the runner rather than the code under test. - # - # What is now lost by not running them is real. `web/test/WebBoundaryTest.kt` - # drives the boundary against a Host built from the same Rust source the desktop - # renderer runs, and measures what a call costs. Turning it on takes two things: - # the truncated skiko fixed, and `web/scripts/build-host.sh` run first, because - # without the Host beside the page those tests report what is missing and pass. - - platform: wasmJs - runs-on: ubuntu-latest - test: false - - platform: android - runs-on: ubuntu-latest - test: true + matrix: ${{ fromJSON(needs.changed.outputs.kotlin) }} steps: - uses: actions/checkout@v4 @@ -285,7 +296,6 @@ jobs: shell: needs: changed - if: ${{ !cancelled() && (matrix.os != 'macos-latest' || needs.changed.outputs.macos != 'false') }} name: Run the shell script tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -294,7 +304,7 @@ jobs: # Both, because some of these scripts are platform specific and skip elsewhere. # install-nik.sh only means anything on macOS, so running the suite on Linux alone # would silently cover none of it. - os: [ubuntu-latest, macos-latest] + os: ${{ fromJSON(needs.changed.outputs.os) }} steps: - uses: actions/checkout@v4 diff --git a/scripts/tests/needs-macos.test.sh b/scripts/tests/needs-macos.test.sh index 335fae2b..0b5aeade 100755 --- a/scripts/tests/needs-macos.test.sh +++ b/scripts/tests/needs-macos.test.sh @@ -12,7 +12,11 @@ check() { # expected description files... } check false "docs only" docs/SPEC.md docs/guide/index.md check false "root markdown and licence" README.md CHANGELOG.md PROJECT.md LICENSE -check false "other workflows" .github/workflows/pages.yml .github/workflows/windows-static.yml +check false "workflows no shell test reads" .github/workflows/pages.yml .github/workflows/windows-static.yml +check true "a workflow the shell tests read" .github/workflows/test-native-renderer.yml +check true "static-renderer workflow" .github/workflows/static-renderer.yml +check false "a path with spaces under docs" "docs/a file with spaces.md" +check true "a quoted non-ASCII name git could not print plainly" '"docs/\303\244.md"' check false "Windows renderer files" renderer/windows/src/Main.kt renderer/desktop/scripts/build-windows.sh scripts/check-windows-consumer.sh check true "test.yml itself" .github/workflows/test.yml check true "the reusable fork workflow" .github/workflows/fork-desktop-modules.yml @@ -23,6 +27,35 @@ check true "the macOS renderer" renderer/macos/src/Main.kt check true "a root file nobody listed" Cargo.toml check true "a script under scripts/" scripts/check.sh check true "Windows desktop sources the JVM job compiles" renderer/desktop/src/NativeWindowShell.kt +# Wiring: test.yml must be valid, and the macOS legs must leave the matrices. +wf="$repo_root/.github/workflows/test.yml" +if python3 -c 'import yaml' 2>/dev/null; then + if python3 - "$wf" <<'PY' +import sys, yaml +d = yaml.safe_load(open(sys.argv[1])) +jobs = d["jobs"] +bad = [n for n, j in jobs.items() if "matrix." in str(j.get("if", ""))] +assert not bad, f"job-level if reads the matrix in {bad}: GitHub rejects the workflow" +for n in ("rust", "kotlin", "shell"): + j = jobs[n] + needs = j["needs"] if isinstance(j["needs"], list) else [j["needs"]] + assert "changed" in needs, f"{n} does not wait for changed" + assert "fromJSON(needs.changed.outputs" in str(j["strategy"]["matrix"]), f"{n} matrix is not built by changed" +PY + then echo "ok - test.yml wiring: no matrix in job ifs, matrices come from changed" + else echo "FAIL - test.yml wiring"; failures=$((failures + 1)); fi +else + echo "skip - no PyYAML for the wiring check" +fi + +# Renames: the old path counts, as the workflow lists it with --no-renames. +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +( cd "$tmp" && git init -q . && git config user.email t@t && git config user.name t && + mkdir -p renderer docs && echo x > renderer/Notes.md && echo y > "docs/seed.md" && + git add -A && git commit -qm base && git mv renderer/Notes.md docs/Notes.md && git commit -qam move ) +got="$(cd "$tmp" && git -c core.quotepath=off diff --no-renames --name-only HEAD^1 HEAD | "$script")" +[[ "$got" == "macos=true" ]] && echo "ok - a file moved out of renderer/ into docs/ runs macOS" || { echo "FAIL - rename: $got"; failures=$((failures + 1)); } + out="$("$script" < /dev/null)"; [[ "$out" == "macos=true" ]] && echo "ok - no file list runs macOS" || { echo "FAIL - empty: $out"; failures=$((failures + 1)); } [[ "$failures" -eq 0 ]] || exit 1 echo "all passed" From afe14cd0c8292b5308fbc92de617bdc7db211f7c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:39:58 +0900 Subject: [PATCH 137/224] Chore: Leave the macOS jobs of the native renderer test out for other platforms' own files The changed job decides with native-needs-macos.sh on a pull request: macos-static, the macOS single-executable leg and ios run unless every changed file belongs to Windows or Linux alone. Tags, main, the nightly run, manual runs, errors and unlisted paths keep them. The single-executable matrix comes from the changed job's output. --- .github/scripts/native-needs-macos.sh | 37 ++++++++++++++++++ .github/workflows/test-native-renderer.yml | 37 +++++++++++++----- scripts/tests/native-needs-macos.test.sh | 44 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 10 deletions(-) create mode 100755 .github/scripts/native-needs-macos.sh create mode 100755 scripts/tests/native-needs-macos.test.sh diff --git a/.github/scripts/native-needs-macos.sh b/.github/scripts/native-needs-macos.sh new file mode 100755 index 00000000..245c52bf --- /dev/null +++ b/.github/scripts/native-needs-macos.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Usage: git diff --no-renames --name-only | native-needs-macos.sh +# +# Reads the changed file names on stdin and prints "macos=true" or "macos=false" for +# GITHUB_OUTPUT. test-native-renderer.yml builds the macOS static renderer, checks the macOS +# single executable and builds the iOS renderer on macOS runners; false means every changed +# file is one of the other platforms' own, so those jobs are left out. It fails open: an empty +# list, an unreadable list or any file not named below prints true. +# +# The other platforms' own files (they feed no macOS job of this workflow): +# renderer/windows/, renderer/staticlib-windows/, renderer/linux/, renderer/staticlib-linux/ +# renderer/desktop/scripts/*.ps1, build-windows.sh, build-linux.sh, +# renderer/desktop/scripts/windows-build-contract.test.sh, windows-metadata/ +# scripts/check-windows-consumer.sh, .github/scripts/check-linux-input-access.sh, +# .github/scripts/atspi-walk.py, .github/workflows/windows-static.yml +# +# Everything else counts as macOS-relevant: the shared renderer sources, the build scripts, +# package-static-renderer.sh and check-single-executable.sh (the macOS legs run them), +# compose-rust/build*, parity scripts and the workflows themselves. +set -uo pipefail + +files="$(cat 2>/dev/null)" || { echo "macos=true"; exit 0; } +[[ -n "$files" ]] || { echo "macos=true"; exit 0; } + +while IFS= read -r file; do + [[ -n "$file" ]] || continue + case "$file" in + renderer/windows/*|renderer/staticlib-windows/*|renderer/linux/*|renderer/staticlib-linux/*) ;; + renderer/desktop/scripts/windows-metadata/*|renderer/desktop/scripts/*.ps1) ;; + renderer/desktop/scripts/build-windows.sh|renderer/desktop/scripts/build-linux.sh) ;; + renderer/desktop/scripts/windows-build-contract.test.sh) ;; + scripts/check-windows-consumer.sh|.github/scripts/check-linux-input-access.sh) ;; + .github/scripts/atspi-walk.py|.github/workflows/windows-static.yml) ;; + *) echo "macos=true"; exit 0 ;; + esac +done <<< "$files" +echo "macos=false" diff --git a/.github/workflows/test-native-renderer.yml b/.github/workflows/test-native-renderer.yml index e07d6c1b..36386719 100644 --- a/.github/workflows/test-native-renderer.yml +++ b/.github/workflows/test-native-renderer.yml @@ -115,6 +115,8 @@ jobs: runs-on: ubuntu-latest outputs: renderer: ${{ steps.check.outputs.renderer }} + macos: ${{ steps.macos.outputs.macos }} + executables: ${{ steps.macos.outputs.executables }} steps: - uses: actions/checkout@v4 with: @@ -146,6 +148,28 @@ jobs: echo "renderer=false" >> "$GITHUB_OUTPUT" fi + - id: macos + # The macOS runners are the scarce ones, so a pull request that only touches another + # platform's own files leaves the macOS jobs out (native-needs-macos.sh). Anything + # but an explicit macos=false keeps them: tags, main, the nightly run, manual runs, + # an error, an empty list or any other path. A job-level condition cannot read a + # matrix, so the single-executable matrix is built here. + if: always() + run: | + macos=true + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + decision="$(git -c core.quotepath=off diff --no-renames --name-only HEAD^1 HEAD | + ./.github/scripts/native-needs-macos.sh)" || decision="macos=true" + [[ "$decision" == "macos=false" ]] && macos=false + fi + all='{"include":[{"target":"macos-aarch64","runs-on":"macos-latest"},{"target":"linux-x64","runs-on":"ubuntu-24.04"},{"target":"linux-arm64","runs-on":"ubuntu-24.04-arm"}]}' + if [[ "$macos" == "false" ]]; then + all="$(jq -c '.include |= map(select(.["runs-on"] != "macos-latest"))' <<< "$all")" + fi + echo "macos=$macos" >> "$GITHUB_OUTPUT" + echo "executables=$all" >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + # Runs on every push to develop and main, and nightly. Nobody here owns a Windows # machine, so this job *is* the Windows verification: if it goes red, Windows is # broken. It proves build, startup, render, clean close and exit 0. It cannot prove @@ -280,7 +304,7 @@ jobs: # x86-64 there, because Kotlin/Native has no arm64 Linux host. macos-static: needs: changed - if: needs.changed.outputs.renderer == 'true' + if: needs.changed.outputs.renderer == 'true' && needs.changed.outputs.macos != 'false' uses: ./.github/workflows/static-renderer.yml with: target: macos-aarch64 @@ -316,14 +340,7 @@ jobs: timeout-minutes: 60 strategy: fail-fast: false - matrix: - include: - - target: macos-aarch64 - runs-on: macos-latest - - target: linux-x64 - runs-on: ubuntu-24.04 - - target: linux-arm64 - runs-on: ubuntu-24.04-arm + matrix: ${{ fromJSON(needs.changed.outputs.executables) }} env: # Mesa draws with the processor; a hosted runner has no card. LIBGL_ALWAYS_SOFTWARE: '1' @@ -390,7 +407,7 @@ jobs: # it evidence for PR-2: one unmodified host, two renderers, one ABI. ios: needs: changed - if: needs.changed.outputs.renderer == 'true' + if: needs.changed.outputs.renderer == 'true' && needs.changed.outputs.macos != 'false' name: Build and smoke test the renderer (iOS, simulator) # macos-26, not macos-latest. Compose Multiplatform's iOS artifacts carry # precompiled Objective-C objects built against the iOS 26 SDK, and one of them diff --git a/scripts/tests/native-needs-macos.test.sh b/scripts/tests/native-needs-macos.test.sh new file mode 100755 index 00000000..3bf66e8e --- /dev/null +++ b/scripts/tests/native-needs-macos.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# .github/scripts/native-needs-macos.sh: which pull requests let test-native-renderer.yml +# leave its macOS jobs out. Anything unknown, or nothing at all, must run them. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/native-needs-macos.sh" +failures=0 +check() { # expected description files... + local expected="$1" what="$2"; shift 2 + local got; got="$(printf '%s\n' "$@" | "$script")" + if [[ "$got" == "macos=$expected" ]]; then echo "ok - $what"; else echo "FAIL - $what: $got"; failures=$((failures + 1)); fi +} +check false "Windows renderer only" renderer/windows/src/Main.kt renderer/desktop/scripts/build-windows.sh .github/workflows/windows-static.yml +check false "Linux renderer only" renderer/linux/src/shared/Window.kt .github/scripts/check-linux-input-access.sh +check true "the packager and the single-executable check (macOS legs run them)" .github/scripts/package-static-renderer.sh renderer/desktop/scripts/build-windows.sh +check true "shared desktop sources" renderer/desktop/src/Main.kt +check true "macOS renderer" renderer/macos/src/Main.kt +check true "iOS renderer" renderer/ios/src/shared/Interpreter.kt +check true "compose-rust build script" compose-rust/build.rs +check true "this workflow" .github/workflows/test-native-renderer.yml +check true "mixed: Windows and shared" renderer/windows/src/Main.kt renderer/libs.versions.toml +check true "a quoted name" '"renderer/windows/\303\244.kt"' +out="$("$script" < /dev/null)"; [[ "$out" == "macos=true" ]] && echo "ok - no file list runs macOS" || { echo "FAIL - empty: $out"; failures=$((failures + 1)); } + +# Wiring: no matrix in a job-level if, and the macOS jobs wait on the changed job's output. +wf="$repo_root/.github/workflows/test-native-renderer.yml" +if python3 -c 'import yaml' 2>/dev/null; then + if python3 - "$wf" <<'PY' +import sys, yaml +jobs = yaml.safe_load(open(sys.argv[1]))["jobs"] +bad = [n for n, j in jobs.items() if "matrix." in str(j.get("if", ""))] +assert not bad, f"job-level if reads the matrix in {bad}" +for n in ("macos-static", "ios"): + assert "needs.changed.outputs.macos != 'false'" in jobs[n]["if"], f"{n} ignores the macOS decision" +assert "fromJSON(needs.changed.outputs.executables)" in str(jobs["single-executable"]["strategy"]["matrix"]) +for n in ("linux-static", "linux-arm64-static", "windows"): + assert "macos" not in str(jobs[n].get("if", "")), f"{n} must not depend on the macOS decision" +PY + then echo "ok - workflow wiring"; else echo "FAIL - workflow wiring"; failures=$((failures + 1)); fi +else + echo "skip - no PyYAML for the wiring check" +fi +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" From 0a0144c9fc610e79c27ac64c5ecbe034d271640b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:46:06 +0900 Subject: [PATCH 138/224] Feat: Change-marker bars in the editor gutter (FR-38 criterion 16) A Bar gutter decoration coloured DiffAdded, DiffModified or DiffRemoved is drawn 4dp wide right of the line numbers with a 12dp gap; Removed beats Modified beats Added on one line; the editor's state description names the change words. No wire change. Editors without change bars keep their exact layout. Tests: fr38_change_bar_is_four_dp_wide_right_of_the_numbers, fr38_change_bars_priority_removed_modified_added, fr38_change_bar_follows_edits, fr38_other_gutter_marks_are_unchanged_next_to_change_bars, fr38_change_bar_adds_a_word_to_the_accessibility_description. --- .../android-kotlin/shared/CodeEditorModel.kt | 45 +++++ .../shared/CodeEditorSurface.kt | 69 ++++++- .../android-kotlin/shared/HostCodeEditor.kt | 4 + .../desktop/src/renderer/CodeEditorModel.kt | 45 +++++ .../desktop/src/renderer/CodeEditorSurface.kt | 69 ++++++- .../desktop/src/renderer/DrawnCodeEditor.kt | 7 +- .../desktop/src/renderer/HostCodeEditor.kt | 4 + .../desktop/test/CodeEditorChangeBarTest.kt | 190 ++++++++++++++++++ renderer/desktop/test/CodeEditorTest.kt | 25 +++ 9 files changed, 445 insertions(+), 13 deletions(-) create mode 100644 renderer/desktop/test/CodeEditorChangeBarTest.kt diff --git a/compose-rust/android-kotlin/shared/CodeEditorModel.kt b/compose-rust/android-kotlin/shared/CodeEditorModel.kt index 3ee19341..25841516 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorModel.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorModel.kt @@ -63,6 +63,23 @@ class LiveGutterMark( val glyph: Int, ) +/** + * What a change-marker bar says about its line. Declared in the order the bars win a line: + * a removal outranks a modification, which outranks an addition. + */ +enum class ChangeMark { Removed, Modified, Added } + +/** The change a mark stands for, or null when it is an ordinary mark beside the line number. */ +fun LiveGutterMark.changeMark(): ChangeMark? { + if (kind != GutterDecorationKind.Bar) return null + return when (color) { + ColorRole.DiffAdded -> ChangeMark.Added + ColorRole.DiffModified -> ChangeMark.Modified + ColorRole.DiffRemoved -> ChangeMark.Removed + else -> null + } +} + /** A range the Host asked to have brought into view. A new request is a new object. */ class RevealRequest(val start: CodePosition, val end: CodePosition, val align: RevealAlign) @@ -717,6 +734,34 @@ class CodeEditorModel( /** The marks beside a line, in the order the Host sent them. */ fun gutterMarksAt(line: Int): List = gutterList.filter { it.line == line } + /** The marks of a line that sit in the mark column: every one that is not a change bar. */ + fun ordinaryMarksAt(line: Int): List = + gutterList.filter { it.line == line && it.changeMark() == null } + + /** Whether any mark is a change bar, which is what makes the gutter reserve room for them. */ + val hasChangeBars: Boolean get() = gutterList.any { it.changeMark() != null } + + /** Whether any mark belongs in the mark column. */ + val hasOrdinaryMarks: Boolean get() = gutterList.any { it.changeMark() == null } + + /** + * The one change bar a line shows. Several can sit on a line; the others stay in the + * model and keep following edits, but only the strongest is drawn. + */ + fun changeBarAt(line: Int): LiveGutterMark? = + gutterList.filter { it.line == line && it.changeMark() != null }.minByOrNull { it.changeMark()!!.ordinal } + + /** The lines that show a change bar, by the change they show, in line order. */ + fun changeLines(): Map> { + val shown = HashMap() + for (mark in gutterList) { + val change = mark.changeMark() ?: continue + val old = shown[mark.line] + if (old == null || change.ordinal < old.ordinal) shown[mark.line] = change + } + return shown.entries.groupBy({ it.value }, { it.key }).mapValues { it.value.sorted() } + } + /** * A selection the Host asked for, written against [baseVersion]. Both ends are moved * along with what the reader typed since. An end that is not a place in the document diff --git a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt index ea927a74..ca48cf43 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt @@ -64,6 +64,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -168,8 +170,56 @@ class CodeEditorLook( val gutterMark: (ColorRole?) -> Color = { lens }, /** The ink of the marker that folds or unfolds a stretch. */ val foldMarker: Color = lineNumber, + /** The width of a change-marker bar, which does not scale with the row. */ + val changeBarWidth: Dp = 4.dp, + /** The space between a change-marker bar and what comes after it. */ + val changeBarGap: Dp = 12.dp, + /** The word a screen reader hears for a changed line. */ + val changeMarkWord: (ChangeMark) -> String = { localizedChangeMarkWord(it) }, ) +/** The word for a changed line in [language], in the same set of languages as the sidebar's. */ +internal fun localizedChangeMarkWord(change: ChangeMark, language: String = androidx.compose.ui.text.intl.Locale.current.language): String = + when (language) { + "ko" -> when (change) { + ChangeMark.Added -> "추가됨" + ChangeMark.Modified -> "수정됨" + ChangeMark.Removed -> "삭제됨" + } + else -> when (change) { + ChangeMark.Added -> "Added" + ChangeMark.Modified -> "Modified" + ChangeMark.Removed -> "Removed" + } + } + +/** The room the gutter keeps for change bars and the gap behind them: none when no line has one. */ +internal fun Density.changeColumnPx(model: CodeEditorModel, look: CodeEditorLook): Int = + if (model.hasChangeBars) (look.changeBarWidth + look.changeBarGap).roundToPx() else 0 + +/** + * What a screen reader hears about the changed lines, or null when there are none. The + * editor is one node, so the lines are listed under the word for their change. + */ +internal fun changeDescription(model: CodeEditorModel, look: CodeEditorLook): String? { + val lines = model.changeLines() + if (lines.isEmpty()) return null + return ChangeMark.entries.filter { it in lines }.joinToString("; ") { change -> + val numbers = lines.getValue(change) + val shown = numbers.take(CHANGE_LINES_SPOKEN).joinToString(", ") { (it + 1).toString() } + val rest = if (numbers.size > CHANGE_LINES_SPOKEN) ", +${numbers.size - CHANGE_LINES_SPOKEN}" else "" + "${look.changeMarkWord(change)}: $shown$rest" + } +} + +private const val CHANGE_LINES_SPOKEN = 50 + +/** Gives the editor's node the description of its changed lines, when it has any. */ +internal fun Modifier.changeSemantics(model: CodeEditorModel, look: CodeEditorLook): Modifier { + val text = changeDescription(model, look) ?: return this + return semantics { stateDescription = text } +} + /** What a surface tells the editor that holds it. All of them are called on the UI thread. */ class CodeEditorCallbacks( /** The model has something queued for the Host. */ @@ -818,13 +868,13 @@ private fun FieldSurface( val rows = remember(contentRevision, overlayRevision) { RowMap.of(model) } val lineCount = rows.lineCount val digits = lineCount.toString().length - val hasMarks = model.gutterMarks.isNotEmpty() + val hasMarks = model.hasOrdinaryMarks val hasFolds = model.folds.isNotEmpty() val markColumnPx = if (hasMarks) rowPx else 0 val foldColumnPx = if (hasFolds) rowPx else 0 val gutterWidthPx = with(density) { digits * digitWidth + (look.gutterPadding * 2).roundToPx() + look.gutterDividerWidth.roundToPx() - } + markColumnPx + foldColumnPx + } + markColumnPx + foldColumnPx + density.changeColumnPx(model, look) // Refilling the window recomposes this, so the field moves to where the new lines are. window.revision @@ -870,6 +920,7 @@ private fun FieldSurface( Box( modifier .boundedHeight(DEFAULT_EDITOR_HEIGHT) + .changeSemantics(model, look) .background(look.container) .clipToBounds() .onSizeChanged { size -> @@ -1100,7 +1151,7 @@ internal fun pressGutter( if (foldColumn > 0 && point.x >= gutterWidth - foldColumn) { model.toggleFold(line) } else if (markColumn > 0 && point.x < markColumn) { - model.gutterMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) + model.ordinaryMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) } } @@ -1331,12 +1382,13 @@ private fun DrawScope.drawGutter( val last = min(rows.lineCount - 1, rows.lineAtRow((scroll + viewportHeight) / rowPx + 1)) val padding = look.gutterPadding.toPx() val divider = look.gutterDividerWidth.toPx() + val changeColumn = changeColumnPx(window.model, look) for (line in first..last) { if (rows.isHidden(line)) continue val label = (line + 1).toString() val color = if (line == caretLine) look.currentLineNumber else look.lineNumber val measured = measurer.measure(label, style.copy(color = color)) - val x = width - divider - foldColumn - padding - measured.size.width + val x = width - divider - foldColumn - changeColumn - padding - measured.size.width val top = rows.textRow(line) * rowPx.toFloat() drawText(measured, topLeft = Offset(x, top)) drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, measurer, style, look) @@ -1357,8 +1409,15 @@ internal fun DrawScope.drawGutterAdornments( look: CodeEditorLook, ) { val cell = rowPx.toFloat() + // The change bar sits right of the numbers, the full row high, so that the bars of + // neighbouring lines meet. + val bar = model.changeBarAt(line) + if (bar != null) { + val left = foldLeft - changeColumnPx(model, look) + drawRect(look.gutterMark(bar.color), Offset(left, top), Size(look.changeBarWidth.toPx(), cell)) + } if (markColumn > 0) { - for (mark in model.gutterMarksAt(line)) { + for (mark in model.ordinaryMarksAt(line)) { val color = look.gutterMark(mark.color) when (mark.kind) { GutterDecorationKind.Dot -> drawCircle(color, cell * 0.2f, Offset(markColumn / 2f, top + cell / 2f)) diff --git a/compose-rust/android-kotlin/shared/HostCodeEditor.kt b/compose-rust/android-kotlin/shared/HostCodeEditor.kt index 1d698958..38beff3d 100644 --- a/compose-rust/android-kotlin/shared/HostCodeEditor.kt +++ b/compose-rust/android-kotlin/shared/HostCodeEditor.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.design.HostPlatform import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.foundation.code.CodeEditorCallbacks @@ -108,6 +109,9 @@ internal fun codeEditorLook(node: Node, theme: ResolvedTheme, tabWidth: Int?): C // A mark takes the role the Host named, and otherwise the lens's quiet ink. gutterMark = { role -> if (role == null) style.lens else theme.color(role) }, foldMarker = style.lineNumber, + // The same for every design system: the width and gap of a change-marker bar. + changeBarWidth = 4.dp, + changeBarGap = 12.dp, ) } diff --git a/renderer/desktop/src/renderer/CodeEditorModel.kt b/renderer/desktop/src/renderer/CodeEditorModel.kt index 3ee19341..25841516 100644 --- a/renderer/desktop/src/renderer/CodeEditorModel.kt +++ b/renderer/desktop/src/renderer/CodeEditorModel.kt @@ -63,6 +63,23 @@ class LiveGutterMark( val glyph: Int, ) +/** + * What a change-marker bar says about its line. Declared in the order the bars win a line: + * a removal outranks a modification, which outranks an addition. + */ +enum class ChangeMark { Removed, Modified, Added } + +/** The change a mark stands for, or null when it is an ordinary mark beside the line number. */ +fun LiveGutterMark.changeMark(): ChangeMark? { + if (kind != GutterDecorationKind.Bar) return null + return when (color) { + ColorRole.DiffAdded -> ChangeMark.Added + ColorRole.DiffModified -> ChangeMark.Modified + ColorRole.DiffRemoved -> ChangeMark.Removed + else -> null + } +} + /** A range the Host asked to have brought into view. A new request is a new object. */ class RevealRequest(val start: CodePosition, val end: CodePosition, val align: RevealAlign) @@ -717,6 +734,34 @@ class CodeEditorModel( /** The marks beside a line, in the order the Host sent them. */ fun gutterMarksAt(line: Int): List = gutterList.filter { it.line == line } + /** The marks of a line that sit in the mark column: every one that is not a change bar. */ + fun ordinaryMarksAt(line: Int): List = + gutterList.filter { it.line == line && it.changeMark() == null } + + /** Whether any mark is a change bar, which is what makes the gutter reserve room for them. */ + val hasChangeBars: Boolean get() = gutterList.any { it.changeMark() != null } + + /** Whether any mark belongs in the mark column. */ + val hasOrdinaryMarks: Boolean get() = gutterList.any { it.changeMark() == null } + + /** + * The one change bar a line shows. Several can sit on a line; the others stay in the + * model and keep following edits, but only the strongest is drawn. + */ + fun changeBarAt(line: Int): LiveGutterMark? = + gutterList.filter { it.line == line && it.changeMark() != null }.minByOrNull { it.changeMark()!!.ordinal } + + /** The lines that show a change bar, by the change they show, in line order. */ + fun changeLines(): Map> { + val shown = HashMap() + for (mark in gutterList) { + val change = mark.changeMark() ?: continue + val old = shown[mark.line] + if (old == null || change.ordinal < old.ordinal) shown[mark.line] = change + } + return shown.entries.groupBy({ it.value }, { it.key }).mapValues { it.value.sorted() } + } + /** * A selection the Host asked for, written against [baseVersion]. Both ends are moved * along with what the reader typed since. An end that is not a place in the document diff --git a/renderer/desktop/src/renderer/CodeEditorSurface.kt b/renderer/desktop/src/renderer/CodeEditorSurface.kt index ea927a74..ca48cf43 100644 --- a/renderer/desktop/src/renderer/CodeEditorSurface.kt +++ b/renderer/desktop/src/renderer/CodeEditorSurface.kt @@ -64,6 +64,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -168,8 +170,56 @@ class CodeEditorLook( val gutterMark: (ColorRole?) -> Color = { lens }, /** The ink of the marker that folds or unfolds a stretch. */ val foldMarker: Color = lineNumber, + /** The width of a change-marker bar, which does not scale with the row. */ + val changeBarWidth: Dp = 4.dp, + /** The space between a change-marker bar and what comes after it. */ + val changeBarGap: Dp = 12.dp, + /** The word a screen reader hears for a changed line. */ + val changeMarkWord: (ChangeMark) -> String = { localizedChangeMarkWord(it) }, ) +/** The word for a changed line in [language], in the same set of languages as the sidebar's. */ +internal fun localizedChangeMarkWord(change: ChangeMark, language: String = androidx.compose.ui.text.intl.Locale.current.language): String = + when (language) { + "ko" -> when (change) { + ChangeMark.Added -> "추가됨" + ChangeMark.Modified -> "수정됨" + ChangeMark.Removed -> "삭제됨" + } + else -> when (change) { + ChangeMark.Added -> "Added" + ChangeMark.Modified -> "Modified" + ChangeMark.Removed -> "Removed" + } + } + +/** The room the gutter keeps for change bars and the gap behind them: none when no line has one. */ +internal fun Density.changeColumnPx(model: CodeEditorModel, look: CodeEditorLook): Int = + if (model.hasChangeBars) (look.changeBarWidth + look.changeBarGap).roundToPx() else 0 + +/** + * What a screen reader hears about the changed lines, or null when there are none. The + * editor is one node, so the lines are listed under the word for their change. + */ +internal fun changeDescription(model: CodeEditorModel, look: CodeEditorLook): String? { + val lines = model.changeLines() + if (lines.isEmpty()) return null + return ChangeMark.entries.filter { it in lines }.joinToString("; ") { change -> + val numbers = lines.getValue(change) + val shown = numbers.take(CHANGE_LINES_SPOKEN).joinToString(", ") { (it + 1).toString() } + val rest = if (numbers.size > CHANGE_LINES_SPOKEN) ", +${numbers.size - CHANGE_LINES_SPOKEN}" else "" + "${look.changeMarkWord(change)}: $shown$rest" + } +} + +private const val CHANGE_LINES_SPOKEN = 50 + +/** Gives the editor's node the description of its changed lines, when it has any. */ +internal fun Modifier.changeSemantics(model: CodeEditorModel, look: CodeEditorLook): Modifier { + val text = changeDescription(model, look) ?: return this + return semantics { stateDescription = text } +} + /** What a surface tells the editor that holds it. All of them are called on the UI thread. */ class CodeEditorCallbacks( /** The model has something queued for the Host. */ @@ -818,13 +868,13 @@ private fun FieldSurface( val rows = remember(contentRevision, overlayRevision) { RowMap.of(model) } val lineCount = rows.lineCount val digits = lineCount.toString().length - val hasMarks = model.gutterMarks.isNotEmpty() + val hasMarks = model.hasOrdinaryMarks val hasFolds = model.folds.isNotEmpty() val markColumnPx = if (hasMarks) rowPx else 0 val foldColumnPx = if (hasFolds) rowPx else 0 val gutterWidthPx = with(density) { digits * digitWidth + (look.gutterPadding * 2).roundToPx() + look.gutterDividerWidth.roundToPx() - } + markColumnPx + foldColumnPx + } + markColumnPx + foldColumnPx + density.changeColumnPx(model, look) // Refilling the window recomposes this, so the field moves to where the new lines are. window.revision @@ -870,6 +920,7 @@ private fun FieldSurface( Box( modifier .boundedHeight(DEFAULT_EDITOR_HEIGHT) + .changeSemantics(model, look) .background(look.container) .clipToBounds() .onSizeChanged { size -> @@ -1100,7 +1151,7 @@ internal fun pressGutter( if (foldColumn > 0 && point.x >= gutterWidth - foldColumn) { model.toggleFold(line) } else if (markColumn > 0 && point.x < markColumn) { - model.gutterMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) + model.ordinaryMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) } } @@ -1331,12 +1382,13 @@ private fun DrawScope.drawGutter( val last = min(rows.lineCount - 1, rows.lineAtRow((scroll + viewportHeight) / rowPx + 1)) val padding = look.gutterPadding.toPx() val divider = look.gutterDividerWidth.toPx() + val changeColumn = changeColumnPx(window.model, look) for (line in first..last) { if (rows.isHidden(line)) continue val label = (line + 1).toString() val color = if (line == caretLine) look.currentLineNumber else look.lineNumber val measured = measurer.measure(label, style.copy(color = color)) - val x = width - divider - foldColumn - padding - measured.size.width + val x = width - divider - foldColumn - changeColumn - padding - measured.size.width val top = rows.textRow(line) * rowPx.toFloat() drawText(measured, topLeft = Offset(x, top)) drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, measurer, style, look) @@ -1357,8 +1409,15 @@ internal fun DrawScope.drawGutterAdornments( look: CodeEditorLook, ) { val cell = rowPx.toFloat() + // The change bar sits right of the numbers, the full row high, so that the bars of + // neighbouring lines meet. + val bar = model.changeBarAt(line) + if (bar != null) { + val left = foldLeft - changeColumnPx(model, look) + drawRect(look.gutterMark(bar.color), Offset(left, top), Size(look.changeBarWidth.toPx(), cell)) + } if (markColumn > 0) { - for (mark in model.gutterMarksAt(line)) { + for (mark in model.ordinaryMarksAt(line)) { val color = look.gutterMark(mark.color) when (mark.kind) { GutterDecorationKind.Dot -> drawCircle(color, cell * 0.2f, Offset(markColumn / 2f, top + cell / 2f)) diff --git a/renderer/desktop/src/renderer/DrawnCodeEditor.kt b/renderer/desktop/src/renderer/DrawnCodeEditor.kt index 167343b1..aacb3d16 100644 --- a/renderer/desktop/src/renderer/DrawnCodeEditor.kt +++ b/renderer/desktop/src/renderer/DrawnCodeEditor.kt @@ -305,11 +305,11 @@ private fun DrawnSurface( val extent = rows.totalRows * rowPx - viewportHeight SideEffect { vertical.max = extent } val digitWidth = remember(look.textStyle) { measurer.measure("0", look.textStyle).size.width } - val markColumn = if (model.gutterMarks.isNotEmpty()) rowPx else 0 + val markColumn = if (model.hasOrdinaryMarks) rowPx else 0 val foldColumn = if (model.folds.isNotEmpty()) rowPx else 0 val gutterWidth = with(density) { rows.lineCount.toString().length * digitWidth + (look.gutterPadding * 2).toPx() + look.gutterDividerWidth.toPx() - } + markColumn + foldColumn + } + markColumn + foldColumn + density.changeColumnPx(model, look) state.textLeft = gutterWidth + with(density) { look.textInset.toPx() } // What the reader's edits queued is sent from here as well, on the composition thread. @@ -342,6 +342,7 @@ private fun DrawnSurface( Box( modifier .boundedHeight(DEFAULT_EDITOR_HEIGHT) + .changeSemantics(model, look) .background(look.container) .clipToBounds() .onSizeChanged { viewportHeight = it.height }, @@ -488,7 +489,7 @@ private fun DrawScope.drawLines( (line + 1).toString(), state.style.copy(color = if (line == selection.caret.line) look.currentLineNumber else look.lineNumber), ) - val padding = look.gutterPadding.toPx() + look.gutterDividerWidth.toPx() + foldColumn + val padding = look.gutterPadding.toPx() + look.gutterDividerWidth.toPx() + foldColumn + changeColumnPx(model, look) drawText(number, topLeft = Offset(gutterWidth - padding - number.size.width, top + (rowPx - number.size.height) / 2)) drawGutterAdornments( model, diff --git a/renderer/desktop/src/renderer/HostCodeEditor.kt b/renderer/desktop/src/renderer/HostCodeEditor.kt index 1d698958..38beff3d 100644 --- a/renderer/desktop/src/renderer/HostCodeEditor.kt +++ b/renderer/desktop/src/renderer/HostCodeEditor.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.design.HostPlatform import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.foundation.code.CodeEditorCallbacks @@ -108,6 +109,9 @@ internal fun codeEditorLook(node: Node, theme: ResolvedTheme, tabWidth: Int?): C // A mark takes the role the Host named, and otherwise the lens's quiet ink. gutterMark = { role -> if (role == null) style.lens else theme.color(role) }, foldMarker = style.lineNumber, + // The same for every design system: the width and gap of a change-marker bar. + changeBarWidth = 4.dp, + changeBarGap = 12.dp, ) } diff --git a/renderer/desktop/test/CodeEditorChangeBarTest.kt b/renderer/desktop/test/CodeEditorChangeBarTest.kt new file mode 100644 index 00000000..6ebc365f --- /dev/null +++ b/renderer/desktop/test/CodeEditorChangeBarTest.kt @@ -0,0 +1,190 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.foundation.code.ChangeMark +import dev.darkpyonix.composerust.foundation.code.CodeEditorLook +import dev.darkpyonix.composerust.foundation.code.CodeEditorModel +import dev.darkpyonix.composerust.foundation.code.CodePosition +import dev.darkpyonix.composerust.foundation.code.EditOrigin +import dev.darkpyonix.composerust.foundation.code.changeMark +import dev.darkpyonix.composerust.foundation.code.drawGutterAdornments +import dev.darkpyonix.composerust.protocol.ColorRole +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.ui.node.NodeTable +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +private const val EDITOR = 5 +private const val ROW = 20 +private const val MARK_COLUMN = 20 +private const val FOLD_LEFT = 150f +private const val ADDED = 39 +private const val REMOVED = 40 +private const val MODIFIED = 41 +private const val DOT = 1 +private const val BAR = 2 + +private val addedInk = Color(0f, 1f, 0f) +private val removedInk = Color(1f, 0f, 0f) +private val modifiedInk = Color(0f, 0f, 1f) +private val plainInk = Color(1f, 0f, 1f) + +private fun look(): CodeEditorLook = CodeEditorLook( + textStyle = TextStyle(), + container = Color.White, + gutter = Color.White, + lineNumber = Color.Black, + currentLineNumber = Color.Black, + currentLine = Color.Transparent, + currentLineBorder = Color.Transparent, + gutterDivider = Color.Transparent, + gutterDividerWidth = 1.dp, + gutterPadding = 8.dp, + textInset = 8.dp, + selection = Color.Gray, + cursor = Color.Black, + underlineWidth = 1.dp, + underline = { Color.Red to dev.darkpyonix.composerust.foundation.code.UnderlineShape.Straight }, + paint = { Color.Black }, + ghostTextAlpha = 0.5f, + lens = Color.Gray, + tabWidth = 4, + hoverDelayMillis = 300L, + commandIsMeta = false, + gutterMark = { role -> + when (role) { + ColorRole.DiffAdded -> addedInk + ColorRole.DiffRemoved -> removedInk + ColorRole.DiffModified -> modifiedInk + else -> plainInk + } + }, +) + +/** Gutter marks as (line, kind, colour code) records, applied through the interpreter. */ +private fun modelWith(text: String, vararg marks: Triple): CodeEditorModel { + val table = NodeTable() + table.apply(Mutation.Create(EDITOR, WidgetKind.CodeEditor)) + table.apply(Mutation.SetProp(EDITOR, PropertyKind.Text, PropertyValue.Text(text))) + val buffer = ByteBuffer.allocate(marks.size * 28).order(ByteOrder.LITTLE_ENDIAN) + for ((line, kind, color) in marks) { + buffer.putInt(0).putInt(line).putShort(kind.toShort()).putShort(color.toShort()).putLong(0L).putInt(0).putInt(0) + } + table.apply(Mutation.SetProp(EDITOR, PropertyKind.GutterDecorations, PropertyValue.Bytes(buffer.array()))) + return table.node(EDITOR)!!.code!! +} + +private val lines = (0 until 6).joinToString("\n") { "l$it" } + +@OptIn(ExperimentalTestApi::class) +class CodeEditorChangeBarTest { + /** Draws the gutter adornments of the first [rows] lines into a bitmap, one row per line. */ + private fun render(model: CodeEditorModel, rows: Int = 6): androidx.compose.ui.graphics.PixelMap { + var measurer: TextMeasurer? = null + val image = ImageBitmap(200, ROW * rows) + runComposeUiTest { + setContent { measurer = rememberTextMeasurer() } + waitForIdle() + } + val look = look() + val scope = CanvasDrawScope() + scope.draw(Density(1f), LayoutDirection.Ltr, Canvas(image), Size(200f, ROW * rows.toFloat())) { + for (line in 0 until rows) { + drawGutterAdornments( + model, line, line * ROW.toFloat(), ROW, + if (model.hasOrdinaryMarks) MARK_COLUMN else 0, + FOLD_LEFT, 0, measurer!!, TextStyle(), look, + ) + } + } + return image.toPixelMap() + } + + /** The bar starts at the fold column's left edge less 4dp and 12dp, and is 4 pixels wide. */ + private val barLeft = (FOLD_LEFT - 16).toInt() + + /** Criterion 16.1: four dp wide, right of the numbers, the full row, and neighbours meet. */ + @Test + fun fr38_change_bar_is_four_dp_wide_right_of_the_numbers() { + val model = modelWith(lines, Triple(1, BAR, ADDED), Triple(2, BAR, ADDED)) + val pixels = render(model) + for (y in ROW until 3 * ROW) { + assertEquals(addedInk, pixels[barLeft, y], "left edge at $y") + assertEquals(addedInk, pixels[barLeft + 3, y], "right edge at $y") + assertNotEquals(addedInk, pixels[barLeft - 1, y]) + assertNotEquals(addedInk, pixels[barLeft + 4, y]) + } + // Nothing above the first changed line or below the last. + assertNotEquals(addedInk, pixels[barLeft, ROW - 1]) + assertNotEquals(addedInk, pixels[barLeft, 3 * ROW]) + } + + /** Criterion 16.3: one bar per line, the strongest of the changes on it. */ + @Test + fun fr38_change_bars_priority_removed_modified_added() { + val model = modelWith( + lines, + Triple(1, BAR, ADDED), Triple(1, BAR, MODIFIED), Triple(1, BAR, REMOVED), + Triple(2, BAR, ADDED), Triple(2, BAR, MODIFIED), + ) + assertEquals(ChangeMark.Removed, model.changeBarAt(1)!!.changeMark()) + assertEquals(ChangeMark.Modified, model.changeBarAt(2)!!.changeMark()) + // The ones that lost stay in the model. + assertEquals(5, model.gutterMarks.size) + val pixels = render(model) + assertEquals(removedInk, pixels[barLeft + 1, ROW + 5]) + assertEquals(modifiedInk, pixels[barLeft + 1, 2 * ROW + 5]) + } + + /** Criterion 16.4: the bar goes where its line goes and leaves with it. */ + @Test + fun fr38_change_bar_follows_edits() { + val model = modelWith(lines, Triple(3, BAR, MODIFIED), Triple(4, BAR, ADDED)) + model.edit(CodePosition(0, 0), CodePosition(0, 0), "x\ny\n", EditOrigin.Reader) + assertEquals(ChangeMark.Modified, model.changeBarAt(5)!!.changeMark()) + assertNull(model.changeBarAt(3)) + // Deleting the changed line takes its bar, and the other one closes up. + model.edit(CodePosition(5, 0), CodePosition(6, 0), "", EditOrigin.Reader) + assertEquals(ChangeMark.Added, model.changeBarAt(5)!!.changeMark()) + assertEquals(mapOf(ChangeMark.Added to listOf(5)), model.changeLines()) + } + + /** Criterion 16.2: a dot and a plain bar stay in the mark column beside the change bars. */ + @Test + fun fr38_other_gutter_marks_are_unchanged_next_to_change_bars() { + val model = modelWith( + lines, + Triple(0, DOT, 0), Triple(1, BAR, 0), Triple(1, BAR, ADDED), Triple(2, BAR, MODIFIED), + ) + val pixels = render(model) + // The dot is centred in the mark column; the plain bar is centred in it, 0.2 of a row wide. + assertEquals(plainInk, pixels[MARK_COLUMN / 2, ROW / 2]) + assertEquals(plainInk, pixels[MARK_COLUMN / 2, ROW + 5]) + assertEquals(addedInk, pixels[barLeft + 1, ROW + 5]) + assertEquals(modifiedInk, pixels[barLeft + 1, 2 * ROW + 5]) + // A model without change bars reserves no room for them. + val plain = modelWith(lines, Triple(0, DOT, 0)) + assertEquals(false, plain.hasChangeBars) + assertEquals(true, model.hasChangeBars) + } +} diff --git a/renderer/desktop/test/CodeEditorTest.kt b/renderer/desktop/test/CodeEditorTest.kt index 3da71010..ec6dba1c 100644 --- a/renderer/desktop/test/CodeEditorTest.kt +++ b/renderer/desktop/test/CodeEditorTest.kt @@ -389,4 +389,29 @@ class CodeEditorTest { assertTrue(shown.contains("let line_50000 = 50000;"), "the field starts at ${shown.take(40)}") assertFalse(shown.startsWith("let line_0 = 0;")) } + + /** Criterion 16.5: a changed line is named in the editor's description, in words rather than colour. */ + @Test + fun fr38_change_bar_adds_a_word_to_the_accessibility_description() = runComposeUiTest { + hostPlatformOverride = HostPlatform.Windows + val buffer = ByteBuffer.allocate(2 * 28).order(ByteOrder.LITTLE_ENDIAN) + for ((line, color) in listOf(1 to 39, 3 to 41)) { + buffer.putInt(0).putInt(line).putShort(2).putShort(color.toShort()).putLong(0L).putInt(0).putInt(0) + } + val connection = FakeHostConnection( + editor( + DesignSystem.Material3, + "a\nb\nc\nd\n", + Mutation.SetProp(EDITOR, PropertyKind.GutterDecorations, PropertyValue.Bytes(buffer.array())), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + val described = onNodeWithTag(nodeTestTag(EDITOR), useUnmergedTree = true) + .fetchSemanticsNode() + .config[SemanticsProperties.StateDescription] + assertTrue(described.contains("2") && described.contains("4"), described) + assertTrue(described.contains("Added") || described.contains("추가됨"), described) + assertTrue(described.contains("Modified") || described.contains("수정됨"), described) + } } From 81bbba03d09d912ea2d7b8f10d1378d9582861aa Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:58:20 +0900 Subject: [PATCH 139/224] Docs: Add FR-71, the roles, states and actions of node accessibility (#473) --- docs/SPEC.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index 54e7953d..7d5bd046 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3822,6 +3822,29 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 시험 이름은 `fr70_`로 시작합니다. +### FR-71 노드 접근성의 역할, 상태, 동작 확장 (`Agreed`, 이슈 #473, 감독자 승인 2026-11-06) + +요청한 쪽: dioxus-compose의 HTML 경로(#170)와 darkpyonix-ember의 에디터 창 자동 감사(#302). FR-68의 `semantics`는 역할 여덟 개와 켜짐·선택 상태만 가집니다. 탭 줄, 트리, 메뉴, 제목, 목록 같은 구조와 펼침·비활성·숨김 상태, 접근성 트리에서 눌러 보는 동작이 없어서 화면 낭독기와 자동 감사가 컨트롤을 구분하지 못합니다. 이 항목은 FR-68의 `Semantics` 수정자(와이어 태그 34)와 접근성 이름(`AccessibleName`)을 **더하기만** 하는 방식으로 넓힙니다. 기존 값과 FR-68의 기준은 바뀌지 않습니다. + +**1단계 범위(이 저장소의 렌더러만)**: + +- **역할 열거**: FR-68의 `None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList` 뒤에 `TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group`을 이어 붙입니다(값 9부터 19까지). +- **상태 비트**: FR-68의 `checked`, `selected`는 그대로 두고 `expanded`(두 비트 세 값: 미지정, 닫힘, 열림), `disabled`, `focused`, `hidden`을 더합니다. 지정하지 않은 상태는 접근성 트리에 나가지 않습니다. `hidden`인 노드는 접근성 트리에서 빠집니다. 모르는 비트가 오면 FR-68과 같이 `ProtocolError`입니다. +- **동작 비트**: `click`(눌러 활성화)과 `focus`(포커스 이동). `click`은 그 노드의 기존 `OnClick` 또는 `Clickable` 핸들러에게 `Clicked`를 그대로 보냅니다. 핸들러가 없는 노드의 `click`은 아무 일도 하지 않습니다. `focus`는 노드의 포커스 요청과 같은 길(FR-65)을 탑니다. +- **플랫폼 표현**: Compose가 가진 역할(`Button`, `Checkbox`, `Switch`, `RadioButton`, `Tab`, `Image`, `DropdownList`)은 그대로 씁니다. `Heading`은 제목 속성으로, `TextBox`는 편집 가능 텍스트로 내보냅니다. Compose에 대응이 없는 나머지 역할은 1단계에서는 가장 가까운 표현(`Button`이나 이름 없는 그룹)에 접근성 이름 끝에 역할 이름을 붙이지 않고 `stateDescription`에 역할 이름을 싣습니다. 플랫폼 고유의 역할(macOS의 탭 그룹, 아웃라인, 메뉴)은 2단계입니다. +- **빈 이름**: 이름이 비어 있으면 자식 `Text`를 합치는 플랫폼 기본을 그대로 씁니다. +- 스키마 해시가 움직입니다(열거 값, 수정자 비트 추가, 추가 전용). + +**2단계(이 항목의 범위 밖, 이슈 #473에 이어서 적습니다)**: Compose 포크에 맞춤 의미 속성 키를 두고, 포크의 macOS, Windows, Linux 접근성 브리지가 그 키를 읽어 플랫폼 고유의 역할(`AXTabGroup`, `AXOutline`, `AXMenu` 등)로 내보냅니다. 창 코드이므로 포크의 변경이고 핀을 옮깁니다(19.8). + +수용 기준. +1. `TabList`부터 `Group`까지 새 역할 열한 개가 모두 와이어로 왕복하고, 접근성 노드가 비어 있지 않은 표현(역할 또는 `stateDescription`)을 가집니다(시험 이름 fr71_every_new_role_round_trips_and_is_exposed). +2. `expanded`, `disabled`, `focused`, `hidden` 상태가 접근성 노드에 나가고, 지정하지 않은 상태는 나가지 않습니다. `hidden`인 노드는 접근성 트리에 없습니다(시험 이름 fr71_state_bits_reach_the_accessibility_node). +3. `click` 동작을 부르면 그 노드의 `OnClick` 핸들러가 불리고 `Clicked` 이벤트가 정확히 한 번 갑니다. 핸들러가 없는 노드에서는 아무 이벤트도 가지 않습니다(시험 이름 fr71_click_action_calls_the_nodes_click_handler). +4. 모르는 상태 비트나 동작 비트는 `ProtocolError`가 되고 프로세스는 계속 돕니다(시험 이름 nfr7_unknown_semantics_bit_is_a_protocol_error). +5. 수정자를 떼면(제거 전달 뒤) 확장 상태와 동작이 사라지고 FR-68의 기본으로 돌아옵니다(시험 이름 fr71_removing_semantics_restores_the_defaults). +6. macOS에서 `Tab`, `Button`, `Heading` 노드가 윈도 아래 접근성 요소로 보이고 `AXPress`가 `click`을 부릅니다. 사람 손으로 확인하는 항목입니다(System Events의 `UI elements of group 1 of window 1`로 열거합니다). + ### FR-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. From 4a6cb153101fb76b7d716becf99e936da94ecfd3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:04:24 +0900 Subject: [PATCH 140/224] Fix: Keep the macOS rule test from naming the planning documents --- scripts/tests/needs-macos.test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/needs-macos.test.sh b/scripts/tests/needs-macos.test.sh index 0b5aeade..2c825fab 100755 --- a/scripts/tests/needs-macos.test.sh +++ b/scripts/tests/needs-macos.test.sh @@ -10,7 +10,7 @@ check() { # expected description files... local got; got="$(printf '%s\n' "$@" | "$script")" if [[ "$got" == "macos=$expected" ]]; then echo "ok - $what"; else echo "FAIL - $what: $got"; failures=$((failures + 1)); fi } -check false "docs only" docs/SPEC.md docs/guide/index.md +check false "docs only" docs/notes/plan.txt docs/guide/index.md check false "root markdown and licence" README.md CHANGELOG.md PROJECT.md LICENSE check false "workflows no shell test reads" .github/workflows/pages.yml .github/workflows/windows-static.yml check true "a workflow the shell tests read" .github/workflows/test-native-renderer.yml @@ -22,7 +22,7 @@ check true "test.yml itself" .github/workflows/test.yml check true "the reusable fork workflow" .github/workflows/fork-desktop-modules.yml check true "a CI script (the shell suite tests it on macOS)" .github/scripts/package-static-renderer.sh check true "Rust source" compose-rust/src/lib.rs -check true "a docs change beside Rust source" docs/SPEC.md compose-rust/src/lib.rs +check true "a docs change beside Rust source" docs/notes/plan.txt compose-rust/src/lib.rs check true "the macOS renderer" renderer/macos/src/Main.kt check true "a root file nobody listed" Cargo.toml check true "a script under scripts/" scripts/check.sh From 879b8385fe26e7734d7377ee852cf60e7e8f7cfd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:15:43 +0900 Subject: [PATCH 141/224] Fix: Make the gutter change-bar draw path allocation free (FR-38 criterion 16) changeBarAt and the ordinary-mark walk are indexed loops, the change column is computed once per frame, hasChangeBars is cached per gutter stamp, the accessibility description is remembered, English words are lowercase. Tests: fr38_gutter_draw_does_not_allocate_without_change_bars, fr38_gutter_draw_does_not_allocate_with_change_bars. --- .../android-kotlin/shared/CodeEditorModel.kt | 65 +++++++++++++++++-- .../shared/CodeEditorSurface.kt | 20 ++++-- .../desktop/src/renderer/CodeEditorModel.kt | 65 +++++++++++++++++-- .../desktop/src/renderer/CodeEditorSurface.kt | 20 ++++-- .../desktop/src/renderer/DrawnCodeEditor.kt | 4 +- .../desktop/test/CodeEditorChangeBarTest.kt | 58 ++++++++++++++++- renderer/desktop/test/CodeEditorTest.kt | 4 +- 7 files changed, 210 insertions(+), 26 deletions(-) diff --git a/compose-rust/android-kotlin/shared/CodeEditorModel.kt b/compose-rust/android-kotlin/shared/CodeEditorModel.kt index 25841516..c5c6824d 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorModel.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorModel.kt @@ -203,6 +203,12 @@ class CodeEditorModel( private val foldList = ArrayList() private val gutterList = ArrayList() + /** Changes whenever the gutter marks are replaced, moved or dropped, for the flags below. */ + private var gutterStamp = 0 + private var flagsStamp = -1 + private var changeBarsPresent = false + private var ordinaryMarksPresent = false + /** Changes whenever a fold is added, moved, removed or flipped, for the cache below. */ private var foldStamp = 0 private var hiddenStamp = -1 @@ -263,6 +269,7 @@ class CodeEditorModel( redoStack.clear() foldList.clear() gutterList.clear() + gutterStamp += 1 foldStamp += 1 selection = CodeSelection.at(CodePosition.Zero) contentRevision += 1 @@ -584,6 +591,7 @@ class CodeEditorModel( /** Replaces every mark beside the line numbers with the Host's new list. */ fun setGutterDecorations(records: List) { gutterList.clear() + gutterStamp += 1 for (record in records) { if (record.kind == GutterDecorationKind.Glyph && !isScalarValue(record.glyph)) { output += EditorOutput.Error("a gutter glyph is ${record.glyph}, which is not a Unicode scalar value") @@ -738,18 +746,66 @@ class CodeEditorModel( fun ordinaryMarksAt(line: Int): List = gutterList.filter { it.line == line && it.changeMark() == null } + /** + * The index in [gutterMarks] of the first mark at or after [from] that sits on [line] in + * the mark column, or -1. Drawing walks the marks with this instead of building a list + * per line, so a frame allocates nothing for them. + */ + fun nextOrdinaryMarkIndex(line: Int, from: Int): Int { + if (!ordinaryMarksPresent()) return -1 + for (i in from until gutterList.size) { + val mark = gutterList[i] + if (mark.line == line && mark.changeMark() == null) return i + } + return -1 + } + + private fun refreshMarkFlags() { + if (flagsStamp == gutterStamp) return + var bars = false + var ordinary = false + for (i in 0 until gutterList.size) { + if (gutterList[i].changeMark() != null) bars = true else ordinary = true + } + changeBarsPresent = bars + ordinaryMarksPresent = ordinary + flagsStamp = gutterStamp + } + + private fun ordinaryMarksPresent(): Boolean { + refreshMarkFlags() + return ordinaryMarksPresent + } + /** Whether any mark is a change bar, which is what makes the gutter reserve room for them. */ - val hasChangeBars: Boolean get() = gutterList.any { it.changeMark() != null } + val hasChangeBars: Boolean + get() { + refreshMarkFlags() + return changeBarsPresent + } /** Whether any mark belongs in the mark column. */ - val hasOrdinaryMarks: Boolean get() = gutterList.any { it.changeMark() == null } + val hasOrdinaryMarks: Boolean get() = ordinaryMarksPresent() /** * The one change bar a line shows. Several can sit on a line; the others stay in the * model and keep following edits, but only the strongest is drawn. */ - fun changeBarAt(line: Int): LiveGutterMark? = - gutterList.filter { it.line == line && it.changeMark() != null }.minByOrNull { it.changeMark()!!.ordinal } + fun changeBarAt(line: Int): LiveGutterMark? { + if (!hasChangeBars) return null + var best: LiveGutterMark? = null + var bestRank = Int.MAX_VALUE + for (i in 0 until gutterList.size) { + val mark = gutterList[i] + if (mark.line != line) continue + val rank = mark.changeMark()?.ordinal ?: continue + if (rank < bestRank) { + best = mark + bestRank = rank + } + } + return best + } /** The lines that show a change bar, by the change they show, in line order. */ fun changeLines(): Map> { @@ -986,6 +1042,7 @@ class CodeEditorModel( moved = true } if (gutterList.isNotEmpty()) { + gutterStamp += 1 val iterator = gutterList.iterator() while (iterator.hasNext()) { val mark = iterator.next() diff --git a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt index ca48cf43..ad9054ea 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt @@ -187,9 +187,9 @@ internal fun localizedChangeMarkWord(change: ChangeMark, language: String = andr ChangeMark.Removed -> "삭제됨" } else -> when (change) { - ChangeMark.Added -> "Added" - ChangeMark.Modified -> "Modified" - ChangeMark.Removed -> "Removed" + ChangeMark.Added -> "added" + ChangeMark.Modified -> "modified" + ChangeMark.Removed -> "removed" } } @@ -215,8 +215,10 @@ internal fun changeDescription(model: CodeEditorModel, look: CodeEditorLook): St private const val CHANGE_LINES_SPOKEN = 50 /** Gives the editor's node the description of its changed lines, when it has any. */ +@Composable internal fun Modifier.changeSemantics(model: CodeEditorModel, look: CodeEditorLook): Modifier { - val text = changeDescription(model, look) ?: return this + val revision = model.overlayRevision + val text = remember(model, revision, look) { changeDescription(model, look) } ?: return this return semantics { stateDescription = text } } @@ -1391,7 +1393,7 @@ private fun DrawScope.drawGutter( val x = width - divider - foldColumn - changeColumn - padding - measured.size.width val top = rows.textRow(line) * rowPx.toFloat() drawText(measured, topLeft = Offset(x, top)) - drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, measurer, style, look) + drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, changeColumn, measurer, style, look) } } @@ -1404,6 +1406,7 @@ internal fun DrawScope.drawGutterAdornments( markColumn: Int, foldLeft: Float, foldColumn: Int, + changeColumn: Int, measurer: TextMeasurer, style: TextStyle, look: CodeEditorLook, @@ -1413,11 +1416,14 @@ internal fun DrawScope.drawGutterAdornments( // neighbouring lines meet. val bar = model.changeBarAt(line) if (bar != null) { - val left = foldLeft - changeColumnPx(model, look) + val left = foldLeft - changeColumn drawRect(look.gutterMark(bar.color), Offset(left, top), Size(look.changeBarWidth.toPx(), cell)) } if (markColumn > 0) { - for (mark in model.ordinaryMarksAt(line)) { + var index = model.nextOrdinaryMarkIndex(line, 0) + while (index >= 0) { + val mark = model.gutterMarks[index] + index = model.nextOrdinaryMarkIndex(line, index + 1) val color = look.gutterMark(mark.color) when (mark.kind) { GutterDecorationKind.Dot -> drawCircle(color, cell * 0.2f, Offset(markColumn / 2f, top + cell / 2f)) diff --git a/renderer/desktop/src/renderer/CodeEditorModel.kt b/renderer/desktop/src/renderer/CodeEditorModel.kt index 25841516..c5c6824d 100644 --- a/renderer/desktop/src/renderer/CodeEditorModel.kt +++ b/renderer/desktop/src/renderer/CodeEditorModel.kt @@ -203,6 +203,12 @@ class CodeEditorModel( private val foldList = ArrayList() private val gutterList = ArrayList() + /** Changes whenever the gutter marks are replaced, moved or dropped, for the flags below. */ + private var gutterStamp = 0 + private var flagsStamp = -1 + private var changeBarsPresent = false + private var ordinaryMarksPresent = false + /** Changes whenever a fold is added, moved, removed or flipped, for the cache below. */ private var foldStamp = 0 private var hiddenStamp = -1 @@ -263,6 +269,7 @@ class CodeEditorModel( redoStack.clear() foldList.clear() gutterList.clear() + gutterStamp += 1 foldStamp += 1 selection = CodeSelection.at(CodePosition.Zero) contentRevision += 1 @@ -584,6 +591,7 @@ class CodeEditorModel( /** Replaces every mark beside the line numbers with the Host's new list. */ fun setGutterDecorations(records: List) { gutterList.clear() + gutterStamp += 1 for (record in records) { if (record.kind == GutterDecorationKind.Glyph && !isScalarValue(record.glyph)) { output += EditorOutput.Error("a gutter glyph is ${record.glyph}, which is not a Unicode scalar value") @@ -738,18 +746,66 @@ class CodeEditorModel( fun ordinaryMarksAt(line: Int): List = gutterList.filter { it.line == line && it.changeMark() == null } + /** + * The index in [gutterMarks] of the first mark at or after [from] that sits on [line] in + * the mark column, or -1. Drawing walks the marks with this instead of building a list + * per line, so a frame allocates nothing for them. + */ + fun nextOrdinaryMarkIndex(line: Int, from: Int): Int { + if (!ordinaryMarksPresent()) return -1 + for (i in from until gutterList.size) { + val mark = gutterList[i] + if (mark.line == line && mark.changeMark() == null) return i + } + return -1 + } + + private fun refreshMarkFlags() { + if (flagsStamp == gutterStamp) return + var bars = false + var ordinary = false + for (i in 0 until gutterList.size) { + if (gutterList[i].changeMark() != null) bars = true else ordinary = true + } + changeBarsPresent = bars + ordinaryMarksPresent = ordinary + flagsStamp = gutterStamp + } + + private fun ordinaryMarksPresent(): Boolean { + refreshMarkFlags() + return ordinaryMarksPresent + } + /** Whether any mark is a change bar, which is what makes the gutter reserve room for them. */ - val hasChangeBars: Boolean get() = gutterList.any { it.changeMark() != null } + val hasChangeBars: Boolean + get() { + refreshMarkFlags() + return changeBarsPresent + } /** Whether any mark belongs in the mark column. */ - val hasOrdinaryMarks: Boolean get() = gutterList.any { it.changeMark() == null } + val hasOrdinaryMarks: Boolean get() = ordinaryMarksPresent() /** * The one change bar a line shows. Several can sit on a line; the others stay in the * model and keep following edits, but only the strongest is drawn. */ - fun changeBarAt(line: Int): LiveGutterMark? = - gutterList.filter { it.line == line && it.changeMark() != null }.minByOrNull { it.changeMark()!!.ordinal } + fun changeBarAt(line: Int): LiveGutterMark? { + if (!hasChangeBars) return null + var best: LiveGutterMark? = null + var bestRank = Int.MAX_VALUE + for (i in 0 until gutterList.size) { + val mark = gutterList[i] + if (mark.line != line) continue + val rank = mark.changeMark()?.ordinal ?: continue + if (rank < bestRank) { + best = mark + bestRank = rank + } + } + return best + } /** The lines that show a change bar, by the change they show, in line order. */ fun changeLines(): Map> { @@ -986,6 +1042,7 @@ class CodeEditorModel( moved = true } if (gutterList.isNotEmpty()) { + gutterStamp += 1 val iterator = gutterList.iterator() while (iterator.hasNext()) { val mark = iterator.next() diff --git a/renderer/desktop/src/renderer/CodeEditorSurface.kt b/renderer/desktop/src/renderer/CodeEditorSurface.kt index ca48cf43..ad9054ea 100644 --- a/renderer/desktop/src/renderer/CodeEditorSurface.kt +++ b/renderer/desktop/src/renderer/CodeEditorSurface.kt @@ -187,9 +187,9 @@ internal fun localizedChangeMarkWord(change: ChangeMark, language: String = andr ChangeMark.Removed -> "삭제됨" } else -> when (change) { - ChangeMark.Added -> "Added" - ChangeMark.Modified -> "Modified" - ChangeMark.Removed -> "Removed" + ChangeMark.Added -> "added" + ChangeMark.Modified -> "modified" + ChangeMark.Removed -> "removed" } } @@ -215,8 +215,10 @@ internal fun changeDescription(model: CodeEditorModel, look: CodeEditorLook): St private const val CHANGE_LINES_SPOKEN = 50 /** Gives the editor's node the description of its changed lines, when it has any. */ +@Composable internal fun Modifier.changeSemantics(model: CodeEditorModel, look: CodeEditorLook): Modifier { - val text = changeDescription(model, look) ?: return this + val revision = model.overlayRevision + val text = remember(model, revision, look) { changeDescription(model, look) } ?: return this return semantics { stateDescription = text } } @@ -1391,7 +1393,7 @@ private fun DrawScope.drawGutter( val x = width - divider - foldColumn - changeColumn - padding - measured.size.width val top = rows.textRow(line) * rowPx.toFloat() drawText(measured, topLeft = Offset(x, top)) - drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, measurer, style, look) + drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, changeColumn, measurer, style, look) } } @@ -1404,6 +1406,7 @@ internal fun DrawScope.drawGutterAdornments( markColumn: Int, foldLeft: Float, foldColumn: Int, + changeColumn: Int, measurer: TextMeasurer, style: TextStyle, look: CodeEditorLook, @@ -1413,11 +1416,14 @@ internal fun DrawScope.drawGutterAdornments( // neighbouring lines meet. val bar = model.changeBarAt(line) if (bar != null) { - val left = foldLeft - changeColumnPx(model, look) + val left = foldLeft - changeColumn drawRect(look.gutterMark(bar.color), Offset(left, top), Size(look.changeBarWidth.toPx(), cell)) } if (markColumn > 0) { - for (mark in model.ordinaryMarksAt(line)) { + var index = model.nextOrdinaryMarkIndex(line, 0) + while (index >= 0) { + val mark = model.gutterMarks[index] + index = model.nextOrdinaryMarkIndex(line, index + 1) val color = look.gutterMark(mark.color) when (mark.kind) { GutterDecorationKind.Dot -> drawCircle(color, cell * 0.2f, Offset(markColumn / 2f, top + cell / 2f)) diff --git a/renderer/desktop/src/renderer/DrawnCodeEditor.kt b/renderer/desktop/src/renderer/DrawnCodeEditor.kt index aacb3d16..b9dc70f4 100644 --- a/renderer/desktop/src/renderer/DrawnCodeEditor.kt +++ b/renderer/desktop/src/renderer/DrawnCodeEditor.kt @@ -452,6 +452,7 @@ private fun DrawScope.drawLines( drawRect(look.gutterDivider, Offset(gutterWidth - width, scroll.toFloat()), Size(width, viewportHeight.toFloat())) } val lenses = model.lensesByLine().toMap() + val changeColumn = changeColumnPx(model, look) for (line in first..last) { if (rows.isHidden(line)) continue val view = state.view(line) @@ -489,7 +490,7 @@ private fun DrawScope.drawLines( (line + 1).toString(), state.style.copy(color = if (line == selection.caret.line) look.currentLineNumber else look.lineNumber), ) - val padding = look.gutterPadding.toPx() + look.gutterDividerWidth.toPx() + foldColumn + changeColumnPx(model, look) + val padding = look.gutterPadding.toPx() + look.gutterDividerWidth.toPx() + foldColumn + changeColumn drawText(number, topLeft = Offset(gutterWidth - padding - number.size.width, top + (rowPx - number.size.height) / 2)) drawGutterAdornments( model, @@ -499,6 +500,7 @@ private fun DrawScope.drawLines( markColumn, gutterWidth - look.gutterDividerWidth.toPx() - foldColumn, foldColumn, + changeColumn, state.measurer!!, state.style, look, diff --git a/renderer/desktop/test/CodeEditorChangeBarTest.kt b/renderer/desktop/test/CodeEditorChangeBarTest.kt index 6ebc365f..2094001c 100644 --- a/renderer/desktop/test/CodeEditorChangeBarTest.kt +++ b/renderer/desktop/test/CodeEditorChangeBarTest.kt @@ -19,6 +19,7 @@ import dev.darkpyonix.composerust.foundation.code.CodeEditorLook import dev.darkpyonix.composerust.foundation.code.CodeEditorModel import dev.darkpyonix.composerust.foundation.code.CodePosition import dev.darkpyonix.composerust.foundation.code.EditOrigin +import dev.darkpyonix.composerust.foundation.code.changeColumnPx import dev.darkpyonix.composerust.foundation.code.changeMark import dev.darkpyonix.composerust.foundation.code.drawGutterAdornments import dev.darkpyonix.composerust.protocol.ColorRole @@ -113,7 +114,7 @@ class CodeEditorChangeBarTest { drawGutterAdornments( model, line, line * ROW.toFloat(), ROW, if (model.hasOrdinaryMarks) MARK_COLUMN else 0, - FOLD_LEFT, 0, measurer!!, TextStyle(), look, + FOLD_LEFT, 0, with(Density(1f)) { changeColumnPx(model, look) }, measurer!!, TextStyle(), look, ) } } @@ -187,4 +188,59 @@ class CodeEditorChangeBarTest { assertEquals(false, plain.hasChangeBars) assertEquals(true, model.hasChangeBars) } + + private val threads = java.lang.management.ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean + + /** + * Bytes this thread allocates per call of the gutter drawing for one line, after a + * warm-up, averaged over many calls. + */ + private fun bytesPerLine(model: CodeEditorModel): Long { + var measurer: TextMeasurer? = null + runComposeUiTest { + setContent { measurer = rememberTextMeasurer() } + waitForIdle() + } + val look = look() + val density = Density(1f) + val style = TextStyle() + val image = ImageBitmap(200, ROW * 6) + var bytes = 0L + CanvasDrawScope().draw(density, LayoutDirection.Ltr, Canvas(image), Size(200f, ROW * 6f)) { + val markColumn = if (model.hasOrdinaryMarks) MARK_COLUMN else 0 + fun pass() { + val changeColumn = changeColumnPx(model, look) + for (line in 0 until 6) { + drawGutterAdornments(model, line, line * ROW.toFloat(), ROW, markColumn, FOLD_LEFT, 0, changeColumn, measurer!!, style, look) + } + } + repeat(2_000) { pass() } + val before = threads.currentThreadAllocatedBytes + repeat(10_000) { pass() } + bytes = (threads.currentThreadAllocatedBytes - before) / (10_000L * 6) + } + return bytes + } + + /** + * Drawing the gutter of a line allocates nothing the editor keeps up. Mark lookups walk + * the mark list by index, so the only bytes left are the temporaries of the drawing + * calls themselves (offsets and sizes the JIT may not elide). The ceiling is 256 bytes + * per line, which a per-line list or iterator would exceed several times over. + */ + @Test + fun fr38_gutter_draw_does_not_allocate_without_change_bars() { + val model = modelWith(lines, Triple(1, DOT, 0), Triple(3, BAR, 0)) + assertEquals(false, model.hasChangeBars) + val bytes = bytesPerLine(model) + kotlin.test.assertTrue(bytes < 256, "allocated $bytes bytes per line") + } + + @Test + fun fr38_gutter_draw_does_not_allocate_with_change_bars() { + val model = modelWith(lines, Triple(1, BAR, ADDED), Triple(2, BAR, MODIFIED), Triple(2, BAR, REMOVED)) + assertEquals(true, model.hasChangeBars) + val bytes = bytesPerLine(model) + kotlin.test.assertTrue(bytes < 256, "allocated $bytes bytes per line") + } } diff --git a/renderer/desktop/test/CodeEditorTest.kt b/renderer/desktop/test/CodeEditorTest.kt index ec6dba1c..7093113d 100644 --- a/renderer/desktop/test/CodeEditorTest.kt +++ b/renderer/desktop/test/CodeEditorTest.kt @@ -411,7 +411,7 @@ class CodeEditorTest { .fetchSemanticsNode() .config[SemanticsProperties.StateDescription] assertTrue(described.contains("2") && described.contains("4"), described) - assertTrue(described.contains("Added") || described.contains("추가됨"), described) - assertTrue(described.contains("Modified") || described.contains("수정됨"), described) + assertTrue(described.contains("added") || described.contains("추가됨"), described) + assertTrue(described.contains("modified") || described.contains("수정됨"), described) } } From 321e88eb45f4702be994b2a57fc9aa6cd3978cb3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:31:00 +0900 Subject: [PATCH 142/224] Chore: Do not start the macOS Kotlin/Native test for the Windows build's own files --- .github/workflows/test-kn-desktop-renderer.yml | 5 +++++ scripts/tests/native-needs-macos.test.sh | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/test-kn-desktop-renderer.yml b/.github/workflows/test-kn-desktop-renderer.yml index 13f67df5..3fa82560 100644 --- a/.github/workflows/test-kn-desktop-renderer.yml +++ b/.github/workflows/test-kn-desktop-renderer.yml @@ -37,6 +37,11 @@ on: - renderer/desktop/src/** - renderer/desktop/module.yaml - renderer/desktop/scripts/** + # The Windows build's own files feed no job here (macOS and Linux modules only). + - '!renderer/desktop/scripts/build-windows.sh' + - '!renderer/desktop/scripts/*.ps1' + - '!renderer/desktop/scripts/windows-build-contract.test.sh' + - '!renderer/desktop/scripts/windows-metadata/**' - renderer/ios/src/shared/** - renderer/scripts/** - renderer/kotlin diff --git a/scripts/tests/native-needs-macos.test.sh b/scripts/tests/native-needs-macos.test.sh index 3bf66e8e..c9ab8729 100755 --- a/scripts/tests/native-needs-macos.test.sh +++ b/scripts/tests/native-needs-macos.test.sh @@ -40,5 +40,11 @@ PY else echo "skip - no PyYAML for the wiring check" fi +# The macOS Kotlin/Native renderer test is not started by the Windows build's own files. +kn="$repo_root/.github/workflows/test-kn-desktop-renderer.yml" +for excluded in build-windows.sh '*.ps1' windows-build-contract.test.sh 'windows-metadata/**'; do + if grep -qF "'!renderer/desktop/scripts/$excluded'" "$kn"; then echo "ok - kn-desktop filter leaves out $excluded" + else echo "FAIL - kn-desktop filter does not exclude $excluded"; failures=$((failures + 1)); fi +done [[ "$failures" -eq 0 ]] || exit 1 echo "all passed" From 220958be6aa1c40421b78b44a64bec9513eac28a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:41:29 +0900 Subject: [PATCH 143/224] Docs: FR-20.8 lets the implicit root window opt in through its window settings (#479) --- docs/SPEC.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index f3cb4290..e795cc7a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1545,7 +1545,7 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 20.4의 규칙은 클래스(와 19.9의 안전 영역)가 바뀔 때만 `WindowSizeChanged`를 보냅니다. 창 크기로 페이지를 짜는 Host(HTML과 CSS 호스트)는 클래스 안에서 1280x820이 1000x620으로 줄어도 다시 배치할 수 없습니다. `window_size()`가 마지막 클래스 변경 때의 크기를 계속 돌려주기 때문입니다. -- **옵트인**: `Window` 노드의 불리언 속성 `report_size`(AttrKey 127, 기본 `false`). Rust에서는 `Window(on_close).report_size(true)`입니다. `Window` 노드가 없는 암묵적 단일 창(루트)은 옵트인할 수 없고 항상 지금의 동작입니다. +- **옵트인**: `Window` 노드의 불리언 속성 `report_size`(AttrKey 127, 기본 `false`). Rust에서는 `Window(on_close).report_size(true)`입니다. `Window` 노드가 없는 암묵적 단일 창(루트, 이슈 #479)은 창 설정의 `report_size`로 옵트인합니다. Rust에서는 `Window::new().with_report_size(true)`이고, `SetWindow` 레코드의 플래그 워드에 비트 하나를 더해 싣습니다(레코드 길이는 그대로, 끈 창의 바이트도 그대로입니다). - **켠 창**: 클래스와 안전 영역이 그대로여도, 창의 폭 또는 높이가 **반올림한 dp**로 바뀌면 같은 `WindowSizeChanged`를 보냅니다. 레코드와 이벤트 태그는 바뀌지 않고 이미 실린 `width_dp`, `height_dp`가 새 크기입니다. - **반올림 규칙**: 폭과 높이를 각각 가장 가까운 정수 dp로 반올림합니다(0.5는 올림). 직전에 보낸 값과 반올림한 값이 같으면 보내지 않으므로 1dp 미만의 흔들림은 무시합니다. `Float`를 그대로 비교하지 않습니다. 보내는 `width_dp`와 `height_dp`는 반올림하지 않은 측정값입니다. - **끈 창**: 지금과 한 바이트도 다르지 않습니다. 기본 동작, 호출 수, 할당이 그대로입니다. @@ -1557,6 +1557,9 @@ if window.class == WindowSizeClass::Expanded { /* 사이드바 */ } 1. `report_size(true)`인 창은 같은 클래스 안에서 1280x820에서 1000x620으로 줄이면 `WindowSizeChanged`를 받고, 그 `width_dp`, `height_dp`가 새 크기입니다(`fr20_8_opted_in_window_reports_dp_change_inside_a_class`). 2. 기본(`false`)인 창은 같은 클래스 안의 크기 변화에 이벤트를 보내지 않습니다. 20.7의 3번이 그대로 통과합니다(`fr20_8_default_window_still_reports_only_class_change`). 3. 켠 창에서 반올림한 dp가 같은 변화(1dp 미만)는 이벤트를 보내지 않고, 정수 dp가 하나 바뀌면 한 번만 보냅니다(`fr20_8_opted_in_window_ignores_sub_dp_jitter`). +4. 암묵적 단일 창이 `with_report_size(true)`로 열렸으면 같은 클래스 안의 1280x820에서 1000x620 변화에도 `WindowSizeChanged`를 받습니다(시험 이름 fr20_8_implicit_root_window_reports_dp_change_inside_a_class). +5. `with_report_size`를 부르지 않은 암묵적 단일 창은 지금과 같고, `SetWindow` 레코드의 바이트도 같습니다(시험 이름 fr20_8_implicit_root_window_default_is_byte_identical). +6. `SetWindow`의 플래그 워드에서 쓰이지 않는 다른 비트는 여전히 `ProtocolError`입니다(시험 이름 fr20_8_set_window_rejects_unknown_flag_bits). #### 20.9 안전 영역은 알려지는 때 도착합니다 (`Agreed`, 이슈 #468, 감독자 승인) From b965e821079794ee82d86eca68789c03fe2e0c31 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:54:55 +0900 Subject: [PATCH 144/224] Chore: Regenerate the protocol files after merging FR-68 and develop (FR-67) AccessibleName 119, the FR-67 controls 121 to 126 and ReportSize 127 now sit together in the descriptor; the staged Android copy is restaged. --- compose-rust/android-kotlin/shared/Protocol.gen.kt | 13 +++++++++++-- compose-rust/schema-hash.txt | 2 +- compose-rust/tests/vectors/vectors.json | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 13 +++++++++++-- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index bd8a57d8..8293dc7b 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Progress } enum class Key { Enter } @@ -1249,6 +1249,9 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation } sealed interface HostEvent { @@ -1301,7 +1304,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -9126323327933126420L + const val SCHEMA_HASH: Long = 5970700763455927495L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1329,6 +1332,7 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1478,6 +1482,10 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the @@ -2196,6 +2204,7 @@ object Protocol { 124 -> PropertyKind.ContainerColor 125 -> PropertyKind.CheckColor 126 -> PropertyKind.ControlSize + 127 -> PropertyKind.ReportSize 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 78d39629..f5a6256a 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x8158c9495d2f68ec +0x52dc30b5d1f1b4c7 diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index e90039ed..35a91aa7 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "9320420745776425196", + "schemaHashUnsigned": "5970700763455927495", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index bd8a57d8..8293dc7b 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Progress } enum class Key { Enter } @@ -1249,6 +1249,9 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation } sealed interface HostEvent { @@ -1301,7 +1304,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -9126323327933126420L + const val SCHEMA_HASH: Long = 5970700763455927495L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1329,6 +1332,7 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1478,6 +1482,10 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the @@ -2196,6 +2204,7 @@ object Protocol { 124 -> PropertyKind.ContainerColor 125 -> PropertyKind.CheckColor 126 -> PropertyKind.ControlSize + 127 -> PropertyKind.ReportSize 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } From 73f428da1589f0bedae6c2df973598e14412fbd2 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:04:48 +0900 Subject: [PATCH 145/224] Feat: The implicit root window opts in to whole-dp size reports (FR-20.8, #479) Window::new().with_report_size(true) sets bit 1 of the SetWindow flags word. The record length and the bytes of a default window are unchanged. The schema hash is regenerated with the rest of the generated files. --- compose-rust/src/codegen.rs | 16 +++- compose-rust/src/protocol.rs | 75 ++++++++++++++++++- compose-rust/src/schema.rs | 11 +++ .../desktop/src/renderer/ComposeRustHost.kt | 7 +- renderer/desktop/src/renderer/NodeTable.kt | 10 +++ renderer/desktop/test/WindowSizeTest.kt | 56 ++++++++++++++ 6 files changed, 164 insertions(+), 11 deletions(-) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index fa557cc0..5a49b3fe 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -201,6 +201,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) "#, @@ -752,14 +757,15 @@ object Protocol { } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second byte. Anything else standing is a record // the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -786,6 +792,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -2878,7 +2885,8 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { .with_size(800, 600) .with_min_size(320, 240) .resizable(false) - .with_zoom_level(-2), + .with_zoom_level(-2) + .with_report_size(true), ), Mutation::Create { node_id: 20, diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 6a83b57a..7aeffefd 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1364,11 +1364,13 @@ impl BatchEncoder { self.put_u16(window.height); self.put_u16(window.min_width); self.put_u16(window.min_height); - // Bit 0 is `resizable`. The mode and the last window policy ride in the - // bits above it that were always zero, so a window that asks for one - // window writes the bytes it always wrote. + // Bit 0 is `resizable` and bit 1 asks for every whole-dp size change. The + // mode and the last window policy ride in the bits above them that were + // always zero, so a window that asks for none writes the bytes it always + // wrote. self.put_u16( u16::from(window.resizable) + | (u16::from(window.report_size) << 1) | ((window.mode as u16) << WINDOW_MODE_SHIFT) | ((window.last_window_closed as u16) << WINDOW_POLICY_SHIFT), ); @@ -1805,10 +1807,11 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { // nibbles above the second byte. Anything else standing is a record the // sides disagree on. let flags = read_u16(bytes, payload + 12)?; - if flags & 0x00FE != 0 { + if flags & 0x00FC != 0 { return Err(ProtocolError::InvalidValueKind(flags)); } let resizable = flags & 1; + let report_size = flags & 0x0002 != 0; let mode_tag = (flags >> WINDOW_MODE_SHIFT) & 0xF; let policy_tag = flags >> WINDOW_POLICY_SHIFT; let zoom = read_u16(bytes, payload + 14)?; @@ -1836,6 +1839,7 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { min_height: read_u16(bytes, payload + 10)?, resizable: resizable == 1, zoom_level, + report_size, mode: crate::schema::WindowMode::try_from(mode_tag) .map_err(|()| ProtocolError::InvalidValueKind(flags))?, last_window_closed: crate::schema::LastWindowClosed::try_from(policy_tag) @@ -3257,6 +3261,69 @@ mod tests { ); } + /// Bit 1 of the flags word asks the implicit window for every whole-dp size change. A + /// window that does not ask encodes the bytes it always did. + #[test] + fn fr20_8_set_window_report_size_bit_roundtrips() { + for report in [false, true] { + let window = crate::schema::Window::new() + .with_title("size") + .with_report_size(report); + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetWindow(window)).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + let flags = read_u16(&bytes, ENVELOPE_LEN + 16).unwrap(); + assert_eq!(flags, 1 | (u16::from(report) << 1)); + assert_eq!( + decode_batch(&bytes).unwrap(), + vec![Mutation::SetWindow(window)] + ); + } + // The default window, byte by byte from the record layout. + let window = crate::schema::Window::new(); + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetWindow(window)).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + let record = &bytes[ENVELOPE_LEN..]; + assert_eq!(record.len(), 32); + assert_eq!(read_u16(record, 0).unwrap(), TAG_SET_WINDOW); + assert_eq!(read_u16(record, 2).unwrap(), 32); + assert_eq!(read_u16(record, 4).unwrap(), window.chrome as u16); + assert_eq!(read_u16(record, 6).unwrap(), window.title_bar as u16); + for at in [8, 10, 12, 14] { + assert_eq!(read_u16(record, at).unwrap(), 0); + } + // Resizable only: the mode and the policy are the defaults for the platform. + let expected_flags = 1 + | ((window.mode as u16) << WINDOW_MODE_SHIFT) + | ((window.last_window_closed as u16) << WINDOW_POLICY_SHIFT); + assert_eq!(read_u16(record, 16).unwrap(), expected_flags); + assert_eq!(read_u16(record, 18).unwrap(), 0); + assert_eq!(read_u32(record, 28).unwrap(), 0); + } + + /// The other low bits of the flags word are still nobody's, so a record that sets + /// one is a protocol error. + #[test] + fn fr20_8_set_window_rejects_unknown_flag_bits() { + let mut encoder = BatchEncoder::default(); + encoder + .encode(&Mutation::SetWindow(crate::schema::Window::new())) + .unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + let flags = read_u16(&bytes, ENVELOPE_LEN + 16).unwrap(); + for bit in 2..8 { + let bad = flags | (1 << bit); + let mut copy = bytes.clone(); + copy[ENVELOPE_LEN + 16..ENVELOPE_LEN + 18].copy_from_slice(&bad.to_le_bytes()); + assert_eq!( + decode_batch(©), + Err(ProtocolError::InvalidValueKind(bad)), + "bit {bit}" + ); + } + } + #[test] fn fr65_request_focus_round_trips_in_eight_bytes() { for node_id in [0_u32, 7, u32::MAX] { diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 471ea969..350aba8f 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -1573,6 +1573,10 @@ pub struct Window { /// last time. A level set here is applied, remembered and reported back the way one /// chosen with the keys is. Kept inside `-8..=8`. pub zoom_level: Option, + /// Whether the implicit single window reports every whole-dp change of its size, + /// not only a change of size class. Off by default. Windows made with `Window` + /// nodes opt in through the node's `report_size` property instead. + pub report_size: bool, /// One window (the default) or several. In the second the root's children are /// `Window` nodes and the rest of this record describes no window. pub mode: WindowMode, @@ -1593,6 +1597,7 @@ impl Window { min_height: 0, resizable: true, zoom_level: None, + report_size: false, mode: WindowMode::Single, last_window_closed: LastWindowClosed::Exit, } @@ -1667,6 +1672,12 @@ impl Window { self } + /// Asks the implicit single window to report every whole-dp change of its size. + pub const fn with_report_size(mut self, report: bool) -> Self { + self.report_size = report; + self + } + /// Starts the window at an application zoom level, kept inside `-8..=8`. pub const fn with_zoom_level(mut self, level: i8) -> Self { self.zoom_level = Some(if level < -8 { diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 455ca32c..b401c1e6 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -558,9 +558,10 @@ private fun ComposeRustTree( } // Read through a derived state so a recomposition does not repeat the map lookup (which // boxes the node id) and the read only runs again when the property itself changes. - // The implicit root window (id 0) has no Window node to carry the property, so it cannot opt in. - val reportEveryDp = if (windowNodeId == 0) false else remember(host, windowNodeId) { - derivedStateOf { host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true } + // The implicit root window (id 0) opts in through its SetWindow settings and any other + // window through its node property; the table answers for both. + val reportEveryDp = remember(host, windowNodeId) { + derivedStateOf { host.table.reportSizeFor(windowNodeId) } }.value // A window that asked for every whole-dp change reads the size here, in the composition, // so a new size recomposes this and reaches the effect. Any other window keeps reading it diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 49605d82..e9dcdb9e 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -200,6 +200,16 @@ class NodeTable { return level?.toInt()?.coerceIn(-8, 8) } + /** + * Whether the window [windowId] asked for every whole-dp change of its size. Node 0 is + * the single window of `SetWindow`, which asks through its settings; a `Window` node + * asks through its `report_size` property. + */ + fun reportSizeFor(windowId: Int): Boolean { + if (windowId == ROOT_ID) return window?.reportSize == true + return node(windowId)?.flag(PropertyKind.ReportSize, false) == true + } + /** * The `Window` node the node [nodeId] is drawn in, itself included, or null for a node * that sits in none (every node of a single window application). diff --git a/renderer/desktop/test/WindowSizeTest.kt b/renderer/desktop/test/WindowSizeTest.kt index 920d8e9d..779c2325 100644 --- a/renderer/desktop/test/WindowSizeTest.kt +++ b/renderer/desktop/test/WindowSizeTest.kt @@ -15,12 +15,15 @@ import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.click import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.Chrome import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.Mutation import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.TitleBar import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.protocol.Window import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass import dev.darkpyonix.composerust.runtime.ComposeRustContent @@ -503,6 +506,59 @@ class WindowSizeTest { assertEquals(600.6f, (dispatcher.events[1] as HostEvent.WindowSizeChanged).widthDp) } + private fun rootWindowBatch(report: Boolean): List = listOf( + Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true, reportSize = report)), + Mutation.Create(LABEL, WidgetKind.Text), + Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("hello")), + ) + + private fun rootWindowSizes(report: Boolean): List { + var sizes: List = emptyList() + runComposeUiTest { + val connection = FakeHostConnection(rootWindowBatch(report)) + var size by mutableStateOf(1280.dp to 820.dp) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(1f)) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(size.first, size.second), + ) + } + } + waitForIdle() + size = 1000.dp to 620.dp + waitForIdle() + sizes = connection.events.filterIsInstance() + } + return sizes + } + + @Test + fun fr20_8_implicit_root_window_reports_dp_change_inside_a_class() { + val sizes = rootWindowSizes(true) + // Both sizes are Expanded wide, and Medium high at 820 and 620 alike, so only the + // opt-in can have sent the second event. + assertTrue(sizes.size >= 2, "expected a report for the dp change, got ${sizes.size}") + assertEquals(1000f, sizes.last().widthDp) + assertEquals(620f, sizes.last().heightDp) + } + + @Test + fun fr20_8_implicit_root_window_default_is_class_only() { + val sizes = rootWindowSizes(false) + assertTrue(sizes.none { it.widthDp == 1000f }, "a window that did not opt in reported a dp change") + } + + @Test + fun fr20_8_report_size_for_root_follows_set_window() { + val table = NodeTable() + assertFalse(table.reportSizeFor(0)) + table.apply(Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true, reportSize = true))) + assertTrue(table.reportSizeFor(0)) + table.apply(Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true))) + assertFalse(table.reportSizeFor(0)) + } + private fun windowBatch(report: Boolean): List = listOf( Mutation.Create(WINDOW, WidgetKind.Window), Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.Bool(report)), From 76f61d82815f2e1cdad082b9387ad1bd58f9aae0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:04:48 +0900 Subject: [PATCH 146/224] Feat: Node accessibility roles, state bits and click action (FR-71, #473) Adds 11 roles (9 to 19), expanded/disabled/focused/hidden state bits and click/focus action bits to the Semantics modifier, add-only. The click action calls the node's existing click handler. The focus action bit is on the wire; the renderer does not expose it yet. --- compose-rust/src/protocol.rs | 57 +++++++- compose-rust/src/schema.rs | 32 ++++- compose-rust/src/ui/mod.rs | 132 +++++++++++++++--- compose-rust/src/writer.rs | 10 ++ compose-rust/tests/fr68_semantics.rs | 107 ++++++++++++++ .../desktop/src/renderer/Accessibility.kt | 64 ++++++++- renderer/desktop/src/renderer/RenderNode.kt | 9 +- .../desktop/test/AccessibilityModifierTest.kt | 107 ++++++++++++++ 8 files changed, 494 insertions(+), 24 deletions(-) diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 6a83b57a..910b8d39 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -2645,13 +2645,68 @@ mod tests { encoder.encode(&mutation).unwrap(); assert_eq!(decode_batch(encoder.finish().unwrap()).unwrap(), [mutation]); - let words = [SemanticsRole::Button as u64, 0x10, 0, 0, 0, 0]; + let words = [SemanticsRole::Button as u64, 0x2000, 0, 0, 0, 0]; assert_eq!( decode_modifier(34, &words), Err(ProtocolError::InvalidModifier(34)) ); } + #[test] + fn fr71_every_new_role_round_trips_and_is_exposed() { + use crate::schema::*; + let roles = [ + (SemanticsRole::TabList, 9), + (SemanticsRole::Tree, 10), + (SemanticsRole::TreeItem, 11), + (SemanticsRole::Menu, 12), + (SemanticsRole::MenuItem, 13), + (SemanticsRole::TextBox, 14), + (SemanticsRole::Heading, 15), + (SemanticsRole::Navigation, 16), + (SemanticsRole::List, 17), + (SemanticsRole::ListItem, 18), + (SemanticsRole::Group, 19), + ]; + let state = (2 << SEMANTICS_EXPANDED_SHIFT) + | (2 << SEMANTICS_DISABLED_SHIFT) + | (1 << SEMANTICS_FOCUSED_SHIFT) + | SEMANTICS_HIDDEN_BIT + | SEMANTICS_CLICK_ACTION_BIT + | SEMANTICS_FOCUS_ACTION_BIT; + assert_eq!(state & !SEMANTICS_STATE_MASK, 0); + for (role, value) in roles { + assert_eq!(role as u64, value, "{role:?} changed its wire value"); + let mutation = Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: Modifier::Semantics { role, state }, + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&mutation).unwrap(); + assert_eq!(decode_batch(encoder.finish().unwrap()).unwrap(), [mutation]); + } + assert!( + ROLE_ENUM_SCHEMA + .iter() + .any(|e| e.name == "SemanticsRole" && e.variants.len() == 19), + "the schema must list all 19 roles" + ); + } + + #[test] + fn nfr7_unknown_semantics_bit_is_a_protocol_error() { + // Bit 13 is the first one above the mask. + let words = [SemanticsRole::Group as u64, 1 << 13, 0, 0, 0, 0]; + assert_eq!( + decode_modifier(34, &words), + Err(ProtocolError::InvalidModifier(34)) + ); + // The highest bit the mask allows still decodes. + let words = [SemanticsRole::Group as u64, 1 << 12, 0, 0, 0, 0]; + assert!(decode_modifier(34, &words).is_ok()); + } + #[test] fn fr51_press_feedback_round_trips() { let expected = [Mutation::SetModifier { diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 471ea969..db406d81 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -833,6 +833,17 @@ define_wire_enum!(SEMANTICS_ROLE_SCHEMA, SemanticsRole { Tab = 6, Image = 7, DropdownList = 8, + TabList = 9, + Tree = 10, + TreeItem = 11, + Menu = 12, + MenuItem = 13, + TextBox = 14, + Heading = 15, + Navigation = 16, + List = 17, + ListItem = 18, + Group = 19, }); // Density roles, because dp density differs per design system. @@ -1964,7 +1975,9 @@ pub enum Modifier { /// selected. `state` holds two tri-state fields, `SEMANTICS_CHECKED_SHIFT` and /// `SEMANTICS_SELECTED_SHIFT` bits up, each 0 for unset, 1 for off and 2 for on. The /// accessible name is the `AccessibleName` property, because a string does not fit in - /// a modifier's words. + /// a modifier's words. Above those, `expanded`, `disabled` and `focused` are tri-state + /// fields in the same encoding, `SEMANTICS_HIDDEN_BIT` leaves the node out of the + /// accessibility tree, and the two action bits expose a click and a focus action. Semantics { role: SemanticsRole, state: u32, @@ -1975,8 +1988,23 @@ pub enum Modifier { pub const SEMANTICS_CHECKED_SHIFT: u32 = 0; /// Where the selected field sits in `Modifier::Semantics::state`. pub const SEMANTICS_SELECTED_SHIFT: u32 = 2; +/// Where the expanded field sits in `Modifier::Semantics::state`: 0 unset, 1 collapsed, +/// 2 expanded. +pub const SEMANTICS_EXPANDED_SHIFT: u32 = 4; +/// Where the disabled field sits in `Modifier::Semantics::state`: 0 unset, 1 enabled, +/// 2 disabled. +pub const SEMANTICS_DISABLED_SHIFT: u32 = 6; +/// Where the focused field sits in `Modifier::Semantics::state`: 0 unset, 1 not focused, +/// 2 focused. +pub const SEMANTICS_FOCUSED_SHIFT: u32 = 8; +/// The bit that excludes the node from the accessibility tree. +pub const SEMANTICS_HIDDEN_BIT: u32 = 1 << 10; +/// The bit that exposes a click action on the node. +pub const SEMANTICS_CLICK_ACTION_BIT: u32 = 1 << 11; +/// The bit that exposes a focus action on the node. +pub const SEMANTICS_FOCUS_ACTION_BIT: u32 = 1 << 12; /// Every bit `Modifier::Semantics::state` may set. -pub const SEMANTICS_STATE_MASK: u32 = 0b1111; +pub const SEMANTICS_STATE_MASK: u32 = 0b1_1111_1111_1111; impl Modifier { /// A border with a width and a paint for each side, in CSS order: top, right, bottom, diff --git a/compose-rust/src/ui/mod.rs b/compose-rust/src/ui/mod.rs index f9202186..4e5290a1 100644 --- a/compose-rust/src/ui/mod.rs +++ b/compose-rust/src/ui/mod.rs @@ -86,28 +86,101 @@ impl Shadow { } } -/// Whether a node is checked or selected, for assistive technology. Each is unset until -/// it is given a value, and an unset one sends nothing. +/// How a node presents itself to assistive technology. Each tri-state field is unset until +/// it is given a value, and an unset one sends nothing. The flags are off until set. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SemanticsState { pub checked: Option, pub selected: Option, + pub expanded: Option, + pub disabled: Option, + pub focused: Option, + pub hidden: bool, + pub click_action: bool, + pub focus_action: bool, } impl SemanticsState { + const NONE: Self = Self { + checked: None, + selected: None, + expanded: None, + disabled: None, + focused: None, + hidden: false, + click_action: false, + focus_action: false, + }; + pub const fn checked(checked: bool) -> Self { Self { checked: Some(checked), - selected: None, + ..Self::NONE } } pub const fn selected(selected: bool) -> Self { Self { - checked: None, selected: Some(selected), + ..Self::NONE + } + } + + pub const fn expanded(expanded: bool) -> Self { + Self { + expanded: Some(expanded), + ..Self::NONE + } + } + + pub const fn disabled(disabled: bool) -> Self { + Self { + disabled: Some(disabled), + ..Self::NONE + } + } + + pub const fn focused(focused: bool) -> Self { + Self { + focused: Some(focused), + ..Self::NONE + } + } + + pub const fn hidden(hidden: bool) -> Self { + Self { + hidden, + ..Self::NONE } } + + pub const fn click_action(exposed: bool) -> Self { + Self { + click_action: exposed, + ..Self::NONE + } + } + + pub const fn focus_action(exposed: bool) -> Self { + Self { + focus_action: exposed, + ..Self::NONE + } + } + + /// The state word the `Semantics` modifier carries. + pub(crate) fn bits(self) -> u32 { + use crate::schema::*; + let tri = |value: Option| value.map_or(0_u32, |on| if on { 2 } else { 1 }); + (tri(self.checked) << SEMANTICS_CHECKED_SHIFT) + | (tri(self.selected) << SEMANTICS_SELECTED_SHIFT) + | (tri(self.expanded) << SEMANTICS_EXPANDED_SHIFT) + | (tri(self.disabled) << SEMANTICS_DISABLED_SHIFT) + | (tri(self.focused) << SEMANTICS_FOCUSED_SHIFT) + | if self.hidden { SEMANTICS_HIDDEN_BIT } else { 0 } + | if self.click_action { SEMANTICS_CLICK_ACTION_BIT } else { 0 } + | if self.focus_action { SEMANTICS_FOCUS_ACTION_BIT } else { 0 } + } } /// The most distinct accessible names a program can keep. Names are kept for the life of @@ -268,10 +341,7 @@ impl Modifier { press_feedback: None, semantics_role: None, semantics_label: None, - semantics_state: SemanticsState { - checked: None, - selected: None, - }, + semantics_state: SemanticsState::NONE, transform: None, animated_alpha: None, animated_background: None, @@ -504,6 +574,42 @@ impl Modifier { self } + /// Whether the node is expanded, as a disclosure or a tree item announces it. + pub const fn expanded(mut self, expanded: bool) -> Self { + self.semantics_state.expanded = Some(expanded); + self + } + + /// Whether the node is disabled for assistive technology. + pub const fn disabled(mut self, disabled: bool) -> Self { + self.semantics_state.disabled = Some(disabled); + self + } + + /// Whether the node holds the focus, as assistive technology reads it. + pub const fn focused(mut self, focused: bool) -> Self { + self.semantics_state.focused = Some(focused); + self + } + + /// Leaves the node out of the accessibility tree when true. + pub const fn hidden(mut self, hidden: bool) -> Self { + self.semantics_state.hidden = hidden; + self + } + + /// Exposes a click action on the node, which runs the node's click handler. + pub const fn click_action(mut self, exposed: bool) -> Self { + self.semantics_state.click_action = exposed; + self + } + + /// Exposes a focus action on the node. + pub const fn focus_action(mut self, exposed: bool) -> Self { + self.semantics_state.focus_action = exposed; + self + } + /// The role, the name and the state in one call. pub fn semantics(self, role: Role, label: &str, state: SemanticsState) -> Self { let mut named = self.role(role).label(label); @@ -625,20 +731,14 @@ pub(crate) fn optional_text(value: Option) -> OwnedAttr { /// The role and state of a Modifier as the one integer the `semantics` attribute carries, /// or none when neither was given. fn semantics_bits(modifier: &Modifier) -> Option { - use crate::schema::{SEMANTICS_CHECKED_SHIFT, SEMANTICS_SELECTED_SHIFT}; - let state = modifier.semantics_state; - if modifier.semantics_role.is_none() && state.checked.is_none() && state.selected.is_none() { + if modifier.semantics_role.is_none() && state == SemanticsState::NONE { return None; } - let tri = |value: Option| value.map_or(0_i64, |on| if on { 2 } else { 1 }); let role = i64::from(u16::from( modifier.semantics_role.unwrap_or(SemanticsRole::None), )); - Some( - role | (tri(state.checked) << (16 + SEMANTICS_CHECKED_SHIFT)) - | (tri(state.selected) << (16 + SEMANTICS_SELECTED_SHIFT)), - ) + Some(role | (i64::from(state.bits()) << 16)) } /// Writes the Modifier attributes, in the order the `rsx!` widgets write them. diff --git a/compose-rust/src/writer.rs b/compose-rust/src/writer.rs index 3c52d483..85f6ea44 100644 --- a/compose-rust/src/writer.rs +++ b/compose-rust/src/writer.rs @@ -942,4 +942,14 @@ mod tests { Some(ProtocolError::MisplacedWindow) ); } + + #[test] + fn nfr7_unknown_semantics_bit_is_a_protocol_error() { + let mut writer = NodeWriter::new(); + let role = i64::from(u16::from(crate::SemanticsRole::Group)); + let accepted = writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 12)))); + assert!(matches!(accepted, Some(Some(_)))); + let refused = writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 13)))); + assert!(matches!(refused, Some(None))); + } } diff --git a/compose-rust/tests/fr68_semantics.rs b/compose-rust/tests/fr68_semantics.rs index ab7649cd..f1edc5a1 100644 --- a/compose-rust/tests/fr68_semantics.rs +++ b/compose-rust/tests/fr68_semantics.rs @@ -176,3 +176,110 @@ fn fr68_a_widget_that_stops_naming_itself_sends_a_removal() { assert_eq!(removed, 1, "one removal of the name was expected"); assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); } + +#[composable] +fn Described() { + let described = remember(|| mutable_state_of(true)); + Column().content(|| { + let flip = described.clone(); + Row() + .on_click(move || flip.update(|d| *d = !*d)) + .content(|| { + Text("flip"); + }); + let modifier = if described.get() { + Modifier.semantics( + Role::Heading, + "Title", + SemanticsState::expanded(true), + ) + } else { + Modifier + }; + Box().modifier(modifier).content(|| {}); + }); +} + +#[test] +fn fr71_removing_semantics_restores_the_defaults() { + use compose_rust::protocol::{HostEvent, Modifier as Wire, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, PropertyKind}; + + let mut host = ComposeHost::with_content(Described); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + assert!(tree.dump().contains("Semantics { role: Heading"), "{}", tree.dump()); + + let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; + let (batch, _) = host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the click is accepted"); + let batches = [batch.to_vec(), host.render_frame(0).expect("the frame encodes").to_vec()]; + + let (mut name_removed, mut modifier_cleared) = (0, 0); + for batch in &batches { + for mutation in decode_batch(batch).expect("the batch decodes") { + match mutation { + Mutation::SetProp { + property: PropertyKind::AccessibleName, + value, + .. + } => { + assert_eq!(value, PropertyValue::None); + name_removed += 1; + } + Mutation::SetModifier { + modifier: Wire::Empty, + .. + } => modifier_cleared += 1, + _ => {} + } + } + tree.apply(batch); + } + assert_eq!(name_removed, 1, "one removal of the name was expected"); + assert!(modifier_cleared >= 1, "the semantics slot was not cleared"); + assert!(!tree.dump().contains("Semantics"), "{}", tree.dump()); + assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); +} + +#[composable] +fn EveryNewRole() { + Column().content(|| { + for role in [ + Role::TabList, + Role::Tree, + Role::TreeItem, + Role::Menu, + Role::MenuItem, + Role::TextBox, + Role::Heading, + Role::Navigation, + Role::List, + Role::ListItem, + Role::Group, + ] { + Box() + .modifier(Modifier.role(role).disabled(false).click_action(true)) + .content(|| {}); + } + }); +} + +#[test] +fn fr71_every_new_role_round_trips_and_is_exposed() { + let dump = start(EveryNewRole).dump(); + for role in [ + "TabList", "Tree", "TreeItem", "Menu", "MenuItem", "TextBox", "Heading", "Navigation", + "List", "ListItem", "Group", + ] { + assert!( + dump.contains(&format!("Semantics {{ role: {role}, state: ")), + "{role} missing in\n{dump}" + ); + } +} diff --git a/renderer/desktop/src/renderer/Accessibility.kt b/renderer/desktop/src/renderer/Accessibility.kt index b581e914..9f20e39a 100644 --- a/renderer/desktop/src/renderer/Accessibility.kt +++ b/renderer/desktop/src/renderer/Accessibility.kt @@ -3,20 +3,33 @@ package dev.darkpyonix.composerust.ui import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.toggleableState import androidx.compose.ui.state.ToggleableState +import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.ui.node.Node /** The two bits of one tri-state field in the `Semantics` state word. */ private const val FIELD_MASK = 0b11 private const val CHECKED_SHIFT = 0 private const val SELECTED_SHIFT = 2 +private const val EXPANDED_SHIFT = 4 +private const val DISABLED_SHIFT = 6 +private const val FOCUSED_SHIFT = 8 +private const val HIDDEN_BIT = 1 shl 10 +private const val CLICK_ACTION_BIT = 1 shl 11 /** A field of the state word: unset, off or on. */ private fun Int.field(shift: Int): Boolean? = when ((this shr shift) and FIELD_MASK) { @@ -34,6 +47,22 @@ private fun SemanticsRole.toRole(): Role? = when (this) { SemanticsRole.Tab -> Role.Tab SemanticsRole.Image -> Role.Image SemanticsRole.DropdownList -> Role.DropdownList + else -> null +} + +/** The word a screen reader gets for a role Compose's `Role` cannot express. */ +private fun SemanticsRole.word(): String? = when (this) { + SemanticsRole.TabList -> "tablist" + SemanticsRole.Tree -> "tree" + SemanticsRole.TreeItem -> "treeitem" + SemanticsRole.Menu -> "menu" + SemanticsRole.MenuItem -> "menuitem" + SemanticsRole.TextBox -> "textbox" + SemanticsRole.Navigation -> "navigation" + SemanticsRole.List -> "list" + SemanticsRole.ListItem -> "listitem" + SemanticsRole.Group -> "group" + else -> null } /** @@ -44,17 +73,44 @@ private fun SemanticsRole.toRole(): Role? = when (this) { * own, so the one place covers a container, a control and a node the Host only labelled. A * node that said nothing gets the same modifier back, so nothing changes for it. */ -internal fun Modifier.accessibility(node: Node): Modifier { +internal fun Modifier.accessibility( + node: Node, + dispatcher: EventDispatcher? = null, + clickHandlerId: Long? = null, +): Modifier { val name = node.text(PropertyKind.AccessibleName).takeIf { it.isNotEmpty() } val declared = node.modifiers.lastOrNull { it is ProtocolModifier.Semantics } as? ProtocolModifier.Semantics if (name == null && declared == null) return this - val role = declared?.role?.toRole() - val checked = declared?.state?.field(CHECKED_SHIFT) - val selected = declared?.state?.field(SELECTED_SHIFT) + val state = declared?.state ?: 0 + val declaredRole = declared?.role + val role = declaredRole?.toRole() + val checked = state.field(CHECKED_SHIFT) + val selected = state.field(SELECTED_SHIFT) + val expanded = state.field(EXPANDED_SHIFT) + val isDisabled = state.field(DISABLED_SHIFT) + val focused = state.field(FOCUSED_SHIFT) + val hidden = state and HIDDEN_BIT != 0 + val isHeading = declaredRole == SemanticsRole.Heading + val description = listOfNotNull( + declaredRole?.word(), + expanded?.let { if (it) "expanded" else "collapsed" }, + ).joinToString(", ").takeIf { it.isNotEmpty() } + val handler = clickHandlerId.takeIf { state and CLICK_ACTION_BIT != 0 } return semantics { if (name != null) contentDescription = name if (role != null) this.role = role + if (isHeading) heading() + if (description != null) stateDescription = description if (checked != null) toggleableState = ToggleableState(checked) if (selected != null) this.selected = selected + if (isDisabled == true) disabled() + if (focused != null) this.focused = focused + if (hidden) hideFromAccessibility() + if (handler != null && dispatcher != null) { + onClick(label = null) { + dispatcher.dispatch(HostEvent.Clicked(node.id, handler)) + true + } + } } } diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 365901ba..da25d233 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -201,7 +201,14 @@ fun RenderNode( ), ) .testTag(nodeTestTag(nodeId)) - .accessibility(node) + .accessibility( + node, + dispatcher, + clickHandler + ?: (node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + as? dev.darkpyonix.composerust.protocol.Modifier.Clickable)?.handlerId + ?: node.handler(PropertyKind.OnClick), + ) // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) diff --git a/renderer/desktop/test/AccessibilityModifierTest.kt b/renderer/desktop/test/AccessibilityModifierTest.kt index 5071b5ec..70e9d255 100644 --- a/renderer/desktop/test/AccessibilityModifierTest.kt +++ b/renderer/desktop/test/AccessibilityModifierTest.kt @@ -10,8 +10,10 @@ import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.runComposeUiTest +import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.IconRole import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.Mutation @@ -292,4 +294,109 @@ class AccessibilityModifierTest { assertTrue(description() != listOf("가져오기"), "the removed name is still spoken") } } + + private val newRoles = mapOf( + SemanticsRole.TabList to "tablist", + SemanticsRole.Tree to "tree", + SemanticsRole.TreeItem to "treeitem", + SemanticsRole.Menu to "menu", + SemanticsRole.MenuItem to "menuitem", + SemanticsRole.TextBox to "textbox", + SemanticsRole.Navigation to "navigation", + SemanticsRole.List to "list", + SemanticsRole.ListItem to "listitem", + SemanticsRole.Group to "group", + ) + + @Test + fun fr71_every_new_role_round_trips_and_is_exposed() { + val box = 1 + for ((role, word) in newRoles) { + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(role, 0)), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertEquals(word, config.getOrNull(SemanticsProperties.StateDescription), "$role") + } + } + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Heading, 0)), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertTrue(config.contains(SemanticsProperties.Heading), "a heading must be marked as one") + } + } + + @Test + fun fr71_state_bits_reach_the_accessibility_node() { + val box = 1 + fun ComposeUiTest.configOf(state: Int, role: SemanticsRole = SemanticsRole.None): androidx.compose.ui.semantics.SemanticsConfiguration { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(role, state)), + ), + ) + return onNodeWithTag(nodeTestTag(box), useUnmergedTree = true).fetchSemanticsNode().config + } + runComposeUiTest { + // Expanded on, disabled on, focused off. + val config = configOf((2 shl 4) or (2 shl 6) or (1 shl 8), SemanticsRole.TreeItem) + assertEquals("treeitem, expanded", config.getOrNull(SemanticsProperties.StateDescription)) + assertTrue(config.contains(SemanticsProperties.Disabled)) + assertEquals(false, config.getOrNull(SemanticsProperties.Focused)) + assertTrue(!config.contains(SemanticsProperties.HideFromAccessibility)) + } + runComposeUiTest { + val config = configOf((1 shl 4) or (2 shl 8)) + assertEquals("collapsed", config.getOrNull(SemanticsProperties.StateDescription)) + assertEquals(true, config.getOrNull(SemanticsProperties.Focused)) + assertTrue(!config.contains(SemanticsProperties.Disabled)) + } + runComposeUiTest { + // Enabled is explicit and sets nothing; hidden leaves the node out of the tree. + val config = configOf((1 shl 6) or (1 shl 10), SemanticsRole.Group) + assertTrue(!config.contains(SemanticsProperties.Disabled)) + assertTrue(config.contains(SemanticsProperties.HideFromAccessibility)) + } + } + + @Test + fun fr71_click_action_calls_the_nodes_click_handler() { + val box = 1 + val clickAction = 1 shl 11 + runComposeUiTest { + val connection = FakeHostConnection( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetProp(box, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.MenuItem, clickAction)), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + onNodeWithTag(nodeTestTag(box)).performSemanticsAction(SemanticsActions.OnClick) + waitForIdle() + val clicks = connection.events.filterIsInstance() + assertEquals(listOf(HostEvent.Clicked(box, SOME_HANDLER)), clicks) + } + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.MenuItem, clickAction)), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertTrue(!config.contains(SemanticsActions.OnClick), "a node without a handler has no click action") + } + } } From df7636a28e16cbd15783647f131331ef7174bcce Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:17:39 +0900 Subject: [PATCH 147/224] Chore: Regenerate the protocol and fix tests for the new Semantics bits and the SetWindow report_size bit --- .../android-kotlin/shared/Accessibility.kt | 64 ++++++++++++++++-- .../android-kotlin/shared/ComposeRustHost.kt | 7 +- .../android-kotlin/shared/NodeTable.kt | 10 +++ .../android-kotlin/shared/Protocol.gen.kt | 28 ++++++-- .../android-kotlin/shared/RenderNode.kt | 9 ++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/ui/mod.rs | 12 +++- compose-rust/src/writer.rs | 3 +- compose-rust/tests/fr53_windows.rs | 3 +- compose-rust/tests/fr68_semantics.rs | 34 +++++++--- compose-rust/tests/vectors/mutations.bin | Bin 5363 -> 5363 bytes compose-rust/tests/vectors/vectors.json | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 28 ++++++-- 13 files changed, 167 insertions(+), 35 deletions(-) diff --git a/compose-rust/android-kotlin/shared/Accessibility.kt b/compose-rust/android-kotlin/shared/Accessibility.kt index b581e914..9f20e39a 100644 --- a/compose-rust/android-kotlin/shared/Accessibility.kt +++ b/compose-rust/android-kotlin/shared/Accessibility.kt @@ -3,20 +3,33 @@ package dev.darkpyonix.composerust.ui import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.toggleableState import androidx.compose.ui.state.ToggleableState +import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.ui.node.Node /** The two bits of one tri-state field in the `Semantics` state word. */ private const val FIELD_MASK = 0b11 private const val CHECKED_SHIFT = 0 private const val SELECTED_SHIFT = 2 +private const val EXPANDED_SHIFT = 4 +private const val DISABLED_SHIFT = 6 +private const val FOCUSED_SHIFT = 8 +private const val HIDDEN_BIT = 1 shl 10 +private const val CLICK_ACTION_BIT = 1 shl 11 /** A field of the state word: unset, off or on. */ private fun Int.field(shift: Int): Boolean? = when ((this shr shift) and FIELD_MASK) { @@ -34,6 +47,22 @@ private fun SemanticsRole.toRole(): Role? = when (this) { SemanticsRole.Tab -> Role.Tab SemanticsRole.Image -> Role.Image SemanticsRole.DropdownList -> Role.DropdownList + else -> null +} + +/** The word a screen reader gets for a role Compose's `Role` cannot express. */ +private fun SemanticsRole.word(): String? = when (this) { + SemanticsRole.TabList -> "tablist" + SemanticsRole.Tree -> "tree" + SemanticsRole.TreeItem -> "treeitem" + SemanticsRole.Menu -> "menu" + SemanticsRole.MenuItem -> "menuitem" + SemanticsRole.TextBox -> "textbox" + SemanticsRole.Navigation -> "navigation" + SemanticsRole.List -> "list" + SemanticsRole.ListItem -> "listitem" + SemanticsRole.Group -> "group" + else -> null } /** @@ -44,17 +73,44 @@ private fun SemanticsRole.toRole(): Role? = when (this) { * own, so the one place covers a container, a control and a node the Host only labelled. A * node that said nothing gets the same modifier back, so nothing changes for it. */ -internal fun Modifier.accessibility(node: Node): Modifier { +internal fun Modifier.accessibility( + node: Node, + dispatcher: EventDispatcher? = null, + clickHandlerId: Long? = null, +): Modifier { val name = node.text(PropertyKind.AccessibleName).takeIf { it.isNotEmpty() } val declared = node.modifiers.lastOrNull { it is ProtocolModifier.Semantics } as? ProtocolModifier.Semantics if (name == null && declared == null) return this - val role = declared?.role?.toRole() - val checked = declared?.state?.field(CHECKED_SHIFT) - val selected = declared?.state?.field(SELECTED_SHIFT) + val state = declared?.state ?: 0 + val declaredRole = declared?.role + val role = declaredRole?.toRole() + val checked = state.field(CHECKED_SHIFT) + val selected = state.field(SELECTED_SHIFT) + val expanded = state.field(EXPANDED_SHIFT) + val isDisabled = state.field(DISABLED_SHIFT) + val focused = state.field(FOCUSED_SHIFT) + val hidden = state and HIDDEN_BIT != 0 + val isHeading = declaredRole == SemanticsRole.Heading + val description = listOfNotNull( + declaredRole?.word(), + expanded?.let { if (it) "expanded" else "collapsed" }, + ).joinToString(", ").takeIf { it.isNotEmpty() } + val handler = clickHandlerId.takeIf { state and CLICK_ACTION_BIT != 0 } return semantics { if (name != null) contentDescription = name if (role != null) this.role = role + if (isHeading) heading() + if (description != null) stateDescription = description if (checked != null) toggleableState = ToggleableState(checked) if (selected != null) this.selected = selected + if (isDisabled == true) disabled() + if (focused != null) this.focused = focused + if (hidden) hideFromAccessibility() + if (handler != null && dispatcher != null) { + onClick(label = null) { + dispatcher.dispatch(HostEvent.Clicked(node.id, handler)) + true + } + } } } diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 455ca32c..b401c1e6 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -558,9 +558,10 @@ private fun ComposeRustTree( } // Read through a derived state so a recomposition does not repeat the map lookup (which // boxes the node id) and the read only runs again when the property itself changes. - // The implicit root window (id 0) has no Window node to carry the property, so it cannot opt in. - val reportEveryDp = if (windowNodeId == 0) false else remember(host, windowNodeId) { - derivedStateOf { host.table.node(windowNodeId)?.flag(PropertyKind.ReportSize, false) == true } + // The implicit root window (id 0) opts in through its SetWindow settings and any other + // window through its node property; the table answers for both. + val reportEveryDp = remember(host, windowNodeId) { + derivedStateOf { host.table.reportSizeFor(windowNodeId) } }.value // A window that asked for every whole-dp change reads the size here, in the composition, // so a new size recomposes this and reaches the effect. Any other window keeps reading it diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 49605d82..e9dcdb9e 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -200,6 +200,16 @@ class NodeTable { return level?.toInt()?.coerceIn(-8, 8) } + /** + * Whether the window [windowId] asked for every whole-dp change of its size. Node 0 is + * the single window of `SetWindow`, which asks through its settings; a `Window` node + * asks through its `report_size` property. + */ + fun reportSizeFor(windowId: Int): Boolean { + if (windowId == ROOT_ID) return window?.reportSize == true + return node(windowId)?.flag(PropertyKind.ReportSize, false) == true + } + /** * The `Window` node the node [nodeId] is drawn in, itself included, or null for a node * that sits in none (every node of a single window application). diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index d84d640d..11b129b9 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -28,7 +28,7 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } -enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group } enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } @@ -245,6 +245,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) sealed interface PropertyValue { @@ -1304,7 +1309,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -8685433650645253909L + const val SCHEMA_HASH: Long = -4799113909004651214L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1488,14 +1493,15 @@ object Protocol { } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second byte. Anything else standing is a record // the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -1522,6 +1528,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -2439,6 +2446,17 @@ object Protocol { 6 -> SemanticsRole.Tab 7 -> SemanticsRole.Image 8 -> SemanticsRole.DropdownList + 9 -> SemanticsRole.TabList + 10 -> SemanticsRole.Tree + 11 -> SemanticsRole.TreeItem + 12 -> SemanticsRole.Menu + 13 -> SemanticsRole.MenuItem + 14 -> SemanticsRole.TextBox + 15 -> SemanticsRole.Heading + 16 -> SemanticsRole.Navigation + 17 -> SemanticsRole.List + 18 -> SemanticsRole.ListItem + 19 -> SemanticsRole.Group else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) } diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 365901ba..da25d233 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -201,7 +201,14 @@ fun RenderNode( ), ) .testTag(nodeTestTag(nodeId)) - .accessibility(node) + .accessibility( + node, + dispatcher, + clickHandler + ?: (node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + as? dev.darkpyonix.composerust.protocol.Modifier.Clickable)?.handlerId + ?: node.handler(PropertyKind.OnClick), + ) // The TextField wires its own key handling, because it has an editor to intercept and a // composition to protect; everything else routes keys here. val keyDownHandler = node.handler(PropertyKind.OnKeyDown) diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 7ca20111..24b4f745 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x8777241cb510c0eb +0xbd661f5399243532 diff --git a/compose-rust/src/ui/mod.rs b/compose-rust/src/ui/mod.rs index 4e5290a1..28c9b164 100644 --- a/compose-rust/src/ui/mod.rs +++ b/compose-rust/src/ui/mod.rs @@ -178,8 +178,16 @@ impl SemanticsState { | (tri(self.disabled) << SEMANTICS_DISABLED_SHIFT) | (tri(self.focused) << SEMANTICS_FOCUSED_SHIFT) | if self.hidden { SEMANTICS_HIDDEN_BIT } else { 0 } - | if self.click_action { SEMANTICS_CLICK_ACTION_BIT } else { 0 } - | if self.focus_action { SEMANTICS_FOCUS_ACTION_BIT } else { 0 } + | if self.click_action { + SEMANTICS_CLICK_ACTION_BIT + } else { + 0 + } + | if self.focus_action { + SEMANTICS_FOCUS_ACTION_BIT + } else { + 0 + } } } diff --git a/compose-rust/src/writer.rs b/compose-rust/src/writer.rs index 85f6ea44..891a5c48 100644 --- a/compose-rust/src/writer.rs +++ b/compose-rust/src/writer.rs @@ -947,7 +947,8 @@ mod tests { fn nfr7_unknown_semantics_bit_is_a_protocol_error() { let mut writer = NodeWriter::new(); let role = i64::from(u16::from(crate::SemanticsRole::Group)); - let accepted = writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 12)))); + let accepted = + writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 12)))); assert!(matches!(accepted, Some(Some(_)))); let refused = writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 13)))); assert!(matches!(refused, Some(None))); diff --git a/compose-rust/tests/fr53_windows.rs b/compose-rust/tests/fr53_windows.rs index 60f74498..bc7d64cf 100644 --- a/compose-rust/tests/fr53_windows.rs +++ b/compose-rust/tests/fr53_windows.rs @@ -662,8 +662,9 @@ fn fr53_malformed_window_wire_is_a_protocol_error() { let mut batch = encoded(&[Mutation::SetWindow(WindowSettings::new())]); // The flags word of the window record: 12 for the envelope, 16 into the record. + // Bit 1 is the report-size flag, so it is a window's and not a stray bit. let flags_at = 12 + 16; - for stray in [0x0002_u16, 0x0004, 0x0080, 0x0200, 0x2000] { + for stray in [0x0004_u16, 0x0080, 0x0200, 0x2000] { let mut bent = batch.clone(); bent[flags_at..flags_at + 2].copy_from_slice(&(1 | stray).to_le_bytes()); assert!( diff --git a/compose-rust/tests/fr68_semantics.rs b/compose-rust/tests/fr68_semantics.rs index f1edc5a1..ae2f007a 100644 --- a/compose-rust/tests/fr68_semantics.rs +++ b/compose-rust/tests/fr68_semantics.rs @@ -188,11 +188,7 @@ fn Described() { Text("flip"); }); let modifier = if described.get() { - Modifier.semantics( - Role::Heading, - "Title", - SemanticsState::expanded(true), - ) + Modifier.semantics(Role::Heading, "Title", SemanticsState::expanded(true)) } else { Modifier }; @@ -202,13 +198,17 @@ fn Described() { #[test] fn fr71_removing_semantics_restores_the_defaults() { - use compose_rust::protocol::{HostEvent, Modifier as Wire, Mutation, PropertyValue, decode_batch}; - use compose_rust::schema::{EventPayload, PropertyKind}; + use compose_rust::protocol::{HostEvent, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, Modifier as Wire, PropertyKind}; let mut host = ComposeHost::with_content(Described); let mut tree = Tree::default(); tree.apply(host.rebuild().expect("the first batch encodes")); - assert!(tree.dump().contains("Semantics { role: Heading"), "{}", tree.dump()); + assert!( + tree.dump().contains("Semantics { role: Heading"), + "{}", + tree.dump() + ); let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; let (batch, _) = host @@ -218,7 +218,10 @@ fn fr71_removing_semantics_restores_the_defaults() { payload: EventPayload::Clicked, }) .expect("the click is accepted"); - let batches = [batch.to_vec(), host.render_frame(0).expect("the frame encodes").to_vec()]; + let batches = [ + batch.to_vec(), + host.render_frame(0).expect("the frame encodes").to_vec(), + ]; let (mut name_removed, mut modifier_cleared) = (0, 0); for batch in &batches { @@ -274,8 +277,17 @@ fn EveryNewRole() { fn fr71_every_new_role_round_trips_and_is_exposed() { let dump = start(EveryNewRole).dump(); for role in [ - "TabList", "Tree", "TreeItem", "Menu", "MenuItem", "TextBox", "Heading", "Navigation", - "List", "ListItem", "Group", + "TabList", + "Tree", + "TreeItem", + "Menu", + "MenuItem", + "TextBox", + "Heading", + "Navigation", + "List", + "ListItem", + "Group", ] { assert!( dump.contains(&format!("Semantics {{ role: {role}, state: ")), diff --git a/compose-rust/tests/vectors/mutations.bin b/compose-rust/tests/vectors/mutations.bin index b5d5222968b7145db3d90e93b08b024224273605..6c63cbcfc672b142074e1c775942f6023733ca68 100644 GIT binary patch delta 14 VcmeyY`B`(r6Fx?!%}@FG*Z?zH1(*N; delta 14 VcmeyY`B`(r6Fx?U%}@FG*Z?z51(pB+ diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 63b11516..21bee6c6 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "9761310423064297707", + "schemaHashUnsigned": "13647630164704900402", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index d84d640d..11b129b9 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -28,7 +28,7 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } -enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group } enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } @@ -245,6 +245,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) sealed interface PropertyValue { @@ -1304,7 +1309,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -8685433650645253909L + const val SCHEMA_HASH: Long = -4799113909004651214L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1488,14 +1493,15 @@ object Protocol { } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second byte. Anything else standing is a record // the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -1522,6 +1528,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -2439,6 +2446,17 @@ object Protocol { 6 -> SemanticsRole.Tab 7 -> SemanticsRole.Image 8 -> SemanticsRole.DropdownList + 9 -> SemanticsRole.TabList + 10 -> SemanticsRole.Tree + 11 -> SemanticsRole.TreeItem + 12 -> SemanticsRole.Menu + 13 -> SemanticsRole.MenuItem + 14 -> SemanticsRole.TextBox + 15 -> SemanticsRole.Heading + 16 -> SemanticsRole.Navigation + 17 -> SemanticsRole.List + 18 -> SemanticsRole.ListItem + 19 -> SemanticsRole.Group else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) } From ba4d0fd101ef7e19ad9e3478666414b0305b8be0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:19:28 +0900 Subject: [PATCH 148/224] Docs: Add FR-72, native file open and save dialogs (#483) Draft requirement for the Host to ask the Renderer for a platform file open or save dialog and receive the result as one event. Open (file, files, folder, folders) and Save with a suggested name and folder, filters, title, initial folder, modal to a Window node, exactly one FileDialogResult per request (chosen, cancelled, unavailable), add-only wire (one command, one event, no new boundary entry point, tag numbers settled when the wire design is reported), desktop first (iOS, Android and Web answer Unavailable), platform dialog code in the Compose fork. Refs #483 --- docs/SPEC.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index e795cc7a..a0677e47 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3867,6 +3867,114 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 5. 수정자를 떼면(제거 전달 뒤) 확장 상태와 동작이 사라지고 FR-68의 기본으로 돌아옵니다(시험 이름 fr71_removing_semantics_restores_the_defaults). 6. macOS에서 `Tab`, `Button`, `Heading` 노드가 윈도 아래 접근성 요소로 보이고 `AXPress`가 `click`을 부릅니다. 사람 손으로 확인하는 항목입니다(System Events의 `UI elements of group 1 of window 1`로 열거합니다). +### FR-72 파일 열기와 저장 대화상자 (`Draft`, 이슈 #483) + +요청한 쪽: darkpyonix-ember#486(파일 선택기와 실제로 보낼 수 있는 전송 시트)와 #550(전송 시트). 앱이 플랫폼의 파일 열기·저장 대화상자를 띄울 수 없어서 파일 전송 화면이 파일을 고를 길이 없습니다. 이 항목은 앱이 대화상자를 요청하고 결과를 이벤트로 받는 길을 더합니다. + +**FR-36과 같은 모양입니다.** Host가 명령 레코드로 요청하고, 플랫폼 API를 부르는 것은 Renderer이며, 결과는 `dispatch_event`의 이벤트로 돌아옵니다. 대화상자는 트리에 자리가 없고(요청이 끝나면 사라지므로 노드로 만들지 않습니다), Host가 `NSOpenPanel`이나 `IFileOpenDialog`를 직접 부르면 플랫폼마다 손으로 쓴 FFI가 생기고 결과가 Renderer를 거치지 않는 두 번째 입구가 됩니다(PR-1, PR-3). 모든 변경은 **추가 전용**이고 기존 태그는 바뀌지 않습니다. + +#### 72.1 요청 + +명령 `ShowFileDialog` 하나가 다섯 가지 일을 합니다. `kind`는 닫힌 열거형입니다. + +| `kind` | 값 | 고르는 것 | 결과의 경로 수 | +|---|---|---|---| +| `OpenFile` | 1 | 파일 하나 | 1 | +| `OpenFiles` | 2 | 파일 여럿 | 1 이상 | +| `OpenFolder` | 3 | 폴더 하나 | 1 | +| `OpenFolders` | 4 | 폴더 여럿 | 1 이상 | +| `Save` | 5 | 저장할 경로 하나 | 1 | + +- **`request: u32`는 Host가 정합니다.** 결과 이벤트가 같은 값을 싣고 돌아옵니다. 아직 결과가 오지 않은 요청과 같은 값을 다시 보내면 그 명령은 버려지고 `ProtocolError`가 한 번 옵니다. 앞의 요청은 영향을 받지 않고, 결과도 그대로 하나 옵니다. +- **`window: u32`는 대화상자를 띄울 `Window` 노드의 id입니다(FR-53).** 0은 암묵적 루트 창(FR-20.8)입니다. 대화상자는 그 창에 대해 모달이고 다른 창은 계속 쓸 수 있습니다. 존재하지 않는 노드이거나 `Window`가 아니면 `UNKNOWN_NODE`로 보고하고, 요청마다 이벤트가 정확히 하나라는 규칙(72.2)에 따라 `Unavailable` 결과를 돌려줍니다. +- `title`(문자열): 대화상자 제목입니다. 비면 플랫폼 기본입니다. macOS의 시트는 제목을 보이지 않으므로 `message` 자리에 씁니다. +- `directory`(문자열): 처음 열 폴더입니다. 절대 경로여야 하고, 비었거나 없거나 읽을 수 없으면 플랫폼의 기본(마지막으로 쓴 폴더)입니다. 없는 폴더는 오류가 아닙니다. +- `name`(문자열): `Save`에서 칸에 미리 채울 파일 이름입니다. 경로 구분자는 허용하지 않고, 있으면 마지막 구분자 뒤만 씁니다. `Open*`에서는 무시합니다. +- **필터**: `filters`는 `(label, extensions)` 목록입니다. `extensions`는 점 없는 확장자를 쉼표로 이은 문자열입니다(`"png,jpg,jpeg"`). 비교는 대소문자를 가리지 않습니다. 목록이 비면 모든 파일을 고를 수 있습니다. 필터는 **고를 수 있는 파일을 제한**하며, 폴더 선택(`OpenFolder`, `OpenFolders`)에서는 무시합니다. `default_filter: u32`는 처음 선택된 필터의 위치이고 범위 밖이면 0입니다. 필터는 16개까지, 필터 하나의 확장자는 32개까지이며 넘으면 넘는 부분을 버립니다. 오류가 아닙니다. +- `Save`는 **덮어쓰기 확인을 항상 플랫폼 대화상자가 합니다.** 이미 있는 파일을 고르면 플랫폼이 묻고, 사용자가 허락한 경우에만 결과가 옵니다. Host가 끌 수 있는 값은 없습니다. 확인 문구와 동작은 플랫폼의 것이어야 사용자가 아는 대화상자와 같기 때문입니다. Renderer는 파일을 만들거나 덮어쓰지 않고 경로만 돌려줍니다. +- `Save`에서 사용자가 확장자 없는 이름을 입력하고 필터가 하나만 선택되어 있으면 플랫폼의 관례에 따라 그 필터의 첫 확장자를 붙일 수 있습니다. 어느 필터가 쓰였는지는 결과의 `filter`로 알립니다. + +Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter("이미지", &["png", "jpg"]).directory(dir), move |result| ...)`의 모양이고 `FileDialogResult`는 72.2의 값을 담습니다. 콜백은 UI 스레드에서 한 번만 불리고, 그 뒤 해제됩니다. 워커 스레드에서는 FR-36의 `NotificationSender`처럼 `Send`인 손잡이로 요청합니다(PR-3). + +#### 72.2 결과 + +- 결과는 **새 이벤트 `FileDialogResult`** 하나입니다. `node_id = 0`, `handler_id = 0`이고, Host 자신에게 오는 사건이라 핸들러 조회를 거치지 않습니다(`NotificationActivated`와 같습니다). Host API는 요청 때 넘긴 콜백을 `request`로 찾아 부릅니다. 앱 전역 훅은 없습니다. 결과는 요청한 쪽의 것이기 때문입니다. +- 페이로드: `request: u32`, `status: u32`, `filter: u32`, `paths`(문자열). +- `status`는 닫힌 열거형입니다. `Chosen=1`, `Cancelled=2`, `Unavailable=3`. 모르는 값은 `ProtocolError`입니다. + - `Chosen`: 사용자가 골랐습니다. `paths`에 경로가 하나 이상 있습니다. + - `Cancelled`: 사용자가 닫거나 취소했습니다. 대화상자가 열려 있는 동안 그 창이 닫힌 경우도 같습니다. `paths`는 비어 있습니다. + - `Unavailable`: 이 환경에서 대화상자를 띄울 수 없습니다(72.6). `paths`는 비어 있습니다. 오류가 아니므로 `ProtocolError`가 아니고, 프로세스는 죽지 않습니다. +- **요청마다 이벤트는 정확히 하나입니다.** 선택하든 취소하든 못 띄우든, 같은 `request`로 하나가 오고 둘은 오지 않습니다. 이 규칙이 있어야 Host가 대기 상태를 풀 수 있습니다. 예외는 프로세스가 끝나는 경우뿐이고, 그때는 받을 Host도 없습니다. +- **`paths`는 한 문자열입니다.** 경로가 여럿이면 `FilesDropped`(FR-27)와 같이 경로 사이를 NUL(U+0000) 한 바이트로 가르며, 이 바이트는 어느 데스크톱의 경로에도 들어갈 수 없습니다. 목록을 나르는 두 번째 방법을 만들지 않습니다. +- **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 경로에 UTF-8이 아닌 바이트가 있으면(Linux에서만 가능) 그 경로를 버리고 나머지를 전달하며(FR-27과 같은 규칙), 하나도 남지 않으면 `Cancelled`가 아니라 `Unavailable`입니다. 사용자가 고른 것이 있었는데 취소로 보이면 거짓이기 때문입니다. +- **긴 경로를 자르지 않습니다.** Windows의 `MAX_PATH`(260)를 넘는 경로도 `IShellItem`이 돌려주는 전체를 그대로 전달하고, 접두사(`\\?\`)는 붙이지 않습니다. 경로 한 개의 길이와 경로 수에 Renderer가 두는 상한은 없습니다. 이벤트의 문자열은 PR-4의 오프셋·길이 규약이라 길이는 배치 버퍼가 감당하는 만큼 늘 수 있어야 하며, 감당하지 못하는 크기가 오면 `ProtocolError`가 아니라 **`Unavailable`**이고 앞의 경로를 자르지 않습니다. +- `filter`: 사용자가 마지막으로 선택한 필터의 위치입니다. 필터를 보내지 않았거나 `Chosen`이 아니면 `0xFFFF_FFFF`입니다. +- **같은 창에 요청이 겹치면 Renderer가 받은 순서대로 하나씩 띄웁니다.** 플랫폼의 모달 대화상자(macOS의 시트가 대표적입니다)는 창마다 하나뿐이기 때문입니다. 앞 요청이 끝나면 다음이 뜹니다. 다른 창의 요청은 서로 기다리지 않습니다. 이 순서는 Renderer 안의 상태이고 Host와 Renderer 사이의 큐가 아닙니다(PR-1). + +#### 72.3 모달과 응답성 + +- **대화상자는 UI 스레드를 막지 않습니다.** 열려 있는 동안에도 이벤트 루프가 돌아 창이 다시 그려지고, 다른 창의 입력, 타이머, 애니메이션, 워커가 보낸 프레임 요청이 처리됩니다. 모달인 것은 **소유한 창의 입력**뿐입니다. 따라서 Renderer는 대화상자를 중첩된 이벤트 루프로 돌리는 방식이 아니라 플랫폼의 비동기 API(macOS의 `beginSheetModalForWindow:completionHandler:`)나 별도 스레드의 호출 뒤 UI 스레드로 돌려받는 방식으로 띄웁니다. 결과 이벤트는 UI 스레드에서 `dispatch_event`로 전달됩니다(PR-1). +- 대화상자가 열린 창의 입력(클릭, 키, 닫기 버튼)은 플랫폼 규칙대로 막힙니다. 창의 `WindowCloseRequested`(FR-53)는 대화상자가 닫힌 뒤에 처리됩니다. 앱 종료 요청은 이 규칙을 따르지 않고 FR-53의 종료 규칙을 따릅니다. +- 대화상자가 열려 있는 동안 소유한 창이 파괴되면(다른 경로로 `Window` 노드를 지움) 대화상자를 닫고 `Cancelled`를 하나 보냅니다. + +#### 72.4 접근성과 키보드 + +- 대화상자는 플랫폼의 것이므로 키보드 조작(탭 이동, 화살표, Enter와 Esc, 주소 칸), 화면 낭독기, 큰 글자, 고대비, 입력기(IME)가 그 플랫폼의 대화상자와 같게 동작합니다. 이 저장소가 그 위에 덧그리거나 그 동작을 대신하는 Compose 위젯 대화상자를 만들지 않습니다. 직접 그린 대화상자는 위 항목들을 하나씩 다시 구현해야 하고 하나만 빠져도 사용자가 아는 대화상자와 달라지기 때문입니다. +- 대화상자가 닫히면 키보드 포커스는 열기 전에 포커스를 가졌던 노드로 돌아옵니다. 그 노드가 없어졌으면 창의 포커스 규칙(FR-65)을 따릅니다. +- Esc는 `Cancelled`입니다. + +#### 72.5 와이어 + +경계 진입점은 하나도 늘지 않습니다(PR-2). 요청은 기존 배치의 레코드이고 결과는 기존 `dispatch_event`의 이벤트입니다. 태그 번호는 이 항목이 정하지 않습니다. 스키마의 태그 표는 pW가 소유하고 열린 변경이 이미 번호를 쓰고 있으므로, **마지막에 비어 있는 다음 번호**를 와이어 설계를 보고할 때 확정하고 이 절에 적습니다. 정해지는 것은 다음입니다. + +- 명령 하나 `ShowFileDialog`: 머리, `request`, `window`, `kind: u16`, `default_filter: u16`, 문자열 넷(`title`, `directory`, `name`, `filters`)입니다. `filters`는 한 문자열이고 `label`과 `extensions` 쌍을 NUL로 이어 붙입니다(`"이미지\0png,jpg\0문서\0pdf,txt"`). 목록을 나르는 두 번째 방법을 만들지 않습니다. +- 이벤트 하나 `FileDialogResult`: 공통 머리 16바이트(`node_id = 0`, `handler_id = 0`) 뒤에 `request: u32`, `status: u32`, `filter: u32`, `paths: (offset, len)`입니다. 지금 `EventPayloadType`에 없는 모양이라 하나를 더합니다. +- `kind`와 `status`는 닫힌 열거형이고 스키마 해시에 들어갑니다. 모르는 값은 `ProtocolError`입니다. +- 이 항목이 더하는 것은 명령 하나, 이벤트 하나, 열거 둘뿐입니다. 기존 태그와 레코드는 바뀌지 않습니다(PR-4의 추가 전용). + +#### 72.6 플랫폼 + +| 플랫폼 | 경로 | 비고 | +|---|---|---| +| macOS | `NSOpenPanel`, `NSSavePanel` | 소유한 창에 시트로 붙습니다 | +| Windows | `IFileOpenDialog`, `IFileSaveDialog`(COM) | 소유한 창의 `HWND`가 소유자입니다 | +| Linux | `xdg-desktop-portal`의 `org.freedesktop.portal.FileChooser` | 포털이 없으면 GTK 파일 선택기로 되돌아갑니다. 둘 다 없으면 `Unavailable` | +| iOS, Android | 이 항목의 범위 밖 | 요청하면 `Unavailable`. `UIDocumentPicker`와 Storage Access Framework는 이슈 #483에 이어 적습니다 | +| Web | 이 항목의 범위 밖 | 요청하면 `Unavailable` | + +- 이 표의 첫 단계는 **데스크톱 셋**입니다. 모바일과 Web은 이슈 #483이 처음부터 뺐고, 뺀 것이 명시된 범위입니다. 그 플랫폼에서도 Host 코드는 갈라지지 않습니다. 같은 요청이 `Unavailable`을 받습니다(FR-27, FR-36의 `Unsupported`와 같은 규칙). +- **플랫폼 대화상자 코드는 Compose 포크(`thisisthepy/compose-multiplatform-core-extended`)에 있고, 이 저장소는 Renderer가 그 호출을 부르는 접착과 명령·이벤트 해석만 가집니다**(19.8, INTENT D19). 포크가 하는 일은 창 핸들을 받아 대화상자를 띄우고 결과를 콜백으로 돌려주는 것까지입니다. +- Linux의 포털은 샌드박스(Flatpak, Snap)에서 앱이 접근할 수 있는 경로를 돌려줍니다. 포털이 문서 포털의 경로(`/run/user//doc/...`)를 돌려주면 그것이 `paths`에 그대로 담깁니다. + +#### 72.7 수용 기준 + +1. 명령 `ShowFileDialog`와 이벤트 `FileDialogResult`가 PR-4 프로토콜 벡터에 들어가 Rust와 Kotlin 양쪽에서 같은 바이트로 왕복합니다. 모르는 `kind`와 `status`가 `ProtocolError`가 되고 프로세스가 죽지 않습니다(시험 이름 fr72_file_dialog_records_round_trip_and_unknown_enums_are_errors). +2. 경계 진입점 목록(PR-2)이 이 변경 전과 같습니다(시험 이름 pr2_boundary_surface_unchanged_by_file_dialog). +3. 선택하든 취소하든 못 띄우든, 같은 `request`를 실은 `FileDialogResult`가 **정확히 하나** 옵니다. Fake Renderer로 세 경우를 확인합니다(시험 이름 fr72_every_request_gets_exactly_one_result). +4. 이미 결과를 기다리는 `request`를 다시 보내면 `ProtocolError`가 한 번 오고 앞 요청의 결과는 하나만 옵니다(시험 이름 fr72_duplicate_pending_request_is_a_protocol_error). +5. 같은 창에 요청 둘을 보내면 두 번째가 첫째의 결과 뒤에 뜨고, 결과도 그 순서로 옵니다. 다른 창의 요청은 기다리지 않습니다(시험 이름 fr72_requests_on_one_window_are_shown_in_order). +6. 존재하지 않는 `window`는 `UNKNOWN_NODE` 보고와 함께 `Unavailable` 결과 하나를 만듭니다(시험 이름 fr72_unknown_window_reports_and_answers_unavailable). +7. 경로가 NUL로 가른 한 문자열로 오고, 한글 경로와 260자를 넘는 경로가 잘리지 않고 UTF-8로 도착합니다. UTF-8이 아닌 경로는 버려지고 하나도 안 남으면 `Unavailable`입니다(시험 이름 fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped). +8. 필터가 16개, 확장자가 32개를 넘으면 넘는 부분만 버려지고 요청은 처리됩니다(시험 이름 fr72_filters_over_the_limit_are_trimmed_not_rejected). +9. 대화상자가 열려 있는 동안 UI 스레드의 이벤트 루프가 돌아, 같은 창 밖의 애니메이션과 다른 창의 입력이 처리됩니다. 대화상자를 연 채 워커가 보낸 프레임 요청이 100ms 안에 렌더됩니다(시험 이름 fr72_ui_loop_keeps_running_while_a_dialog_is_open, 데스크톱 렌더러 시험). +10. 소유한 창을 지우면 열려 있던 대화상자가 닫히고 `Cancelled`가 하나 옵니다(시험 이름 fr72_closing_the_owner_window_cancels_the_dialog). +11. iOS, Android, Web 렌더러(와 포털도 GTK도 없는 Linux)에서 요청이 `Unavailable` 하나를 돌려주고 `ProtocolError`가 없습니다(시험 이름 fr72_unsupported_platform_answers_unavailable). +12. **수동 확인, macOS·Windows·Linux(포털과 GTK 폴백 각각)의 배포 빌드에서 기록합니다.** + - `OpenFile`, `OpenFiles`, `OpenFolder`, `OpenFolders`, `Save`가 플랫폼 대화상자로 뜨고 `title`, `directory`, `name`이 반영됩니다. + - 필터가 고를 수 있는 파일을 제한하고 `default_filter`가 처음 선택된 필터입니다. + - 존재하는 파일을 `Save`로 고르면 플랫폼이 덮어쓰기를 묻고, 거절하면 대화상자가 그대로 열려 있으며 이벤트가 오지 않습니다. + - macOS에서 시트로 창에 붙고, 시트가 열려 있는 동안 그 창만 입력이 막힙니다. + - 키보드만으로 열기부터 선택·취소까지 할 수 있고, 화면 낭독기가 대화상자를 읽습니다. + - 취소하고 선택한 뒤 포커스가 열기 전의 노드로 돌아옵니다. + - 공백, 한글, 이모지, 260자를 넘는 경로가 그대로 도착합니다. + +#### 72.8 열린 질문 + +1. **필터에 플랫폼 형식(UTI, MIME)을 더할지.** 확장자만으로는 확장자 없는 형식(macOS의 번들, Linux의 MIME)을 못 거릅니다. 첫 단계는 확장자와 레이블만 받습니다. 요청하는 앱(Ember)이 확장자로 충분한지 확인이 필요합니다. +2. **폴더 선택에서 폴더 만들기 단추.** 플랫폼 기본이 다릅니다(macOS는 켜짐, Windows는 항상). 지금은 플랫폼 기본을 그대로 씁니다. +3. **마지막으로 쓴 폴더의 기억.** `directory`가 비었을 때 플랫폼이 기억하는 폴더를 쓰는데, 플랫폼마다 앱 단위인지 대화상자 단위인지 다릅니다. 앱이 기억을 정하고 싶다면 `directory`를 직접 넘깁니다. +4. **모바일과 Web.** `UIDocumentPicker`, Storage Access Framework, 브라우저의 File System Access API는 반환값이 경로가 아니라 접근 권한을 가진 핸들(URI)이라서 이 항목의 `paths`로는 표현할 수 없습니다. 별도 항목에서 정합니다. + ### FR-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. From 6a14af5de957733819b6ab4e74885cb7237961ba Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:20:11 +0900 Subject: [PATCH 149/224] Docs: Cap file dialog results at 10000 paths and stage the platforms (FR-72, #483) --- docs/SPEC.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index a0677e47..c20ede0d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3907,7 +3907,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" - **요청마다 이벤트는 정확히 하나입니다.** 선택하든 취소하든 못 띄우든, 같은 `request`로 하나가 오고 둘은 오지 않습니다. 이 규칙이 있어야 Host가 대기 상태를 풀 수 있습니다. 예외는 프로세스가 끝나는 경우뿐이고, 그때는 받을 Host도 없습니다. - **`paths`는 한 문자열입니다.** 경로가 여럿이면 `FilesDropped`(FR-27)와 같이 경로 사이를 NUL(U+0000) 한 바이트로 가르며, 이 바이트는 어느 데스크톱의 경로에도 들어갈 수 없습니다. 목록을 나르는 두 번째 방법을 만들지 않습니다. - **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 경로에 UTF-8이 아닌 바이트가 있으면(Linux에서만 가능) 그 경로를 버리고 나머지를 전달하며(FR-27과 같은 규칙), 하나도 남지 않으면 `Cancelled`가 아니라 `Unavailable`입니다. 사용자가 고른 것이 있었는데 취소로 보이면 거짓이기 때문입니다. -- **긴 경로를 자르지 않습니다.** Windows의 `MAX_PATH`(260)를 넘는 경로도 `IShellItem`이 돌려주는 전체를 그대로 전달하고, 접두사(`\\?\`)는 붙이지 않습니다. 경로 한 개의 길이와 경로 수에 Renderer가 두는 상한은 없습니다. 이벤트의 문자열은 PR-4의 오프셋·길이 규약이라 길이는 배치 버퍼가 감당하는 만큼 늘 수 있어야 하며, 감당하지 못하는 크기가 오면 `ProtocolError`가 아니라 **`Unavailable`**이고 앞의 경로를 자르지 않습니다. +- **긴 경로를 자르지 않습니다.** Windows의 `MAX_PATH`(260)를 넘는 경로도 `IShellItem`이 돌려주는 전체를 그대로 전달하고, 접두사(`\\?\`)는 붙이지 않습니다. 경로 한 개의 길이에 Renderer가 두는 상한은 없습니다. **한 번에 돌려주는 경로는 10000개까지입니다.** 사용자가 그보다 많이 골랐거나 이벤트의 문자열이 배치 버퍼가 감당하는 크기를 넘으면 `ProtocolError`가 아니라 **`Unavailable`**이고, 앞의 경로만 잘라 `Chosen`으로 보내지 않습니다. 사용자가 고른 것이 일부만 전달되면 앱이 그 사실을 모르기 때문입니다. - `filter`: 사용자가 마지막으로 선택한 필터의 위치입니다. 필터를 보내지 않았거나 `Chosen`이 아니면 `0xFFFF_FFFF`입니다. - **같은 창에 요청이 겹치면 Renderer가 받은 순서대로 하나씩 띄웁니다.** 플랫폼의 모달 대화상자(macOS의 시트가 대표적입니다)는 창마다 하나뿐이기 때문입니다. 앞 요청이 끝나면 다음이 뜹니다. 다른 창의 요청은 서로 기다리지 않습니다. 이 순서는 Renderer 안의 상태이고 Host와 Renderer 사이의 큐가 아닙니다(PR-1). @@ -3942,7 +3942,8 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" | iOS, Android | 이 항목의 범위 밖 | 요청하면 `Unavailable`. `UIDocumentPicker`와 Storage Access Framework는 이슈 #483에 이어 적습니다 | | Web | 이 항목의 범위 밖 | 요청하면 `Unavailable` | -- 이 표의 첫 단계는 **데스크톱 셋**입니다. 모바일과 Web은 이슈 #483이 처음부터 뺐고, 뺀 것이 명시된 범위입니다. 그 플랫폼에서도 Host 코드는 갈라지지 않습니다. 같은 요청이 `Unavailable`을 받습니다(FR-27, FR-36의 `Unsupported`와 같은 규칙). +- **구현은 단계로 나눕니다.** 1단계는 공통(스키마, codegen, Fake 시험, Kotlin 해석기)과 macOS, 2단계는 Windows, 3단계는 Linux 포털(X11 한정, GTK 폴백)입니다. 아직 구현되지 않은 플랫폼의 Renderer는 요청마다 즉시 `Unavailable`을 돌려줍니다. 지원하지 않는 플랫폼은 `Unavailable`이라는 규칙의 같은 경로입니다. +- 이 표의 대상은 **데스크톱 셋**입니다. 모바일과 Web은 이슈 #483이 처음부터 뺐고, 뺀 것이 명시된 범위입니다. 그 플랫폼에서도 Host 코드는 갈라지지 않습니다. 같은 요청이 `Unavailable`을 받습니다(FR-27, FR-36의 `Unsupported`와 같은 규칙). - **플랫폼 대화상자 코드는 Compose 포크(`thisisthepy/compose-multiplatform-core-extended`)에 있고, 이 저장소는 Renderer가 그 호출을 부르는 접착과 명령·이벤트 해석만 가집니다**(19.8, INTENT D19). 포크가 하는 일은 창 핸들을 받아 대화상자를 띄우고 결과를 콜백으로 돌려주는 것까지입니다. - Linux의 포털은 샌드박스(Flatpak, Snap)에서 앱이 접근할 수 있는 경로를 돌려줍니다. 포털이 문서 포털의 경로(`/run/user//doc/...`)를 돌려주면 그것이 `paths`에 그대로 담깁니다. @@ -3955,7 +3956,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" 5. 같은 창에 요청 둘을 보내면 두 번째가 첫째의 결과 뒤에 뜨고, 결과도 그 순서로 옵니다. 다른 창의 요청은 기다리지 않습니다(시험 이름 fr72_requests_on_one_window_are_shown_in_order). 6. 존재하지 않는 `window`는 `UNKNOWN_NODE` 보고와 함께 `Unavailable` 결과 하나를 만듭니다(시험 이름 fr72_unknown_window_reports_and_answers_unavailable). 7. 경로가 NUL로 가른 한 문자열로 오고, 한글 경로와 260자를 넘는 경로가 잘리지 않고 UTF-8로 도착합니다. UTF-8이 아닌 경로는 버려지고 하나도 안 남으면 `Unavailable`입니다(시험 이름 fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped). -8. 필터가 16개, 확장자가 32개를 넘으면 넘는 부분만 버려지고 요청은 처리됩니다(시험 이름 fr72_filters_over_the_limit_are_trimmed_not_rejected). +8. 10001개 경로를 고르면 `Unavailable`이고 `Chosen`이 일부만 가지 않습니다(시험 이름 fr72_more_than_ten_thousand_paths_answers_unavailable). 필터가 16개, 확장자가 32개를 넘으면 넘는 부분만 버려지고 요청은 처리됩니다(시험 이름 fr72_filters_over_the_limit_are_trimmed_not_rejected). 9. 대화상자가 열려 있는 동안 UI 스레드의 이벤트 루프가 돌아, 같은 창 밖의 애니메이션과 다른 창의 입력이 처리됩니다. 대화상자를 연 채 워커가 보낸 프레임 요청이 100ms 안에 렌더됩니다(시험 이름 fr72_ui_loop_keeps_running_while_a_dialog_is_open, 데스크톱 렌더러 시험). 10. 소유한 창을 지우면 열려 있던 대화상자가 닫히고 `Cancelled`가 하나 옵니다(시험 이름 fr72_closing_the_owner_window_cancels_the_dialog). 11. iOS, Android, Web 렌더러(와 포털도 GTK도 없는 Linux)에서 요청이 `Unavailable` 하나를 돌려주고 `ProtocolError`가 없습니다(시험 이름 fr72_unsupported_platform_answers_unavailable). From cea1d0266b6900bb681299eb40a96d89bf3e6d12 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:55:56 +0900 Subject: [PATCH 150/224] Docs: Record the FR-72 wire tags and the empty Chosen rule (#483) --- docs/SPEC.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index c20ede0d..e010f303 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3908,6 +3908,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" - **`paths`는 한 문자열입니다.** 경로가 여럿이면 `FilesDropped`(FR-27)와 같이 경로 사이를 NUL(U+0000) 한 바이트로 가르며, 이 바이트는 어느 데스크톱의 경로에도 들어갈 수 없습니다. 목록을 나르는 두 번째 방법을 만들지 않습니다. - **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 경로에 UTF-8이 아닌 바이트가 있으면(Linux에서만 가능) 그 경로를 버리고 나머지를 전달하며(FR-27과 같은 규칙), 하나도 남지 않으면 `Cancelled`가 아니라 `Unavailable`입니다. 사용자가 고른 것이 있었는데 취소로 보이면 거짓이기 때문입니다. - **긴 경로를 자르지 않습니다.** Windows의 `MAX_PATH`(260)를 넘는 경로도 `IShellItem`이 돌려주는 전체를 그대로 전달하고, 접두사(`\\?\`)는 붙이지 않습니다. 경로 한 개의 길이에 Renderer가 두는 상한은 없습니다. **한 번에 돌려주는 경로는 10000개까지입니다.** 사용자가 그보다 많이 골랐거나 이벤트의 문자열이 배치 버퍼가 감당하는 크기를 넘으면 `ProtocolError`가 아니라 **`Unavailable`**이고, 앞의 경로만 잘라 `Chosen`으로 보내지 않습니다. 사용자가 고른 것이 일부만 전달되면 앱이 그 사실을 모르기 때문입니다. +- `Chosen`인데 경로가 하나도 없으면 Renderer는 `Cancelled`를 보내고, Host는 경로 없는 `Chosen`을 받으면 `Unavailable`로 다룹니다. 앱이 빈 목록을 선택으로 처리하지 않게 하려는 것입니다. - `filter`: 사용자가 마지막으로 선택한 필터의 위치입니다. 필터를 보내지 않았거나 `Chosen`이 아니면 `0xFFFF_FFFF`입니다. - **같은 창에 요청이 겹치면 Renderer가 받은 순서대로 하나씩 띄웁니다.** 플랫폼의 모달 대화상자(macOS의 시트가 대표적입니다)는 창마다 하나뿐이기 때문입니다. 앞 요청이 끝나면 다음이 뜹니다. 다른 창의 요청은 서로 기다리지 않습니다. 이 순서는 Renderer 안의 상태이고 Host와 Renderer 사이의 큐가 아닙니다(PR-1). @@ -3925,7 +3926,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" #### 72.5 와이어 -경계 진입점은 하나도 늘지 않습니다(PR-2). 요청은 기존 배치의 레코드이고 결과는 기존 `dispatch_event`의 이벤트입니다. 태그 번호는 이 항목이 정하지 않습니다. 스키마의 태그 표는 pW가 소유하고 열린 변경이 이미 번호를 쓰고 있으므로, **마지막에 비어 있는 다음 번호**를 와이어 설계를 보고할 때 확정하고 이 절에 적습니다. 정해지는 것은 다음입니다. +경계 진입점은 하나도 늘지 않습니다(PR-2). 요청은 기존 배치의 레코드이고 결과는 기존 `dispatch_event`의 이벤트입니다. 태그 번호는 스키마의 태그 표를 가진 pW가 정했습니다. 명령 `ShowFileDialog`는 **26**(그때까지의 마지막 명령은 `RequestFocus`=25)이고, 이벤트 `FileDialogResult`는 **50**(마지막 이벤트 `ApplicationReopen`=47, 열린 변경이 48과 49를 씁니다)입니다. 정해진 것은 다음입니다. - 명령 하나 `ShowFileDialog`: 머리, `request`, `window`, `kind: u16`, `default_filter: u16`, 문자열 넷(`title`, `directory`, `name`, `filters`)입니다. `filters`는 한 문자열이고 `label`과 `extensions` 쌍을 NUL로 이어 붙입니다(`"이미지\0png,jpg\0문서\0pdf,txt"`). 목록을 나르는 두 번째 방법을 만들지 않습니다. - 이벤트 하나 `FileDialogResult`: 공통 머리 16바이트(`node_id = 0`, `handler_id = 0`) 뒤에 `request: u32`, `status: u32`, `filter: u32`, `paths: (offset, len)`입니다. 지금 `EventPayloadType`에 없는 모양이라 하나를 더합니다. From 345e83c8c4e8524e17de2e4f2dbb6339fdac76f8 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:55:56 +0900 Subject: [PATCH 151/224] Feat: ShowFileDialog command and FileDialogResult event, stage 1 (FR-72) Host to Renderer command ShowFileDialog (tag 26) and the FileDialogResult event (tag 50), add-only, no new boundary entry point. Rust: FileDialog builder, FileDialogSender, request id table, exactly one callback per request. Kotlin: FileDialogCenter normalizes the request, orders requests per window, answers Unavailable on platforms without a dialog, cancels a window's dialogs when it is removed, and delivers one event per request. The macOS platform implementation follows with the fork pin. Covers FR-72 criteria 1 to 8, 10 and 11 (criterion 9 and 12 need the platform dialogs). Refs #483 --- .../android-kotlin/shared/ComposeRustHost.kt | 5 + .../android-kotlin/shared/FileDialogHost.kt | 408 +++++++++++++++ .../android-kotlin/shared/NodeTable.kt | 39 +- .../android-kotlin/shared/Protocol.gen.kt | 69 ++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/boundary.rs | 39 ++ compose-rust/src/codegen.rs | 102 +++- compose-rust/src/file_dialog.rs | 464 ++++++++++++++++++ compose-rust/src/lib.rs | 2 + compose-rust/src/protocol.rs | 265 +++++++++- compose-rust/src/runtime/seam.rs | 15 + compose-rust/src/schema.rs | 53 +- compose-rust/tests/fr72_file_dialog.rs | 321 ++++++++++++ compose-rust/tests/vectors/events.bin | Bin 952 -> 1025 bytes compose-rust/tests/vectors/mutations.bin | Bin 5363 -> 5484 bytes compose-rust/tests/vectors/vectors.json | 11 +- renderer/android/src/shared/FileDialogHost.kt | 1 + renderer/desktop/src/WindowManager.kt | 3 + renderer/desktop/src/protocol/Protocol.gen.kt | 69 ++- .../desktop/src/renderer/ComposeRustHost.kt | 5 + .../src/renderer/FakeHostConnection.kt | 3 + .../desktop/src/renderer/FileDialogHost.kt | 408 +++++++++++++++ renderer/desktop/src/renderer/NodeTable.kt | 39 +- renderer/desktop/test/FileDialogTest.kt | 377 ++++++++++++++ renderer/desktop/test/ProtocolVectorsTest.kt | 73 ++- renderer/ios/src/shared/FileDialogHost.kt | 1 + renderer/linux/src/shared/FileDialogHost.kt | 1 + renderer/macos/src/shared/FileDialogHost.kt | 1 + renderer/web/src/shared/FileDialogHost.kt | 1 + renderer/windows/src/shared/FileDialogHost.kt | 1 + 30 files changed, 2756 insertions(+), 22 deletions(-) create mode 100644 compose-rust/android-kotlin/shared/FileDialogHost.kt create mode 100644 compose-rust/src/file_dialog.rs create mode 100644 compose-rust/tests/fr72_file_dialog.rs create mode 120000 renderer/android/src/shared/FileDialogHost.kt create mode 100644 renderer/desktop/src/renderer/FileDialogHost.kt create mode 100644 renderer/desktop/test/FileDialogTest.kt create mode 120000 renderer/ios/src/shared/FileDialogHost.kt create mode 120000 renderer/linux/src/shared/FileDialogHost.kt create mode 120000 renderer/macos/src/shared/FileDialogHost.kt create mode 120000 renderer/web/src/shared/FileDialogHost.kt create mode 120000 renderer/windows/src/shared/FileDialogHost.kt diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 455ca32c..dfdfab70 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -134,6 +134,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // After the first batch, so the first thing the Host hears is a press that started // the application, if one did, and then what the platform says about permission. table.notifications.attach(this) + table.fileDialogs.attach(this) } /** Dispatches synchronously and returns the Host's consumption result. */ @@ -167,6 +168,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // answer from the platform asks for a frame for exactly this, because the frame is // where the UI thread, and so the Host, can be reached from any other thread. table.notifications.drain() + table.fileDialogs.drain() applyTransaction { apply -> connection.renderFrame(frameTimeNanos, apply) } } @@ -180,6 +182,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher fun shutdown() { table.notifications.shutdown() + table.fileDialogs.shutdown() connection.shutdown() } @@ -403,6 +406,8 @@ private fun ComposeRustTree( // A notification centre answers on a thread of its own, and the way from any thread to // this one is a frame request. The centre asks through the same source this loop serves. SideEffect { host.table.notifications.wake = frames::request } + // A file dialog's answer arrives the same way, from whichever thread the platform uses. + SideEffect { host.table.fileDialogs.wake = frames::request } // Whether the window is the one in use decides whether a notification that asked to be // shown only when it is not is shown at all, and coming back to the window is when a // permission changed in the system's settings is looked for. diff --git a/compose-rust/android-kotlin/shared/FileDialogHost.kt b/compose-rust/android-kotlin/shared/FileDialogHost.kt new file mode 100644 index 00000000..0217ca14 --- /dev/null +++ b/compose-rust/android-kotlin/shared/FileDialogHost.kt @@ -0,0 +1,408 @@ +package dev.darkpyonix.composerust.ui.platform + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.runtime.EventDispatcher +import kotlinx.coroutines.channels.Channel + +/** The limits a request and its answer are held to. */ +object FileDialogLimits { + /** Filters kept from one request; the rest are dropped. */ + const val MAX_FILTERS = 16 + + /** Extensions kept from one filter; the rest are dropped. */ + const val MAX_EXTENSIONS = 32 + + /** Paths one answer may carry. More is not delivered as a shorter list: it is unavailable. */ + const val MAX_PATHS = 10_000 + + /** + * Bytes of paths one answer may carry, separators included. + * + * Half of what the largest event buffer the desktop connection will grow to, so the + * record around the paths always fits. A platform with a smaller buffer sets a smaller + * limit on its [FileDialogCenter]. + */ + const val MAX_PAYLOAD_BYTES = 32 * 1024 * 1024 +} + +/** One filter of a dialog: what the user sees, and the extensions it lets through. */ +data class FileDialogFilter( + val label: String, + /** Lower case, without a leading dot or `*.`. Empty means every file. */ + val extensions: List, +) + +/** + * One dialog, as a platform is asked to show it, after the request was checked and + * normalised. + * + * [windowNode] is the `Window` node the dialog belongs to, or 0 for the implicit root + * window. [name] is a bare file name, and only a save dialog has one. [filters] is empty for + * the folder kinds. [defaultFilter] is a position inside [filters], or 0. + */ +data class FileDialogSpec( + val request: Int, + val windowNode: Int, + val kind: FileDialogKind, + val title: String, + val directory: String, + val name: String, + val filters: List, + val defaultFilter: Int, +) + +/** + * One path a dialog returned. A platform that hands over text passes [Text]; one whose paths + * are bytes (a Linux file system does not promise UTF-8) passes [Bytes], and the ones that + * are not UTF-8 are dropped before the Host sees anything. + */ +sealed interface FileDialogPath { + class Text(val value: String) : FileDialogPath + class Bytes(val value: ByteArray) : FileDialogPath +} + +/** How a platform's dialog ended. */ +sealed interface FileDialogOutcome { + /** + * The user chose. [filter] is the position of the filter that was selected, or null when + * the dialog had none. + */ + class Chosen(val paths: List, val filter: Int? = null) : FileDialogOutcome { + companion object { + fun ofText(paths: List, filter: Int? = null): Chosen = + Chosen(paths.map { FileDialogPath.Text(it) }, filter) + } + } + + /** The user closed the dialog. */ + data object Cancelled : FileDialogOutcome + + /** The dialog could not be shown. */ + data object Unavailable : FileDialogOutcome +} + +/** + * One platform's file dialogs. + * + * [show] is called on the UI thread and must return without waiting for the user. The + * outcome goes to [onResult], from whichever thread the platform answers on, once. Calling + * it again is harmless: only the first counts. + */ +interface FileDialogPlatform { + /** + * Shows [request] on [window], the handle for the window it belongs to. The handle is + * whatever [FileDialogCenter.windowResolver] gave for the request's window node, and is + * null for the implicit root window or when nothing resolves one. + */ + fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) + + /** + * The window a dialog was shown on is going away. The platform closes that window's + * dialog if it has one open. The request ends with a cancel whether or not it does. + */ + fun cancelWindow(window: Any?) {} +} + +/** + * A platform with no file dialog yet, or a run that cannot show one. Every request is + * answered at once with [FileDialogOutcome.Unavailable]; that is not a protocol error. + */ +object UnsupportedFileDialogs : FileDialogPlatform { + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) = + onResult(FileDialogOutcome.Unavailable) +} + +/** + * The file dialogs this process uses. + * + * Installed by each platform's entry point before the Host starts, the way the notification + * centre is. A run nobody installed anything for answers every request as unavailable. + */ +object FileDialogs { + var platform: FileDialogPlatform = UnsupportedFileDialogs +} + +/** + * What the interpreter does with file dialog requests, and the one route the answers take + * back to the Host. + * + * It owns the rules that do not depend on a platform: a request number waits for one answer + * only, requests on one window are shown one after the other in the order they came, a + * window that goes away ends its dialog with a cancel, and an answer is delivered on the UI + * thread inside a frame, whole or not at all. The platform underneath only shows and reports. + */ +class FileDialogCenter( + private val platform: FileDialogPlatform = FileDialogs.platform, + private val maxPayloadBytes: Int = FileDialogLimits.MAX_PAYLOAD_BYTES, +) { + /** What `request` made of the request, for the table to report. */ + enum class Accepted { Accepted, Duplicate, UnknownWindow } + + private class Entry(val spec: FileDialogSpec) { + /** The platform was asked, or the request is already answered and only waits to be delivered. */ + var started = false + + /** An answer is on its way to being delivered, so nothing else starts on this window yet. */ + var finishing = false + var handle: Any? = null + } + + private class Completion(val request: Int, val outcome: FileDialogOutcome) + + /** + * Answers from the platform, from any thread. A channel because it is the one queue every + * target can share across threads without a lock of its own, and unlimited because what + * goes in is a handful of answers. + */ + private val inbox = Channel(Channel.UNLIMITED) + + /** Requests that have not been delivered an answer yet, in the order they came. */ + private val pending = LinkedHashMap() + + private var dispatcher: EventDispatcher? = null + private var draining = false + + /** How an answer asks for the frame that delivers it. */ + var wake: () -> Unit = {} + + /** + * Gives the platform's handle for a `Window` node, read when the dialog is shown. Node 0 + * has none. The window manager of a multi-window application installs this. + */ + var windowResolver: (Int) -> Any? = { null } + + /** How many requests are waiting for an answer. */ + internal val pendingCount: Int get() = pending.size + + /** + * Takes the request, normalised, unless its number is already waiting for an answer. + * + * [windowKnown] says whether the window it names is the implicit root or an existing + * `Window` node. A request on an unknown window is still answered, as unavailable. + */ + fun request(mutation: Mutation.ShowFileDialog, windowKnown: Boolean): Accepted { + if (pending.containsKey(mutation.request)) return Accepted.Duplicate + val entry = Entry(normalize(mutation)) + pending[mutation.request] = entry + if (!windowKnown) { + entry.started = true + entry.finishing = true + inbox.trySend(Completion(mutation.request, FileDialogOutcome.Unavailable)) + wake() + return Accepted.UnknownWindow + } + wake() + return Accepted.Accepted + } + + /** + * The `Window` node [windowNode] was removed. Its dialog is closed and every request on it + * ends with one cancel. + */ + fun windowRemoved(windowNode: Int) { + if (windowNode == 0) return + var any = false + for (entry in pending.values.toList()) { + if (entry.spec.windowNode != windowNode || entry.finishing) continue + any = true + entry.finishing = true + if (entry.started) platform.cancelWindow(entry.handle) + inbox.trySend(Completion(entry.spec.request, FileDialogOutcome.Cancelled)) + } + if (any) wake() + } + + /** Connects the center to the Host, once its first batch has been applied. */ + fun attach(host: EventDispatcher) { + dispatcher = host + drain() + } + + /** The process is ending. */ + fun shutdown() { + dispatcher = null + pending.clear() + while (inbox.tryReceive().getOrNull() != null) Unit + } + + /** + * Starts what may start and hands every answer to the Host, on the UI thread. + * + * Called where the Host can be entered: after its start, and at the top of every frame + * an answer asked for. A request is started here and not where it arrives, because the + * batch that carried it may also have created the window it belongs to, and the platform + * window exists only once the batch is through. + */ + fun drain() { + val host = dispatcher ?: return + if (draining) return + draining = true + try { + var progress = true + while (progress) { + progress = false + while (true) { + val done = inbox.tryReceive().getOrNull() ?: break + // Only the first answer to a request counts; a second finds nothing waiting. + val entry = pending.remove(done.request) ?: continue + progress = true + host.dispatch(resultEvent(entry.spec, done.outcome)) + } + for (entry in pending.values.toList()) { + if (entry.started || entry.finishing) continue + if (pending.values.any { it !== entry && it.spec.windowNode == entry.spec.windowNode && (it.started || it.finishing) }) continue + start(entry) + progress = true + } + } + } finally { + draining = false + } + } + + private fun start(entry: Entry) { + entry.started = true + val request = entry.spec.request + val handle = windowResolver(entry.spec.windowNode) + entry.handle = handle + try { + platform.show(handle, entry.spec) { outcome -> + inbox.trySend(Completion(request, outcome)) + wake() + } + } catch (failure: Exception) { + // A platform that throws has no dialog to show, which is the same answer. + inbox.trySend(Completion(request, FileDialogOutcome.Unavailable)) + } + } + + private fun resultEvent(spec: FileDialogSpec, outcome: FileDialogOutcome): HostEvent { + val none = -1 + return when (val checked = judge(outcome)) { + is Checked.Paths -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = FileDialogStatus.Chosen, + filter = checked.filter?.takeIf { it in spec.filters.indices } ?: none, + paths = checked.joined, + ) + is Checked.Ended -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = checked.status, + filter = none, + paths = "", + ) + } + } + + private sealed interface Checked { + class Paths(val joined: String, val filter: Int?) : Checked + class Ended(val status: FileDialogStatus) : Checked + } + + /** + * What the Host is told about an outcome. + * + * Paths that are not well formed text are dropped and the rest go on. When a choice was + * made and none is left, it is unavailable and not a cancel: the user picked something, + * so a cancel would be false. A choice with too many paths, or too many bytes of them, is + * unavailable too, never a shorter list: the application would not know it was short. + */ + private fun judge(outcome: FileDialogOutcome): Checked = when (outcome) { + FileDialogOutcome.Cancelled -> Checked.Ended(FileDialogStatus.Cancelled) + FileDialogOutcome.Unavailable -> Checked.Ended(FileDialogStatus.Unavailable) + is FileDialogOutcome.Chosen -> when { + outcome.paths.isEmpty() -> Checked.Ended(FileDialogStatus.Cancelled) + outcome.paths.size > FileDialogLimits.MAX_PATHS -> Checked.Ended(FileDialogStatus.Unavailable) + else -> { + val kept = outcome.paths.mapNotNull { wellFormedPath(it) } + val bytes = kept.sumOf { it.encodeToByteArray().size.toLong() + 1L } + when { + kept.isEmpty() -> Checked.Ended(FileDialogStatus.Unavailable) + bytes > maxPayloadBytes.toLong() -> Checked.Ended(FileDialogStatus.Unavailable) + else -> Checked.Paths(kept.joinToString("\u0000"), outcome.filter) + } + } + } + } + + companion object { + /** + * Checks a request and puts it in the form a platform can rely on: filters and + * extensions within their limits, extensions lower case without a leading dot or + * `*.`, a default filter that exists, a bare file name, and nothing in the fields a + * kind does not use. + */ + fun normalize(mutation: Mutation.ShowFileDialog): FileDialogSpec { + val folders = mutation.kind == FileDialogKind.OpenFolder || mutation.kind == FileDialogKind.OpenFolders + val filters = if (folders) emptyList() else parseFilters(mutation.filters) + val name = if (mutation.kind == FileDialogKind.Save) { + mutation.name.substringAfterLast('/').substringAfterLast('\\') + } else { + "" + } + return FileDialogSpec( + request = mutation.request, + windowNode = mutation.window, + kind = mutation.kind, + title = mutation.title, + directory = mutation.directory, + name = name, + filters = filters, + defaultFilter = mutation.defaultFilter.takeIf { it in filters.indices } ?: 0, + ) + } + + private fun parseFilters(encoded: String): List { + if (encoded.isEmpty()) return emptyList() + // Label, extensions, label, extensions. A label with nothing after it lets every + // file through, like one with no extensions. + val parts = encoded.split('\u0000') + val filters = ArrayList() + var index = 0 + while (index < parts.size && filters.size < FileDialogLimits.MAX_FILTERS) { + val extensions = (parts.getOrNull(index + 1) ?: "") + .split(',') + .map { it.trim().removePrefix("*.").removePrefix(".").lowercase() } + .filter { it.isNotEmpty() } + .take(FileDialogLimits.MAX_EXTENSIONS) + filters += FileDialogFilter(parts[index], extensions) + index += 2 + } + return filters + } + + /** The path as text, or null for one that is empty, holds a NUL, or is not valid Unicode text. */ + private fun wellFormedPath(path: FileDialogPath): String? { + val text = when (path) { + is FileDialogPath.Text -> path.value + is FileDialogPath.Bytes -> try { + path.value.decodeToString(throwOnInvalidSequence = true) + } catch (failure: Exception) { + return null + } + } + if (text.isEmpty()) return null + var index = 0 + while (index < text.length) { + val unit = text[index] + when { + unit == '\u0000' -> return null + unit.isHighSurrogate() -> { + if (index + 1 >= text.length || !text[index + 1].isLowSurrogate()) return null + index++ + } + unit.isLowSurrogate() -> return null + } + index++ + } + return text + } + } +} diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index cbf2fb04..092b14a1 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -93,6 +93,8 @@ data class TableError(val code: Int, val message: String) { const val MISPLACED_WINDOW = 13 /** A node moved from the content of one `Window` into another, or out of its window. */ const val MOVE_BETWEEN_WINDOWS = 14 + /** A file dialog request whose number is still waiting for its answer. The command is dropped. */ + const val DUPLICATE_FILE_DIALOG = 15 } } @@ -166,6 +168,15 @@ class NodeTable { val notifications: dev.darkpyonix.composerust.ui.platform.NotificationCenter = dev.darkpyonix.composerust.ui.platform.NotificationCenter() + /** + * The file dialogs the Host has asked for, and their answers on the way back. + * + * Not cleared with the tree, for the reason the notifications are not: a request the Host + * is waiting on is still owed its one answer after a resync. + */ + val fileDialogs: dev.darkpyonix.composerust.ui.platform.FileDialogCenter = + dev.darkpyonix.composerust.ui.platform.FileDialogCenter() + private var revision = 0L /** Increments on each `RequestFocus(0)` so the root composition clears active focus. */ @@ -258,6 +269,9 @@ class NodeTable { * start over and anything kept from before would collide with them. */ fun clear() { + // The windows go with the nodes, and a dialog open on one of them ends with a cancel. + nodes.filterValues { it.widget == WidgetKind.Window }.keys.toList() + .forEach(fileDialogs::windowRemoved) animations.clear() nodes.clear() codeNodes.clear() @@ -332,6 +346,10 @@ class NodeTable { is Mutation.WithdrawNotification -> notifications.withdraw(mutation.key) is Mutation.RequestNotificationPermission -> notifications.requestPermission() + // Not a node either. A request on a window that is not there is answered as + // unavailable, so the Host's one answer per request holds, and is reported too. + is Mutation.ShowFileDialog -> showFileDialog(mutation) + // Played on this side's frame clock until it ends. Whether the node has // somewhere to play it is asked once the whole batch is in. is Mutation.StartAnimation -> animations.start(mutation.animation) @@ -394,6 +412,22 @@ class NodeTable { focusRequests += mutation.nodeId } + private fun showFileDialog(mutation: Mutation.ShowFileDialog) { + val windowKnown = mutation.window == ROOT_ID || + nodes[mutation.window]?.widget == WidgetKind.Window + when (fileDialogs.request(mutation, windowKnown)) { + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Accepted -> Unit + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Duplicate -> fail( + TableError.DUPLICATE_FILE_DIALOG, + "ShowFileDialog request ${mutation.request} is already waiting for its answer", + ) + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.UnknownWindow -> fail( + TableError.UNKNOWN_NODE, + "ShowFileDialog for unknown window ${mutation.window}", + ) + } + } + private fun requestFocus(mutation: Mutation.RequestFocus) { if (mutation.nodeId == ROOT_ID) { revision += 1 @@ -629,7 +663,10 @@ class NodeTable { private fun removeSubtree(nodeId: Int) { val node = nodes.remove(nodeId) ?: return codeNodes.remove(nodeId) - if (node.widget == WidgetKind.Window) windowThemes.remove(nodeId) + if (node.widget == WidgetKind.Window) { + windowThemes.remove(nodeId) + fileDialogs.windowRemoved(nodeId) + } animations.removeNode(nodeId) materials -= node.modifiers.count { it is ProtocolModifier.Material } node.children.toList().forEach(::removeSubtree) diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index d23eca5e..bb1af4af 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -116,6 +116,10 @@ enum class WindowMode { Single, Windows } enum class LastWindowClosed { Exit, KeepRunning } +enum class FileDialogKind { OpenFile, OpenFiles, OpenFolder, OpenFolders, Save } + +enum class FileDialogStatus { Chosen, Cancelled, Unavailable } + /** * The `TypeRole` property of text with no rung of the ladder at all: CSS text, whose * size, weight and font are its own. Present and zero; an absent property is the @@ -1249,6 +1253,23 @@ sealed interface Mutation { /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation } sealed interface HostEvent { @@ -1295,13 +1316,14 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FileDialogResult(override val nodeId: Int, override val handlerId: Long, val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 2293438994272253525L + const val SCHEMA_HASH: Long = -3763091213759536850L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1330,6 +1352,7 @@ object Protocol { private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1483,6 +1506,19 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the @@ -1756,6 +1792,7 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FileDialogResult -> event.paths.toByteArray(StandardCharsets.UTF_8) } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1798,6 +1835,7 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FileDialogResult -> 36 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1844,6 +1882,7 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FileDialogResult -> 50 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -2011,6 +2050,12 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FileDialogResult -> { + out.putInt(event.request) + out.putInt(fileDialogStatusTag(event.status)) + out.putInt(event.filter) + writeStringReference(out, recordLength, text!!) + } } if (text != null) out.put(text) return out.position() - start @@ -2237,6 +2282,12 @@ object Protocol { NotificationPermission.Unsupported -> 4 } + private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) { + FileDialogStatus.Chosen -> 1 + FileDialogStatus.Cancelled -> 2 + FileDialogStatus.Unavailable -> 3 + } + private fun animationEventKindTag(value: AnimationEventKind): Int = when (value) { AnimationEventKind.Ready -> 1 AnimationEventKind.Active -> 2 @@ -2817,6 +2868,22 @@ object Protocol { else -> throw ProtocolException("unknown LastWindowClosed tag $tag", offset) } + private fun fileDialogKind(tag: Int, offset: Int): FileDialogKind = when (tag) { + 1 -> FileDialogKind.OpenFile + 2 -> FileDialogKind.OpenFiles + 3 -> FileDialogKind.OpenFolder + 4 -> FileDialogKind.OpenFolders + 5 -> FileDialogKind.Save + else -> throw ProtocolException("unknown FileDialogKind tag $tag", offset) + } + + private fun fileDialogStatus(tag: Int, offset: Int): FileDialogStatus = when (tag) { + 1 -> FileDialogStatus.Chosen + 2 -> FileDialogStatus.Cancelled + 3 -> FileDialogStatus.Unavailable + else -> throw ProtocolException("unknown FileDialogStatus tag $tag", offset) + } + private fun paint(bits: Long, offset: Int): Paint { val value = bits.toInt() return when (val kind = (bits ushr 32).toInt()) { diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 6a4bc72a..3b14fe41 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x1fd3ee9d37a07655 +0xcbc6d07a9278212e diff --git a/compose-rust/src/boundary.rs b/compose-rust/src/boundary.rs index d5fc8772..fc8732c4 100644 --- a/compose-rust/src/boundary.rs +++ b/compose-rust/src/boundary.rs @@ -564,6 +564,30 @@ impl Host { self.render_and_finish(0) } + /// Hands the end of a file dialog to the callback that asked for it. + /// + /// No node and no handler: a dialog is not in the tree, and the request number names the + /// callback. An answer nobody is waiting for is dropped. + fn file_dialog_result( + &mut self, + request: u32, + status: crate::schema::FileDialogStatus, + filter: u32, + paths: &str, + ) -> Result<(&[u8], i64), ProtocolError> { + let mut delivered = false; + { + let _dispatch_guard = EventDispatchGuard::enter(); + self.runtime.run_in_context(&mut || { + delivered = crate::file_dialog::deliver(request, status, filter, paths) + }); + } + if !delivered { + return self.empty_batch(); + } + self.render_and_finish(0) + } + /// Tells the components that listen that the application was opened with no window. fn application_reopen(&mut self) -> Result<(&[u8], i64), ProtocolError> { let mut delivered = false; @@ -678,6 +702,18 @@ impl Host { } return self.application_reopen(); } + // The end of a file dialog: the Host's own business, found by request number. + EventPayload::FileDialogResult { + request, + status, + filter, + paths, + } => { + if event.node_id != 0 || event.handler_id != 0 { + return Err(ProtocolError::InvalidValueKind(0)); + } + return self.file_dialog_result(request, status, filter, paths); + } _ => {} } // The window's size belongs to no node and no handler: the Renderer measures the @@ -846,6 +882,9 @@ impl Host { ); }); self.flush_notifications(); + // File dialogs an application asked for, from a handler, a component or a worker. + let batch = self.runtime.batch_mut(); + crate::file_dialog::drain(|request| batch.file_dialog(request)); // Animations an application started from a handler, a component or a worker. let batch = self.runtime.batch_mut(); crate::animation::drain(|mutation| batch.write(mutation)); diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index fa557cc0..b317a038 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -465,6 +465,23 @@ sealed interface Mutation { /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation } "#, @@ -546,6 +563,11 @@ sealed interface Mutation { ", val kind: Int, val window: Int, val x: kotlin.Float, val y: kotlin.Float", ), EventPayloadType::WindowRequest => output.push_str(", val request: Int, val key: String"), + // The filter is the position of the filter in use, or -1 (the wire's 0xFFFFFFFF) + // when there is none. + EventPayloadType::FileDialogResult => output.push_str( + ", val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String", + ), } output.push_str(") : HostEvent\n"); } @@ -597,6 +619,7 @@ object Protocol { private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -750,6 +773,19 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the @@ -1011,6 +1047,14 @@ object Protocol { ) .unwrap(); } + EventPayloadType::FileDialogResult => { + writeln!( + output, + " is HostEvent.{} -> event.paths.toByteArray(StandardCharsets.UTF_8)", + event.name + ) + .unwrap(); + } EventPayloadType::None => { writeln!( output, @@ -1102,6 +1146,8 @@ object Protocol { | EventPayloadType::WindowDragOver => 32, // A word and a string reference, the protocol error's shape. EventPayloadType::WindowRequest => 28, + // The request, the status and the filter, then the paths' reference. + EventPayloadType::FileDialogResult => 36, }; writeln!( output, @@ -1384,6 +1430,18 @@ object Protocol { output.push_str(" out.putFloat(event.y)\n"); output.push_str(" }\n"); } + EventPayloadType::FileDialogResult => { + writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap(); + output.push_str(" out.putInt(event.request)\n"); + output.push_str( + " out.putInt(fileDialogStatusTag(event.status))\n", + ); + output.push_str(" out.putInt(event.filter)\n"); + output.push_str( + " writeStringReference(out, recordLength, text!!)\n", + ); + output.push_str(" }\n"); + } EventPayloadType::WindowRequest => { writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap(); output.push_str(" out.putInt(event.request)\n"); @@ -1546,6 +1604,19 @@ object Protocol { .unwrap(); } output.push_str(" }\n\n"); + // The dialog's status travels out too: the Renderer is the side that learns how it ended. + output.push_str( + " private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) {\n", + ); + for variant in crate::schema::FILE_DIALOG_STATUS_SCHEMA { + writeln!( + output, + " FileDialogStatus.{} -> {}", + variant.name, variant.tag + ) + .unwrap(); + } + output.push_str(" }\n\n"); // The enums the Renderer writes into animation events and the motion setting. for (function, name, variants) in [ ( @@ -2995,6 +3066,18 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { modifier: Modifier::MatchParentSize, }, Mutation::RequestFocus { node_id: 1 }, + // A dialog on the implicit root window, with every string filled and a list of two + // filters in the one NUL separated string. + Mutation::ShowFileDialog { + request: 7, + window: 0, + kind: crate::schema::FileDialogKind::OpenFiles, + default_filter: 1, + title: "파일 고르기", + directory: "/home/한글", + name: "보고서.txt", + filters: "이미지\0png,jpg\0문서\0pdf,txt", + }, ]; let mut encoder = BatchEncoder::default(); for mutation in &mutations { @@ -3267,6 +3350,16 @@ pub fn generate_event_vector() -> Result, ProtocolError> { handler_id: 0, payload: EventPayload::ApplicationReopen, }, + HostEvent { + node_id: 0, + handler_id: 0, + payload: EventPayload::FileDialogResult { + request: 7, + status: crate::schema::FileDialogStatus::Chosen, + filter: 1, + paths: "/Users/한글/문서.txt\0/tmp/a b.png", + }, + }, ]; let mut output = Vec::new(); let mut encoded = Vec::new(); @@ -3286,19 +3379,19 @@ pub fn generate_vector_description() -> String { "mutations": {{ "file": "mutations.bin", "description": "One batch covering every record, property value, modifier layout, drawing command, asset, message, notification command, palette entry, text run, split pane property, the HTML and CSS drawing elements, the animations the Renderer plays and code editor record", - "recordCount": 103, + "recordCount": 104, "palette": [ {{ "role": "Primary", "scheme": "Light", "argb": "ffe8590c" }}, {{ "role": "Primary", "scheme": "Dark", "argb": "ffff8a4c" }}, {{ "role": "SyntaxKeyword", "scheme": "Light", "argb": "80112233" }} ], "spanRecordLength": 36, - "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {{\n let 한 = \"😀\";\n}}\n", "mut ", "문서", "보조 창", "ws/1:2", "tab/3"], + "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {{\n let 한 = \"😀\";\n}}\n", "mut ", "문서", "보조 창", "ws/1:2", "tab/3", "파일 고르기", "/home/한글", "보고서.txt", "이미지\u0000png,jpg\u0000문서\u0000pdf,txt"], "assets": [{{ "assetId": 5, "kind": "Png", "bytes": "89504e47" }}] }}, "events": {{ "file": "events.bin", - "description": "Thirty-three independently decodable event records concatenated in schema order", + "description": "Thirty-four independently decodable event records concatenated in schema order", "records": [ {{ "type": "Clicked", "offset": 0, "length": 16, "nodeId": 7, "handlerId": 11 }}, {{ "type": "TextChanged", "offset": 16, "length": 30, "nodeId": 8, "handlerId": 12, "text": "한글" }}, @@ -3332,7 +3425,8 @@ pub fn generate_vector_description() -> String { {{ "type": "WindowUnavailable", "offset": 870, "length": 16, "nodeId": 20, "handlerId": 205 }}, {{ "type": "WindowClosed", "offset": 886, "length": 16, "nodeId": 20, "handlerId": 206 }}, {{ "type": "WindowRequested", "offset": 902, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }}, - {{ "type": "ApplicationReopen", "offset": 936, "length": 16, "nodeId": 0, "handlerId": 0 }} + {{ "type": "ApplicationReopen", "offset": 936, "length": 16, "nodeId": 0, "handlerId": 0 }}, + {{ "type": "FileDialogResult", "offset": 952, "length": 73, "nodeId": 0, "handlerId": 0, "request": 7, "status": "Chosen", "filter": 1, "paths": "/Users/한글/문서.txt\u0000/tmp/a b.png" }} ] }} }} diff --git a/compose-rust/src/file_dialog.rs b/compose-rust/src/file_dialog.rs new file mode 100644 index 00000000..08f80402 --- /dev/null +++ b/compose-rust/src/file_dialog.rs @@ -0,0 +1,464 @@ +//! File dialogs: asking the user to pick or name a file from the platform's own panel. +//! +//! The Host does not call the platform. The Renderer owns the windows and the platform's +//! panel, so a request crosses as a command record in an ordinary batch, and the answer +//! comes back as an ordinary event. A dialog is not a node: it has no place in the tree and +//! it is gone once it has answered. +//! +//! Every request gets exactly one answer, whether the user chose, cancelled, or this run has +//! no way to show a panel. The Host numbers each request, remembers the callback under that +//! number, calls it once when the matching event arrives, and drops it. An answer for a +//! number nobody is waiting on is ignored. +//! +//! ```ignore +//! FileDialog::open_files() +//! .title("Choose files to send") +//! .filter("Images", &["png", "jpg"]) +//! .show(|result| { +//! if let FileDialogResult::Chosen { paths, .. } = result { +//! send(paths); +//! } +//! }); +//! ``` +//! +//! [`FileDialog::show`] belongs on the Renderer's UI thread, from a handler or a component, +//! because its callback runs there. A worker thread asks through [`FileDialogSender`], whose +//! callback has to be `Send` for the same reason: it is stored by the Host and called on the +//! UI thread. Nothing here is a queue on the boundary. The queue is the Host's own and it is +//! written into the next batch inside a call. + +use crate::schema::{FileDialogKind, FileDialogStatus}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +/// How a dialog ended. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FileDialogResult { + /// The user chose. `paths` holds at least one absolute path, exactly as the platform + /// gave it. `filter` is the position of the filter that was in use, when the request + /// carried filters. + Chosen { + paths: Vec, + filter: Option, + }, + /// The user closed the dialog, or the window it belonged to went away while it was open. + Cancelled, + /// This run cannot show a dialog: the platform has none yet, the window does not exist, + /// or the answer could not be delivered whole. Not an error, and not a cancel. + Unavailable, +} + +/// One file dialog, built and then shown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileDialog { + kind: FileDialogKind, + window: u32, + title: String, + directory: String, + name: String, + filters: Vec<(String, Vec)>, + default_filter: usize, +} + +impl FileDialog { + fn new(kind: FileDialogKind) -> Self { + Self { + kind, + window: 0, + title: String::new(), + directory: String::new(), + name: String::new(), + filters: Vec::new(), + default_filter: 0, + } + } + + /// Choose one file. + pub fn open_file() -> Self { + Self::new(FileDialogKind::OpenFile) + } + + /// Choose one or more files. + pub fn open_files() -> Self { + Self::new(FileDialogKind::OpenFiles) + } + + /// Choose one folder. + pub fn open_folder() -> Self { + Self::new(FileDialogKind::OpenFolder) + } + + /// Choose one or more folders. + pub fn open_folders() -> Self { + Self::new(FileDialogKind::OpenFolders) + } + + /// Choose a path to save to. The platform asks before overwriting; the Renderer only + /// hands back the path and never writes the file. + pub fn save() -> Self { + Self::new(FileDialogKind::Save) + } + + /// The dialog's title. Empty is the platform's default. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// The folder the dialog opens in. It should be absolute. One that is empty, missing + /// or unreadable falls back to the platform's default, and is not an error. + pub fn directory(mut self, directory: impl Into) -> Self { + self.directory = directory.into(); + self + } + + /// The file name a save dialog starts with. Ignored by the open kinds. A path + /// separator in it keeps only what follows the last one. + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Adds a filter: a label and the extensions it allows, without dots and compared + /// without regard to case. Ignored when choosing folders. Without any filter every file + /// can be chosen. + pub fn filter(mut self, label: impl Into, extensions: &[&str]) -> Self { + self.filters.push(( + label.into(), + extensions + .iter() + .map(|extension| (*extension).to_owned()) + .collect(), + )); + self + } + + /// The position of the filter selected first. A position past the last filter selects + /// the first. + pub fn default_filter(mut self, position: usize) -> Self { + self.default_filter = position; + self + } + + /// The `Window` node the dialog belongs to and is modal for. 0 is the implicit root + /// window of an application with one window. + pub fn window(mut self, node_id: u32) -> Self { + self.window = node_id; + self + } + + /// The window the dialog belongs to, by handle. + pub fn in_window(self, window: crate::windows::WindowHandle) -> Self { + self.window(window.id()) + } + + /// Asks for the dialog. `on_result` is called once, on the UI thread, when it ends, and + /// is then dropped. + /// + /// Call this on the UI thread, from a component or an event handler. From a worker use + /// [`FileDialogSender`]. + pub fn show(self, on_result: impl FnOnce(FileDialogResult) + 'static) { + let request = next_request(); + let callback: LocalCallback = Box::new(on_result); + LOCAL.with_borrow_mut(|callbacks| { + callbacks.insert(request, callback); + }); + submit(self.into_request(request)); + } + + fn into_request(self, id: u32) -> Request { + Request { + id, + window: self.window, + kind: self.kind, + default_filter: u16::try_from(self.default_filter).unwrap_or(u16::MAX), + title: self.title, + directory: self.directory, + name: self.name, + filters: encode_filters(&self.filters), + } + } +} + +/// A handle a worker thread asks for dialogs through. +/// +/// `Send`, `Copy` and free to make: the queue it feeds belongs to the Host. The callback is +/// stored by the Host and run on the UI thread, so it has to be `Send`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FileDialogSender { + _private: (), +} + +impl FileDialogSender { + pub const fn new() -> Self { + Self { _private: () } + } + + /// Asks for `dialog` and calls `on_result` once on the UI thread when it ends. + pub fn show( + &self, + dialog: FileDialog, + on_result: impl FnOnce(FileDialogResult) + Send + 'static, + ) { + let request = next_request(); + let callback: RemoteCallback = Box::new(on_result); + remote().push((request, callback)); + submit(dialog.into_request(request)); + } +} + +/// One request waiting for the next batch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Request { + pub(crate) id: u32, + pub(crate) window: u32, + pub(crate) kind: FileDialogKind, + pub(crate) default_filter: u16, + pub(crate) title: String, + pub(crate) directory: String, + pub(crate) name: String, + pub(crate) filters: String, +} + +/// The filters as the wire carries them: each label, then its extensions joined by commas, +/// all separated by NUL. A NUL or a comma inside a part would shift every later part, so +/// they are dropped from the parts rather than sent. +fn encode_filters(filters: &[(String, Vec)]) -> String { + let mut out = String::new(); + for (label, extensions) in filters { + if !out.is_empty() { + out.push('\0'); + } + out.extend(label.chars().filter(|c| *c != '\0')); + out.push('\0'); + let mut first = true; + for extension in extensions { + if !first { + out.push(','); + } + first = false; + out.extend(extension.chars().filter(|c| *c != '\0' && *c != ',')); + } + } + out +} + +type LocalCallback = Box; +type RemoteCallback = Box; + +thread_local! { + /// Callbacks asked for on the UI thread, which need not be `Send`. + static LOCAL: RefCell> = RefCell::new(HashMap::new()); +} + +/// Callbacks a worker asked for, kept until the UI thread delivers the answer. +static REMOTE: Mutex> = Mutex::new(Vec::new()); + +/// Requests waiting for the next batch. Shared across threads, because a worker asks. +static QUEUE: Mutex> = Mutex::new(Vec::new()); + +/// Whether the queue has anything in it, read without taking the lock, so a batch that has +/// nothing to write pays one atomic load. +static PENDING: AtomicBool = AtomicBool::new(false); + +/// The next request number. 0 is skipped so a zeroed record never looks like a request. +static NEXT_REQUEST: AtomicU32 = AtomicU32::new(1); + +fn next_request() -> u32 { + loop { + let id = NEXT_REQUEST.fetch_add(1, Ordering::Relaxed); + if id != 0 { + return id; + } + } +} + +fn queue() -> MutexGuard<'static, Vec> { + // A worker that panicked while holding the lock does not make the queue wrong. + QUEUE.lock().unwrap_or_else(|poison| poison.into_inner()) +} + +fn remote() -> MutexGuard<'static, Vec<(u32, RemoteCallback)>> { + REMOTE.lock().unwrap_or_else(|poison| poison.into_inner()) +} + +fn submit(request: Request) { + queue().push(request); + PENDING.store(true, Ordering::Release); + // Inside a Host call on the UI thread the batch that call produces carries it, so a + // frame request would only buy an empty frame. + if !crate::boundary::in_host_call() { + crate::boundary::request_frame_from_worker(); + } +} + +/// Hands every queued request to `emit` and empties the queue. +pub(crate) fn drain(mut emit: impl FnMut(&Request)) { + if !PENDING.swap(false, Ordering::AcqRel) { + return; + } + let mut taken = std::mem::take(&mut *queue()); + for request in &taken { + emit(request); + } + taken.clear(); + let mut guard = queue(); + if guard.is_empty() { + *guard = taken; + } else { + // Something was asked for while these were being written, and is still waiting. + PENDING.store(true, Ordering::Release); + } +} + +/// Hands an answer to the callback that asked for it. Returns whether there was one. +/// +/// A request that is not waiting, because it was already answered or never asked for, finds +/// no callback and is ignored. `paths` is the wire string: paths separated by NUL. +pub(crate) fn deliver(request: u32, status: FileDialogStatus, filter: u32, paths: &str) -> bool { + let result = match status { + FileDialogStatus::Chosen => { + let paths: Vec = paths + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_owned) + .collect(); + // A choice with nothing chosen is not one the Renderer sends. Reporting it as a + // pick would hand the application an empty list to index into. + if paths.is_empty() { + FileDialogResult::Unavailable + } else { + FileDialogResult::Chosen { + paths, + filter: (filter != u32::MAX).then_some(filter as usize), + } + } + } + FileDialogStatus::Cancelled => FileDialogResult::Cancelled, + FileDialogStatus::Unavailable => FileDialogResult::Unavailable, + }; + // Taken out before it runs: the callback may ask for another dialog, which edits the + // table being read here. + if let Some(callback) = LOCAL.with_borrow_mut(|callbacks| callbacks.remove(&request)) { + callback(result); + return true; + } + let callback = { + let mut waiting = remote(); + waiting + .iter() + .position(|(id, _)| *id == request) + .map(|at| waiting.swap_remove(at).1) + }; + match callback { + Some(callback) => { + callback(result); + true + } + None => false, + } +} + +/// Forgets every callback and anything still queued. Used between tests. +#[doc(hidden)] +pub fn reset_file_dialogs() { + queue().clear(); + PENDING.store(false, Ordering::Release); + remote().clear(); + LOCAL.with_borrow_mut(HashMap::clear); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::rc::Rc; + + #[test] + fn fr72_filters_are_one_nul_separated_string() { + assert_eq!(encode_filters(&[]), ""); + let filters = vec![ + ( + "Images".to_owned(), + vec!["png".to_owned(), "jpg".to_owned()], + ), + ("Docs".to_owned(), vec!["pdf".to_owned(), "txt".to_owned()]), + ]; + assert_eq!(encode_filters(&filters), "Images\0png,jpg\0Docs\0pdf,txt"); + let hostile = vec![("A\0B".to_owned(), vec!["p,n\0g".to_owned()])]; + assert_eq!(encode_filters(&hostile), "AB\0png"); + } + + #[test] + fn fr72_paths_are_split_on_nul_and_a_stray_answer_is_ignored() { + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = Rc::clone(&seen); + let request = next_request(); + let callback: LocalCallback = + Box::new(move |result: FileDialogResult| sink.borrow_mut().push(result)); + LOCAL.with_borrow_mut(|callbacks| { + callbacks.insert(request, callback); + }); + assert!(deliver( + request, + FileDialogStatus::Chosen, + 1, + "/home/한글/문서.txt\0/tmp/a b.png" + )); + assert_eq!( + *seen.borrow(), + vec![FileDialogResult::Chosen { + paths: vec!["/home/한글/문서.txt".to_owned(), "/tmp/a b.png".to_owned()], + filter: Some(1), + }] + ); + // Answered once; the callback is gone. + assert!(!deliver(request, FileDialogStatus::Cancelled, u32::MAX, "")); + assert!(!deliver( + u32::MAX, + FileDialogStatus::Cancelled, + u32::MAX, + "" + )); + assert_eq!(seen.borrow().len(), 1); + } + + #[test] + fn fr72_a_pick_with_no_paths_is_unavailable_not_chosen() { + let seen = Rc::new(Cell::new(None)); + let sink = Rc::clone(&seen); + let request = next_request(); + let callback: LocalCallback = + Box::new(move |result: FileDialogResult| sink.set(Some(result))); + LOCAL.with_borrow_mut(|callbacks| { + callbacks.insert(request, callback); + }); + deliver(request, FileDialogStatus::Chosen, u32::MAX, ""); + assert_eq!(seen.take(), Some(FileDialogResult::Unavailable)); + } + + #[test] + fn fr72_a_worker_callback_is_called_once_with_its_own_answer() { + let seen = std::sync::Arc::new(Mutex::new(Vec::new())); + let sink = std::sync::Arc::clone(&seen); + let request = next_request(); + let callback: RemoteCallback = + Box::new(move |result: FileDialogResult| sink.lock().unwrap().push(result)); + remote().push((request, callback)); + assert!(deliver( + request, + FileDialogStatus::Unavailable, + u32::MAX, + "" + )); + assert!(!deliver( + request, + FileDialogStatus::Unavailable, + u32::MAX, + "" + )); + assert_eq!(*seen.lock().unwrap(), vec![FileDialogResult::Unavailable]); + } +} diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 4edd0475..0197bbd2 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -33,6 +33,7 @@ pub mod codegen; pub mod design; pub mod drawing; mod extensions; +pub mod file_dialog; pub mod fonts; pub mod highlight; pub mod input; @@ -70,6 +71,7 @@ pub use code::{ }; pub use design::design_system; pub use drawing::{DrawCommand, DrawList, DrawListBuilder}; +pub use file_dialog::{FileDialog, FileDialogResult, FileDialogSender}; pub use fonts::{FontRef, FontRefs, FontStack}; pub use input::{FileDrop, KeyEvent, PointerEvent, PointerKinds, RangeRequest, pointer_button}; pub use message::{Message, show_message}; diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index ecddb722..f2b42645 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1,10 +1,10 @@ //! Fixed-layout little-endian boundary protocol. use crate::schema::{ - AssetKind, ColorScheme, DesignSystem, Key, MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, - Modifier, MotionRole, NotificationImportance, NotificationPermission, NotificationPresentation, - Paint, PropertyKind, Selection, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, WidgetKind, - modifier_extra_words, + AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, Key, + MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, + NotificationImportance, NotificationPermission, NotificationPresentation, Paint, PropertyKind, + Selection, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, WidgetKind, modifier_extra_words, }; use core::fmt; @@ -36,6 +36,11 @@ const TAG_SET_FOLDED: u16 = 22; const TAG_SET_WINDOW_THEME: u16 = 23; const TAG_FOCUS_WINDOW: u16 = 24; const TAG_REQUEST_FOCUS: u16 = 25; +// 26 asks for a platform file dialog. Its answer is the event numbered 50. +const TAG_SHOW_FILE_DIALOG: u16 = 26; +/// `ShowFileDialog` after its header: the request, the window, the kind and the default +/// filter, then four string references. 44 bytes, so the record is 48. +const SHOW_FILE_DIALOG_PAYLOAD_LEN: usize = 4 + 4 + 2 + 2 + 4 * 8; /// The head of a `StartAnimation` record, before its keyframes. const ANIMATION_HEADER_LEN: usize = 52; /// A keyframe before its value: offset, timing, step position, flags and four numbers. @@ -245,6 +250,23 @@ pub enum Mutation<'a> { RequestFocus { node_id: u32, }, + /// Asks the platform to show a file dialog on a window and answers later, once, with a + /// `FileDialogResult` event that carries `request`. + /// + /// `window` is a `Window` node id, or 0 for the implicit root window. `filters` is one + /// string: a label and its comma-joined extensions as a pair, pairs joined by NUL + /// (`"Images\0png,jpg\0Docs\0pdf,txt"`). `default_filter` is the position of the + /// filter selected first. + ShowFileDialog { + request: u32, + window: u32, + kind: FileDialogKind, + default_filter: u16, + title: &'a str, + directory: &'a str, + name: &'a str, + filters: &'a str, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -332,6 +354,10 @@ const EVENT_WINDOW_UNAVAILABLE: u16 = 44; const EVENT_WINDOW_CLOSED: u16 = 45; const EVENT_WINDOW_REQUESTED: u16 = 46; const EVENT_APPLICATION_REOPEN: u16 = 47; +// 48 and 49 are numbered by changes that are still open. +const EVENT_FILE_DIALOG_RESULT: u16 = 50; +/// `FileDialogResult` in full: the 16 byte header, three words and the paths' reference. +const FILE_DIALOG_RESULT_LEN: usize = 36; /// Where `SetWindow` keeps the mode and the last window policy: in the bits of the /// `resizable` word above the flag. @@ -647,6 +673,22 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { EVENT_APPLICATION_REOPEN if record_len == 16 => { crate::schema::EventPayload::ApplicationReopen } + EVENT_FILE_DIALOG_RESULT if record_len == FILE_DIALOG_RESULT_LEN => { + let raw_status = read_u32(bytes, 20)?; + let status = u16::try_from(raw_status) + .ok() + .and_then(|tag| FileDialogStatus::try_from(tag).ok()) + .ok_or(ProtocolError::InvalidValueKind( + raw_status.min(u32::from(u16::MAX)) as u16, + ))?; + crate::schema::EventPayload::FileDialogResult { + request: read_u32(bytes, 16)?, + status, + filter: read_u32(bytes, 24)?, + paths: read_string(bytes, 28, record_len)?, + } + } + EVENT_FILE_DIALOG_RESULT => return Err(ProtocolError::InvalidRecordLength), EVENT_WINDOW_CLOSE_REQUESTED..=EVENT_APPLICATION_REOPEN => { return Err(ProtocolError::InvalidRecordLength); } @@ -984,6 +1026,28 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P } _ => {} } + if let crate::schema::EventPayload::FileDialogResult { + request, + status, + filter, + paths, + } = event.payload + { + put_event_header( + output, + EVENT_FILE_DIALOG_RESULT, + FILE_DIALOG_RESULT_LEN as u16, + event, + ); + output.extend_from_slice(&request.to_le_bytes()); + output.extend_from_slice(&u32::from(status as u16).to_le_bytes()); + output.extend_from_slice(&filter.to_le_bytes()); + let len = u32::try_from(paths.len()).map_err(|_| ProtocolError::LengthOverflow)?; + output.extend_from_slice(&(FILE_DIALOG_RESULT_LEN as u32).to_le_bytes()); + output.extend_from_slice(&len.to_le_bytes()); + output.extend_from_slice(paths.as_bytes()); + return Ok(()); + } if let crate::schema::EventPayload::ZoomChanged { k, os, level } = event.payload { output.extend_from_slice(&EVENT_ZOOM_CHANGED.to_le_bytes()); output.extend_from_slice(&28_u16.to_le_bytes()); @@ -1059,7 +1123,8 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P | crate::schema::EventPayload::WindowFocusChanged { .. } | crate::schema::EventPayload::WindowMoved { .. } | crate::schema::EventPayload::WindowDrag { .. } - | crate::schema::EventPayload::WindowDragOver { .. } => unreachable!(), + | crate::schema::EventPayload::WindowDragOver { .. } + | crate::schema::EventPayload::FileDialogResult { .. } => unreachable!(), crate::schema::EventPayload::AnimationEvent { .. } | crate::schema::EventPayload::Pointer { .. } | crate::schema::EventPayload::ReducedMotionChanged(_) @@ -1356,6 +1421,26 @@ impl BatchEncoder { self.begin_record(TAG_REQUEST_FOCUS, 4); self.put_u32(*node_id); } + Mutation::ShowFileDialog { + request, + window, + kind, + default_filter, + title, + directory, + name, + filters, + } => { + self.begin_record(TAG_SHOW_FILE_DIALOG, SHOW_FILE_DIALOG_PAYLOAD_LEN as u16); + self.put_u32(*request); + self.put_u32(*window); + self.put_u16(*kind as u16); + self.put_u16(*default_filter); + self.put_string_ref(title)?; + self.put_string_ref(directory)?; + self.put_string_ref(name)?; + self.put_string_ref(filters)?; + } Mutation::SetWindow(window) => { self.begin_record(TAG_SET_WINDOW, 28); self.put_u16(window.chrome as u16); @@ -1798,6 +1883,21 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { TAG_REQUEST_FOCUS if len == 8 => Mutation::RequestFocus { node_id: read_u32(bytes, payload)?, }, + TAG_SHOW_FILE_DIALOG if len == 4 + SHOW_FILE_DIALOG_PAYLOAD_LEN => { + let raw_kind = read_u16(bytes, payload + 8)?; + Mutation::ShowFileDialog { + request: read_u32(bytes, payload)?, + window: read_u32(bytes, payload + 4)?, + kind: FileDialogKind::try_from(raw_kind) + .map_err(|()| ProtocolError::InvalidValueKind(raw_kind))?, + default_filter: read_u16(bytes, payload + 10)?, + title: read_string(bytes, payload + 12, records_len)?, + directory: read_string(bytes, payload + 20, records_len)?, + name: read_string(bytes, payload + 28, records_len)?, + filters: read_string(bytes, payload + 36, records_len)?, + } + } + TAG_SHOW_FILE_DIALOG => return Err(ProtocolError::InvalidRecordLength), TAG_SET_WINDOW if len == 32 => { let chrome = read_u16(bytes, payload)?; let title_bar = read_u16(bytes, payload + 2)?; @@ -3250,6 +3350,161 @@ mod tests { assert_eq!(decode_batch(&bad), Err(ProtocolError::InvalidRecordLength)); } + /// The request and the result are fixed records. Both survive a round trip with + /// Korean text and NUL separated lists in them, an unknown kind or status is a + /// protocol error, and no prefix of either record decodes or panics. + #[test] + fn fr72_file_dialog_records_round_trip_and_unknown_enums_are_errors() { + let filters = "이미지\0png,jpg\0Docs\0pdf,txt"; + for kind in [ + FileDialogKind::OpenFile, + FileDialogKind::OpenFiles, + FileDialogKind::OpenFolder, + FileDialogKind::OpenFolders, + FileDialogKind::Save, + ] { + let request = Mutation::ShowFileDialog { + request: 7, + window: 20, + kind, + default_filter: 1, + title: "파일 고르기", + directory: "/home/한글", + name: "보고서.txt", + filters, + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&request).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + assert_eq!( + read_u16(&bytes, ENVELOPE_LEN).unwrap(), + TAG_SHOW_FILE_DIALOG + ); + assert_eq!(read_u16(&bytes, ENVELOPE_LEN + 2).unwrap(), 48); + assert_eq!(decode_batch(&bytes).unwrap(), vec![request]); + } + + let empty = Mutation::ShowFileDialog { + request: u32::MAX, + window: 0, + kind: FileDialogKind::Save, + default_filter: 0, + title: "", + directory: "", + name: "", + filters: "", + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&empty).unwrap(); + assert_eq!( + decode_batch(encoder.finish().unwrap()).unwrap(), + vec![empty] + ); + + let full = Mutation::ShowFileDialog { + request: 1, + window: 2, + kind: FileDialogKind::OpenFile, + default_filter: 3, + title: "t", + directory: "/d", + name: "n", + filters, + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&full).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + for raw_kind in [0_u16, 6, 9, u16::MAX] { + let mut bad = bytes.clone(); + bad[ENVELOPE_LEN + 12..ENVELOPE_LEN + 14].copy_from_slice(&raw_kind.to_le_bytes()); + assert_eq!( + decode_batch(&bad), + Err(ProtocolError::InvalidValueKind(raw_kind)) + ); + } + let mut short = bytes.clone(); + short[ENVELOPE_LEN + 2..ENVELOPE_LEN + 4].copy_from_slice(&44_u16.to_le_bytes()); + assert_eq!( + decode_batch(&short), + Err(ProtocolError::InvalidRecordLength) + ); + for cut in 0..bytes.len() { + assert!( + decode_batch(&bytes[..cut]).is_err(), + "a batch cut at {cut} bytes decoded" + ); + } + + let paths = "/Users/한글/문서.txt\0/tmp/b c.png"; + for (status, filter) in [ + (FileDialogStatus::Chosen, 1_u32), + (FileDialogStatus::Cancelled, u32::MAX), + (FileDialogStatus::Unavailable, u32::MAX), + ] { + let result = HostEvent { + node_id: 0, + handler_id: 0, + payload: crate::EventPayload::FileDialogResult { + request: 41, + status, + filter, + paths: if status == FileDialogStatus::Chosen { + paths + } else { + "" + }, + }, + }; + let mut bytes = Vec::new(); + encode_event(&result, &mut bytes).unwrap(); + assert_eq!(read_u16(&bytes, 0).unwrap(), EVENT_FILE_DIALOG_RESULT); + assert_eq!( + usize::from(read_u16(&bytes, 2).unwrap()), + FILE_DIALOG_RESULT_LEN + ); + assert_eq!(decode_event(&bytes).unwrap(), result); + } + + let result = HostEvent { + node_id: 0, + handler_id: 0, + payload: crate::EventPayload::FileDialogResult { + request: 3, + status: FileDialogStatus::Chosen, + filter: 0, + paths, + }, + }; + let mut bytes = Vec::new(); + encode_event(&result, &mut bytes).unwrap(); + for raw_status in [0_u32, 4, 0x1_0000, u32::MAX] { + let mut bad = bytes.clone(); + bad[20..24].copy_from_slice(&raw_status.to_le_bytes()); + assert_eq!( + decode_event(&bad), + Err(ProtocolError::InvalidValueKind( + raw_status.min(u32::from(u16::MAX)) as u16 + )) + ); + } + let mut wrong_length = bytes.clone(); + wrong_length[2..4].copy_from_slice(&32_u16.to_le_bytes()); + assert_eq!( + decode_event(&wrong_length), + Err(ProtocolError::InvalidRecordLength) + ); + let mut not_utf8 = bytes.clone(); + let last = not_utf8.len() - 1; + not_utf8[last] = 0xFF; + assert_eq!(decode_event(¬_utf8), Err(ProtocolError::InvalidUtf8)); + for cut in 0..bytes.len() { + assert!( + decode_event(&bytes[..cut]).is_err(), + "an event cut at {cut} bytes decoded" + ); + } + } + #[test] fn fr65_text_field_on_composition_change_delivers_bool() { use std::cell::RefCell; diff --git a/compose-rust/src/runtime/seam.rs b/compose-rust/src/runtime/seam.rs index 215d19cc..ea62e8b6 100644 --- a/compose-rust/src/runtime/seam.rs +++ b/compose-rust/src/runtime/seam.rs @@ -162,6 +162,21 @@ impl Batch { } } + /// Writes one file dialog request. Like a notification it names no node: the dialog is + /// not in the tree, and `window` only says which window it is modal for. + pub(crate) fn file_dialog(&mut self, request: &crate::file_dialog::Request) { + self.write(Mutation::ShowFileDialog { + request: request.id, + window: request.window, + kind: request.kind, + default_filter: request.default_filter, + title: &request.title, + directory: &request.directory, + name: &request.name, + filters: &request.filters, + }); + } + /// Writes one edit the application asked of a code editor: replace `range` of the /// document as it stood at `base_version` with `text`. The Renderer moves it past what /// the reader typed since, or refuses it and reports `request_id` back. diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 15767490..f07408d1 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -170,6 +170,9 @@ pub enum EventPayloadType { /// A window the platform or the user asked the application for: a request number, then /// the `RestoreKey` as a string reference. The one window event that carries a string. WindowRequest, + /// What became of one file dialog: the request number, the status, the filter in use + /// and the paths as one string reference. + FileDialogResult, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -186,10 +189,10 @@ pub const SCHEMA_DESCRIPTOR: &str = concat!( "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,report_size;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback;", "keys=Enter;", - "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", + "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen,FileDialogResult;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", - "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus" + "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus,ShowFileDialog" ); const fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { @@ -341,6 +344,7 @@ const fn schema_hash() -> u64 { EventPayloadType::WindowDrag => 26, EventPayloadType::WindowDragOver => 27, EventPayloadType::WindowRequest => 28, + EventPayloadType::FileDialogResult => 29, }], ); index += 1; @@ -1429,6 +1433,14 @@ pub const ROLE_ENUM_SCHEMA: &[RoleEnumSchema] = &[ name: "LastWindowClosed", variants: LAST_WINDOW_CLOSED_SCHEMA, }, + RoleEnumSchema { + name: "FileDialogKind", + variants: FILE_DIALOG_KIND_SCHEMA, + }, + RoleEnumSchema { + name: "FileDialogStatus", + variants: FILE_DIALOG_STATUS_SCHEMA, + }, ]; /// Every place that takes a colour takes a `Paint`, so colour is expressed once. @@ -1519,6 +1531,25 @@ define_wire_enum!(LAST_WINDOW_CLOSED_SCHEMA, LastWindowClosed { KeepRunning = 1, }); +// What a file dialog asks the user to pick. Closed: each kind is a different platform panel +// with different rules about how many paths come back, so a free-form set of flags would +// allow combinations no panel can honour. +define_wire_enum!(FILE_DIALOG_KIND_SCHEMA, FileDialogKind { + OpenFile = 1, + OpenFiles = 2, + OpenFolder = 3, + OpenFolders = 4, + Save = 5, +}); + +// How a file dialog ended. `Unavailable` means this run has no way to show one, which is an +// answer and not an error, and is never used for a user's cancel. +define_wire_enum!(FILE_DIALOG_STATUS_SCHEMA, FileDialogStatus { + Chosen = 1, + Cancelled = 2, + Unavailable = 3, +}); + /// What an application may decide about its own window. /// /// The window belongs to the Renderer, so this is short and stays short. What is here is @@ -3098,6 +3129,16 @@ pub enum EventPayload<'a> { /// The user opened the application again while it had no window. Delivered to the /// root. ApplicationReopen, + /// A file dialog ended. `request` is the number the Host gave it, `filter` is the + /// position of the filter in use (`u32::MAX` when there is none) and `paths` holds the + /// chosen paths separated by one NUL byte, empty unless the status is `Chosen`. + /// Delivered to the Host itself, with no node and no handler. + FileDialogResult { + request: u32, + status: FileDialogStatus, + filter: u32, + paths: &'a str, + }, } pub const EVENT_SCHEMA: &[EventSchema] = &[ @@ -3314,6 +3355,14 @@ pub const EVENT_SCHEMA: &[EventSchema] = &[ tag: 47, payload: EventPayloadType::None, }, + // Tags 48 and 49 are taken by changes that are still open. Like the notification + // events this addresses the Host itself: a dialog is not in the tree, so there is no + // node and no handler to name, and the request number finds the callback. + EventSchema { + name: "FileDialogResult", + tag: 50, + payload: EventPayloadType::FileDialogResult, + }, ]; /// One argument of a boundary operation. diff --git a/compose-rust/tests/fr72_file_dialog.rs b/compose-rust/tests/fr72_file_dialog.rs new file mode 100644 index 00000000..1f59daaf --- /dev/null +++ b/compose-rust/tests/fr72_file_dialog.rs @@ -0,0 +1,321 @@ +//! File dialogs from the Host's side: what a request looks like in a batch, how the answer +//! finds the callback that asked, and that the boundary gained nothing for any of it. +//! +//! The Renderer's half, showing the platform's panel, is not here. What is here is +//! everything the Host promises: one request number per ask, one callback call per number, +//! and paths that arrive whole. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::protocol::{ + HostEvent, Mutation, ProtocolError, decode_batch, decode_event, encode_event, +}; +use compose_rust::runtime::*; +use compose_rust::schema::{ + BOUNDARY_SCHEMA, DesignSystem, EventPayload, FileDialogKind, FileDialogStatus, PropertyKind, + Theme, +}; +use compose_rust::ui::*; +use compose_rust::{FileDialog, FileDialogResult, FileDialogSender}; +use std::cell::RefCell; +use std::sync::Mutex; +use support::Tree; + +/// The tests below share the Host's request queue, which is process wide, so they take +/// turns. Each one drains everything it asked for before it lets go. +static SERIAL: Mutex<()> = Mutex::new(()); + +thread_local! { + static RESULTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +static WORKER_RESULTS: Mutex> = Mutex::new(Vec::new()); + +fn record(label: &'static str) -> impl FnOnce(FileDialogResult) + 'static { + move |result| RESULTS.with_borrow_mut(|results| results.push((label, result))) +} + +fn results() -> Vec<(&'static str, FileDialogResult)> { + RESULTS.with_borrow(Clone::clone) +} + +#[composable] +fn Askers() { + Button("choose").on_click(|| { + FileDialog::open_files() + .title("Choose") + .filter("Images", &["png", "jpg"]) + .show(record("choose")); + }); + Button("cancel").on_click(|| { + FileDialog::save().name("a.txt").show(record("cancel")); + }); + Button("unavailable").on_click(|| { + FileDialog::open_folder() + .window(0) + .show(record("unavailable")); + }); + Button("worker").on_click(|| { + FileDialogSender::new().show(FileDialog::open_file(), |result| { + WORKER_RESULTS.lock().unwrap().push(result); + }); + }); +} + +fn event(payload: EventPayload<'static>) -> HostEvent<'static> { + HostEvent { + node_id: 0, + handler_id: 0, + payload, + } +} + +fn result_event( + request: u32, + status: FileDialogStatus, + filter: u32, + paths: &'static str, +) -> HostEvent<'static> { + event(EventPayload::FileDialogResult { + request, + status, + filter, + paths, + }) +} + +/// The dialogs a batch asks for, as (request, kind, window). +fn dialogs(batch: &[u8]) -> Vec<(u32, FileDialogKind, u32)> { + decode_batch(batch) + .expect("the batch decodes") + .into_iter() + .filter_map(|mutation| match mutation { + Mutation::ShowFileDialog { + request, + kind, + window, + .. + } => Some((request, kind, window)), + _ => None, + }) + .collect() +} + +struct Session { + host: ComposeHost, + tree: Tree, +} + +impl Session { + fn start() -> Self { + let mut host = ComposeHost::with_theme(Askers, Theme::unified(DesignSystem::Material3)); + let mut tree = Tree::default(); + let first = host.rebuild().expect("the first batch encodes").to_vec(); + tree.apply(&first); + assert!(dialogs(&first).is_empty(), "nothing is asked for at start"); + Self { host, tree } + } + + /// Presses the button and returns the one dialog request the press produced. + fn press(&mut self, label: &str) -> (u32, FileDialogKind, u32) { + let (node_id, handler_id) = self + .tree + .find("Button", Some(label), 0, PropertyKind::OnClick); + let (batch, _) = self + .host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the press dispatches"); + let mut asked = dialogs(batch); + assert_eq!(asked.len(), 1, "one press, one request: {asked:?}"); + asked.remove(0) + } + + fn answer(&mut self, event: HostEvent<'static>) { + self.host.dispatch(event).expect("the answer dispatches"); + } +} + +/// Selecting, cancelling and being unable to show all end in exactly one call of the +/// callback that asked, and an answer that comes twice or for nobody calls nothing. +#[test] +fn fr72_every_request_gets_exactly_one_result() { + let _turn = SERIAL.lock().unwrap_or_else(|poison| poison.into_inner()); + RESULTS.with_borrow_mut(Vec::clear); + WORKER_RESULTS.lock().unwrap().clear(); + let mut session = Session::start(); + + let (chosen, chosen_kind, chosen_window) = session.press("choose"); + let (cancelled, cancelled_kind, _) = session.press("cancel"); + let (unavailable, unavailable_kind, _) = session.press("unavailable"); + let (worker, worker_kind, _) = session.press("worker"); + assert_eq!(chosen_kind, FileDialogKind::OpenFiles); + assert_eq!( + chosen_window, 0, + "no window named is the implicit root window" + ); + assert_eq!(cancelled_kind, FileDialogKind::Save); + assert_eq!(unavailable_kind, FileDialogKind::OpenFolder); + assert_eq!(worker_kind, FileDialogKind::OpenFile); + let mut numbers = vec![chosen, cancelled, unavailable, worker]; + numbers.sort_unstable(); + numbers.dedup(); + assert_eq!(numbers.len(), 4, "every request has a number of its own"); + assert!(results().is_empty(), "nobody is called before the answer"); + + session.answer(result_event( + chosen, + FileDialogStatus::Chosen, + 1, + "/home/a.png\0/home/b.jpg", + )); + session.answer(result_event( + cancelled, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + session.answer(result_event( + unavailable, + FileDialogStatus::Unavailable, + u32::MAX, + "", + )); + session.answer(result_event( + worker, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + assert_eq!( + results(), + vec![ + ( + "choose", + FileDialogResult::Chosen { + paths: vec!["/home/a.png".to_owned(), "/home/b.jpg".to_owned()], + filter: Some(1), + } + ), + ("cancel", FileDialogResult::Cancelled), + ("unavailable", FileDialogResult::Unavailable), + ] + ); + assert_eq!( + *WORKER_RESULTS.lock().unwrap(), + vec![FileDialogResult::Cancelled], + "a worker's callback is called on the UI thread, once" + ); + + // The same answers again, and one for a number nobody asked for: nothing is called and + // nothing fails. + session.answer(result_event( + chosen, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + session.answer(result_event( + worker, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + session.answer(result_event( + u32::MAX, + FileDialogStatus::Unavailable, + u32::MAX, + "", + )); + assert_eq!(results().len(), 3); + assert_eq!(WORKER_RESULTS.lock().unwrap().len(), 1); + + // The answer belongs to the Host: a node or a handler on it is a malformed event. + let on_a_node = HostEvent { + node_id: 5, + handler_id: 0, + payload: EventPayload::FileDialogResult { + request: chosen, + status: FileDialogStatus::Cancelled, + filter: u32::MAX, + paths: "", + }, + }; + assert!(session.host.dispatch(on_a_node).is_err()); +} + +/// A path is whatever the platform gave, whole: Korean text and paths longer than 260 +/// characters arrive unchanged and in order. One that is not UTF-8 never gets as far as the +/// callback, because the event does not decode. +#[test] +fn fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped() { + let _turn = SERIAL.lock().unwrap_or_else(|poison| poison.into_inner()); + RESULTS.with_borrow_mut(Vec::clear); + let mut session = Session::start(); + let (request, _, _) = session.press("choose"); + + let long: &'static str = Box::leak(format!("/data/{}.bin", "가".repeat(300)).into_boxed_str()); + assert!(long.chars().count() > 260); + let paths: &'static str = Box::leak(format!("/Users/한글/문서 1.txt\0{long}").into_boxed_str()); + + // Over the wire and back, the way the Renderer's event reaches the Host. + let sent = result_event(request, FileDialogStatus::Chosen, 0, paths); + let mut bytes = Vec::new(); + encode_event(&sent, &mut bytes).unwrap(); + assert_eq!(decode_event(&bytes).unwrap(), sent); + + // A path that is not UTF-8 makes the whole event a protocol error, and the request + // stays waiting for the answer the Renderer sends instead. + let mut corrupt = bytes.clone(); + let last = corrupt.len() - 1; + corrupt[last] = 0xFF; + assert_eq!(decode_event(&corrupt), Err(ProtocolError::InvalidUtf8)); + assert!(results().is_empty()); + + session.answer(sent); + assert_eq!( + results(), + vec![( + "choose", + FileDialogResult::Chosen { + paths: vec!["/Users/한글/문서 1.txt".to_owned(), long.to_owned()], + filter: Some(0), + } + )] + ); +} + +/// A dialog adds a record and an event to the protocol and nothing to the boundary: the +/// same five entry points, and no exported symbol in the dialog's module. +#[test] +fn pr2_boundary_surface_unchanged_by_file_dialog() { + let symbols: Vec<&str> = BOUNDARY_SCHEMA + .iter() + .map(|operation| operation.symbol) + .collect(); + assert_eq!( + symbols, + vec![ + "compose_rust_host_init", + "compose_rust_host_dispatch_event", + "compose_rust_host_render_frame", + "compose_rust_host_release_batch", + "compose_rust_host_shutdown", + ] + ); + let exports = include_str!("../src/boundary.rs") + .matches("#[unsafe(no_mangle)]") + .count(); + assert_eq!( + exports, + symbols.len(), + "boundary.rs exports one function per entry point" + ); + let dialog = include_str!("../src/file_dialog.rs"); + assert!(!dialog.contains("no_mangle")); + assert!(!dialog.contains("extern \"C\"")); +} diff --git a/compose-rust/tests/vectors/events.bin b/compose-rust/tests/vectors/events.bin index c9f4e3fc7565c2da59f4cda7e30c1eb8dafb4263..30574a56c67de9cbae83a8f03a788e6a1276a04d 100644 GIT binary patch delta 81 zcmdnN-pH|G2eYRUg9-yOUQb`fsPsd9|ZK|Mi+3Z(8Q)l~j~4 R=$GUc=qD;9=@sOq0{~|X6mkFn delta 7 OcmZqV*ulPG2QvT)^#Zg2 diff --git a/compose-rust/tests/vectors/mutations.bin b/compose-rust/tests/vectors/mutations.bin index b5d5222968b7145db3d90e93b08b024224273605..73ab624394c159299aae5737b95c652871e3376c 100644 GIT binary patch delta 506 zcmZXQ%S!@L6vofJV-G=Rd?YpoDOiijp0;RXwX2{_1TBJgmc5&WU<6T%f)*CZMF`Re zqJju2lyK3aZHxRPqD?xkY7<2DjkS=PnKQrpo%5Y@FZZ=YZ#5HHNV5~oCCovlB|=`d zb2Am=>K<}6M8ps#L@n9MOo|fsvO{5&{46cHrEzv5wxuCO z`sf}SThRlkf={3udi3`>ixmipTZzov)BNE}mu!x$Dl^m8I#M41_lO3AZ7t#4h8`*ojUm~ zlRRVgWL{=v#@xw<%)yMslUteH8OtW0WtOh55@KKgB8DCy0n)_;#C!}E3=@ESpj8Yr zfwUx$-UOt@f%Hir%?6~;L+PtP8U&btkRRwW5FexuWH%Fo1lT+W23Cf9KyeSSN|0KJ zcoYNE=5&@`PDwRk1_l)*Ngg0O1*pkuGB2MvBgIi`0&o`4 entries[id]?.platform } backend.setApplicationListener(appListener) sync() } @@ -86,6 +88,7 @@ class WindowManager( /** Stops managing and closes every window. The Host and the table are left as they are. */ fun shutdown() { host.afterBatch = null + table.fileDialogs.windowResolver = { null } backend.setApplicationListener(null) for (entry in entries.values.toList()) closeEntry(entry) waiting.clear() diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index d23eca5e..bb1af4af 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -116,6 +116,10 @@ enum class WindowMode { Single, Windows } enum class LastWindowClosed { Exit, KeepRunning } +enum class FileDialogKind { OpenFile, OpenFiles, OpenFolder, OpenFolders, Save } + +enum class FileDialogStatus { Chosen, Cancelled, Unavailable } + /** * The `TypeRole` property of text with no rung of the ladder at all: CSS text, whose * size, weight and font are its own. Present and zero; an absent property is the @@ -1249,6 +1253,23 @@ sealed interface Mutation { /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation } sealed interface HostEvent { @@ -1295,13 +1316,14 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FileDialogResult(override val nodeId: Int, override val handlerId: Long, val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 2293438994272253525L + const val SCHEMA_HASH: Long = -3763091213759536850L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1330,6 +1352,7 @@ object Protocol { private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1483,6 +1506,19 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`; the mode and the last window policy are the @@ -1756,6 +1792,7 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FileDialogResult -> event.paths.toByteArray(StandardCharsets.UTF_8) } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1798,6 +1835,7 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FileDialogResult -> 36 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1844,6 +1882,7 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FileDialogResult -> 50 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -2011,6 +2050,12 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FileDialogResult -> { + out.putInt(event.request) + out.putInt(fileDialogStatusTag(event.status)) + out.putInt(event.filter) + writeStringReference(out, recordLength, text!!) + } } if (text != null) out.put(text) return out.position() - start @@ -2237,6 +2282,12 @@ object Protocol { NotificationPermission.Unsupported -> 4 } + private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) { + FileDialogStatus.Chosen -> 1 + FileDialogStatus.Cancelled -> 2 + FileDialogStatus.Unavailable -> 3 + } + private fun animationEventKindTag(value: AnimationEventKind): Int = when (value) { AnimationEventKind.Ready -> 1 AnimationEventKind.Active -> 2 @@ -2817,6 +2868,22 @@ object Protocol { else -> throw ProtocolException("unknown LastWindowClosed tag $tag", offset) } + private fun fileDialogKind(tag: Int, offset: Int): FileDialogKind = when (tag) { + 1 -> FileDialogKind.OpenFile + 2 -> FileDialogKind.OpenFiles + 3 -> FileDialogKind.OpenFolder + 4 -> FileDialogKind.OpenFolders + 5 -> FileDialogKind.Save + else -> throw ProtocolException("unknown FileDialogKind tag $tag", offset) + } + + private fun fileDialogStatus(tag: Int, offset: Int): FileDialogStatus = when (tag) { + 1 -> FileDialogStatus.Chosen + 2 -> FileDialogStatus.Cancelled + 3 -> FileDialogStatus.Unavailable + else -> throw ProtocolException("unknown FileDialogStatus tag $tag", offset) + } + private fun paint(bits: Long, offset: Int): Paint { val value = bits.toInt() return when (val kind = (bits ushr 32).toInt()) { diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 455ca32c..dfdfab70 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -134,6 +134,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // After the first batch, so the first thing the Host hears is a press that started // the application, if one did, and then what the platform says about permission. table.notifications.attach(this) + table.fileDialogs.attach(this) } /** Dispatches synchronously and returns the Host's consumption result. */ @@ -167,6 +168,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // answer from the platform asks for a frame for exactly this, because the frame is // where the UI thread, and so the Host, can be reached from any other thread. table.notifications.drain() + table.fileDialogs.drain() applyTransaction { apply -> connection.renderFrame(frameTimeNanos, apply) } } @@ -180,6 +182,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher fun shutdown() { table.notifications.shutdown() + table.fileDialogs.shutdown() connection.shutdown() } @@ -403,6 +406,8 @@ private fun ComposeRustTree( // A notification centre answers on a thread of its own, and the way from any thread to // this one is a frame request. The centre asks through the same source this loop serves. SideEffect { host.table.notifications.wake = frames::request } + // A file dialog's answer arrives the same way, from whichever thread the platform uses. + SideEffect { host.table.fileDialogs.wake = frames::request } // Whether the window is the one in use decides whether a notification that asked to be // shown only when it is not is shown at all, and coming back to the window is when a // permission changed in the system's settings is looked for. diff --git a/renderer/desktop/src/renderer/FakeHostConnection.kt b/renderer/desktop/src/renderer/FakeHostConnection.kt index 0991e8bb..f1b1675d 100644 --- a/renderer/desktop/src/renderer/FakeHostConnection.kt +++ b/renderer/desktop/src/renderer/FakeHostConnection.kt @@ -70,6 +70,9 @@ class FakeHostConnection( // The same for the notification permission: the Renderer reporting what the platform // said, sent once at start, and nothing a fixture's reply is about. if (event is HostEvent.NotificationPermissionChanged) return 0 + // The end of a file dialog is the Renderer reporting too, not something a fixture's + // reply to a click is about. + if (event is HostEvent.FileDialogResult) return 0 // And the motion setting, for the same reason. if (event is HostEvent.ReducedMotionChanged) return 0 // And the zoom, reported at start and on every change. diff --git a/renderer/desktop/src/renderer/FileDialogHost.kt b/renderer/desktop/src/renderer/FileDialogHost.kt new file mode 100644 index 00000000..0217ca14 --- /dev/null +++ b/renderer/desktop/src/renderer/FileDialogHost.kt @@ -0,0 +1,408 @@ +package dev.darkpyonix.composerust.ui.platform + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.runtime.EventDispatcher +import kotlinx.coroutines.channels.Channel + +/** The limits a request and its answer are held to. */ +object FileDialogLimits { + /** Filters kept from one request; the rest are dropped. */ + const val MAX_FILTERS = 16 + + /** Extensions kept from one filter; the rest are dropped. */ + const val MAX_EXTENSIONS = 32 + + /** Paths one answer may carry. More is not delivered as a shorter list: it is unavailable. */ + const val MAX_PATHS = 10_000 + + /** + * Bytes of paths one answer may carry, separators included. + * + * Half of what the largest event buffer the desktop connection will grow to, so the + * record around the paths always fits. A platform with a smaller buffer sets a smaller + * limit on its [FileDialogCenter]. + */ + const val MAX_PAYLOAD_BYTES = 32 * 1024 * 1024 +} + +/** One filter of a dialog: what the user sees, and the extensions it lets through. */ +data class FileDialogFilter( + val label: String, + /** Lower case, without a leading dot or `*.`. Empty means every file. */ + val extensions: List, +) + +/** + * One dialog, as a platform is asked to show it, after the request was checked and + * normalised. + * + * [windowNode] is the `Window` node the dialog belongs to, or 0 for the implicit root + * window. [name] is a bare file name, and only a save dialog has one. [filters] is empty for + * the folder kinds. [defaultFilter] is a position inside [filters], or 0. + */ +data class FileDialogSpec( + val request: Int, + val windowNode: Int, + val kind: FileDialogKind, + val title: String, + val directory: String, + val name: String, + val filters: List, + val defaultFilter: Int, +) + +/** + * One path a dialog returned. A platform that hands over text passes [Text]; one whose paths + * are bytes (a Linux file system does not promise UTF-8) passes [Bytes], and the ones that + * are not UTF-8 are dropped before the Host sees anything. + */ +sealed interface FileDialogPath { + class Text(val value: String) : FileDialogPath + class Bytes(val value: ByteArray) : FileDialogPath +} + +/** How a platform's dialog ended. */ +sealed interface FileDialogOutcome { + /** + * The user chose. [filter] is the position of the filter that was selected, or null when + * the dialog had none. + */ + class Chosen(val paths: List, val filter: Int? = null) : FileDialogOutcome { + companion object { + fun ofText(paths: List, filter: Int? = null): Chosen = + Chosen(paths.map { FileDialogPath.Text(it) }, filter) + } + } + + /** The user closed the dialog. */ + data object Cancelled : FileDialogOutcome + + /** The dialog could not be shown. */ + data object Unavailable : FileDialogOutcome +} + +/** + * One platform's file dialogs. + * + * [show] is called on the UI thread and must return without waiting for the user. The + * outcome goes to [onResult], from whichever thread the platform answers on, once. Calling + * it again is harmless: only the first counts. + */ +interface FileDialogPlatform { + /** + * Shows [request] on [window], the handle for the window it belongs to. The handle is + * whatever [FileDialogCenter.windowResolver] gave for the request's window node, and is + * null for the implicit root window or when nothing resolves one. + */ + fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) + + /** + * The window a dialog was shown on is going away. The platform closes that window's + * dialog if it has one open. The request ends with a cancel whether or not it does. + */ + fun cancelWindow(window: Any?) {} +} + +/** + * A platform with no file dialog yet, or a run that cannot show one. Every request is + * answered at once with [FileDialogOutcome.Unavailable]; that is not a protocol error. + */ +object UnsupportedFileDialogs : FileDialogPlatform { + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) = + onResult(FileDialogOutcome.Unavailable) +} + +/** + * The file dialogs this process uses. + * + * Installed by each platform's entry point before the Host starts, the way the notification + * centre is. A run nobody installed anything for answers every request as unavailable. + */ +object FileDialogs { + var platform: FileDialogPlatform = UnsupportedFileDialogs +} + +/** + * What the interpreter does with file dialog requests, and the one route the answers take + * back to the Host. + * + * It owns the rules that do not depend on a platform: a request number waits for one answer + * only, requests on one window are shown one after the other in the order they came, a + * window that goes away ends its dialog with a cancel, and an answer is delivered on the UI + * thread inside a frame, whole or not at all. The platform underneath only shows and reports. + */ +class FileDialogCenter( + private val platform: FileDialogPlatform = FileDialogs.platform, + private val maxPayloadBytes: Int = FileDialogLimits.MAX_PAYLOAD_BYTES, +) { + /** What `request` made of the request, for the table to report. */ + enum class Accepted { Accepted, Duplicate, UnknownWindow } + + private class Entry(val spec: FileDialogSpec) { + /** The platform was asked, or the request is already answered and only waits to be delivered. */ + var started = false + + /** An answer is on its way to being delivered, so nothing else starts on this window yet. */ + var finishing = false + var handle: Any? = null + } + + private class Completion(val request: Int, val outcome: FileDialogOutcome) + + /** + * Answers from the platform, from any thread. A channel because it is the one queue every + * target can share across threads without a lock of its own, and unlimited because what + * goes in is a handful of answers. + */ + private val inbox = Channel(Channel.UNLIMITED) + + /** Requests that have not been delivered an answer yet, in the order they came. */ + private val pending = LinkedHashMap() + + private var dispatcher: EventDispatcher? = null + private var draining = false + + /** How an answer asks for the frame that delivers it. */ + var wake: () -> Unit = {} + + /** + * Gives the platform's handle for a `Window` node, read when the dialog is shown. Node 0 + * has none. The window manager of a multi-window application installs this. + */ + var windowResolver: (Int) -> Any? = { null } + + /** How many requests are waiting for an answer. */ + internal val pendingCount: Int get() = pending.size + + /** + * Takes the request, normalised, unless its number is already waiting for an answer. + * + * [windowKnown] says whether the window it names is the implicit root or an existing + * `Window` node. A request on an unknown window is still answered, as unavailable. + */ + fun request(mutation: Mutation.ShowFileDialog, windowKnown: Boolean): Accepted { + if (pending.containsKey(mutation.request)) return Accepted.Duplicate + val entry = Entry(normalize(mutation)) + pending[mutation.request] = entry + if (!windowKnown) { + entry.started = true + entry.finishing = true + inbox.trySend(Completion(mutation.request, FileDialogOutcome.Unavailable)) + wake() + return Accepted.UnknownWindow + } + wake() + return Accepted.Accepted + } + + /** + * The `Window` node [windowNode] was removed. Its dialog is closed and every request on it + * ends with one cancel. + */ + fun windowRemoved(windowNode: Int) { + if (windowNode == 0) return + var any = false + for (entry in pending.values.toList()) { + if (entry.spec.windowNode != windowNode || entry.finishing) continue + any = true + entry.finishing = true + if (entry.started) platform.cancelWindow(entry.handle) + inbox.trySend(Completion(entry.spec.request, FileDialogOutcome.Cancelled)) + } + if (any) wake() + } + + /** Connects the center to the Host, once its first batch has been applied. */ + fun attach(host: EventDispatcher) { + dispatcher = host + drain() + } + + /** The process is ending. */ + fun shutdown() { + dispatcher = null + pending.clear() + while (inbox.tryReceive().getOrNull() != null) Unit + } + + /** + * Starts what may start and hands every answer to the Host, on the UI thread. + * + * Called where the Host can be entered: after its start, and at the top of every frame + * an answer asked for. A request is started here and not where it arrives, because the + * batch that carried it may also have created the window it belongs to, and the platform + * window exists only once the batch is through. + */ + fun drain() { + val host = dispatcher ?: return + if (draining) return + draining = true + try { + var progress = true + while (progress) { + progress = false + while (true) { + val done = inbox.tryReceive().getOrNull() ?: break + // Only the first answer to a request counts; a second finds nothing waiting. + val entry = pending.remove(done.request) ?: continue + progress = true + host.dispatch(resultEvent(entry.spec, done.outcome)) + } + for (entry in pending.values.toList()) { + if (entry.started || entry.finishing) continue + if (pending.values.any { it !== entry && it.spec.windowNode == entry.spec.windowNode && (it.started || it.finishing) }) continue + start(entry) + progress = true + } + } + } finally { + draining = false + } + } + + private fun start(entry: Entry) { + entry.started = true + val request = entry.spec.request + val handle = windowResolver(entry.spec.windowNode) + entry.handle = handle + try { + platform.show(handle, entry.spec) { outcome -> + inbox.trySend(Completion(request, outcome)) + wake() + } + } catch (failure: Exception) { + // A platform that throws has no dialog to show, which is the same answer. + inbox.trySend(Completion(request, FileDialogOutcome.Unavailable)) + } + } + + private fun resultEvent(spec: FileDialogSpec, outcome: FileDialogOutcome): HostEvent { + val none = -1 + return when (val checked = judge(outcome)) { + is Checked.Paths -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = FileDialogStatus.Chosen, + filter = checked.filter?.takeIf { it in spec.filters.indices } ?: none, + paths = checked.joined, + ) + is Checked.Ended -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = checked.status, + filter = none, + paths = "", + ) + } + } + + private sealed interface Checked { + class Paths(val joined: String, val filter: Int?) : Checked + class Ended(val status: FileDialogStatus) : Checked + } + + /** + * What the Host is told about an outcome. + * + * Paths that are not well formed text are dropped and the rest go on. When a choice was + * made and none is left, it is unavailable and not a cancel: the user picked something, + * so a cancel would be false. A choice with too many paths, or too many bytes of them, is + * unavailable too, never a shorter list: the application would not know it was short. + */ + private fun judge(outcome: FileDialogOutcome): Checked = when (outcome) { + FileDialogOutcome.Cancelled -> Checked.Ended(FileDialogStatus.Cancelled) + FileDialogOutcome.Unavailable -> Checked.Ended(FileDialogStatus.Unavailable) + is FileDialogOutcome.Chosen -> when { + outcome.paths.isEmpty() -> Checked.Ended(FileDialogStatus.Cancelled) + outcome.paths.size > FileDialogLimits.MAX_PATHS -> Checked.Ended(FileDialogStatus.Unavailable) + else -> { + val kept = outcome.paths.mapNotNull { wellFormedPath(it) } + val bytes = kept.sumOf { it.encodeToByteArray().size.toLong() + 1L } + when { + kept.isEmpty() -> Checked.Ended(FileDialogStatus.Unavailable) + bytes > maxPayloadBytes.toLong() -> Checked.Ended(FileDialogStatus.Unavailable) + else -> Checked.Paths(kept.joinToString("\u0000"), outcome.filter) + } + } + } + } + + companion object { + /** + * Checks a request and puts it in the form a platform can rely on: filters and + * extensions within their limits, extensions lower case without a leading dot or + * `*.`, a default filter that exists, a bare file name, and nothing in the fields a + * kind does not use. + */ + fun normalize(mutation: Mutation.ShowFileDialog): FileDialogSpec { + val folders = mutation.kind == FileDialogKind.OpenFolder || mutation.kind == FileDialogKind.OpenFolders + val filters = if (folders) emptyList() else parseFilters(mutation.filters) + val name = if (mutation.kind == FileDialogKind.Save) { + mutation.name.substringAfterLast('/').substringAfterLast('\\') + } else { + "" + } + return FileDialogSpec( + request = mutation.request, + windowNode = mutation.window, + kind = mutation.kind, + title = mutation.title, + directory = mutation.directory, + name = name, + filters = filters, + defaultFilter = mutation.defaultFilter.takeIf { it in filters.indices } ?: 0, + ) + } + + private fun parseFilters(encoded: String): List { + if (encoded.isEmpty()) return emptyList() + // Label, extensions, label, extensions. A label with nothing after it lets every + // file through, like one with no extensions. + val parts = encoded.split('\u0000') + val filters = ArrayList() + var index = 0 + while (index < parts.size && filters.size < FileDialogLimits.MAX_FILTERS) { + val extensions = (parts.getOrNull(index + 1) ?: "") + .split(',') + .map { it.trim().removePrefix("*.").removePrefix(".").lowercase() } + .filter { it.isNotEmpty() } + .take(FileDialogLimits.MAX_EXTENSIONS) + filters += FileDialogFilter(parts[index], extensions) + index += 2 + } + return filters + } + + /** The path as text, or null for one that is empty, holds a NUL, or is not valid Unicode text. */ + private fun wellFormedPath(path: FileDialogPath): String? { + val text = when (path) { + is FileDialogPath.Text -> path.value + is FileDialogPath.Bytes -> try { + path.value.decodeToString(throwOnInvalidSequence = true) + } catch (failure: Exception) { + return null + } + } + if (text.isEmpty()) return null + var index = 0 + while (index < text.length) { + val unit = text[index] + when { + unit == '\u0000' -> return null + unit.isHighSurrogate() -> { + if (index + 1 >= text.length || !text[index + 1].isLowSurrogate()) return null + index++ + } + unit.isLowSurrogate() -> return null + } + index++ + } + return text + } + } +} diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index cbf2fb04..092b14a1 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -93,6 +93,8 @@ data class TableError(val code: Int, val message: String) { const val MISPLACED_WINDOW = 13 /** A node moved from the content of one `Window` into another, or out of its window. */ const val MOVE_BETWEEN_WINDOWS = 14 + /** A file dialog request whose number is still waiting for its answer. The command is dropped. */ + const val DUPLICATE_FILE_DIALOG = 15 } } @@ -166,6 +168,15 @@ class NodeTable { val notifications: dev.darkpyonix.composerust.ui.platform.NotificationCenter = dev.darkpyonix.composerust.ui.platform.NotificationCenter() + /** + * The file dialogs the Host has asked for, and their answers on the way back. + * + * Not cleared with the tree, for the reason the notifications are not: a request the Host + * is waiting on is still owed its one answer after a resync. + */ + val fileDialogs: dev.darkpyonix.composerust.ui.platform.FileDialogCenter = + dev.darkpyonix.composerust.ui.platform.FileDialogCenter() + private var revision = 0L /** Increments on each `RequestFocus(0)` so the root composition clears active focus. */ @@ -258,6 +269,9 @@ class NodeTable { * start over and anything kept from before would collide with them. */ fun clear() { + // The windows go with the nodes, and a dialog open on one of them ends with a cancel. + nodes.filterValues { it.widget == WidgetKind.Window }.keys.toList() + .forEach(fileDialogs::windowRemoved) animations.clear() nodes.clear() codeNodes.clear() @@ -332,6 +346,10 @@ class NodeTable { is Mutation.WithdrawNotification -> notifications.withdraw(mutation.key) is Mutation.RequestNotificationPermission -> notifications.requestPermission() + // Not a node either. A request on a window that is not there is answered as + // unavailable, so the Host's one answer per request holds, and is reported too. + is Mutation.ShowFileDialog -> showFileDialog(mutation) + // Played on this side's frame clock until it ends. Whether the node has // somewhere to play it is asked once the whole batch is in. is Mutation.StartAnimation -> animations.start(mutation.animation) @@ -394,6 +412,22 @@ class NodeTable { focusRequests += mutation.nodeId } + private fun showFileDialog(mutation: Mutation.ShowFileDialog) { + val windowKnown = mutation.window == ROOT_ID || + nodes[mutation.window]?.widget == WidgetKind.Window + when (fileDialogs.request(mutation, windowKnown)) { + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Accepted -> Unit + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Duplicate -> fail( + TableError.DUPLICATE_FILE_DIALOG, + "ShowFileDialog request ${mutation.request} is already waiting for its answer", + ) + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.UnknownWindow -> fail( + TableError.UNKNOWN_NODE, + "ShowFileDialog for unknown window ${mutation.window}", + ) + } + } + private fun requestFocus(mutation: Mutation.RequestFocus) { if (mutation.nodeId == ROOT_ID) { revision += 1 @@ -629,7 +663,10 @@ class NodeTable { private fun removeSubtree(nodeId: Int) { val node = nodes.remove(nodeId) ?: return codeNodes.remove(nodeId) - if (node.widget == WidgetKind.Window) windowThemes.remove(nodeId) + if (node.widget == WidgetKind.Window) { + windowThemes.remove(nodeId) + fileDialogs.windowRemoved(nodeId) + } animations.removeNode(nodeId) materials -= node.modifiers.count { it is ProtocolModifier.Material } node.children.toList().forEach(::removeSubtree) diff --git a/renderer/desktop/test/FileDialogTest.kt b/renderer/desktop/test/FileDialogTest.kt new file mode 100644 index 00000000..538c6732 --- /dev/null +++ b/renderer/desktop/test/FileDialogTest.kt @@ -0,0 +1,377 @@ +package dev.darkpyonix.composerust.test + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.TableError +import dev.darkpyonix.composerust.ui.platform.FileDialogCenter +import dev.darkpyonix.composerust.ui.platform.FileDialogOutcome +import dev.darkpyonix.composerust.ui.platform.FileDialogPath +import dev.darkpyonix.composerust.ui.platform.FileDialogPlatform +import dev.darkpyonix.composerust.ui.platform.FileDialogSpec +import dev.darkpyonix.composerust.ui.platform.FileDialogs +import dev.darkpyonix.composerust.ui.platform.UnsupportedFileDialogs +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** A platform that remembers what it was asked and answers when the test says so. */ +private class FakeFileDialogs : FileDialogPlatform { + class Shown(val window: Any?, val spec: FileDialogSpec, val onResult: (FileDialogOutcome) -> Unit) + + val shown = mutableListOf() + val cancelledWindows = mutableListOf() + + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) { + shown += Shown(window, request, onResult) + } + + override fun cancelWindow(window: Any?) { + cancelledWindows += window + } + + fun forRequest(request: Int): Shown = shown.single { it.spec.request == request } +} + +private fun dialog( + request: Int, + window: Int = 0, + kind: FileDialogKind = FileDialogKind.OpenFiles, + defaultFilter: Int = 0, + name: String = "", + filters: String = "", +) = Mutation.ShowFileDialog( + request = request, + window = window, + kind = kind, + defaultFilter = defaultFilter, + title = "파일 고르기", + directory = "/home/한글", + name = name, + filters = filters, +) + +/** + * The interpreter's half of file dialogs: what it checks, what it normalises, and the one + * way an answer reaches the Host. Whether a real platform panel opens is a manual check per + * platform; everything on this side of it is checked here against a platform that only + * remembers. + */ +class FileDialogTest { + @AfterTest + fun restore() { + FileDialogs.platform = UnsupportedFileDialogs + } + + private fun hostWith(platform: FileDialogPlatform): Pair { + FileDialogs.platform = platform + val connection = FakeHostConnection() + val host = ComposeRustHost(connection) + host.start() + return host to connection + } + + /** Delivers what the platform reported, the way the frame an answer asks for does. */ + private fun ComposeRustHost.frame() = renderFrame(0) + + /** + * Asks for a dialog on a window of its own, so that requests made together are all shown + * at once and the test does not depend on the order a window shows them in. + */ + private fun ComposeRustHost.ask( + request: Int, + kind: FileDialogKind = FileDialogKind.OpenFiles, + defaultFilter: Int = 0, + name: String = "", + filters: String = "", + ) { + table.apply(Mutation.Create(100 + request, WidgetKind.Window)) + table.apply(dialog(request, 100 + request, kind, defaultFilter, name, filters)) + } + + private fun FakeHostConnection.results() = events.filterIsInstance() + + private fun FakeHostConnection.errorCodes() = + events.filterIsInstance().map { it.code } + + @Test + fun fr72_unknown_window_reports_and_answers_unavailable() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + + host.table.apply(dialog(1, window = 42)) + // A node that exists but is not a window is as unknown as one that does not exist. + host.table.apply(Mutation.Create(7, WidgetKind.Box)) + host.table.apply(dialog(2, window = 7)) + host.frame() + + assertTrue(platform.shown.isEmpty(), "no platform dialog is shown for a window that is not there") + assertEquals( + listOf(1 to FileDialogStatus.Unavailable, 2 to FileDialogStatus.Unavailable), + connection.results().map { it.request to it.status }, + ) + assertTrue(connection.results().all { it.paths.isEmpty() && it.filter == -1 }) + assertEquals(listOf(TableError.UNKNOWN_NODE, TableError.UNKNOWN_NODE), connection.errorCodes()) + } + + @Test + fun fr72_duplicate_pending_request_is_a_protocol_error() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + + host.table.apply(dialog(5)) + host.table.apply(dialog(5, kind = FileDialogKind.Save)) + host.frame() + + assertEquals(1, platform.shown.size, "the second command is dropped") + assertEquals(FileDialogKind.OpenFiles, platform.shown.single().spec.kind) + assertEquals(listOf(TableError.DUPLICATE_FILE_DIALOG), connection.errorCodes()) + assertTrue(connection.results().isEmpty(), "the first request is still waiting") + + platform.forRequest(5).onResult(FileDialogOutcome.Chosen.ofText(listOf("/a/b.txt"))) + host.frame() + assertEquals( + listOf(5 to FileDialogStatus.Chosen), + connection.results().map { it.request to it.status }, + "the first request keeps its single answer", + ) + assertEquals(1, connection.errorCodes().size, "and the duplicate was reported once") + + // The number is free again once it was answered. + host.table.apply(dialog(5)) + host.frame() + assertEquals(2, platform.shown.size) + assertEquals(1, connection.errorCodes().size) + } + + @Test + fun fr72_filters_over_the_limit_are_trimmed_not_rejected() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + + val encoded = (0 until 20).joinToString("\u0000") { filter -> + val extensions = (0 until 40).joinToString(",") { extension -> + when (extension % 3) { + 0 -> "*.EXT$extension" + 1 -> ".Ext$extension" + else -> "EXT$extension" + } + } + "F$filter\u0000$extensions" + } + host.ask(1, defaultFilter = 99, filters = encoded) + host.ask(2, defaultFilter = 3, filters = encoded) + host.ask(3, kind = FileDialogKind.OpenFolder, filters = encoded) + host.ask(4, kind = FileDialogKind.Save, name = "dir/sub\\report.txt", filters = "Text\u0000TXT") + host.ask(5, kind = FileDialogKind.OpenFile, name = "ignored.txt") + host.frame() + + assertTrue(connection.errorCodes().isEmpty(), "trimming is not an error") + assertEquals(5, platform.shown.size, "every request is shown") + val first = platform.forRequest(1).spec + assertEquals(16, first.filters.size) + assertEquals("F0", first.filters.first().label) + assertEquals("F15", first.filters.last().label) + assertTrue(first.filters.all { it.extensions.size == 32 }) + assertEquals(listOf("ext0", "ext1", "ext2"), first.filters.first().extensions.take(3)) + assertEquals("ext31", first.filters.first().extensions.last()) + assertEquals(0, first.defaultFilter, "a default filter out of range is the first") + assertEquals(3, platform.forRequest(2).spec.defaultFilter) + assertTrue(platform.forRequest(3).spec.filters.isEmpty(), "a folder dialog has no filters") + assertEquals("report.txt", platform.forRequest(4).spec.name) + assertEquals(listOf("txt"), platform.forRequest(4).spec.filters.single().extensions) + assertEquals("", platform.forRequest(5).spec.name, "an open dialog has no file name") + } + + @Test + fun fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + val long = "/" + "a".repeat(300) + val korean = "/home/한글/문서 1.txt" + + host.ask(1, filters = "Docs\u0000txt") + host.ask(2) + host.ask(3) + host.ask(4) + host.frame() + platform.forRequest(1).onResult( + FileDialogOutcome.Chosen( + listOf( + FileDialogPath.Text(korean), + FileDialogPath.Text(long), + FileDialogPath.Bytes(byteArrayOf(0x2F, 0xFF.toByte(), 0xFE.toByte())), + FileDialogPath.Bytes("/tmp/é".encodeToByteArray()), + FileDialogPath.Text("/lone\uD800surrogate"), + FileDialogPath.Text(""), + ), + filter = 0, + ), + ) + platform.forRequest(2).onResult( + FileDialogOutcome.Chosen(listOf(FileDialogPath.Bytes(byteArrayOf(0xC3.toByte())))), + ) + platform.forRequest(3).onResult(FileDialogOutcome.Chosen(emptyList())) + platform.forRequest(4).onResult(FileDialogOutcome.Cancelled) + host.frame() + + val results = connection.results().associateBy { it.request } + assertEquals(FileDialogStatus.Chosen, results.getValue(1).status) + assertEquals(listOf(korean, long, "/tmp/é"), results.getValue(1).paths.split('\u0000')) + assertTrue(results.getValue(1).paths.split('\u0000').all { it.isNotEmpty() }) + assertEquals(0, results.getValue(1).filter) + // Something was chosen and nothing survived: unavailable, because a cancel would be false. + assertEquals(FileDialogStatus.Unavailable, results.getValue(2).status) + assertEquals("", results.getValue(2).paths) + // Nothing was chosen at all, which is what a cancel is. + assertEquals(FileDialogStatus.Cancelled, results.getValue(3).status) + assertEquals(FileDialogStatus.Cancelled, results.getValue(4).status) + } + + @Test + fun fr72_more_than_ten_thousand_paths_answers_unavailable() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.ask(1) + host.ask(2) + host.frame() + + platform.forRequest(1).onResult( + FileDialogOutcome.Chosen.ofText((0..10_000).map { "/files/$it" }), + ) + platform.forRequest(2).onResult( + FileDialogOutcome.Chosen.ofText((1..10_000).map { "/files/$it" }), + ) + host.frame() + + val results = connection.results().associateBy { it.request } + assertEquals(FileDialogStatus.Unavailable, results.getValue(1).status, "10001 paths") + assertEquals("", results.getValue(1).paths, "never a shorter list under Chosen") + assertEquals(FileDialogStatus.Chosen, results.getValue(2).status, "10000 paths still fit") + assertEquals(10_000, results.getValue(2).paths.split('\u0000').size) + + // Too many bytes of paths is unavailable for the same reason. + val answers = mutableListOf() + val fake = FakeFileDialogs() + val limited = FileDialogCenter(fake, maxPayloadBytes = 64) + limited.attach(EventDispatcher { event -> answers += event; true }) + limited.request(dialog(9), windowKnown = true) + limited.drain() + fake.forRequest(9).onResult(FileDialogOutcome.Chosen.ofText(listOf("/" + "x".repeat(40), "/" + "y".repeat(40)))) + limited.drain() + val bytes = answers.filterIsInstance().single() + assertEquals(FileDialogStatus.Unavailable, bytes.status) + assertEquals("", bytes.paths) + } + + @Test + fun fr72_closing_the_owner_window_cancels_the_dialog() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.table.fileDialogs.windowResolver = { node -> "window-$node" } + host.table.apply(Mutation.Create(5, WidgetKind.Window)) + host.table.apply(dialog(1, window = 5)) + host.table.apply(dialog(2, window = 5)) + host.frame() + assertEquals(listOf("window-5"), platform.shown.map { it.window }) + assertEquals(1, platform.shown.size, "the second request on the window waits for the first") + + host.table.apply(Mutation.Remove(5)) + host.frame() + + assertEquals(listOf("window-5"), platform.cancelledWindows, "the open dialog is told to close") + assertEquals( + listOf(1 to FileDialogStatus.Cancelled, 2 to FileDialogStatus.Cancelled), + connection.results().map { it.request to it.status }, + "each request ends with one cancel", + ) + assertEquals(1, platform.shown.size, "the waiting request was never shown") + + // The platform answering late changes nothing: the request was already answered. + platform.forRequest(1).onResult(FileDialogOutcome.Chosen.ofText(listOf("/late"))) + host.frame() + assertEquals(2, connection.results().size) + assertTrue(connection.errorCodes().isEmpty()) + } + + @Test + fun fr72_unsupported_platform_answers_unavailable() { + val (host, connection) = hostWith(UnsupportedFileDialogs) + host.table.apply(dialog(1)) + host.table.apply(dialog(2, kind = FileDialogKind.Save)) + host.frame() + host.frame() + + assertEquals( + listOf(1 to FileDialogStatus.Unavailable, 2 to FileDialogStatus.Unavailable), + connection.results().map { it.request to it.status }, + ) + assertTrue(connection.errorCodes().isEmpty(), "an unsupported platform is not a protocol error") + } + + @Test + fun fr72_every_request_gets_exactly_one_result() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.table.apply(dialog(1)) + host.table.apply(Mutation.Create(5, WidgetKind.Window)) + host.table.apply(dialog(2, window = 5)) + host.table.apply(dialog(3, window = 6)) + host.table.apply(dialog(4, window = 5)) + host.frame() + + // Chosen, then answered again and differently: only the first counts. + platform.forRequest(1).onResult(FileDialogOutcome.Chosen.ofText(listOf("/a"))) + platform.forRequest(1).onResult(FileDialogOutcome.Cancelled) + // Cancelled, from a thread of the platform's own. + val worker = thread { platform.forRequest(2).onResult(FileDialogOutcome.Cancelled) } + worker.join() + host.frame() + // Request 4 was waiting behind request 2 and is shown now. + host.frame() + platform.forRequest(4).onResult(FileDialogOutcome.Unavailable) + host.frame() + host.frame() + + val byRequest = connection.results().groupBy { it.request } + assertEquals(setOf(1, 2, 3, 4), byRequest.keys) + assertTrue(byRequest.values.all { it.size == 1 }, "one answer each: ${connection.results()}") + assertEquals(FileDialogStatus.Chosen, byRequest.getValue(1).single().status) + assertEquals(FileDialogStatus.Cancelled, byRequest.getValue(2).single().status) + assertEquals(FileDialogStatus.Unavailable, byRequest.getValue(3).single().status) + assertEquals(FileDialogStatus.Unavailable, byRequest.getValue(4).single().status) + assertTrue(connection.results().all { it.nodeId == 0 && it.handlerId == 0L }) + } + + @Test + fun fr72_requests_on_one_window_are_shown_in_order() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.table.apply(Mutation.Create(5, WidgetKind.Window)) + host.table.apply(dialog(1)) + host.table.apply(dialog(2)) + host.table.apply(dialog(3, window = 5)) + host.frame() + + assertEquals( + listOf(1, 3), + platform.shown.map { it.spec.request }, + "the second request on the root window waits, the other window does not", + ) + + platform.forRequest(1).onResult(FileDialogOutcome.Cancelled) + host.frame() + assertEquals(listOf(1, 3, 2), platform.shown.map { it.spec.request }) + platform.forRequest(2).onResult(FileDialogOutcome.Cancelled) + platform.forRequest(3).onResult(FileDialogOutcome.Cancelled) + host.frame() + assertEquals(listOf(1, 2, 3), connection.results().map { it.request }) + } +} diff --git a/renderer/desktop/test/ProtocolVectorsTest.kt b/renderer/desktop/test/ProtocolVectorsTest.kt index 45a402a1..8118e7b5 100644 --- a/renderer/desktop/test/ProtocolVectorsTest.kt +++ b/renderer/desktop/test/ProtocolVectorsTest.kt @@ -10,7 +10,10 @@ import java.nio.ByteOrder import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.ProtocolException import dev.darkpyonix.composerust.protocol.SyntaxSpanRecord import dev.darkpyonix.composerust.protocol.Severity import dev.darkpyonix.composerust.protocol.HoverPhase @@ -113,6 +116,8 @@ class ProtocolVectorsTest { is Mutation.PostNotification -> false is Mutation.WithdrawNotification -> false is Mutation.RequestNotificationPermission -> false + // The vector's dialog is on the implicit root window, which always exists. + is Mutation.ShowFileDialog -> false is Mutation.EditCode -> mutation.nodeId !in created is Mutation.SetSelection -> mutation.nodeId !in created is Mutation.RevealRange -> mutation.nodeId !in created @@ -207,6 +212,72 @@ class ProtocolVectorsTest { assertEquals(bytes.copyOfRange(274, 294).toList(), changed.array().copyOf(changedLength).toList()) } + /** + * The file dialog request and its result, read from and encoded to the bytes the Host + * wrote: four string references, a kind and a default filter, and three words and a + * string reference. + */ + @Test + fun fr72_the_file_dialog_records_in_the_vector_round_trip() { + assertEquals( + listOf( + Mutation.ShowFileDialog( + request = 7, + window = 0, + kind = FileDialogKind.OpenFiles, + defaultFilter = 1, + title = "파일 고르기", + directory = "/home/한글", + name = "보고서.txt", + filters = "이미지\u0000png,jpg\u0000문서\u0000pdf,txt", + ), + ), + decodeVector("mutations.bin").filterIsInstance(), + ) + + val bytes = vectorFile("events.bin").readBytes() + val out = ByteBuffer.allocate(128) + val length = Protocol.encodeEvent( + HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = 7, + status = FileDialogStatus.Chosen, + filter = 1, + paths = "/Users/한글/문서.txt\u0000/tmp/a b.png", + ), + out, + ) + assertEquals(73, length) + assertEquals(bytes.copyOfRange(952, 952 + 73).toList(), out.array().copyOf(length).toList()) + } + + /** A kind the Renderer does not know is a decode error, which the host reports instead of crashing. */ + @Test + fun fr72_an_unknown_file_dialog_kind_is_a_protocol_error() { + val bytes = vectorFile("mutations.bin").readBytes() + val view = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val recordsLength = view.getInt(4) + var offset = 12 + var patched = false + while (offset < recordsLength) { + val tag = view.getShort(offset).toInt() + val length = view.getShort(offset + 2).toInt() + if (tag == 26) { + for (unknown in listOf(0, 6, 9)) { + view.putShort(offset + 12, unknown.toShort()) + val failure = runCatching { + Protocol.decode(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)) { } + }.exceptionOrNull() + assertTrue(failure is ProtocolException, "kind $unknown decoded instead of being refused: $failure") + } + patched = true + } + offset += length + } + assertTrue(patched, "the vector carries a file dialog request") + } + @Test fun fr10_every_modifier_variant_in_the_vector_round_trips() { val modifiers = decodeVector("mutations.bin") @@ -572,7 +643,7 @@ class ProtocolVectorsTest { "$event at $offset", ) } - assertEquals(952, bytes.size) + assertEquals(1025, bytes.size) } /** diff --git a/renderer/ios/src/shared/FileDialogHost.kt b/renderer/ios/src/shared/FileDialogHost.kt new file mode 120000 index 00000000..32c63032 --- /dev/null +++ b/renderer/ios/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/FileDialogHost.kt b/renderer/linux/src/shared/FileDialogHost.kt new file mode 120000 index 00000000..32c63032 --- /dev/null +++ b/renderer/linux/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/FileDialogHost.kt b/renderer/macos/src/shared/FileDialogHost.kt new file mode 120000 index 00000000..32c63032 --- /dev/null +++ b/renderer/macos/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/web/src/shared/FileDialogHost.kt b/renderer/web/src/shared/FileDialogHost.kt new file mode 120000 index 00000000..32c63032 --- /dev/null +++ b/renderer/web/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/FileDialogHost.kt b/renderer/windows/src/shared/FileDialogHost.kt new file mode 120000 index 00000000..32c63032 --- /dev/null +++ b/renderer/windows/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file From 0f05f177f81e594c35c6e1f7243497d638fc0714 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:23:29 +0900 Subject: [PATCH 152/224] Fix: A window that only crosses a height class boundary reports its size (FR-20) The reporter ran in a side effect that read the measured size unobserved, and only a width class change recomposed it, so a phone-width tall window (390x844) never sent its first WindowSizeChanged and the Host kept 0x0. The height class is now state read in composition. Adds the regression test, and a test that a Compact window which opts in reports at its first measurement. --- .../desktop/src/renderer/ComposeRustHost.kt | 10 +++++ renderer/desktop/test/WindowSizeTest.kt | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index b401c1e6..f263cd11 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -440,11 +440,17 @@ private fun ComposeRustTree( // and the design system is resolved against it, because a system is allowed to // answer differently in a desktop window than on a phone. var sizeClass by remember(host) { mutableStateOf(WindowSizeClass.Compact) } + // The height class is state too, so that a window which only crosses a height boundary + // (a phone-width window that is tall) recomposes and reaches the reporter below. Read + // only through the effect, the size is not observed, and a window whose width class did + // not change would never be reported. + var heightClass by remember(host) { mutableStateOf(WindowHeightClass.Compact) } var measuredSize by remember(host, windowNodeId) { mutableStateOf(null) } val measured = Modifier.onSizeChanged { size -> with(density) { val widthDp = size.width.toDp().value sizeClass = windowSizeClassOf(widthDp) + heightClass = windowHeightClassOf(size.height.toDp().value) measuredSize = size } } @@ -568,6 +574,10 @@ private fun ComposeRustTree( // only inside the effect, as before, so a resize inside a size class costs it no // recomposition. val observedSize = if (reportEveryDp) measuredSize else null + // Subscribes this scope to the height class: reading the class here is what makes a + // height-only class change recompose, and so run the effect below with the new size. + @Suppress("UNUSED_VARIABLE") + val observedHeightClass = heightClass SideEffect { (observedSize ?: measuredSize)?.let { size -> with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } diff --git a/renderer/desktop/test/WindowSizeTest.kt b/renderer/desktop/test/WindowSizeTest.kt index 779c2325..9677f4ab 100644 --- a/renderer/desktop/test/WindowSizeTest.kt +++ b/renderer/desktop/test/WindowSizeTest.kt @@ -506,6 +506,50 @@ class WindowSizeTest { assertEquals(600.6f, (dispatcher.events[1] as HostEvent.WindowSizeChanged).widthDp) } + @Test + fun fr20_a_window_that_only_crosses_a_height_boundary_still_reports_at_the_first_measurement() = runComposeUiTest { + val connection = FakeHostConnection( + listOf( + Mutation.Create(LABEL, WidgetKind.Text), + Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("hello")), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(1f)) { + // A phone: Compact wide like the state both sides start in, Medium high. + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(390.dp, 844.dp)) + } + } + waitForIdle() + val sizes = connection.events.filterIsInstance() + assertEquals(1, sizes.size, "a height-only class change reaches the Host") + assertEquals(WindowSizeClass.Compact, sizes[0].sizeClass) + assertEquals(WindowHeightClass.Medium, sizes[0].heightClass) + assertEquals(390f, sizes[0].widthDp) + assertEquals(844f, sizes[0].heightDp) + } + + @Test + fun fr20_8_a_compact_window_that_opts_in_reports_its_size_at_the_first_measurement() { + // 390 by 300 is Compact wide and Compact high, the state both sides start in, so a + // window that did not opt in stays silent. One that did has never told the Host its + // size, and its rounded size differs from the (0, 0) it starts with. + val quiet = RecordingDispatcher() + assertFalse(WindowSizeReporter().report(390f, 300f, quiet)) + assertEquals(0, quiet.events.size) + + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + assertTrue(reporter.report(390f, 300f, dispatcher, everyDpChange = true)) + assertEquals(1, dispatcher.events.size) + val first = dispatcher.events[0] as HostEvent.WindowSizeChanged + assertEquals(390f, first.widthDp) + assertEquals(300f, first.heightDp) + // The same size again is silent. + assertFalse(reporter.report(390f, 300f, dispatcher, everyDpChange = true)) + assertEquals(1, dispatcher.events.size) + } + private fun rootWindowBatch(report: Boolean): List = listOf( Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true, reportSize = report)), Mutation.Create(LABEL, WidgetKind.Text), From d6575e455d2fa3df43a8e8aeccbaddaeaf5a23e1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:32:55 +0900 Subject: [PATCH 153/224] Chore: Delete a pull request's Actions caches when it is merged or closed Caches saved by a pull request run can only be read by that pull request, but each one counted about 8 GB against the 10 GB limit, so the large Windows caches were evicted before any run reused them. A workflow on pull_request closed deletes refs/pull/N/merge caches only, with actions: write, and never fails. --- .github/scripts/delete-pr-caches.sh | 31 +++++++++++++++++++ .github/workflows/cache-cleanup.yml | 39 +++++++++++++++++++++++ scripts/tests/delete-pr-caches.test.sh | 43 ++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100755 .github/scripts/delete-pr-caches.sh create mode 100644 .github/workflows/cache-cleanup.yml create mode 100755 scripts/tests/delete-pr-caches.test.sh diff --git a/.github/scripts/delete-pr-caches.sh b/.github/scripts/delete-pr-caches.sh new file mode 100755 index 00000000..229277a8 --- /dev/null +++ b/.github/scripts/delete-pr-caches.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Usage: delete-pr-caches.sh +# +# Deletes the Actions caches that runs of one pull request saved (ref refs/pull//merge). +# A pull request's caches can only be read by that pull request, so once it is merged or +# closed they are dead weight against the repository's 10 GB limit, and they push out the +# caches that other runs could use. +# +# It deletes nothing but that one ref, and it never fails the run: a cache that is already +# gone or a listing that errors is reported as a warning. +set -uo pipefail + +number="${1:-}" +if [[ ! "$number" =~ ^[0-9]+$ ]]; then + echo "::warning title=No cache cleanup::'$number' is not a pull request number" + exit 0 +fi +ref="refs/pull/$number/merge" + +if ! ids="$(gh cache list --ref "$ref" --limit 100 --json id --jq '.[].id' 2>&1)"; then + echo "::warning title=Could not list caches::$ids" + exit 0 +fi +[[ -n "$ids" ]] || { echo "no caches under $ref"; exit 0; } + +for id in $ids; do + [[ "$id" =~ ^[0-9]+$ ]] || continue + if gh cache delete "$id" >/dev/null 2>&1; then echo "deleted cache $id of $ref" + else echo "::warning title=Cache not deleted::cache $id of $ref"; fi +done +exit 0 diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 00000000..4cd1944d --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,39 @@ +# Deletes a pull request's Actions caches when it is merged or closed. +# +# A cache saved by a pull request run belongs to that pull request's ref and nothing else can +# read it, but it still counts against the repository's 10 GB limit. Every pull request here +# saved about 8 GB of Kotlin and Gradle caches, so two of them fill the limit and GitHub +# evicts the oldest entries first, which is how the large Windows caches never survived. +# +# Only the closed pull request's own ref is touched. The number comes from the event payload +# through an environment variable, never into the script text. +name: Cache cleanup + +on: + pull_request: + types: [closed] + +concurrency: + group: cache-cleanup-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: {} + +jobs: + delete: + name: Delete the closed pull request's caches + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + # The base branch's copy of the script, not the pull request's. + ref: ${{ github.event.pull_request.base.sha }} + - name: Delete the caches saved under refs/pull/N/merge + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: ./.github/scripts/delete-pr-caches.sh "$PR_NUMBER" diff --git a/scripts/tests/delete-pr-caches.test.sh b/scripts/tests/delete-pr-caches.test.sh new file mode 100755 index 00000000..84b8080f --- /dev/null +++ b/scripts/tests/delete-pr-caches.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# .github/scripts/delete-pr-caches.sh: deletes one pull request's caches and nothing else, +# and never fails the run. A fake gh records what it was asked. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/delete-pr-caches.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/delete-caches.XXXXXX")" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +cat > "$work/bin/gh" <<'GH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_LOG" +case "$1 $2" in + "cache list") [[ "${FAKE_LIST_FAILS:-}" ]] && { echo "HTTP 403" >&2; exit 1; }; printf '11\n12\n' ;; + "cache delete") [[ "$3" == "${FAKE_DELETE_FAILS:-}" ]] && exit 1; exit 0 ;; +esac +GH +chmod +x "$work/bin/gh" +failures=0 +ok() { echo "ok - $1"; } +bad() { echo "FAIL - $1"; failures=$((failures + 1)); } +run() { : > "$work/log"; PATH="$work/bin:$PATH" FAKE_LOG="$work/log" "$@"; } + +run "$script" 42 >/dev/null; rc=$? +[[ $rc -eq 0 ]] && ok "exits 0" || bad "exit $rc" +grep -q -- "--ref refs/pull/42/merge" "$work/log" && ok "lists only the pull request's ref" || bad "list ref" +[[ "$(grep -c 'cache delete' "$work/log")" -eq 2 ]] && ok "deletes each listed cache" || bad "delete count" +! grep -q 'refs/heads\|refs/tags' "$work/log" && ok "touches no branch or tag ref" || bad "other ref" + +out="$(FAKE_LIST_FAILS=1 run "$script" 42)"; rc=$? +[[ $rc -eq 0 && "$out" == *"::warning"* ]] && ok "a failing listing warns and exits 0" || bad "list failure: $rc $out" +out="$(FAKE_DELETE_FAILS=11 run "$script" 42)"; rc=$? +[[ $rc -eq 0 && "$out" == *"::warning"* && "$out" == *"deleted cache 12"* ]] && ok "a failing delete warns, the rest go on" || bad "delete failure: $out" +for arg in "" "abc" "42; rm -rf /" "../x"; do + out="$(run "$script" "$arg")"; rc=$? + [[ $rc -eq 0 && "$out" == *"::warning"* && ! -s "$work/log" ]] && ok "refuses '$arg'" || bad "accepted '$arg'" +done +wf="$repo_root/.github/workflows/cache-cleanup.yml" +grep -q 'types: \[closed\]' "$wf" && ok "runs on closed pull requests" || bad "trigger" +grep -q 'actions: write' "$wf" && ! grep -qE 'contents: write|pull-requests: write' "$wf" && ok "only actions: write" || bad "permissions" +! grep -qE 'run:.*\$\{\{' "$wf" && ok "no expression inside a run line" || bad "expression in run" +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" From 7047688c97b6ad65b5777664b3f1d90f5f94361c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:33:27 +0900 Subject: [PATCH 154/224] Fix: Import WindowHeightClass in ComposeRustHost --- compose-rust/android-kotlin/shared/ComposeRustHost.kt | 11 +++++++++++ renderer/desktop/src/renderer/ComposeRustHost.kt | 1 + 2 files changed, 12 insertions(+) diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index b401c1e6..824a06bb 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -41,6 +41,7 @@ import dev.darkpyonix.composerust.foundation.systemChrome import androidx.compose.ui.graphics.Color import dev.darkpyonix.composerust.protocol.ColorRole import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.FrameRequests @@ -440,11 +441,17 @@ private fun ComposeRustTree( // and the design system is resolved against it, because a system is allowed to // answer differently in a desktop window than on a phone. var sizeClass by remember(host) { mutableStateOf(WindowSizeClass.Compact) } + // The height class is state too, so that a window which only crosses a height boundary + // (a phone-width window that is tall) recomposes and reaches the reporter below. Read + // only through the effect, the size is not observed, and a window whose width class did + // not change would never be reported. + var heightClass by remember(host) { mutableStateOf(WindowHeightClass.Compact) } var measuredSize by remember(host, windowNodeId) { mutableStateOf(null) } val measured = Modifier.onSizeChanged { size -> with(density) { val widthDp = size.width.toDp().value sizeClass = windowSizeClassOf(widthDp) + heightClass = windowHeightClassOf(size.height.toDp().value) measuredSize = size } } @@ -568,6 +575,10 @@ private fun ComposeRustTree( // only inside the effect, as before, so a resize inside a size class costs it no // recomposition. val observedSize = if (reportEveryDp) measuredSize else null + // Subscribes this scope to the height class: reading the class here is what makes a + // height-only class change recompose, and so run the effect below with the new size. + @Suppress("UNUSED_VARIABLE") + val observedHeightClass = heightClass SideEffect { (observedSize ?: measuredSize)?.let { size -> with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index f263cd11..824a06bb 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -41,6 +41,7 @@ import dev.darkpyonix.composerust.foundation.systemChrome import androidx.compose.ui.graphics.Color import dev.darkpyonix.composerust.protocol.ColorRole import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.FrameRequests From 7ee11d3184fc869a1d8f4f0a5c12c0f9a7c88dc9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:33:40 +0900 Subject: [PATCH 155/224] Docs: One path that is not UTF-8 makes a file dialog choice unavailable (FR-72, #483) --- docs/SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index e010f303..e3ba9201 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3906,7 +3906,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" - `Unavailable`: 이 환경에서 대화상자를 띄울 수 없습니다(72.6). `paths`는 비어 있습니다. 오류가 아니므로 `ProtocolError`가 아니고, 프로세스는 죽지 않습니다. - **요청마다 이벤트는 정확히 하나입니다.** 선택하든 취소하든 못 띄우든, 같은 `request`로 하나가 오고 둘은 오지 않습니다. 이 규칙이 있어야 Host가 대기 상태를 풀 수 있습니다. 예외는 프로세스가 끝나는 경우뿐이고, 그때는 받을 Host도 없습니다. - **`paths`는 한 문자열입니다.** 경로가 여럿이면 `FilesDropped`(FR-27)와 같이 경로 사이를 NUL(U+0000) 한 바이트로 가르며, 이 바이트는 어느 데스크톱의 경로에도 들어갈 수 없습니다. 목록을 나르는 두 번째 방법을 만들지 않습니다. -- **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 경로에 UTF-8이 아닌 바이트가 있으면(Linux에서만 가능) 그 경로를 버리고 나머지를 전달하며(FR-27과 같은 규칙), 하나도 남지 않으면 `Cancelled`가 아니라 `Unavailable`입니다. 사용자가 고른 것이 있었는데 취소로 보이면 거짓이기 때문입니다. +- **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 고른 경로 중 하나라도 UTF-8로 나타낼 수 없으면(Linux에서만 가능하고, macOS에서 경로를 문자열로 읽지 못하는 경우도 같습니다) **결과 전체가 `Unavailable`**이고 나머지 경로도 보내지 않습니다. 고른 개수와 돌려준 개수가 다른 `Chosen`을 앱이 알아챌 길이 없고, 취소로 보내면 사용자가 고른 것이 있었는데 거짓이기 때문입니다. 파일을 떨어뜨리는 FR-27은 건너뛰고 나머지를 전달하지만, 대화상자에서 고른 것은 사용자가 이름을 짚어 고른 것이라 규칙이 다릅니다. - **긴 경로를 자르지 않습니다.** Windows의 `MAX_PATH`(260)를 넘는 경로도 `IShellItem`이 돌려주는 전체를 그대로 전달하고, 접두사(`\\?\`)는 붙이지 않습니다. 경로 한 개의 길이에 Renderer가 두는 상한은 없습니다. **한 번에 돌려주는 경로는 10000개까지입니다.** 사용자가 그보다 많이 골랐거나 이벤트의 문자열이 배치 버퍼가 감당하는 크기를 넘으면 `ProtocolError`가 아니라 **`Unavailable`**이고, 앞의 경로만 잘라 `Chosen`으로 보내지 않습니다. 사용자가 고른 것이 일부만 전달되면 앱이 그 사실을 모르기 때문입니다. - `Chosen`인데 경로가 하나도 없으면 Renderer는 `Cancelled`를 보내고, Host는 경로 없는 `Chosen`을 받으면 `Unavailable`로 다룹니다. 앱이 빈 목록을 선택으로 처리하지 않게 하려는 것입니다. - `filter`: 사용자가 마지막으로 선택한 필터의 위치입니다. 필터를 보내지 않았거나 `Chosen`이 아니면 `0xFFFF_FFFF`입니다. @@ -3956,7 +3956,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" 4. 이미 결과를 기다리는 `request`를 다시 보내면 `ProtocolError`가 한 번 오고 앞 요청의 결과는 하나만 옵니다(시험 이름 fr72_duplicate_pending_request_is_a_protocol_error). 5. 같은 창에 요청 둘을 보내면 두 번째가 첫째의 결과 뒤에 뜨고, 결과도 그 순서로 옵니다. 다른 창의 요청은 기다리지 않습니다(시험 이름 fr72_requests_on_one_window_are_shown_in_order). 6. 존재하지 않는 `window`는 `UNKNOWN_NODE` 보고와 함께 `Unavailable` 결과 하나를 만듭니다(시험 이름 fr72_unknown_window_reports_and_answers_unavailable). -7. 경로가 NUL로 가른 한 문자열로 오고, 한글 경로와 260자를 넘는 경로가 잘리지 않고 UTF-8로 도착합니다. UTF-8이 아닌 경로는 버려지고 하나도 안 남으면 `Unavailable`입니다(시험 이름 fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped). +7. 경로가 NUL로 가른 한 문자열로 오고, 한글 경로와 260자를 넘는 경로가 잘리지 않고 UTF-8로 도착합니다. UTF-8로 나타낼 수 없는 경로가 하나라도 있으면 결과 전체가 `Unavailable`입니다(시험 이름 fr72_paths_arrive_whole_utf8_and_a_non_utf8_path_answers_unavailable). 8. 10001개 경로를 고르면 `Unavailable`이고 `Chosen`이 일부만 가지 않습니다(시험 이름 fr72_more_than_ten_thousand_paths_answers_unavailable). 필터가 16개, 확장자가 32개를 넘으면 넘는 부분만 버려지고 요청은 처리됩니다(시험 이름 fr72_filters_over_the_limit_are_trimmed_not_rejected). 9. 대화상자가 열려 있는 동안 UI 스레드의 이벤트 루프가 돌아, 같은 창 밖의 애니메이션과 다른 창의 입력이 처리됩니다. 대화상자를 연 채 워커가 보낸 프레임 요청이 100ms 안에 렌더됩니다(시험 이름 fr72_ui_loop_keeps_running_while_a_dialog_is_open, 데스크톱 렌더러 시험). 10. 소유한 창을 지우면 열려 있던 대화상자가 닫히고 `Cancelled`가 하나 옵니다(시험 이름 fr72_closing_the_owner_window_cancels_the_dialog). From 567480a0fd598ae89b9e88285e03dd1907999fa0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:33:40 +0900 Subject: [PATCH 156/224] Fix: A non-UTF-8 path makes the whole file dialog choice unavailable (FR-72) The choice used to lose the odd path and send the rest, so the application got fewer paths than the user picked and could not tell. Covers FR-72 criterion 7. Refs #483 --- compose-rust/tests/fr72_file_dialog.rs | 2 +- .../desktop/src/renderer/FileDialogHost.kt | 21 ++++++++++--------- renderer/desktop/test/FileDialogTest.kt | 12 ++++------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/compose-rust/tests/fr72_file_dialog.rs b/compose-rust/tests/fr72_file_dialog.rs index 1f59daaf..69bc9a1c 100644 --- a/compose-rust/tests/fr72_file_dialog.rs +++ b/compose-rust/tests/fr72_file_dialog.rs @@ -252,7 +252,7 @@ fn fr72_every_request_gets_exactly_one_result() { /// characters arrive unchanged and in order. One that is not UTF-8 never gets as far as the /// callback, because the event does not decode. #[test] -fn fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped() { +fn fr72_paths_arrive_whole_utf8_and_a_non_utf8_path_answers_unavailable() { let _turn = SERIAL.lock().unwrap_or_else(|poison| poison.into_inner()); RESULTS.with_borrow_mut(Vec::clear); let mut session = Session::start(); diff --git a/renderer/desktop/src/renderer/FileDialogHost.kt b/renderer/desktop/src/renderer/FileDialogHost.kt index 0217ca14..ca4942eb 100644 --- a/renderer/desktop/src/renderer/FileDialogHost.kt +++ b/renderer/desktop/src/renderer/FileDialogHost.kt @@ -56,8 +56,8 @@ data class FileDialogSpec( /** * One path a dialog returned. A platform that hands over text passes [Text]; one whose paths - * are bytes (a Linux file system does not promise UTF-8) passes [Bytes], and the ones that - * are not UTF-8 are dropped before the Host sees anything. + * are bytes (a Linux file system does not promise UTF-8) passes [Bytes]. One that is not UTF-8 + * makes the whole choice unavailable. */ sealed interface FileDialogPath { class Text(val value: String) : FileDialogPath @@ -309,9 +309,9 @@ class FileDialogCenter( /** * What the Host is told about an outcome. * - * Paths that are not well formed text are dropped and the rest go on. When a choice was - * made and none is left, it is unavailable and not a cancel: the user picked something, - * so a cancel would be false. A choice with too many paths, or too many bytes of them, is + * A path that is not well formed text makes the whole choice unavailable: the other paths + * are not sent either, because the application could not tell that the list is shorter + * than what the user picked, and a cancel would be false. A choice with too many paths, or too many bytes of them, is * unavailable too, never a shorter list: the application would not know it was short. */ private fun judge(outcome: FileDialogOutcome): Checked = when (outcome) { @@ -321,12 +321,13 @@ class FileDialogCenter( outcome.paths.isEmpty() -> Checked.Ended(FileDialogStatus.Cancelled) outcome.paths.size > FileDialogLimits.MAX_PATHS -> Checked.Ended(FileDialogStatus.Unavailable) else -> { - val kept = outcome.paths.mapNotNull { wellFormedPath(it) } - val bytes = kept.sumOf { it.encodeToByteArray().size.toLong() + 1L } + val checked = outcome.paths.map { wellFormedPath(it) } + val texts = checked.filterNotNull() when { - kept.isEmpty() -> Checked.Ended(FileDialogStatus.Unavailable) - bytes > maxPayloadBytes.toLong() -> Checked.Ended(FileDialogStatus.Unavailable) - else -> Checked.Paths(kept.joinToString("\u0000"), outcome.filter) + texts.size != checked.size -> Checked.Ended(FileDialogStatus.Unavailable) + texts.sumOf { it.encodeToByteArray().size.toLong() + 1L } > maxPayloadBytes.toLong() -> + Checked.Ended(FileDialogStatus.Unavailable) + else -> Checked.Paths(texts.joinToString("\u0000"), outcome.filter) } } } diff --git a/renderer/desktop/test/FileDialogTest.kt b/renderer/desktop/test/FileDialogTest.kt index 538c6732..60bc310b 100644 --- a/renderer/desktop/test/FileDialogTest.kt +++ b/renderer/desktop/test/FileDialogTest.kt @@ -191,7 +191,7 @@ class FileDialogTest { } @Test - fun fr72_paths_arrive_whole_utf8_and_invalid_ones_are_dropped() { + fun fr72_paths_arrive_whole_utf8_and_a_non_utf8_path_answers_unavailable() { val platform = FakeFileDialogs() val (host, connection) = hostWith(platform) val long = "/" + "a".repeat(300) @@ -207,10 +207,7 @@ class FileDialogTest { listOf( FileDialogPath.Text(korean), FileDialogPath.Text(long), - FileDialogPath.Bytes(byteArrayOf(0x2F, 0xFF.toByte(), 0xFE.toByte())), - FileDialogPath.Bytes("/tmp/é".encodeToByteArray()), - FileDialogPath.Text("/lone\uD800surrogate"), - FileDialogPath.Text(""), + FileDialogPath.Bytes("/tmp/\u00e9".encodeToByteArray()), ), filter = 0, ), @@ -224,10 +221,9 @@ class FileDialogTest { val results = connection.results().associateBy { it.request } assertEquals(FileDialogStatus.Chosen, results.getValue(1).status) - assertEquals(listOf(korean, long, "/tmp/é"), results.getValue(1).paths.split('\u0000')) - assertTrue(results.getValue(1).paths.split('\u0000').all { it.isNotEmpty() }) + assertEquals(listOf(korean, long, "/tmp/\u00e9"), results.getValue(1).paths.split('\u0000')) assertEquals(0, results.getValue(1).filter) - // Something was chosen and nothing survived: unavailable, because a cancel would be false. + // One path is not text, so the whole choice is unavailable and the others are not sent. assertEquals(FileDialogStatus.Unavailable, results.getValue(2).status) assertEquals("", results.getValue(2).paths) // Nothing was chosen at all, which is what a cancel is. From c7e1bb1bac0eb1532e955f8a15067aee0b7e4c91 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:34:46 +0900 Subject: [PATCH 157/224] WIP: PR runs restore caches only; saves happen on push, schedule and dispatch --- .github/workflows/fork-desktop-modules.yml | 2 +- .github/workflows/static-renderer.yml | 14 +++++- .github/workflows/test-compose-from-fork.yml | 20 +++++++- .github/workflows/test-graalvm-renderer.yml | 46 ++++++++++++++++-- .../workflows/test-kn-desktop-renderer.yml | 14 +++++- .github/workflows/test-native-renderer.yml | 13 ++++- .github/workflows/test-samples.yml | 47 +++++++++++++++++-- .../workflows/test-windows-resize-metrics.yml | 6 +-- .github/workflows/test.yml | 28 ++++++++++- .github/workflows/windows-static.yml | 4 +- 10 files changed, 171 insertions(+), 23 deletions(-) diff --git a/.github/workflows/fork-desktop-modules.yml b/.github/workflows/fork-desktop-modules.yml index a63af54a..f9775ce6 100644 --- a/.github/workflows/fork-desktop-modules.yml +++ b/.github/workflows/fork-desktop-modules.yml @@ -45,7 +45,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: false + cache-read-only: ${{ github.event_name == 'pull_request' }} gradle-home-cache-excludes: | caches/build-cache-1 diff --git a/.github/workflows/static-renderer.yml b/.github/workflows/static-renderer.yml index 4e4b9685..3830d958 100644 --- a/.github/workflows/static-renderer.yml +++ b/.github/workflows/static-renderer.yml @@ -141,7 +141,8 @@ jobs: key: compose-${{ steps.target.outputs.compose_key }}-${{ hashFiles('renderer/scripts/build-compose.sh', 'renderer/scripts/compose-fork.changes') }} - name: Cache the Kotlin toolchain - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ${{ steps.target.outputs.toolchain }} @@ -168,7 +169,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target ${{ steps.target.outputs.compose }} - name: Keep the patched Compose for the next run - if: steps.compose-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.compose-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: ~/.m2/repository @@ -285,3 +286,12 @@ jobs: ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz.sha256 retention-days: 7 + + - name: Save the Kotlin toolchain (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ steps.target.outputs.toolchain }} + ~/.konan + key: ${{ steps.cache-1.outputs.cache-primary-key }} diff --git a/.github/workflows/test-compose-from-fork.yml b/.github/workflows/test-compose-from-fork.yml index a237a3fc..24e110ed 100644 --- a/.github/workflows/test-compose-from-fork.yml +++ b/.github/workflows/test-compose-from-fork.yml @@ -166,7 +166,8 @@ jobs: caches/build-cache-1 - name: Cache Kotlin/Native for the Compose build - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: ~/.konan key: konan-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('renderer/scripts/build-compose.sh') }} @@ -174,7 +175,8 @@ jobs: konan-${{ runner.os }}-${{ runner.arch }}- - name: Cache the Kotlin toolchain the renderer builds with - uses: actions/cache@v4 + id: cache-2 + uses: actions/cache/restore@v4 with: path: ${{ matrix.toolchain_cache }} key: kotlin-native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('renderer/kotlin', 'renderer/libs.versions.toml', 'renderer/**/module.yaml') }} @@ -323,3 +325,17 @@ jobs: # comparison with the ext.2 baseline: DarkPyonix/compose-rust#361. run: | echo "the fork and the patched build published different contents, as the moved pin is expected to; see the comparison step" >&2 + + - name: Save Kotlin/Native for the Compose build (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.konan + key: ${{ steps.cache-1.outputs.cache-primary-key }} + + - name: Save the Kotlin toolchain the renderer builds with (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ matrix.toolchain_cache }} + key: ${{ steps.cache-2.outputs.cache-primary-key }} diff --git a/.github/workflows/test-graalvm-renderer.yml b/.github/workflows/test-graalvm-renderer.yml index f1540c8f..c99d930f 100644 --- a/.github/workflows/test-graalvm-renderer.yml +++ b/.github/workflows/test-graalvm-renderer.yml @@ -82,7 +82,8 @@ jobs: - uses: actions/checkout@v4 - name: Cache Liberica NIK - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: ~/.cache/compose-rust/nik key: nik-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/install-nik.sh') }} @@ -91,7 +92,8 @@ jobs: run: ./scripts/install-nik.sh --github-env - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-2 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -224,6 +226,22 @@ jobs: path: renderer/build/native-image/dist/ retention-days: 7 + - name: Save Liberica NIK (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.cache/compose-rust/nik + key: ${{ steps.cache-1.outputs.cache-primary-key }} + + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-2.outputs.cache-primary-key }} + compose-desktop: name: Build the Compose fork's desktop modules # The Linux image carries no Java toolkit, which takes changes Compose's desktop modules @@ -323,7 +341,8 @@ jobs: libgl1-mesa-dev fonts-noto-cjk xvfb xauth dbus-x11 - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-3 + uses: actions/cache/restore@v4 with: path: | ~/.cache/JetBrains/Kotlin @@ -415,6 +434,15 @@ jobs: path: renderer/build/native-image-linux/dist/ retention-days: 7 + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-3.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-3.outputs.cache-primary-key }} + linux-arm64: needs: compose-desktop name: Build and smoke test the GraalVM renderer (Linux, arm64) @@ -439,7 +467,8 @@ jobs: libgl1-mesa-dev fonts-noto-cjk xvfb xauth dbus-x11 - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-4 + uses: actions/cache/restore@v4 with: path: | ~/.cache/JetBrains/Kotlin @@ -479,3 +508,12 @@ jobs: name: graalvm-renderer-linux-arm64 path: renderer/build/native-image-linux/dist/ retention-days: 7 + + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-4.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-4.outputs.cache-primary-key }} diff --git a/.github/workflows/test-kn-desktop-renderer.yml b/.github/workflows/test-kn-desktop-renderer.yml index 13f67df5..8adc1831 100644 --- a/.github/workflows/test-kn-desktop-renderer.yml +++ b/.github/workflows/test-kn-desktop-renderer.yml @@ -135,7 +135,8 @@ jobs: key: compose-${{ matrix.compose_key }}-${{ hashFiles('renderer/scripts/build-compose.sh', 'renderer/scripts/compose-fork.changes') }} - name: Cache the Kotlin toolchain - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ${{ matrix.toolchain }} @@ -177,7 +178,7 @@ jobs: run: ./renderer/scripts/require-fork-artifacts.sh ${{ matrix.module }} - name: Keep the patched Compose for the next run - if: steps.compose-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.compose-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: ~/.m2/repository @@ -293,3 +294,12 @@ jobs: # The same check as the macOS job's, with the main dispatcher Linux has. if: matrix.module == 'linux' run: xvfb-run -a -s '-screen 0 1280x1024x24' ./renderer/desktop/scripts/smoke-test-kn.sh compose-rust/tests/fixtures/consumer/target/release/consumer linux + + - name: Save the Kotlin toolchain (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ matrix.toolchain }} + ~/.konan + key: ${{ steps.cache-1.outputs.cache-primary-key }} diff --git a/.github/workflows/test-native-renderer.yml b/.github/workflows/test-native-renderer.yml index e07d6c1b..cb377d1e 100644 --- a/.github/workflows/test-native-renderer.yml +++ b/.github/workflows/test-native-renderer.yml @@ -403,7 +403,8 @@ jobs: - uses: actions/checkout@v4 - name: Cache the Kotlin toolchain, konan and Maven dependencies - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -485,6 +486,16 @@ jobs: ${{ runner.temp }}/ios-navigation.png retention-days: 7 + - name: Save the Kotlin toolchain, konan and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.konan + ~/.m2/repository + key: ${{ steps.cache-1.outputs.cache-primary-key }} + # Everything below runs only for a version tag, and only after every platform above has # built and smoke tested. A crates.io version can be yanked but never replaced, and the # crate's build script points at the release artifact for its own version, so publishing diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index e33c99ca..a3d6dfb4 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -166,8 +166,9 @@ jobs: libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev - name: Cache the Kotlin toolchain and Maven dependencies + id: cache-1 if: runner.os == 'Windows' - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/.m2/repository key: kotlin-${{ matrix.target }}-${{ hashFiles('renderer/**/module.yaml') }} @@ -299,6 +300,13 @@ jobs: name: samples-${{ matrix.target }} path: artifacts/ + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: (runner.os == 'Windows') && github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.m2/repository + key: ${{ steps.cache-1.outputs.cache-primary-key }} + android: needs: requested @@ -331,9 +339,10 @@ jobs: echo "ANDROID_NDK_HOME=$sdk/ndk/27.1.12297006" >> "$GITHUB_ENV" - name: Cache Gradle and Maven dependencies + id: cache-2 # Gradle, not the Kotlin toolchain: an application's Kotlin is compiled by its own # Gradle build here rather than by ours. - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | ~/.gradle/caches @@ -361,6 +370,16 @@ jobs: name: samples-android path: artifacts/ + - name: Save Gradle and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.m2/repository + key: ${{ steps.cache-2.outputs.cache-primary-key }} + ios: needs: requested name: Build the sample (iOS, simulator) @@ -377,7 +396,8 @@ jobs: rustup target add aarch64-apple-ios-sim - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-3 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -416,6 +436,15 @@ jobs: name: samples-ios-simulator path: artifacts/ + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-3.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-3.outputs.cache-primary-key }} + web: needs: requested name: Build the sample (web) @@ -437,7 +466,8 @@ jobs: java-version: '21' - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-4 + uses: actions/cache/restore@v4 with: path: | ~/.cache/JetBrains/Kotlin @@ -460,6 +490,15 @@ jobs: name: samples-web path: artifacts/ + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-4.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-4.outputs.cache-primary-key }} + release: name: Attach the samples to the GitHub release needs: [build, android, ios, web] diff --git a/.github/workflows/test-windows-resize-metrics.yml b/.github/workflows/test-windows-resize-metrics.yml index a6e618aa..24c9a007 100644 --- a/.github/workflows/test-windows-resize-metrics.yml +++ b/.github/workflows/test-windows-resize-metrics.yml @@ -113,7 +113,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target mingwX64 - name: Keep them for the next run - if: steps.kit-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.kit-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -252,7 +252,7 @@ jobs: run: ./renderer/desktop/scripts/build-windows.sh - name: Keep the Kotlin toolchain for the next run - if: always() && steps.toolchain-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (always() && steps.toolchain-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -533,7 +533,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target macosArm64 - name: Keep it for the next run - if: steps.kit-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.kit-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: ~/.m2/repository diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a3e7fde..1be4669f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,7 +120,8 @@ jobs: cargo --version - name: Cache cargo registry and build artifacts - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ~/.cargo/registry/index @@ -164,6 +165,17 @@ jobs: DXC_SKIP_KOTLIN: '1' run: ./scripts/check.sh + - name: Save cargo registry and build artifacts (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ steps.cache-1.outputs.cache-primary-key }} + generated: # The generated files, regenerated from this tree's schema and compared with what is # checked in. The Rust gate's staleness tests say that a file is stale; this says what @@ -259,9 +271,10 @@ jobs: - uses: actions/checkout@v4 - name: Cache the Kotlin toolchain, konan and Maven dependencies + id: cache-2 # Keyed on the platform as well as the inputs, so each job carries only what it # actually downloads instead of every job restoring every toolchain. - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -294,6 +307,17 @@ jobs: working-directory: renderer run: ./kotlin test -p ${{ matrix.platform }} + - name: Save the Kotlin toolchain, konan and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.cache/JetBrains/Kotlin + ~/.konan + ~/.m2/repository + key: ${{ steps.cache-2.outputs.cache-primary-key }} + shell: needs: changed name: Run the shell script tests (${{ matrix.os }}) diff --git a/.github/workflows/windows-static.yml b/.github/workflows/windows-static.yml index 2327a469..90ff955a 100644 --- a/.github/workflows/windows-static.yml +++ b/.github/workflows/windows-static.yml @@ -103,7 +103,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target mingwX64 - name: Keep them for the next run - if: steps.kit-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.kit-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -246,7 +246,7 @@ jobs: run: ./renderer/desktop/scripts/build-windows.sh - name: Keep the Kotlin toolchain for the next run - if: always() && steps.toolchain-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (always() && steps.toolchain-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | From 8543197f06655875358a7650ead38eb39768a6a3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:34:53 +0900 Subject: [PATCH 158/224] Chore: Harden the cache cleanup test and list up to 1000 caches Asserts the script is checked out from the base commit and that pull_request_target is absent, lists up to 1000 caches with a warning at the limit, and names the workflow in the CI naming list. --- .github/scripts/delete-pr-caches.sh | 5 ++++- AGENTS.md | 1 + scripts/tests/delete-pr-caches.test.sh | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/scripts/delete-pr-caches.sh b/.github/scripts/delete-pr-caches.sh index 229277a8..1201aeb6 100755 --- a/.github/scripts/delete-pr-caches.sh +++ b/.github/scripts/delete-pr-caches.sh @@ -17,12 +17,15 @@ if [[ ! "$number" =~ ^[0-9]+$ ]]; then fi ref="refs/pull/$number/merge" -if ! ids="$(gh cache list --ref "$ref" --limit 100 --json id --jq '.[].id' 2>&1)"; then +if ! ids="$(gh cache list --ref "$ref" --limit 1000 --json id --jq '.[].id' 2>&1)"; then echo "::warning title=Could not list caches::$ids" exit 0 fi [[ -n "$ids" ]] || { echo "no caches under $ref"; exit 0; } +count="$(wc -l <<< "$ids" | tr -d ' ')" +[[ "$count" -lt 1000 ]] || echo "::warning title=More caches may remain::listed $count caches, the most the listing returns; run again to delete the rest" + for id in $ids; do [[ "$id" =~ ^[0-9]+$ ]] || continue if gh cache delete "$id" >/dev/null 2>&1; then echo "deleted cache $id of $ref" diff --git a/AGENTS.md b/AGENTS.md index ea616fce..ddd42bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ Workflow files and names, in `.github/workflows/`: - `release-sync.yml`, "Release sync": develop to main sync; its script is `.github/scripts/release/sync-release.sh`. - `publish-crates.yml`, "Publish to crates.io": the tag release, started by `workflow_run` once the renderer workflow has succeeded, or by hand with `workflow_dispatch` and an existing `v*` tag to re-run a failed publish. - `pages.yml`, "Pages": docs. +- `cache-cleanup.yml`, "Cache cleanup": deletes a closed pull request's Actions caches. - `test-.yml`, " test": a special check for one target. - `test-graalvm-renderer.yml`, "GraalVM renderer test": builds and smoke tests the GraalVM native-image renderer on macOS and Linux. It exists until the window code moves to the fork, then moves to the fork's CI and is deleted here. - `heavy-checks.yml`, "Heavy checks", job "Heavy checks passed on this head": the merge gate for the heavy workflows below. diff --git a/scripts/tests/delete-pr-caches.test.sh b/scripts/tests/delete-pr-caches.test.sh index 84b8080f..89c8369d 100755 --- a/scripts/tests/delete-pr-caches.test.sh +++ b/scripts/tests/delete-pr-caches.test.sh @@ -38,6 +38,9 @@ done wf="$repo_root/.github/workflows/cache-cleanup.yml" grep -q 'types: \[closed\]' "$wf" && ok "runs on closed pull requests" || bad "trigger" grep -q 'actions: write' "$wf" && ! grep -qE 'contents: write|pull-requests: write' "$wf" && ok "only actions: write" || bad "permissions" +grep -q 'base.sha' "$wf" && ok "checks out the base commit's script" || bad "checkout is not base.sha" +! grep -q 'pull_request_target' "$wf" && ok "no pull_request_target" || bad "pull_request_target" +grep -q -- "--limit 1000" "$script" && ok "lists up to 1000 caches" || bad "list limit" ! grep -qE 'run:.*\$\{\{' "$wf" && ok "no expression inside a run line" || bad "expression in run" [[ "$failures" -eq 0 ]] || exit 1 echo "all passed" From 855aa2ca92013f39a44f355f808e3f1c322857bc Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:35:30 +0900 Subject: [PATCH 159/224] Chore: Pull request runs only restore caches; the nightly on main saves them Every pull request saved its own ~8 GB of Kotlin and Gradle caches under its own ref, so two of them filled the 10 GB limit and the Windows caches never survived. Each actions/cache step is now a restore plus a save step at the end of the job that is off on pull requests, setup-gradle is read-only there, and test.yml gets a nightly run on main (18:00 UTC) so its caches exist on the default branch, which every pull request may read. A test reads the workflows and checks it. --- .github/workflows/test-compose-from-fork.yml | 2 +- .github/workflows/test-graalvm-renderer.yml | 2 +- .github/workflows/test.yml | 6 +++ scripts/tests/cache-pr-restore-only.test.sh | 43 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100755 scripts/tests/cache-pr-restore-only.test.sh diff --git a/.github/workflows/test-compose-from-fork.yml b/.github/workflows/test-compose-from-fork.yml index 24e110ed..af644b7e 100644 --- a/.github/workflows/test-compose-from-fork.yml +++ b/.github/workflows/test-compose-from-fork.yml @@ -161,7 +161,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: false + cache-read-only: ${{ github.event_name == 'pull_request' }} gradle-home-cache-excludes: | caches/build-cache-1 diff --git a/.github/workflows/test-graalvm-renderer.yml b/.github/workflows/test-graalvm-renderer.yml index c99d930f..bf73a4d5 100644 --- a/.github/workflows/test-graalvm-renderer.yml +++ b/.github/workflows/test-graalvm-renderer.yml @@ -276,7 +276,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: false + cache-read-only: ${{ github.event_name == 'pull_request' }} gradle-home-cache-excludes: | caches/build-cache-1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1be4669f..bf8c12f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,12 @@ on: # and running again on the push tested the identical tree and doubled the actions page. pull_request: workflow_dispatch: + # Nightly on the default branch, so the Kotlin, Gradle and cargo caches exist on main, which + # every pull request may read. Pull request runs only restore caches; this run saves them. + # 18:00 UTC is 03:00 in Korea, when the team's own runs are fewest; if it meets a heavy run + # it waits for a macOS runner, it does not fail. + schedule: + - cron: '0 18 * * *' concurrency: # One run per pull request: a new commit cancels the older run. Pushes to develop, diff --git a/scripts/tests/cache-pr-restore-only.test.sh b/scripts/tests/cache-pr-restore-only.test.sh new file mode 100755 index 00000000..8195a9e1 --- /dev/null +++ b/scripts/tests/cache-pr-restore-only.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# A pull request run only restores caches. Saving happens on push, schedule and dispatch, +# on the default branch, where every pull request can read what it saved. A pull request that +# saved its own copy filled the repository's 10 GB limit and pushed the others out. +# +# Read from the workflow files, since no tool here runs a workflow: no step uses the combined +# actions/cache (it saves after the job whatever the event), every actions/cache/save step is +# off on pull requests, and setup-gradle is read-only there. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +python3 - "$repo_root/.github/workflows" <<'PY' +import sys, glob, yaml +failures = 0 +def bad(msg): + global failures + print("FAIL -", msg); failures += 1 +saves = 0 +for path in sorted(glob.glob(sys.argv[1] + "/*.yml")): + doc = yaml.safe_load(open(path)) + for job, body in (doc.get("jobs") or {}).items(): + for step in body.get("steps") or []: + uses = str(step.get("uses", "")) + where = f"{path.split('/')[-1]}:{job}:{step.get('name', uses)}" + if uses.startswith("actions/cache@"): + bad(f"{where} uses the combined actions/cache, which saves on pull requests") + if uses.startswith("actions/cache/save@"): + saves += 1 + cond = str(step.get("if", "")) + if "github.event_name != 'pull_request'" not in cond: + bad(f"{where} can save on a pull request: if: {cond!r}") + if uses.startswith("gradle/actions/setup-gradle@"): + ro = str(step.get("with", {}).get("cache-read-only", "")) + if "pull_request" not in ro: + bad(f"{where} is not read-only on pull requests: {ro!r}") +if saves == 0: + bad("no actions/cache/save step found; the check is looking at nothing") +if failures: + sys.exit(1) +print(f"ok - {saves} save steps are off on pull requests, no combined cache, gradle read-only there") +PY +rc=$? +[[ $rc -eq 0 ]] && echo "all passed" +exit $rc From a7b1517790370ab0280fd9376e9e8401c2997603 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:37:39 +0900 Subject: [PATCH 160/224] Test: Every cache restore needs a save with both guards and its own primary key --- scripts/tests/cache-pr-restore-only.test.sh | 30 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/tests/cache-pr-restore-only.test.sh b/scripts/tests/cache-pr-restore-only.test.sh index 8195a9e1..25cbd38b 100755 --- a/scripts/tests/cache-pr-restore-only.test.sh +++ b/scripts/tests/cache-pr-restore-only.test.sh @@ -15,23 +15,49 @@ def bad(msg): global failures print("FAIL -", msg); failures += 1 saves = 0 +import re for path in sorted(glob.glob(sys.argv[1] + "/*.yml")): doc = yaml.safe_load(open(path)) for job, body in (doc.get("jobs") or {}).items(): - for step in body.get("steps") or []: + steps = body.get("steps") or [] + restore_ids, saved_ids = {}, set() + for pos, step in enumerate(steps): uses = str(step.get("uses", "")) where = f"{path.split('/')[-1]}:{job}:{step.get('name', uses)}" if uses.startswith("actions/cache@"): bad(f"{where} uses the combined actions/cache, which saves on pull requests") + if uses.startswith("actions/cache/restore@"): + if not step.get("id"): + bad(f"{where} has no id, so nothing can save its key") + else: + restore_ids[step["id"]] = (where, pos) if uses.startswith("actions/cache/save@"): saves += 1 - cond = str(step.get("if", "")) + cond = " ".join(str(step.get("if", "")).split()) if "github.event_name != 'pull_request'" not in cond: bad(f"{where} can save on a pull request: if: {cond!r}") + m = re.search(r"steps\.([\w-]+)\.outputs\.cache-hit != 'true'", cond) + if not m: + bad(f"{where} does not require cache-hit != 'true': if: {cond!r}") + if "||" in cond.replace("(", "").replace(")", "") and "||" in re.sub(r"\([^()]*\)", "", cond): + bad(f"{where} has a top-level || in its condition, which can bypass the guards: {cond!r}") + key = str(step.get("with", {}).get("key", "")) + k = re.search(r"steps\.([\w-]+)\.outputs\.cache-primary-key", key) + if not k: + bad(f"{where} does not save under the restore step's cache-primary-key: {key!r}") + elif m and k.group(1) != m.group(1): + bad(f"{where} checks {m.group(1)} but saves {k.group(1)}'s key") + if k: + saved_ids.add(k.group(1)) + if k.group(1) in restore_ids and restore_ids[k.group(1)][1] > pos: + bad(f"{where} comes before its restore step") if uses.startswith("gradle/actions/setup-gradle@"): ro = str(step.get("with", {}).get("cache-read-only", "")) if "pull_request" not in ro: bad(f"{where} is not read-only on pull requests: {ro!r}") + for rid, (where, _) in restore_ids.items(): + if rid not in saved_ids: + bad(f"{where} is restored but never saved by a step in its job") if saves == 0: bad("no actions/cache/save step found; the check is looking at nothing") if failures: From 527ab6f33dd0754f3826b7d943c6e29c4b69bfff Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:40:52 +0900 Subject: [PATCH 161/224] Docs: Say why one unreadable path fails the whole file dialog choice, with a hint for the app (FR-72, #483) --- docs/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index e3ba9201..e8405a3d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3906,7 +3906,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" - `Unavailable`: 이 환경에서 대화상자를 띄울 수 없습니다(72.6). `paths`는 비어 있습니다. 오류가 아니므로 `ProtocolError`가 아니고, 프로세스는 죽지 않습니다. - **요청마다 이벤트는 정확히 하나입니다.** 선택하든 취소하든 못 띄우든, 같은 `request`로 하나가 오고 둘은 오지 않습니다. 이 규칙이 있어야 Host가 대기 상태를 풀 수 있습니다. 예외는 프로세스가 끝나는 경우뿐이고, 그때는 받을 Host도 없습니다. - **`paths`는 한 문자열입니다.** 경로가 여럿이면 `FilesDropped`(FR-27)와 같이 경로 사이를 NUL(U+0000) 한 바이트로 가르며, 이 바이트는 어느 데스크톱의 경로에도 들어갈 수 없습니다. 목록을 나르는 두 번째 방법을 만들지 않습니다. -- **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 고른 경로 중 하나라도 UTF-8로 나타낼 수 없으면(Linux에서만 가능하고, macOS에서 경로를 문자열로 읽지 못하는 경우도 같습니다) **결과 전체가 `Unavailable`**이고 나머지 경로도 보내지 않습니다. 고른 개수와 돌려준 개수가 다른 `Chosen`을 앱이 알아챌 길이 없고, 취소로 보내면 사용자가 고른 것이 있었는데 거짓이기 때문입니다. 파일을 떨어뜨리는 FR-27은 건너뛰고 나머지를 전달하지만, 대화상자에서 고른 것은 사용자가 이름을 짚어 고른 것이라 규칙이 다릅니다. +- **경로는 절대 경로이고 UTF-8입니다.** 상대 경로와 심볼릭 링크를 따라간 경로가 아니라 대화상자가 돌려준 그대로입니다. 고른 경로 중 하나라도 UTF-8로 나타낼 수 없으면(Linux에서만 가능하고, macOS에서 경로를 문자열로 읽지 못하는 경우도 같습니다) **결과 전체가 `Unavailable`**이고 나머지 경로도 보내지 않습니다. 고른 개수와 돌려준 개수가 다른 `Chosen`을 앱이 알아챌 길이 없고, 취소로 보내면 사용자가 고른 것이 있었는데 거짓이기 때문입니다. 파일을 떨어뜨리는 FR-27은 건너뛰고 나머지를 전달하지만, 대화상자에서 고른 것은 사용자가 이름을 짚어 고른 것이라 규칙이 다릅니다. 사용자는 나머지 파일도 받지 못하는 대신 앱이 무엇이 잘못되었는지 말할 수 있습니다. 앱은 이 `Unavailable`을 "선택한 파일 중 이름을 읽을 수 없는 것이 있습니다. 파일 이름을 확인한 뒤 다시 선택해 주세요"처럼 파일 이름을 확인하라는 안내로 보여 주는 것이 좋습니다(대화상자를 띄울 수 없는 환경과 같은 상태값이므로 문구는 앱이 상황에 맞게 정합니다). - **긴 경로를 자르지 않습니다.** Windows의 `MAX_PATH`(260)를 넘는 경로도 `IShellItem`이 돌려주는 전체를 그대로 전달하고, 접두사(`\\?\`)는 붙이지 않습니다. 경로 한 개의 길이에 Renderer가 두는 상한은 없습니다. **한 번에 돌려주는 경로는 10000개까지입니다.** 사용자가 그보다 많이 골랐거나 이벤트의 문자열이 배치 버퍼가 감당하는 크기를 넘으면 `ProtocolError`가 아니라 **`Unavailable`**이고, 앞의 경로만 잘라 `Chosen`으로 보내지 않습니다. 사용자가 고른 것이 일부만 전달되면 앱이 그 사실을 모르기 때문입니다. - `Chosen`인데 경로가 하나도 없으면 Renderer는 `Cancelled`를 보내고, Host는 경로 없는 `Chosen`을 받으면 `Unavailable`로 다룹니다. 앱이 빈 목록을 선택으로 처리하지 않게 하려는 것입니다. - `filter`: 사용자가 마지막으로 선택한 필터의 위치입니다. 필터를 보내지 않았거나 `Chosen`이 아니면 `0xFFFF_FFFF`입니다. From 7091a753cdd15a994065fc51ebdde7faf356bc0c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:50:15 +0900 Subject: [PATCH 162/224] Docs: A file dialog filter that keeps no extension is dropped (FR-72, #483) --- docs/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index e8405a3d..647aea9d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3890,7 +3890,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 - `title`(문자열): 대화상자 제목입니다. 비면 플랫폼 기본입니다. macOS의 시트는 제목을 보이지 않으므로 `message` 자리에 씁니다. - `directory`(문자열): 처음 열 폴더입니다. 절대 경로여야 하고, 비었거나 없거나 읽을 수 없으면 플랫폼의 기본(마지막으로 쓴 폴더)입니다. 없는 폴더는 오류가 아닙니다. - `name`(문자열): `Save`에서 칸에 미리 채울 파일 이름입니다. 경로 구분자는 허용하지 않고, 있으면 마지막 구분자 뒤만 씁니다. `Open*`에서는 무시합니다. -- **필터**: `filters`는 `(label, extensions)` 목록입니다. `extensions`는 점 없는 확장자를 쉼표로 이은 문자열입니다(`"png,jpg,jpeg"`). 비교는 대소문자를 가리지 않습니다. 목록이 비면 모든 파일을 고를 수 있습니다. 필터는 **고를 수 있는 파일을 제한**하며, 폴더 선택(`OpenFolder`, `OpenFolders`)에서는 무시합니다. `default_filter: u32`는 처음 선택된 필터의 위치이고 범위 밖이면 0입니다. 필터는 16개까지, 필터 하나의 확장자는 32개까지이며 넘으면 넘는 부분을 버립니다. 오류가 아닙니다. +- **필터**: `filters`는 `(label, extensions)` 목록입니다. `extensions`는 점 없는 확장자를 쉼표로 이은 문자열입니다(`"png,jpg,jpeg"`). 비교는 대소문자를 가리지 않습니다. 목록이 비면 모든 파일을 고를 수 있습니다. 필터는 **고를 수 있는 파일을 제한**하며, 폴더 선택(`OpenFolder`, `OpenFolders`)에서는 무시합니다. `default_filter: u32`는 처음 선택된 필터의 위치이고 범위 밖이면 0입니다. 필터는 16개까지, 필터 하나의 확장자는 32개까지이며 넘으면 넘는 부분을 버립니다. 오류가 아닙니다. 확장자를 적었는데 정리하고 나면 하나도 남지 않는 필터(`"."`만 적은 경우)는 "모든 파일"이 되면 요청과 반대이므로 그 필터를 목록에서 뺍니다. `default_filter`는 빠진 필터를 센 원래 위치로 해석하고 같은 필터를 계속 가리킵니다. - `Save`는 **덮어쓰기 확인을 항상 플랫폼 대화상자가 합니다.** 이미 있는 파일을 고르면 플랫폼이 묻고, 사용자가 허락한 경우에만 결과가 옵니다. Host가 끌 수 있는 값은 없습니다. 확인 문구와 동작은 플랫폼의 것이어야 사용자가 아는 대화상자와 같기 때문입니다. Renderer는 파일을 만들거나 덮어쓰지 않고 경로만 돌려줍니다. - `Save`에서 사용자가 확장자 없는 이름을 입력하고 필터가 하나만 선택되어 있으면 플랫폼의 관례에 따라 그 필터의 첫 확장자를 붙일 수 있습니다. 어느 필터가 쓰였는지는 결과의 `filter`로 알립니다. From 1739b787b204776f9749fd06c904ba23ce145a6b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:50:15 +0900 Subject: [PATCH 163/224] Fix: Drop a file dialog filter whose extensions all clean to nothing (FR-72) A filter of only dots would have meant every file, the opposite of the request. The default filter follows its filter when an earlier one is dropped. Refs #483 --- .../desktop/src/renderer/FileDialogHost.kt | 25 +++++++++++++------ renderer/desktop/test/FileDialogTest.kt | 6 ++++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/renderer/desktop/src/renderer/FileDialogHost.kt b/renderer/desktop/src/renderer/FileDialogHost.kt index ca4942eb..06702f11 100644 --- a/renderer/desktop/src/renderer/FileDialogHost.kt +++ b/renderer/desktop/src/renderer/FileDialogHost.kt @@ -342,7 +342,8 @@ class FileDialogCenter( */ fun normalize(mutation: Mutation.ShowFileDialog): FileDialogSpec { val folders = mutation.kind == FileDialogKind.OpenFolder || mutation.kind == FileDialogKind.OpenFolders - val filters = if (folders) emptyList() else parseFilters(mutation.filters) + val parsed = if (folders) emptyList() else parseFilters(mutation.filters) + val filters = parsed.map { it.second } val name = if (mutation.kind == FileDialogKind.Save) { mutation.name.substringAfterLast('/').substringAfterLast('\\') } else { @@ -356,24 +357,32 @@ class FileDialogCenter( directory = mutation.directory, name = name, filters = filters, - defaultFilter = mutation.defaultFilter.takeIf { it in filters.indices } ?: 0, + // The default is a position in the list the Host sent, so it follows its filter + // when an earlier one was dropped. + defaultFilter = parsed.indexOfFirst { it.first == mutation.defaultFilter }.takeIf { it >= 0 } ?: 0, ) } - private fun parseFilters(encoded: String): List { + private fun parseFilters(encoded: String): List> { if (encoded.isEmpty()) return emptyList() // Label, extensions, label, extensions. A label with nothing after it lets every // file through, like one with no extensions. val parts = encoded.split('\u0000') - val filters = ArrayList() + val filters = ArrayList>() var index = 0 - while (index < parts.size && filters.size < FileDialogLimits.MAX_FILTERS) { - val extensions = (parts.getOrNull(index + 1) ?: "") - .split(',') + var position = 0 + while (index < parts.size && position < FileDialogLimits.MAX_FILTERS) { + val named = (parts.getOrNull(index + 1) ?: "").split(',').filter { it.isNotBlank() } + val extensions = named .map { it.trim().removePrefix("*.").removePrefix(".").lowercase() } .filter { it.isNotEmpty() } .take(FileDialogLimits.MAX_EXTENSIONS) - filters += FileDialogFilter(parts[index], extensions) + // A filter that names extensions and keeps none of them (a lone ".") would + // otherwise mean every file, the opposite of what it asked for. + if (named.isEmpty() || extensions.isNotEmpty()) { + filters += position to FileDialogFilter(parts[index], extensions) + } + position++ index += 2 } return filters diff --git a/renderer/desktop/test/FileDialogTest.kt b/renderer/desktop/test/FileDialogTest.kt index 60bc310b..26c40200 100644 --- a/renderer/desktop/test/FileDialogTest.kt +++ b/renderer/desktop/test/FileDialogTest.kt @@ -171,10 +171,11 @@ class FileDialogTest { host.ask(3, kind = FileDialogKind.OpenFolder, filters = encoded) host.ask(4, kind = FileDialogKind.Save, name = "dir/sub\\report.txt", filters = "Text\u0000TXT") host.ask(5, kind = FileDialogKind.OpenFile, name = "ignored.txt") + host.ask(6, defaultFilter = 2, filters = "Dots\u0000.,*.\u0000All\u0000\u0000Txt\u0000txt") host.frame() assertTrue(connection.errorCodes().isEmpty(), "trimming is not an error") - assertEquals(5, platform.shown.size, "every request is shown") + assertEquals(6, platform.shown.size, "every request is shown") val first = platform.forRequest(1).spec assertEquals(16, first.filters.size) assertEquals("F0", first.filters.first().label) @@ -183,6 +184,9 @@ class FileDialogTest { assertEquals(listOf("ext0", "ext1", "ext2"), first.filters.first().extensions.take(3)) assertEquals("ext31", first.filters.first().extensions.last()) assertEquals(0, first.defaultFilter, "a default filter out of range is the first") + val dots = platform.forRequest(6).spec + assertEquals(listOf("All", "Txt"), dots.filters.map { it.label }, "a filter of only dots is dropped, an empty one is kept") + assertEquals(1, dots.defaultFilter, "the default follows its filter after an earlier one was dropped") assertEquals(3, platform.forRequest(2).spec.defaultFilter) assertTrue(platform.forRequest(3).spec.filters.isEmpty(), "a folder dialog has no filters") assertEquals("report.txt", platform.forRequest(4).spec.name) From 2bb2f029fe8bf41532f4b7e3369b188890f141b1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:51:03 +0900 Subject: [PATCH 164/224] Test: Read the workflows as text so the cache and macOS rule tests run on macOS too PyYAML is not on the macOS runner: the cache test failed there and the macOS rule test's wiring check skipped silently. Both read the workflow files line by line now. --- scripts/tests/cache-pr-restore-only.test.sh | 101 +++++++++++++------- scripts/tests/needs-macos.test.sh | 25 ++--- 2 files changed, 73 insertions(+), 53 deletions(-) diff --git a/scripts/tests/cache-pr-restore-only.test.sh b/scripts/tests/cache-pr-restore-only.test.sh index 25cbd38b..a9441e2a 100755 --- a/scripts/tests/cache-pr-restore-only.test.sh +++ b/scripts/tests/cache-pr-restore-only.test.sh @@ -4,65 +4,98 @@ # saved its own copy filled the repository's 10 GB limit and pushed the others out. # # Read from the workflow files, since no tool here runs a workflow: no step uses the combined -# actions/cache (it saves after the job whatever the event), every actions/cache/save step is -# off on pull requests, and setup-gradle is read-only there. +# actions/cache (it saves after the job whatever the event), every restore has a save in its +# job that needs both "not a pull request" and "no cache hit" and saves under that restore's +# primary key, and setup-gradle is read-only on pull requests. +# +# The workflows are read line by line with the standard library only: PyYAML is not on every +# runner (the macOS one lacks it), and a test that skips without it checks nothing there. set -uo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -python3 - "$repo_root/.github/workflows" <<'PY' -import sys, glob, yaml +command -v python3 >/dev/null || { echo "FAIL - python3 is needed to read the workflows"; exit 1; } +python3 -I - "$repo_root/.github/workflows" <<'PY' +import glob, re, sys + failures = 0 def bad(msg): global failures print("FAIL -", msg); failures += 1 + +def steps_of(lines): + """Yields (job, step lines) for every step of every job: steps sit at six spaces.""" + job, current = None, None + for line in lines + ["x:"]: + m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if m: + if current: yield job, current + job, current = m.group(1), None + continue + if re.match(r"^ -( |$)", line): + if current: yield job, current + current = [line] + elif current is not None: + if line.strip() and not line.startswith(" ") and not line.lstrip().startswith("#"): + yield job, current + current = None + else: + current.append(line) + if current: yield job, current + +def field(step, name): + for line in step: + m = re.match(rf"^(?: - | ){name}:\s*(.*)$", line) + if m: return m.group(1).strip() + return "" + +def with_field(step, name): + for line in step: + m = re.match(rf"^ {name}:\s*(.*)$", line) + if m: return m.group(1).strip() + return "" + saves = 0 -import re for path in sorted(glob.glob(sys.argv[1] + "/*.yml")): - doc = yaml.safe_load(open(path)) - for job, body in (doc.get("jobs") or {}).items(): - steps = body.get("steps") or [] + lines = open(path).read().split("\n") + per_job = {} + for job, step in steps_of(lines): + per_job.setdefault(job, []).append(step) + for job, steps in per_job.items(): restore_ids, saved_ids = {}, set() for pos, step in enumerate(steps): - uses = str(step.get("uses", "")) - where = f"{path.split('/')[-1]}:{job}:{step.get('name', uses)}" + uses = field(step, "uses") + name = field(step, "name") or uses + where = f"{path.split('/')[-1]}:{job}:{name}" if uses.startswith("actions/cache@"): bad(f"{where} uses the combined actions/cache, which saves on pull requests") if uses.startswith("actions/cache/restore@"): - if not step.get("id"): - bad(f"{where} has no id, so nothing can save its key") - else: - restore_ids[step["id"]] = (where, pos) + sid = field(step, "id") + if not sid: bad(f"{where} has no id, so nothing can save its key") + else: restore_ids[sid] = (where, pos) if uses.startswith("actions/cache/save@"): saves += 1 - cond = " ".join(str(step.get("if", "")).split()) + cond = " ".join(field(step, "if").split()) if "github.event_name != 'pull_request'" not in cond: bad(f"{where} can save on a pull request: if: {cond!r}") - m = re.search(r"steps\.([\w-]+)\.outputs\.cache-hit != 'true'", cond) - if not m: - bad(f"{where} does not require cache-hit != 'true': if: {cond!r}") - if "||" in cond.replace("(", "").replace(")", "") and "||" in re.sub(r"\([^()]*\)", "", cond): + hit = re.search(r"steps\.([\w-]+)\.outputs\.cache-hit != 'true'", cond) + if not hit: bad(f"{where} does not require cache-hit != 'true': if: {cond!r}") + if "||" in re.sub(r"\([^()]*\)", "", cond): bad(f"{where} has a top-level || in its condition, which can bypass the guards: {cond!r}") - key = str(step.get("with", {}).get("key", "")) + key = with_field(step, "key") k = re.search(r"steps\.([\w-]+)\.outputs\.cache-primary-key", key) - if not k: - bad(f"{where} does not save under the restore step's cache-primary-key: {key!r}") - elif m and k.group(1) != m.group(1): - bad(f"{where} checks {m.group(1)} but saves {k.group(1)}'s key") + if not k: bad(f"{where} does not save under a restore step's cache-primary-key: {key!r}") + elif hit and k.group(1) != hit.group(1): bad(f"{where} checks {hit.group(1)} but saves {k.group(1)}'s key") if k: saved_ids.add(k.group(1)) if k.group(1) in restore_ids and restore_ids[k.group(1)][1] > pos: bad(f"{where} comes before its restore step") if uses.startswith("gradle/actions/setup-gradle@"): - ro = str(step.get("with", {}).get("cache-read-only", "")) - if "pull_request" not in ro: - bad(f"{where} is not read-only on pull requests: {ro!r}") + ro = with_field(step, "cache-read-only") + if "pull_request" not in ro: bad(f"{where} is not read-only on pull requests: {ro!r}") for rid, (where, _) in restore_ids.items(): - if rid not in saved_ids: - bad(f"{where} is restored but never saved by a step in its job") -if saves == 0: - bad("no actions/cache/save step found; the check is looking at nothing") -if failures: - sys.exit(1) -print(f"ok - {saves} save steps are off on pull requests, no combined cache, gradle read-only there") + if rid not in saved_ids: bad(f"{where} is restored but never saved by a step in its job") +if saves < 10: bad(f"only {saves} save steps found; the reader is looking at nothing") +if failures: sys.exit(1) +print(f"ok - {saves} save steps are guarded, no combined cache, gradle read-only on pull requests") PY rc=$? [[ $rc -eq 0 ]] && echo "all passed" diff --git a/scripts/tests/needs-macos.test.sh b/scripts/tests/needs-macos.test.sh index 2c825fab..29a3bf2c 100755 --- a/scripts/tests/needs-macos.test.sh +++ b/scripts/tests/needs-macos.test.sh @@ -27,26 +27,13 @@ check true "the macOS renderer" renderer/macos/src/Main.kt check true "a root file nobody listed" Cargo.toml check true "a script under scripts/" scripts/check.sh check true "Windows desktop sources the JVM job compiles" renderer/desktop/src/NativeWindowShell.kt -# Wiring: test.yml must be valid, and the macOS legs must leave the matrices. +# Wiring: test.yml must be valid, and the macOS legs must leave the matrices. Read as text +# (PyYAML is not on every runner, and a check that skips there proves nothing). wf="$repo_root/.github/workflows/test.yml" -if python3 -c 'import yaml' 2>/dev/null; then - if python3 - "$wf" <<'PY' -import sys, yaml -d = yaml.safe_load(open(sys.argv[1])) -jobs = d["jobs"] -bad = [n for n, j in jobs.items() if "matrix." in str(j.get("if", ""))] -assert not bad, f"job-level if reads the matrix in {bad}: GitHub rejects the workflow" -for n in ("rust", "kotlin", "shell"): - j = jobs[n] - needs = j["needs"] if isinstance(j["needs"], list) else [j["needs"]] - assert "changed" in needs, f"{n} does not wait for changed" - assert "fromJSON(needs.changed.outputs" in str(j["strategy"]["matrix"]), f"{n} matrix is not built by changed" -PY - then echo "ok - test.yml wiring: no matrix in job ifs, matrices come from changed" - else echo "FAIL - test.yml wiring"; failures=$((failures + 1)); fi -else - echo "skip - no PyYAML for the wiring check" -fi +# A job-level if sits at four spaces; GitHub rejects one that reads the matrix. +if grep -E '^ if:.*matrix\.' "$wf" >/dev/null; then echo "FAIL - a job-level if reads the matrix"; failures=$((failures + 1)); else echo "ok - no job-level if reads the matrix"; fi +from_json="$(grep -c 'fromJSON(needs.changed.outputs' "$wf")" +[[ "$from_json" -ge 3 ]] && echo "ok - rust, kotlin and shell take their matrix from changed" || { echo "FAIL - only $from_json matrices come from changed"; failures=$((failures + 1)); } # Renames: the old path counts, as the workflow lists it with --no-renames. tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT From bec76092a3907bf9b9f07fa2fd5c5cc9800c9fba Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:51:14 +0900 Subject: [PATCH 165/224] Test: Read the native workflow as text so the wiring check runs on macOS too --- scripts/tests/native-needs-macos.test.sh | 29 ++++++++++-------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/scripts/tests/native-needs-macos.test.sh b/scripts/tests/native-needs-macos.test.sh index c9ab8729..f63c1b95 100755 --- a/scripts/tests/native-needs-macos.test.sh +++ b/scripts/tests/native-needs-macos.test.sh @@ -22,24 +22,19 @@ check true "mixed: Windows and shared" renderer/windows/src/Main.kt renderer/lib check true "a quoted name" '"renderer/windows/\303\244.kt"' out="$("$script" < /dev/null)"; [[ "$out" == "macos=true" ]] && echo "ok - no file list runs macOS" || { echo "FAIL - empty: $out"; failures=$((failures + 1)); } -# Wiring: no matrix in a job-level if, and the macOS jobs wait on the changed job's output. +# Wiring, read as text (PyYAML is not on every runner, and a check that skips there proves +# nothing): no job-level if reads the matrix, the macOS jobs wait on the changed job's +# decision, and the single-executable matrix comes from it. wf="$repo_root/.github/workflows/test-native-renderer.yml" -if python3 -c 'import yaml' 2>/dev/null; then - if python3 - "$wf" <<'PY' -import sys, yaml -jobs = yaml.safe_load(open(sys.argv[1]))["jobs"] -bad = [n for n, j in jobs.items() if "matrix." in str(j.get("if", ""))] -assert not bad, f"job-level if reads the matrix in {bad}" -for n in ("macos-static", "ios"): - assert "needs.changed.outputs.macos != 'false'" in jobs[n]["if"], f"{n} ignores the macOS decision" -assert "fromJSON(needs.changed.outputs.executables)" in str(jobs["single-executable"]["strategy"]["matrix"]) -for n in ("linux-static", "linux-arm64-static", "windows"): - assert "macos" not in str(jobs[n].get("if", "")), f"{n} must not depend on the macOS decision" -PY - then echo "ok - workflow wiring"; else echo "FAIL - workflow wiring"; failures=$((failures + 1)); fi -else - echo "skip - no PyYAML for the wiring check" -fi +check_wf() { # description pattern expected(yes|no) + if grep -qE "$2" "$wf"; then found=yes; else found=no; fi + if [[ "$found" == "$3" ]]; then echo "ok - $1"; else echo "FAIL - $1"; failures=$((failures + 1)); fi +} +check_wf "no job-level if reads the matrix" '^ if:.*matrix\.' no +check_wf "macos-static follows the macOS decision" "needs.changed.outputs.renderer == 'true' && needs.changed.outputs.macos != 'false'" yes +check_wf "single-executable takes its matrix from changed" 'fromJSON\(needs.changed.outputs.executables\)' yes +[[ "$(grep -c "needs.changed.outputs.macos != 'false'" "$wf")" -eq 2 ]] && echo "ok - exactly macos-static and ios follow it" || { echo "FAIL - the macOS decision is used by other jobs than macos-static and ios"; failures=$((failures + 1)); } + # The macOS Kotlin/Native renderer test is not started by the Windows build's own files. kn="$repo_root/.github/workflows/test-kn-desktop-renderer.yml" for excluded in build-windows.sh '*.ps1' windows-build-contract.test.sh 'windows-metadata/**'; do From c0a5ee9cc8ecd9af6191e0b12c7c4f36badec21a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:54:56 +0900 Subject: [PATCH 166/224] Fix: The accessibility click action ignores disabled and not enabled nodes (FR-71) A node with Enabled false or the disabled state no longer registers the click action, so a disabled button cannot be pressed through the accessibility tree. Adds fr71_click_action_does_not_fire_on_a_disabled_node and fr71_click_action_does_not_fire_on_an_enabled_false_node (both fail on the old code) and re-wraps a long generated comment. --- .../android-kotlin/shared/Accessibility.kt | 5 +- .../android-kotlin/shared/Protocol.gen.kt | 4 +- compose-rust/src/codegen.rs | 4 +- renderer/desktop/src/protocol/Protocol.gen.kt | 4 +- .../desktop/src/renderer/Accessibility.kt | 5 +- .../desktop/test/AccessibilityModifierTest.kt | 49 +++++++++++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/compose-rust/android-kotlin/shared/Accessibility.kt b/compose-rust/android-kotlin/shared/Accessibility.kt index 9f20e39a..d9b4abdc 100644 --- a/compose-rust/android-kotlin/shared/Accessibility.kt +++ b/compose-rust/android-kotlin/shared/Accessibility.kt @@ -95,7 +95,10 @@ internal fun Modifier.accessibility( declaredRole?.word(), expanded?.let { if (it) "expanded" else "collapsed" }, ).joinToString(", ").takeIf { it.isNotEmpty() } - val handler = clickHandlerId.takeIf { state and CLICK_ACTION_BIT != 0 } + // A disabled node is not pressed, whichever way it was disabled: by this modifier or by + // the node's own enabled property that the ordinary click path honours. + val pressable = isDisabled != true && node.flag(PropertyKind.Enabled, default = true) + val handler = clickHandlerId.takeIf { pressable && state and CLICK_ACTION_BIT != 0 } return semantics { if (name != null) contentDescription = name if (role != null) this.role = role diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 11b129b9..54ee124b 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -1494,8 +1494,8 @@ object Protocol { TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the - // mode and the last window policy are the nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 5a49b3fe..73ba0649 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -758,8 +758,8 @@ object Protocol { TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the - // mode and the last window policy are the nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 11b129b9..54ee124b 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -1494,8 +1494,8 @@ object Protocol { TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the - // mode and the last window policy are the nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) diff --git a/renderer/desktop/src/renderer/Accessibility.kt b/renderer/desktop/src/renderer/Accessibility.kt index 9f20e39a..d9b4abdc 100644 --- a/renderer/desktop/src/renderer/Accessibility.kt +++ b/renderer/desktop/src/renderer/Accessibility.kt @@ -95,7 +95,10 @@ internal fun Modifier.accessibility( declaredRole?.word(), expanded?.let { if (it) "expanded" else "collapsed" }, ).joinToString(", ").takeIf { it.isNotEmpty() } - val handler = clickHandlerId.takeIf { state and CLICK_ACTION_BIT != 0 } + // A disabled node is not pressed, whichever way it was disabled: by this modifier or by + // the node's own enabled property that the ordinary click path honours. + val pressable = isDisabled != true && node.flag(PropertyKind.Enabled, default = true) + val handler = clickHandlerId.takeIf { pressable && state and CLICK_ACTION_BIT != 0 } return semantics { if (name != null) contentDescription = name if (role != null) this.role = role diff --git a/renderer/desktop/test/AccessibilityModifierTest.kt b/renderer/desktop/test/AccessibilityModifierTest.kt index 70e9d255..5d931fd0 100644 --- a/renderer/desktop/test/AccessibilityModifierTest.kt +++ b/renderer/desktop/test/AccessibilityModifierTest.kt @@ -399,4 +399,53 @@ class AccessibilityModifierTest { assertTrue(!config.contains(SemanticsActions.OnClick), "a node without a handler has no click action") } } + + /** + * Text is not a pressable container, so only the accessibility modifier can register a + * click action on it. Performs the action when the node has one and returns the clicks. + */ + private fun clicksFromAccessibilityOn(extra: List, state: Int): List { + val text = 1 + var clicks = emptyList() + runComposeUiTest { + val connection = FakeHostConnection( + listOf( + Mutation.Create(text, WidgetKind.Text), + Mutation.SetProp(text, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + Mutation.SetModifier(text, 0, ProtocolModifier.Semantics(SemanticsRole.MenuItem, state)), + ) + extra, + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + val config = onNodeWithTag(nodeTestTag(text)).fetchSemanticsNode().config + if (config.contains(SemanticsActions.OnClick)) { + onNodeWithTag(nodeTestTag(text)).performSemanticsAction(SemanticsActions.OnClick) + waitForIdle() + } + clicks = connection.events.filterIsInstance() + } + return clicks + } + + @Test + fun fr71_click_action_does_not_fire_on_a_disabled_node() { + val clickAction = 1 shl 11 + val disabled = 2 shl 6 + val enabledClicks = clicksFromAccessibilityOn(emptyList(), clickAction) + assertEquals(listOf(HostEvent.Clicked(1, SOME_HANDLER)), enabledClicks, "control: the enabled node clicks once") + val clicks = clicksFromAccessibilityOn(emptyList(), clickAction or disabled) + assertEquals(emptyList(), clicks, "a node disabled by its semantics state is not clicked") + } + + @Test + fun fr71_click_action_does_not_fire_on_an_enabled_false_node() { + val clickAction = 1 shl 11 + val enabledClicks = clicksFromAccessibilityOn(emptyList(), clickAction) + assertEquals(listOf(HostEvent.Clicked(1, SOME_HANDLER)), enabledClicks, "control: the enabled node clicks once") + val clicks = clicksFromAccessibilityOn( + listOf(Mutation.SetProp(1, PropertyKind.Enabled, PropertyValue.Bool(false))), + clickAction, + ) + assertEquals(emptyList(), clicks, "a node whose enabled property is false is not clicked") + } } From fa0a6130ae569628d3599d6b0e86b01e4cafec06 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:55:23 +0900 Subject: [PATCH 167/224] Fix: A WindowDrag(false) child excludes its press from the WindowDrag(true) region (#482) The drag region's enabled branch skips drag() when the press was marked by a no-drag node, the way the Windows and Linux caption handling already compares it. The marker is two Long fields, so a press allocates nothing. Specification FR-19 criterion 4. Tests: fr19_no_drag_child_does_not_start_the_window_drag, fr19_press_on_the_empty_drag_region_starts_the_window_drag. --- .../android-kotlin/shared/ComposeRustHost.kt | 13 ++- .../android-kotlin/shared/ModifierChain.kt | 8 +- .../desktop/src/renderer/ComposeRustHost.kt | 13 ++- .../desktop/src/renderer/ModifierChain.kt | 8 +- .../desktop/test/WindowDragExclusionTest.kt | 81 +++++++++++++++++++ 5 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 renderer/desktop/test/WindowDragExclusionTest.kt diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 455ca32c..adcb7e35 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -78,7 +78,16 @@ import androidx.compose.runtime.getValue import androidx.compose.foundation.isSystemInDarkTheme import dev.darkpyonix.composerust.design.HostPlatform -internal class WindowDragExclusion { var downKey: Pair? = null } +/** + * The press a `WindowDrag(false)` node saw, as pointer id and uptime. Two fields rather than + * a pair, so marking a press allocates nothing. + */ +internal class WindowDragExclusion { + var downId: Long = -1L + var downUptime: Long = -1L + fun mark(id: Long, uptime: Long) { downId = id; downUptime = uptime } + fun isMarked(id: Long, uptime: Long): Boolean = downId == id && downUptime == uptime +} internal val LocalWindowDragExclusion = staticCompositionLocalOf { null } /** @@ -598,7 +607,7 @@ private fun ComposeRustTree( val dragStrip = if (caption.fullSize && caption.height > 0.dp) Modifier.pointerInput(caption.height, dragWindow) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed && dragExclusion.downKey != (down.id.value to down.uptimeMillis) && down.position.y < caption.height.toPx()) { + if (!down.isConsumed && !dragExclusion.isMarked(down.id.value, down.uptimeMillis) && down.position.y < caption.height.toPx()) { while (true) { val change = awaitPointerEvent(PointerEventPass.Final).changes.firstOrNull { it.id == down.id } ?: break if (change.isConsumed || !change.pressed) break diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 4b6828c8..8d69ec5c 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -164,13 +164,17 @@ internal fun List.toComposeModifier( awaitEachGesture { if (value.enabled) { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed) { + // A no-drag node under the press has marked it already (children see the + // Main pass first), so the window stays where it is. + val excludedHere = exclusion?.isMarked(down.id.value, down.uptimeMillis) == true + if (!down.isConsumed && !excludedHere) { down.consume() drag() } } else { // Mark this down for the strip without consuming it, so ancestors can click. - val excluded = awaitFirstDown(requireUnconsumed = false); exclusion?.downKey = excluded.id.value to excluded.uptimeMillis + val excluded = awaitFirstDown(requireUnconsumed = false) + exclusion?.mark(excluded.id.value, excluded.uptimeMillis) } } } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 455ca32c..adcb7e35 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -78,7 +78,16 @@ import androidx.compose.runtime.getValue import androidx.compose.foundation.isSystemInDarkTheme import dev.darkpyonix.composerust.design.HostPlatform -internal class WindowDragExclusion { var downKey: Pair? = null } +/** + * The press a `WindowDrag(false)` node saw, as pointer id and uptime. Two fields rather than + * a pair, so marking a press allocates nothing. + */ +internal class WindowDragExclusion { + var downId: Long = -1L + var downUptime: Long = -1L + fun mark(id: Long, uptime: Long) { downId = id; downUptime = uptime } + fun isMarked(id: Long, uptime: Long): Boolean = downId == id && downUptime == uptime +} internal val LocalWindowDragExclusion = staticCompositionLocalOf { null } /** @@ -598,7 +607,7 @@ private fun ComposeRustTree( val dragStrip = if (caption.fullSize && caption.height > 0.dp) Modifier.pointerInput(caption.height, dragWindow) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed && dragExclusion.downKey != (down.id.value to down.uptimeMillis) && down.position.y < caption.height.toPx()) { + if (!down.isConsumed && !dragExclusion.isMarked(down.id.value, down.uptimeMillis) && down.position.y < caption.height.toPx()) { while (true) { val change = awaitPointerEvent(PointerEventPass.Final).changes.firstOrNull { it.id == down.id } ?: break if (change.isConsumed || !change.pressed) break diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 4b6828c8..8d69ec5c 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -164,13 +164,17 @@ internal fun List.toComposeModifier( awaitEachGesture { if (value.enabled) { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed) { + // A no-drag node under the press has marked it already (children see the + // Main pass first), so the window stays where it is. + val excludedHere = exclusion?.isMarked(down.id.value, down.uptimeMillis) == true + if (!down.isConsumed && !excludedHere) { down.consume() drag() } } else { // Mark this down for the strip without consuming it, so ancestors can click. - val excluded = awaitFirstDown(requireUnconsumed = false); exclusion?.downKey = excluded.id.value to excluded.uptimeMillis + val excluded = awaitFirstDown(requireUnconsumed = false) + exclusion?.mark(excluded.id.value, excluded.uptimeMillis) } } } diff --git a/renderer/desktop/test/WindowDragExclusionTest.kt b/renderer/desktop/test/WindowDragExclusionTest.kt new file mode 100644 index 00000000..e941a55a --- /dev/null +++ b/renderer/desktop/test/WindowDragExclusionTest.kt @@ -0,0 +1,81 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.LocalWindowDrag +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val REGION = 1 +private const val CHILD = 2 +private const val LABEL = 3 + +/** A `WindowDrag(false)` node inside a `WindowDrag(true)` region keeps the window still. */ +@OptIn(ExperimentalTestApi::class) +class WindowDragExclusionTest { + private val frames = FrameRequestSource() + + private fun mutations(childClickable: Boolean) = buildList { + add(Mutation.Create(REGION, WidgetKind.Box)) + add(Mutation.SetModifier(REGION, 0, ProtocolModifier.WindowDrag(true))) + add(Mutation.SetModifier(REGION, 1, ProtocolModifier.Size(150f, 150f))) + add(Mutation.Create(CHILD, WidgetKind.Box)) + add(Mutation.SetModifier(CHILD, 0, ProtocolModifier.WindowDrag(false))) + if (childClickable) add(Mutation.SetProp(CHILD, PropertyKind.OnClick, PropertyValue.Integer(1L))) + add(Mutation.Create(LABEL, WidgetKind.Text)) + add(Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("no drag"))) + add(Mutation.Insert(CHILD, LABEL, 0)) + add(Mutation.Insert(REGION, CHILD, 0)) + } + + @Test + fun fr19_no_drag_child_does_not_start_the_window_drag() = runComposeUiTest { + var drags = 0 + val connection = FakeHostConnection(mutations(childClickable = true)) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalWindowDrag provides { drags++ }) { + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(200.dp, 200.dp)) + } + } + onNodeWithText("no drag", useUnmergedTree = true).performMouseInput { + moveTo(center); press(); moveBy(Offset(30f, 0f)); release() + } + assertEquals(0, drags) + onNodeWithText("no drag", useUnmergedTree = true).performMouseInput { moveTo(center); press(); release() } + assertEquals(0, drags) + assertEquals(1, connection.events.filterIsInstance().size) + } + + @Test + fun fr19_press_on_the_empty_drag_region_starts_the_window_drag() = runComposeUiTest { + var drags = 0 + val connection = FakeHostConnection(mutations(childClickable = false)) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalWindowDrag provides { drags++ }) { + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(200.dp, 200.dp)) + } + } + // The child is as small as its label, so the far corner of the 150 by 150 region is empty. + onRoot().performMouseInput { moveTo(Offset(140f, 140f)); press(); release() } + assertEquals(1, drags) + } +} From 5155921a27cb17ce4b78221948cf4ebd65568060 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:34:47 +0900 Subject: [PATCH 168/224] Fix: Remember the control look, reset the drag exclusion on release, strengthen the drag tests (FR-67, #482) HostToggle and HostSlider no longer build a ControlLook on every recomposition; it is remembered on the values it reads, and a node with none of them shares ControlLook.None. The drag exclusion clears when the marked press ends. New tests: fr67_control_look_is_not_recomputed_for_unchanged_properties, fr19_a_press_that_starts_in_the_region_keeps_dragging_over_a_non_drag_child; the empty region test is renamed as a control case. --- .../android-kotlin/shared/ComposeRustHost.kt | 1 + .../android-kotlin/shared/ControlLook.kt | 67 +++++++++++++++++++ .../android-kotlin/shared/HostSlider.kt | 4 +- .../android-kotlin/shared/HostToggle.kt | 4 +- .../android-kotlin/shared/ModifierChain.kt | 5 ++ .../desktop/src/renderer/ComposeRustHost.kt | 1 + renderer/desktop/src/renderer/ControlLook.kt | 67 +++++++++++++++++++ renderer/desktop/src/renderer/HostSlider.kt | 4 +- renderer/desktop/src/renderer/HostToggle.kt | 4 +- .../desktop/src/renderer/ModifierChain.kt | 5 ++ renderer/desktop/test/ControlColoursTest.kt | 29 ++++++++ .../desktop/test/WindowDragExclusionTest.kt | 20 +++++- 12 files changed, 202 insertions(+), 9 deletions(-) diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index adcb7e35..cd304279 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -86,6 +86,7 @@ internal class WindowDragExclusion { var downId: Long = -1L var downUptime: Long = -1L fun mark(id: Long, uptime: Long) { downId = id; downUptime = uptime } + fun clear() { downId = -1L; downUptime = -1L } fun isMarked(id: Long, uptime: Long): Boolean = downId == id && downUptime == uptime } internal val LocalWindowDragExclusion = staticCompositionLocalOf { null } diff --git a/compose-rust/android-kotlin/shared/ControlLook.kt b/compose-rust/android-kotlin/shared/ControlLook.kt index 83cc0763..302318c7 100644 --- a/compose-rust/android-kotlin/shared/ControlLook.kt +++ b/compose-rust/android-kotlin/shared/ControlLook.kt @@ -1,10 +1,13 @@ package dev.darkpyonix.composerust.design +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.resolvedShape @@ -46,6 +49,70 @@ class ControlLook( } } +/** + * The look of [node], rebuilt only when something it reads has changed. + * + * A recomposition that changes none of the six properties, the corner modifier or the theme + * hands back the look it made last time, and a node that sends none of them gets the shared + * [ControlLook.None], so the steady state allocates nothing. A property that is removed + * makes its slot null, which differs from the value it held, so the look returns to the + * defaults. + */ +@Composable +internal fun rememberControlLook(node: Node, theme: ResolvedTheme): ControlLook { + val cache = remember { ControlLookCache() } + return cache.read(node, theme) +} + +private class ControlLookCache { + private var primed = false + private var theme: ResolvedTheme? = null + private var track: PropertyValue? = null + private var thumb: PropertyValue? = null + private var activeTrack: PropertyValue? = null + private var container: PropertyValue? = null + private var check: PropertyValue? = null + private var size: PropertyValue? = null + private var corner: dev.darkpyonix.composerust.protocol.Modifier? = null + private var named = false + private var look: ControlLook = ControlLook.None + + fun read(node: Node, current: ResolvedTheme): ControlLook { + val props = node.props + val newTrack = props[PropertyKind.TrackColor] + val newThumb = props[PropertyKind.ThumbColor] + val newActive = props[PropertyKind.ActiveTrackColor] + val newContainer = props[PropertyKind.ContainerColor] + val newCheck = props[PropertyKind.CheckColor] + val newSize = props[PropertyKind.ControlSize] + var newNamed = false + var newCorner: dev.darkpyonix.composerust.protocol.Modifier? = null + val modifiers = node.modifiers + for (index in modifiers.indices) { + val value = modifiers[index] + val isNamed = value is dev.darkpyonix.composerust.protocol.Modifier.Shape || + value is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + if (isNamed) newNamed = true + if (isNamed || value is dev.darkpyonix.composerust.protocol.Modifier.CornerEach) newCorner = value + } + if (primed && theme === current && track == newTrack && thumb == newThumb && + activeTrack == newActive && container == newContainer && check == newCheck && + size == newSize && named == newNamed && corner == newCorner + ) { + return look + } + primed = true + theme = current + track = newTrack; thumb = newThumb; activeTrack = newActive + container = newContainer; check = newCheck; size = newSize + named = newNamed; corner = newCorner + val empty = newTrack == null && newThumb == null && newActive == null && + newContainer == null && newCheck == null && newSize == null && !newNamed + look = if (empty) ControlLook.None else node.controlLook(current) + return look + } +} + /** Reads the look a node asked for. Colours go through the theme like every other paint. */ internal fun Node.controlLook(theme: ResolvedTheme): ControlLook { fun colour(kind: PropertyKind): Color? = paintProp(kind)?.let { theme.color(it) } diff --git a/compose-rust/android-kotlin/shared/HostSlider.kt b/compose-rust/android-kotlin/shared/HostSlider.kt index 11ccfa63..bdb3ffb9 100644 --- a/compose-rust/android-kotlin/shared/HostSlider.kt +++ b/compose-rust/android-kotlin/shared/HostSlider.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme -import dev.darkpyonix.composerust.design.controlLook +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -72,7 +72,7 @@ internal fun HostSlider( onChange = report, modifier = modifier, theme = theme, - look = node.controlLook(theme), + look = rememberControlLook(node, theme), ) } diff --git a/compose-rust/android-kotlin/shared/HostToggle.kt b/compose-rust/android-kotlin/shared/HostToggle.kt index ec59674e..c3ae1406 100644 --- a/compose-rust/android-kotlin/shared/HostToggle.kt +++ b/compose-rust/android-kotlin/shared/HostToggle.kt @@ -3,7 +3,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme -import dev.darkpyonix.composerust.design.controlLook +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.design.ToggleRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind @@ -48,6 +48,6 @@ internal fun HostToggle( }, modifier = modifier, theme = theme, - look = node.controlLook(theme), + look = rememberControlLook(node, theme), ) } diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 8d69ec5c..73540ffd 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.ui import androidx.compose.animation.animateContentSize import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.PointerEventPass import dev.darkpyonix.composerust.runtime.LocalWindowDragExclusion @@ -175,6 +176,10 @@ internal fun List.toComposeModifier( // Mark this down for the strip without consuming it, so ancestors can click. val excluded = awaitFirstDown(requireUnconsumed = false) exclusion?.mark(excluded.id.value, excluded.uptimeMillis) + // Forget the press once it ends, so a later press that happens to + // share an id and a timestamp is not taken for this one. + waitForUpOrCancellation() + exclusion?.clear() } } } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index adcb7e35..cd304279 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -86,6 +86,7 @@ internal class WindowDragExclusion { var downId: Long = -1L var downUptime: Long = -1L fun mark(id: Long, uptime: Long) { downId = id; downUptime = uptime } + fun clear() { downId = -1L; downUptime = -1L } fun isMarked(id: Long, uptime: Long): Boolean = downId == id && downUptime == uptime } internal val LocalWindowDragExclusion = staticCompositionLocalOf { null } diff --git a/renderer/desktop/src/renderer/ControlLook.kt b/renderer/desktop/src/renderer/ControlLook.kt index 83cc0763..302318c7 100644 --- a/renderer/desktop/src/renderer/ControlLook.kt +++ b/renderer/desktop/src/renderer/ControlLook.kt @@ -1,10 +1,13 @@ package dev.darkpyonix.composerust.design +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.resolvedShape @@ -46,6 +49,70 @@ class ControlLook( } } +/** + * The look of [node], rebuilt only when something it reads has changed. + * + * A recomposition that changes none of the six properties, the corner modifier or the theme + * hands back the look it made last time, and a node that sends none of them gets the shared + * [ControlLook.None], so the steady state allocates nothing. A property that is removed + * makes its slot null, which differs from the value it held, so the look returns to the + * defaults. + */ +@Composable +internal fun rememberControlLook(node: Node, theme: ResolvedTheme): ControlLook { + val cache = remember { ControlLookCache() } + return cache.read(node, theme) +} + +private class ControlLookCache { + private var primed = false + private var theme: ResolvedTheme? = null + private var track: PropertyValue? = null + private var thumb: PropertyValue? = null + private var activeTrack: PropertyValue? = null + private var container: PropertyValue? = null + private var check: PropertyValue? = null + private var size: PropertyValue? = null + private var corner: dev.darkpyonix.composerust.protocol.Modifier? = null + private var named = false + private var look: ControlLook = ControlLook.None + + fun read(node: Node, current: ResolvedTheme): ControlLook { + val props = node.props + val newTrack = props[PropertyKind.TrackColor] + val newThumb = props[PropertyKind.ThumbColor] + val newActive = props[PropertyKind.ActiveTrackColor] + val newContainer = props[PropertyKind.ContainerColor] + val newCheck = props[PropertyKind.CheckColor] + val newSize = props[PropertyKind.ControlSize] + var newNamed = false + var newCorner: dev.darkpyonix.composerust.protocol.Modifier? = null + val modifiers = node.modifiers + for (index in modifiers.indices) { + val value = modifiers[index] + val isNamed = value is dev.darkpyonix.composerust.protocol.Modifier.Shape || + value is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + if (isNamed) newNamed = true + if (isNamed || value is dev.darkpyonix.composerust.protocol.Modifier.CornerEach) newCorner = value + } + if (primed && theme === current && track == newTrack && thumb == newThumb && + activeTrack == newActive && container == newContainer && check == newCheck && + size == newSize && named == newNamed && corner == newCorner + ) { + return look + } + primed = true + theme = current + track = newTrack; thumb = newThumb; activeTrack = newActive + container = newContainer; check = newCheck; size = newSize + named = newNamed; corner = newCorner + val empty = newTrack == null && newThumb == null && newActive == null && + newContainer == null && newCheck == null && newSize == null && !newNamed + look = if (empty) ControlLook.None else node.controlLook(current) + return look + } +} + /** Reads the look a node asked for. Colours go through the theme like every other paint. */ internal fun Node.controlLook(theme: ResolvedTheme): ControlLook { fun colour(kind: PropertyKind): Color? = paintProp(kind)?.let { theme.color(it) } diff --git a/renderer/desktop/src/renderer/HostSlider.kt b/renderer/desktop/src/renderer/HostSlider.kt index 11ccfa63..bdb3ffb9 100644 --- a/renderer/desktop/src/renderer/HostSlider.kt +++ b/renderer/desktop/src/renderer/HostSlider.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme -import dev.darkpyonix.composerust.design.controlLook +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -72,7 +72,7 @@ internal fun HostSlider( onChange = report, modifier = modifier, theme = theme, - look = node.controlLook(theme), + look = rememberControlLook(node, theme), ) } diff --git a/renderer/desktop/src/renderer/HostToggle.kt b/renderer/desktop/src/renderer/HostToggle.kt index ec59674e..c3ae1406 100644 --- a/renderer/desktop/src/renderer/HostToggle.kt +++ b/renderer/desktop/src/renderer/HostToggle.kt @@ -3,7 +3,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme -import dev.darkpyonix.composerust.design.controlLook +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.design.ToggleRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind @@ -48,6 +48,6 @@ internal fun HostToggle( }, modifier = modifier, theme = theme, - look = node.controlLook(theme), + look = rememberControlLook(node, theme), ) } diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 8d69ec5c..73540ffd 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.ui import androidx.compose.animation.animateContentSize import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.PointerEventPass import dev.darkpyonix.composerust.runtime.LocalWindowDragExclusion @@ -175,6 +176,10 @@ internal fun List.toComposeModifier( // Mark this down for the strip without consuming it, so ancestors can click. val excluded = awaitFirstDown(requireUnconsumed = false) exclusion?.mark(excluded.id.value, excluded.uptimeMillis) + // Forget the press once it ends, so a later press that happens to + // share an id and a timestamp is not taken for this one. + waitForUpOrCancellation() + exclusion?.clear() } } } diff --git a/renderer/desktop/test/ControlColoursTest.kt b/renderer/desktop/test/ControlColoursTest.kt index 68951460..768e3b2a 100644 --- a/renderer/desktop/test/ControlColoursTest.kt +++ b/renderer/desktop/test/ControlColoursTest.kt @@ -246,4 +246,33 @@ class ControlColoursTest { assertEquals(reference.pixels, removed.pixels, "$system: the pixels after the removal") } } + + @Test + fun fr67_control_look_is_not_recomputed_for_unchanged_properties() { + val system = DesignSystem.entries.first() + val theme = dev.darkpyonix.composerust.design.ResolvedTheme( + system = system, + tokens = dev.darkpyonix.composerust.protocol.DesignTokens.of(system), + rules = dev.darkpyonix.composerust.design.rulesFor(system), + dark = false, + ) + val node = dev.darkpyonix.composerust.ui.node.Node(control, WidgetKind.Switch) + node.props[PropertyKind.TrackColor] = literal(ink) + val looks = mutableListOf() + runComposeUiTest { + val tick = androidx.compose.runtime.mutableIntStateOf(0) + setContent { + tick.intValue + looks += dev.darkpyonix.composerust.design.rememberControlLook(node, theme) + } + waitForIdle() + tick.intValue = 1 + waitForIdle() + node.props[PropertyKind.TrackColor] = literal(red) + waitForIdle() + } + check(looks.size >= 2) { "the content was composed ${looks.size} times" } + assertEquals(true, looks[0] === looks[1], "an unchanged node gets the look it had") + assertEquals(false, looks.last() === looks[0], "a changed colour makes a new look") + } } diff --git a/renderer/desktop/test/WindowDragExclusionTest.kt b/renderer/desktop/test/WindowDragExclusionTest.kt index e941a55a..0089bb3b 100644 --- a/renderer/desktop/test/WindowDragExclusionTest.kt +++ b/renderer/desktop/test/WindowDragExclusionTest.kt @@ -65,8 +65,9 @@ class WindowDragExclusionTest { assertEquals(1, connection.events.filterIsInstance().size) } + /** A control case: it also passes without the fix. The regression test is the one above. */ @Test - fun fr19_press_on_the_empty_drag_region_starts_the_window_drag() = runComposeUiTest { + fun fr19_control_press_on_the_empty_drag_region_still_starts_the_window_drag() = runComposeUiTest { var drags = 0 val connection = FakeHostConnection(mutations(childClickable = false)) setContent { @@ -78,4 +79,21 @@ class WindowDragExclusionTest { onRoot().performMouseInput { moveTo(Offset(140f, 140f)); press(); release() } assertEquals(1, drags) } + + @Test + fun fr19_a_press_that_starts_in_the_region_keeps_dragging_over_a_non_drag_child() = runComposeUiTest { + var drags = 0 + val connection = FakeHostConnection(mutations(childClickable = false)) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalWindowDrag provides { drags++ }) { + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(200.dp, 200.dp)) + } + } + // The press starts on the empty part of the region, then moves over the child. The child + // never saw the down, so nothing is marked and the drag goes on. + onRoot().performMouseInput { + moveTo(Offset(140f, 140f)); press(); moveTo(Offset(10f, 10f)); release() + } + assertEquals(1, drags) + } } From 1bf8fc63f258496853f08820cb16589dad17dbb9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:40:53 +0900 Subject: [PATCH 169/224] Docs: Add FR-73, writing text to the clipboard from application code (#471) --- docs/SPEC.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index c20ede0d..c5f4f9a3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3976,6 +3976,27 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" 3. **마지막으로 쓴 폴더의 기억.** `directory`가 비었을 때 플랫폼이 기억하는 폴더를 쓰는데, 플랫폼마다 앱 단위인지 대화상자 단위인지 다릅니다. 앱이 기억을 정하고 싶다면 `directory`를 직접 넘깁니다. 4. **모바일과 Web.** `UIDocumentPicker`, Storage Access Framework, 브라우저의 File System Access API는 반환값이 경로가 아니라 접근 권한을 가진 핸들(URI)이라서 이 항목의 `paths`로는 표현할 수 없습니다. 별도 항목에서 정합니다. +### FR-73 프로그램에서 클립보드에 글자 쓰기 (`Draft`, 이슈 #471) + +요청한 쪽: darkpyonix-ember(FR-T14, 채널 오너 화면의 초대 링크 '복사' 버튼). 지금 복사는 사용자가 끌어서 고른 글자를 Renderer가 클립보드에 직접 넣는 것(FR-33, FR-47)뿐이어서, 앱 코드가 "이 글자를 복사해 줘"라고 말할 길이 없습니다. 이 항목은 그 길을 더합니다. 읽기(붙여넣기를 프로그램이 읽는 것)는 범위 밖입니다. + +**FR-36, FR-72와 같은 모양입니다.** Host가 명령 레코드 하나로 요청하고, 플랫폼 클립보드를 부르는 것은 Renderer입니다. 경계에 함수가 늘지 않고(PR-2) 스키마의 명령 하나가 늘며, 스키마 해시가 움직입니다(추가 전용). + +- **명령 `SetClipboardText`(명령 태그 27)**: 페이로드는 문자열 하나(`text`)이고 `TextChanged`와 같은 길이 붙은 문자열 꼬리 규약을 씁니다(PR-4). 26은 FR-72의 `ShowFileDialog`입니다. +- **Renderer**: Compose의 플랫폼 클립보드(Compose 기본 클립보드 API)에 `text`를 일반 글자로 넣습니다. 새 플랫폼 코드는 없습니다. 빈 문자열은 클립보드를 빈 글자로 바꿉니다. +- **이벤트는 없습니다.** 요청 하나에 결과 이벤트를 만들지 않습니다. 클립보드를 쓸 수 없는 환경(헤드리스 등)에서는 조용히 아무 일도 하지 않으며 `ProtocolError`가 아닙니다. 쓰기가 성공했는지 알아야 하는 앱은 지금 없습니다. 필요해지면 FR-72처럼 요청 번호와 결과 이벤트를 더하는 일이 추가 전용으로 가능합니다. +- **Host API**: `compose_rust::clipboard::set_text(&str)`. 이벤트 핸들러나 효과 안에서 부릅니다(다른 Host 호출과 같은 규칙입니다. 작업자 스레드에서 직접 부르지 않고, 신호를 통해 UI 쪽에서 부릅니다, PR-3). 호출한 프레임의 배치에 명령으로 실려 나갑니다. +- **크기**: `text`는 64 KiB까지입니다. 넘으면 호출은 아무것도 보내지 않고 `Err`를 돌려줍니다. 초대 링크나 코드 조각이 쓰는 크기이며, 큰 데이터를 클립보드에 싣는 용도가 아닙니다. +- **민감한 글자**: 클립보드는 다른 앱과 클립보드 기록 도구가 읽을 수 있습니다. 비밀번호, 토큰, 개인 키처럼 남에게 보이면 안 되는 값을 이 호출로 복사하지 않습니다. Renderer는 `text`를 로그에 남기지 않습니다(길이만 남길 수 있습니다). 비밀번호 칸의 값을 복사하게 만드는 길은 이 항목에 없습니다. + +수용 기준. +1. `set_text("초대 링크")`가 명령 레코드 하나(태그 27)로 인코딩되고 Rust와 Kotlin이 같은 바이트로 왕복합니다(프로토콜 벡터)(시험 이름 fr73_set_clipboard_text_round_trips). +2. Renderer가 명령을 받으면 플랫폼 클립보드 대역(가짜 클립보드)에 그 글자가 들어갑니다. 한글과 줄바꿈과 이모지가 그대로입니다(시험 이름 fr73_renderer_writes_the_text_to_the_clipboard). +3. 빈 문자열은 클립보드를 빈 글자로 바꿉니다(시험 이름 fr73_empty_text_clears_the_clipboard). +4. 64 KiB를 넘는 글자는 `Err`이고 배치에 아무것도 실리지 않습니다(시험 이름 fr73_oversized_text_is_refused_by_the_host). +5. 클립보드를 쓸 수 없는 환경에서 명령은 아무 일도 하지 않고 프로세스는 계속 돕니다(시험 이름 fr73_unavailable_clipboard_is_silently_ignored). +6. Renderer의 어느 로그 줄에도 `text`의 내용이 들어가지 않습니다(시험 이름 fr73_clipboard_text_is_never_logged). + ### FR-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. From 5dbfa6db6d2fd9e102627d20e22bf61088a4c477 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:41:02 +0900 Subject: [PATCH 170/224] Chore: Regenerate the protocol files after merging develop (FR-67) --- .../android-kotlin/shared/Protocol.gen.kt | 30 +++++++++++++++---- compose-rust/schema-hash.txt | 2 +- compose-rust/tests/vectors/vectors.json | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 30 +++++++++++++++---- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 8293dc7b..3bbe8c1c 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -28,7 +28,7 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } -enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group } enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } @@ -245,6 +245,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) sealed interface PropertyValue { @@ -1304,7 +1309,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 5970700763455927495L + const val SCHEMA_HASH: Long = 3343052262313842838L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1488,14 +1493,15 @@ object Protocol { } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -1522,6 +1528,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -2445,6 +2452,17 @@ object Protocol { 6 -> SemanticsRole.Tab 7 -> SemanticsRole.Image 8 -> SemanticsRole.DropdownList + 9 -> SemanticsRole.TabList + 10 -> SemanticsRole.Tree + 11 -> SemanticsRole.TreeItem + 12 -> SemanticsRole.Menu + 13 -> SemanticsRole.MenuItem + 14 -> SemanticsRole.TextBox + 15 -> SemanticsRole.Heading + 16 -> SemanticsRole.Navigation + 17 -> SemanticsRole.List + 18 -> SemanticsRole.ListItem + 19 -> SemanticsRole.Group else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) } diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index f5a6256a..15e69276 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x52dc30b5d1f1b4c7 +0x2e64e85200c85896 diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 35a91aa7..63720bfa 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "5970700763455927495", + "schemaHashUnsigned": "3343052262313842838", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 8293dc7b..3bbe8c1c 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -28,7 +28,7 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } -enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group } enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } @@ -245,6 +245,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) sealed interface PropertyValue { @@ -1304,7 +1309,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 5970700763455927495L + const val SCHEMA_HASH: Long = 3343052262313842838L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1488,14 +1493,15 @@ object Protocol { } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -1522,6 +1528,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -2445,6 +2452,17 @@ object Protocol { 6 -> SemanticsRole.Tab 7 -> SemanticsRole.Image 8 -> SemanticsRole.DropdownList + 9 -> SemanticsRole.TabList + 10 -> SemanticsRole.Tree + 11 -> SemanticsRole.TreeItem + 12 -> SemanticsRole.Menu + 13 -> SemanticsRole.MenuItem + 14 -> SemanticsRole.TextBox + 15 -> SemanticsRole.Heading + 16 -> SemanticsRole.Navigation + 17 -> SemanticsRole.List + 18 -> SemanticsRole.ListItem + 19 -> SemanticsRole.Group else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) } From 08b15d7ab9b19bba797bbb271565dcabe5a26b51 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:04:01 +0900 Subject: [PATCH 171/224] Test: Make four Kotlin tests independent of the machine's language and appearance (#436, #492) NativeContextMenuTest and NativeClipboardTest pin the default Locale to English for the test, because the default context menu labels come from it. TextFieldTest pins the system theme to light, because the placeholder test compares the darkest pixel of the field and a dark theme makes the background the darkest pixel in both renders. All three restore what they changed. No production code changes. --- renderer/desktop/test/NativeClipboardTest.kt | 16 ++++++++++++++++ renderer/desktop/test/NativeContextMenuTest.kt | 16 ++++++++++++++++ renderer/desktop/test/TextFieldTest.kt | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/renderer/desktop/test/NativeClipboardTest.kt b/renderer/desktop/test/NativeClipboardTest.kt index 87ee065f..5543ae80 100644 --- a/renderer/desktop/test/NativeClipboardTest.kt +++ b/renderer/desktop/test/NativeClipboardTest.kt @@ -24,6 +24,8 @@ import dev.darkpyonix.composerust.ui.platform.NativeMenuEntry import dev.darkpyonix.composerust.ui.platform.PasteboardClipboard import dev.darkpyonix.composerust.ui.platform.WindowClipboardImpl import java.awt.datatransfer.DataFlavor +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -40,6 +42,20 @@ import kotlin.test.assertTrue */ class NativeClipboardTest { + private var savedLocale: java.util.Locale? = null + + /** The menu labels come from the default locale; a Korean machine would label them 잘라내기. */ + @BeforeTest + fun pinEnglishLocale() { + savedLocale = java.util.Locale.getDefault() + java.util.Locale.setDefault(java.util.Locale.ENGLISH) + } + + @AfterTest + fun restoreLocale() { + savedLocale?.let { java.util.Locale.setDefault(it) } + } + private var board = "" private val clipboard = WindowClipboardImpl(PasteboardClipboard({ board }, { board = it })) diff --git a/renderer/desktop/test/NativeContextMenuTest.kt b/renderer/desktop/test/NativeContextMenuTest.kt index 052324c9..f094fc37 100644 --- a/renderer/desktop/test/NativeContextMenuTest.kt +++ b/renderer/desktop/test/NativeContextMenuTest.kt @@ -43,6 +43,8 @@ import org.thisisthepy.compose.window.WindowEvent import dev.darkpyonix.composerust.ui.platform.receive import org.jetbrains.skia.Surface import kotlinx.coroutines.Dispatchers +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -59,6 +61,20 @@ private const val FIELD = "field" */ class NativeContextMenuTest { + private var savedLocale: java.util.Locale? = null + + /** The menu labels come from the default locale; a Korean machine would label them 잘라내기. */ + @BeforeTest + fun pinEnglishLocale() { + savedLocale = java.util.Locale.getDefault() + java.util.Locale.setDefault(java.util.Locale.ENGLISH) + } + + @AfterTest + fun restoreLocale() { + savedLocale?.let { java.util.Locale.setDefault(it) } + } + /** Every menu the native representation was asked for, as the entries it was given. */ private val nativeRequests = mutableListOf>() diff --git a/renderer/desktop/test/TextFieldTest.kt b/renderer/desktop/test/TextFieldTest.kt index 4dd660fd..251f1f0d 100644 --- a/renderer/desktop/test/TextFieldTest.kt +++ b/renderer/desktop/test/TextFieldTest.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -56,6 +58,20 @@ private fun ComposeUiTest.darkestInk(): Int { @OptIn(ExperimentalTestApi::class) class TextFieldTest { + + private var savedDark: Boolean? = null + + /** The placeholder test measures ink on a light field; a dark system would make both renders background. */ + @BeforeTest + fun pinLightAppearance() { + savedDark = dev.darkpyonix.composerust.runtime.systemDarkOverride + dev.darkpyonix.composerust.runtime.systemDarkOverride = false + } + + @AfterTest + fun restoreAppearance() { + dev.darkpyonix.composerust.runtime.systemDarkOverride = savedDark + } /** * A placeholder is not text, and it is not set as though it were. * From 0a72eaad30322e26616e151b02a48eddb510a383 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:06:05 +0900 Subject: [PATCH 172/224] Docs: Note the file dialog request in the changelog (#483) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53cf83f..0ceca620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Native file open and save dialogs (host side) + +The Host can ask for a platform file dialog with `FileDialog` (open a file, files, a folder or folders, or save with a suggested name and folder, filters, a title and an initial folder) and receives one result per request: the chosen absolute paths, a cancel, or "unavailable". Every platform answers "unavailable" until its dialog is wired in; macOS follows ([#483](https://github.com/DarkPyonix/compose-rust/issues/483)). + ## v0.0.6 ### Focus ring only for keyboard focus From 0d9f830b8ba968316bfd979d47e1b44c7db835ef Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:13:34 +0900 Subject: [PATCH 173/224] Chore: Regenerate the protocol files after merging develop (FR-72) --- .../android-kotlin/shared/FileDialogHost.kt | 46 +++++++----- .../android-kotlin/shared/Protocol.gen.kt | 69 +++++++++++++++++- compose-rust/schema-hash.txt | 2 +- compose-rust/tests/vectors/mutations.bin | Bin 5363 -> 5484 bytes compose-rust/tests/vectors/vectors.json | 11 +-- renderer/desktop/src/protocol/Protocol.gen.kt | 69 +++++++++++++++++- 6 files changed, 171 insertions(+), 26 deletions(-) diff --git a/compose-rust/android-kotlin/shared/FileDialogHost.kt b/compose-rust/android-kotlin/shared/FileDialogHost.kt index 0217ca14..06702f11 100644 --- a/compose-rust/android-kotlin/shared/FileDialogHost.kt +++ b/compose-rust/android-kotlin/shared/FileDialogHost.kt @@ -56,8 +56,8 @@ data class FileDialogSpec( /** * One path a dialog returned. A platform that hands over text passes [Text]; one whose paths - * are bytes (a Linux file system does not promise UTF-8) passes [Bytes], and the ones that - * are not UTF-8 are dropped before the Host sees anything. + * are bytes (a Linux file system does not promise UTF-8) passes [Bytes]. One that is not UTF-8 + * makes the whole choice unavailable. */ sealed interface FileDialogPath { class Text(val value: String) : FileDialogPath @@ -309,9 +309,9 @@ class FileDialogCenter( /** * What the Host is told about an outcome. * - * Paths that are not well formed text are dropped and the rest go on. When a choice was - * made and none is left, it is unavailable and not a cancel: the user picked something, - * so a cancel would be false. A choice with too many paths, or too many bytes of them, is + * A path that is not well formed text makes the whole choice unavailable: the other paths + * are not sent either, because the application could not tell that the list is shorter + * than what the user picked, and a cancel would be false. A choice with too many paths, or too many bytes of them, is * unavailable too, never a shorter list: the application would not know it was short. */ private fun judge(outcome: FileDialogOutcome): Checked = when (outcome) { @@ -321,12 +321,13 @@ class FileDialogCenter( outcome.paths.isEmpty() -> Checked.Ended(FileDialogStatus.Cancelled) outcome.paths.size > FileDialogLimits.MAX_PATHS -> Checked.Ended(FileDialogStatus.Unavailable) else -> { - val kept = outcome.paths.mapNotNull { wellFormedPath(it) } - val bytes = kept.sumOf { it.encodeToByteArray().size.toLong() + 1L } + val checked = outcome.paths.map { wellFormedPath(it) } + val texts = checked.filterNotNull() when { - kept.isEmpty() -> Checked.Ended(FileDialogStatus.Unavailable) - bytes > maxPayloadBytes.toLong() -> Checked.Ended(FileDialogStatus.Unavailable) - else -> Checked.Paths(kept.joinToString("\u0000"), outcome.filter) + texts.size != checked.size -> Checked.Ended(FileDialogStatus.Unavailable) + texts.sumOf { it.encodeToByteArray().size.toLong() + 1L } > maxPayloadBytes.toLong() -> + Checked.Ended(FileDialogStatus.Unavailable) + else -> Checked.Paths(texts.joinToString("\u0000"), outcome.filter) } } } @@ -341,7 +342,8 @@ class FileDialogCenter( */ fun normalize(mutation: Mutation.ShowFileDialog): FileDialogSpec { val folders = mutation.kind == FileDialogKind.OpenFolder || mutation.kind == FileDialogKind.OpenFolders - val filters = if (folders) emptyList() else parseFilters(mutation.filters) + val parsed = if (folders) emptyList() else parseFilters(mutation.filters) + val filters = parsed.map { it.second } val name = if (mutation.kind == FileDialogKind.Save) { mutation.name.substringAfterLast('/').substringAfterLast('\\') } else { @@ -355,24 +357,32 @@ class FileDialogCenter( directory = mutation.directory, name = name, filters = filters, - defaultFilter = mutation.defaultFilter.takeIf { it in filters.indices } ?: 0, + // The default is a position in the list the Host sent, so it follows its filter + // when an earlier one was dropped. + defaultFilter = parsed.indexOfFirst { it.first == mutation.defaultFilter }.takeIf { it >= 0 } ?: 0, ) } - private fun parseFilters(encoded: String): List { + private fun parseFilters(encoded: String): List> { if (encoded.isEmpty()) return emptyList() // Label, extensions, label, extensions. A label with nothing after it lets every // file through, like one with no extensions. val parts = encoded.split('\u0000') - val filters = ArrayList() + val filters = ArrayList>() var index = 0 - while (index < parts.size && filters.size < FileDialogLimits.MAX_FILTERS) { - val extensions = (parts.getOrNull(index + 1) ?: "") - .split(',') + var position = 0 + while (index < parts.size && position < FileDialogLimits.MAX_FILTERS) { + val named = (parts.getOrNull(index + 1) ?: "").split(',').filter { it.isNotBlank() } + val extensions = named .map { it.trim().removePrefix("*.").removePrefix(".").lowercase() } .filter { it.isNotEmpty() } .take(FileDialogLimits.MAX_EXTENSIONS) - filters += FileDialogFilter(parts[index], extensions) + // A filter that names extensions and keeps none of them (a lone ".") would + // otherwise mean every file, the opposite of what it asked for. + if (named.isEmpty() || extensions.isNotEmpty()) { + filters += position to FileDialogFilter(parts[index], extensions) + } + position++ index += 2 } return filters diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 3bbe8c1c..d56d8c98 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -118,6 +118,10 @@ enum class WindowMode { Single, Windows } enum class LastWindowClosed { Exit, KeepRunning } +enum class FileDialogKind { OpenFile, OpenFiles, OpenFolder, OpenFolders, Save } + +enum class FileDialogStatus { Chosen, Cancelled, Unavailable } + /** * The `TypeRole` property of text with no rung of the ladder at all: CSS text, whose * size, weight and font are its own. Present and zero; an absent property is the @@ -1257,6 +1261,23 @@ sealed interface Mutation { /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation } sealed interface HostEvent { @@ -1303,13 +1324,14 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FileDialogResult(override val nodeId: Int, override val handlerId: Long, val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 3343052262313842838L + const val SCHEMA_HASH: Long = -152494910627624401L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1338,6 +1360,7 @@ object Protocol { private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1491,6 +1514,19 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the @@ -1766,6 +1802,7 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FileDialogResult -> event.paths.toByteArray(StandardCharsets.UTF_8) } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1808,6 +1845,7 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FileDialogResult -> 36 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1854,6 +1892,7 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FileDialogResult -> 50 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -2021,6 +2060,12 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FileDialogResult -> { + out.putInt(event.request) + out.putInt(fileDialogStatusTag(event.status)) + out.putInt(event.filter) + writeStringReference(out, recordLength, text!!) + } } if (text != null) out.put(text) return out.position() - start @@ -2254,6 +2299,12 @@ object Protocol { NotificationPermission.Unsupported -> 4 } + private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) { + FileDialogStatus.Chosen -> 1 + FileDialogStatus.Cancelled -> 2 + FileDialogStatus.Unavailable -> 3 + } + private fun animationEventKindTag(value: AnimationEventKind): Int = when (value) { AnimationEventKind.Ready -> 1 AnimationEventKind.Active -> 2 @@ -2857,6 +2908,22 @@ object Protocol { else -> throw ProtocolException("unknown LastWindowClosed tag $tag", offset) } + private fun fileDialogKind(tag: Int, offset: Int): FileDialogKind = when (tag) { + 1 -> FileDialogKind.OpenFile + 2 -> FileDialogKind.OpenFiles + 3 -> FileDialogKind.OpenFolder + 4 -> FileDialogKind.OpenFolders + 5 -> FileDialogKind.Save + else -> throw ProtocolException("unknown FileDialogKind tag $tag", offset) + } + + private fun fileDialogStatus(tag: Int, offset: Int): FileDialogStatus = when (tag) { + 1 -> FileDialogStatus.Chosen + 2 -> FileDialogStatus.Cancelled + 3 -> FileDialogStatus.Unavailable + else -> throw ProtocolException("unknown FileDialogStatus tag $tag", offset) + } + private fun paint(bits: Long, offset: Int): Paint { val value = bits.toInt() return when (val kind = (bits ushr 32).toInt()) { diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 15e69276..b8b14ff9 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x2e64e85200c85896 +0xfde23aafcdfc4a2f diff --git a/compose-rust/tests/vectors/mutations.bin b/compose-rust/tests/vectors/mutations.bin index 6c63cbcfc672b142074e1c775942f6023733ca68..c76916cf68313a6f03a85349b12e2f217bbd40bc 100644 GIT binary patch delta 506 zcmZXQ%S!@L6vofJV-G=Rd?YpoDOiijp0;RXwX2{_1TBJgmc5&WU<6T%f)*CZMF`Re zqJju2lyK3aZHxRPqD?xkY7<2DjkS=PnKQrpo%5Y@FZZ=YZ#5HHNV5~oCCovlB|=`d zb2Am=>K<}6M8ps#L@n9MOo|fsvO{5&{46cHrEzv5wxuCO z`sf}SThRlkf={3udi3`>ixmipTZzov)BNE}mu!x$Dl^m8I#M41_lO3AZ7t#4h8`*ojUm~ zlRRVgWL{=v#@xw<%)yMslUteH8OtW0WtOh55@KKgB8DCy0n)_;#C!}E3=@ESpj8Yr zfwUx$-UOt@f%Hir%?6~;L+PtP8U&btkRRwW5FexuWH%Fo1lT+W23Cf9KyeSSN|0KJ zcoYNE=5&@`PDwRk1_l)*Ngg0O1*pkuGB2MvBgIi`0&o`4 { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the @@ -1766,6 +1802,7 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FileDialogResult -> event.paths.toByteArray(StandardCharsets.UTF_8) } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1808,6 +1845,7 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FileDialogResult -> 36 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1854,6 +1892,7 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FileDialogResult -> 50 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -2021,6 +2060,12 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FileDialogResult -> { + out.putInt(event.request) + out.putInt(fileDialogStatusTag(event.status)) + out.putInt(event.filter) + writeStringReference(out, recordLength, text!!) + } } if (text != null) out.put(text) return out.position() - start @@ -2254,6 +2299,12 @@ object Protocol { NotificationPermission.Unsupported -> 4 } + private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) { + FileDialogStatus.Chosen -> 1 + FileDialogStatus.Cancelled -> 2 + FileDialogStatus.Unavailable -> 3 + } + private fun animationEventKindTag(value: AnimationEventKind): Int = when (value) { AnimationEventKind.Ready -> 1 AnimationEventKind.Active -> 2 @@ -2857,6 +2908,22 @@ object Protocol { else -> throw ProtocolException("unknown LastWindowClosed tag $tag", offset) } + private fun fileDialogKind(tag: Int, offset: Int): FileDialogKind = when (tag) { + 1 -> FileDialogKind.OpenFile + 2 -> FileDialogKind.OpenFiles + 3 -> FileDialogKind.OpenFolder + 4 -> FileDialogKind.OpenFolders + 5 -> FileDialogKind.Save + else -> throw ProtocolException("unknown FileDialogKind tag $tag", offset) + } + + private fun fileDialogStatus(tag: Int, offset: Int): FileDialogStatus = when (tag) { + 1 -> FileDialogStatus.Chosen + 2 -> FileDialogStatus.Cancelled + 3 -> FileDialogStatus.Unavailable + else -> throw ProtocolException("unknown FileDialogStatus tag $tag", offset) + } + private fun paint(bits: Long, offset: Int): Paint { val value = bits.toInt() return when (val kind = (bits ushr 32).toInt()) { From 753360035437fbaff186516f83a878821b2eee6b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:11:14 +0900 Subject: [PATCH 174/224] Docs: Record the verified status of FR-66 to FR-70 FR-67, FR-69 and FR-70 are Done: their acceptance criteria are covered by passing tests on develop (fr67_*, fr69_*, fr70_*). FR-66 and FR-68 are Agreed, not Done: FR-66 criterion 1 checks the label position and that FontSize changes the label size, not the exact Sm value; FR-68 criterion 1 names Slider and the design system's Slider has no range semantics node (thisisthepy/compose-multiplatform-core-extended#180). --- docs/SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index c5f4f9a3..a87912da 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -3774,7 +3774,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 3. `Menu(true)`의 팝업 위쪽 가장자리가 앵커의 아래쪽 가장자리 이상에 놓여 앵커를 가리지 않습니다. 4. `MenuItem("Open").selected(true)`는 왼쪽 정렬된 행으로 그려지고 선택 의미 정보(`selected = true`)와 선택 배경을 가집니다. -### FR-66 Button이 행이 되는 방법: 왼쪽 정렬 라벨, 글자 크기, 내용 슬롯 (`Draft`, 이슈 #425) +### FR-66 Button이 행이 되는 방법: 왼쪽 정렬 라벨, 글자 크기, 내용 슬롯 (`Agreed`, 이슈 #425) 요청한 쪽: darkpyonix-ember(목업 `style.css`의 `.nav`: 높이 38px, 가로 여백 `--sp3`, 라벨과 아이콘과 개수가 한 줄, 개수는 오른쪽 끝). 목록 행, 내비게이션 행, 트리 행은 눌리는 면 전체가 컨트롤이어야 합니다. 지금은 `Button(text)`가 라벨을 가운데에 놓고 자기 글자 크기를 정해서, 앱이 Button 위에 칠한 행과 클릭 영역을 따로 얹어야 합니다. @@ -3787,7 +3787,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 2. `text_align`, `font_size`를 부르지 않으면 두 속성이 나가지 않고 렌더러 결과가 바뀌지 않습니다. 3. 자식으로 `Icon`, `Text`, `Spacer.weight(1.0)`, 개수 `Text`를 가진 Button에서 개수가 Button의 오른쪽 안쪽 가장자리에 붙고, Button 전체를 누르면 `on_click`이 한 번 옵니다. -### FR-67 Switch, Slider, Checkbox의 색과 크기 (`Draft`, 이슈 #426, #428) +### FR-67 Switch, Slider, Checkbox의 색과 크기 (`Done`, 이슈 #426, #428) 요청한 쪽: darkpyonix-ember(목업 `style.css`). 값: `.switch`는 36x20px 알약, 꺼짐 트랙 `--track`(라이트 `#ecebe6`, 다크 `#2f2e2b`), 켜짐 트랙 `--ink`(`#1c1b19` / `#f0eee9`), 손잡이 16px 원(`--surface`, 위치 2px, 켜짐은 왼쪽 18px). `.track`은 높이 6px, 반지름 3px, 배경 `--track`, 채움 색 따로. 체크박스는 18x18 `--ink` 사각형에 흰 체크(diff 줄의 변형은 14x14). 툴킷 `Switch`는 54x32에 플랫폼 초록 트랙이고, `Slider` 트랙은 카드 면과 구분되지 않으며, `Checkbox`는 체크되어도 속이 빈 동그라미라서 목업과 맞출 수 없습니다. @@ -3804,7 +3804,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 4. 색과 크기를 하나도 주지 않은 세 위젯은 지금과 같은 속성을 보냅니다. 5. 색과 크기를 준 컨트롤에서 그 속성을 제거하면(제거 전달 뒤) 측정 크기와 색이 디자인 시스템 기본값으로 돌아옵니다. 시험 이름은 `fr67_`로 시작합니다. -### FR-68 어느 노드에나 붙는 접근성 수정자 (`Draft`, 이슈 #429, #426) +### FR-68 어느 노드에나 붙는 접근성 수정자 (`Agreed`, 이슈 #429, #426) 요청한 쪽: darkpyonix-ember. 누를 수 있는 `Box`, 컨테이너 `on_click`, Canvas로 그린 스위치 위의 투명 Button은 화면 낭독기에 역할도 이름도 없고, 아이콘만 있는 Button의 이름은 아이콘의 뜻("추가")이 되어 컨트롤의 목적(가져오기, 내려받기)과 다릅니다. @@ -3819,7 +3819,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 4. 이름을 준 `Switch(true)`의 접근성 노드가 이름과 켜짐 상태를 가집니다. 5. 이름을 준 노드에서 `accessible_name`을 제거하면(제거 전달 뒤) 접근성 이름이 위젯의 기본(보이는 라벨이나 아이콘의 뜻)으로 돌아옵니다. -### FR-69 비활성 Button의 라벨 대비 (`Draft`, 이슈 #432) +### FR-69 비활성 Button의 라벨 대비 (`Done`, 이슈 #432) 요청한 쪽: darkpyonix-ember#249(버전 관리의 커밋 버튼). `Primary` 종류의 비활성 Button(채움 `--ink`)이 검은 바탕에 회색 라벨로 그려져 읽기 어렵습니다. @@ -3830,7 +3830,7 @@ Rust 테스트와 Kotlin 인터프리터 테스트는 CI의 "Test"에서, 창을 1. 라이트와 다크, `Primary`와 `Secondary`와 `Text`, 모든 디자인 시스템의 비활성 Button에서 라벨과 채움(채움이 없으면 바탕)의 대비가 3:1 이상입니다. 모든 조합을 도는 시험입니다. 2. 활성 Button의 모양은 바뀌지 않습니다. -### FR-70 Row 안의 내용 크기 컨테이너와 글자 최소 폭 (`Agreed`, 이슈 #427) +### FR-70 Row 안의 내용 크기 컨테이너와 글자 최소 폭 (`Done`, 이슈 #427) 요청한 쪽: darkpyonix-ember(목업 `style.css`의 설정 행: 제목과 힌트를 담은 `Column.weight(1)` 옆에 36x20px 스위치). 승인: 감독자 B(위임 권한). `Row` 안에서 폭을 정하지 않은 `Box`가 남은 폭을 가져가면 힌트 열의 폭이 0에 가까워지고, 한글 문장이 한 글자씩 줄바꿈됩니다. From 63155d018cb63ea1796184d2685640b93c520d0f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:11:14 +0900 Subject: [PATCH 175/224] Docs: Changelog entries for the editor change-marker bars and the WindowDrag(false) fix --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53cf83f..fa4ed8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Change-marker bars in the code editor gutter + +A `Bar` gutter decoration whose colour is `DiffAdded`, `DiffModified` or `DiffRemoved` is drawn as a 4dp change marker right of the line numbers, with a 12dp gap before the code. One record per line, no wire change. When several share a line, `DiffRemoved` wins over `DiffModified` over `DiffAdded`. `Dot`, `Bar` and `Glyph` with other colours are unchanged, and an editor without change bars keeps its layout. The editor's state description names the changed lines for assistive technology. + +### A `WindowDrag(false)` child keeps its press out of a drag region on macOS + +A press that starts on a node with `WindowDrag(false)` inside a `WindowDrag(true)` region no longer moves the window. + ## v0.0.6 ### Focus ring only for keyboard focus From bf2ecfb8cc345469ed02e69beae545fcd7c9e2d3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:12:44 +0900 Subject: [PATCH 176/224] Revert "Docs: Changelog entries for the editor change-marker bars and the WindowDrag(false) fix" This reverts commit 63155d018cb63ea1796184d2685640b93c520d0f. --- CHANGELOG.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4ed8d3..e53cf83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,5 @@ # Changelog -## Unreleased - -### Change-marker bars in the code editor gutter - -A `Bar` gutter decoration whose colour is `DiffAdded`, `DiffModified` or `DiffRemoved` is drawn as a 4dp change marker right of the line numbers, with a 12dp gap before the code. One record per line, no wire change. When several share a line, `DiffRemoved` wins over `DiffModified` over `DiffAdded`. `Dot`, `Bar` and `Glyph` with other colours are unchanged, and an editor without change bars keeps its layout. The editor's state description names the changed lines for assistive technology. - -### A `WindowDrag(false)` child keeps its press out of a drag region on macOS - -A press that starts on a node with `WindowDrag(false)` inside a `WindowDrag(true)` region no longer moves the window. - ## v0.0.6 ### Focus ring only for keyboard focus From 1c79a4657cc04b752b55543681858f7a7e4f8e2f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:17:28 +0900 Subject: [PATCH 177/224] Docs: Add FR-38 criterion 17, Host edits of one batch are one undo step (#496) Approved by the supervisor (delegated). Requested by Ember for find and replace all. No new command and no wire change: the edits stay one EditCode each, the Renderer joins the Host edits of one batch into one undo entry, and the version still grows once per edit so criterion 2 holds. Added criteria only. --- docs/SPEC.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index c5f4f9a3..5ffddc7d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2335,6 +2335,7 @@ Notification::new("세션이 끝났습니다") - Host는 편집 명령 `EditCode(request_id, base_version, range, text)`로 바꿉니다(서식 맞춤, 완성 수락, 원격 파일 다시 읽기). - Renderer는 `base_version`이 현재 버전보다 오래되었으면 그 사이의 변경을 거쳐 범위를 옮긴 뒤 적용합니다. 옮긴 범위가 사용자가 그 사이에 고친 곳과 겹치면 적용하지 않고 거절 이벤트(`CodeEditRejected`, 핸들러 `OnEditRejected`)를 보냅니다. Host가 사용자의 입력을 덮어쓰지 않습니다. - Host가 적용한 편집도 변경 이벤트로 다시 나갑니다. 그래서 Host와 그 너머(언어 서버, 확장 호스트)가 보는 버전이 하나로 이어집니다. +- **한 배치의 Host 편집은 실행 취소 한 단계입니다.** 같은 편집기에 대해 한 배치(Host의 한 프레임) 안에 도착한 `EditCode`는 사용자가 한 번의 실행 취소로 되돌릴 수 있는 한 단계로 묶입니다. 모두 같은 `base_version`에 대해 쓰인 것이면 서로 겹치지 않는 편집은 모두 적용됩니다("모두 바꾸기"). 새 명령과 새 레코드는 없습니다. Rust 쪽에는 `CodeEditorHandle::edit_group(base_version, 편집 목록)`이 있고, 편집마다 `EditCode` 하나를 차례로 씁니다. 편집마다 자기 `request_id`를 가지므로 겹쳐서 거절된 편집만 `CodeEditRejected`로 돌아오고, 나머지는 적용된 채 같은 단계에 남습니다. 변경 이벤트는 편집마다 하나씩 나가고 버전도 편집마다 하나씩 늘어납니다. 이 방식이라야 변경 이벤트를 차례로 적용해 Renderer의 버퍼를 재현한다는 기준 2가 그대로 지켜집니다. - 문서 전체 교체(파일 열기)는 버전 0부터 다시 시작합니다(38.1). #### 38.4 장식 @@ -2420,6 +2421,12 @@ Notification::new("세션이 끝났습니다") 4. 편집을 따라 움직이는 규칙은 기준 14와 같습니다. 앞에 줄을 끼우면 막대가 아래로 따라가고, 그 줄을 지우면 사라집니다. Host로 가는 이벤트는 없습니다. 5. 접근성. 변경 표시 막대가 있는 줄의 접근성 설명에 "추가됨", "수정됨", "삭제됨" 중 하나가 붙고(플랫폼 로케일의 낱말), 색만으로 구분하지 않습니다. 6. 시험 이름은 `fr38_`로 시작합니다. 폭 4dp와 위치는 픽셀 시험으로, 우선순위와 이동은 모델 시험으로, 기존 `Bar` 회귀는 기준 14의 시험이 그대로 통과하는 것으로 확인합니다. +17. 한 배치의 Host 편집은 실행 취소 한 단계입니다(이슈 #496, Ember의 찾아 바꾸기 "모두 바꾸기"). 승인: 감독자 B(위임 권한). 새 명령과 와이어 변경은 없습니다. + 1. 같은 편집기에 대한 `EditCode` 셋이 한 배치로 오면 실행 취소를 한 번 하면 셋이 모두 되돌아가고, 다시 실행을 한 번 하면 셋이 모두 돌아옵니다. 되돌릴 때의 변경 이벤트는 기존 규칙대로 바뀐 순서대로 하나씩 나갑니다. + 2. 두 배치로 나뉘어 온 `EditCode`는 실행 취소 두 단계입니다. 배치 하나에 `EditCode`가 하나뿐인 지금의 경우는 한 단계로 지금과 같습니다. + 3. 변경 이벤트는 편집마다 하나씩 나가고 버전은 편집마다 하나씩 늘어납니다. 변경 이벤트를 차례로 적용한 결과가 Renderer의 버퍼와 같다는 기준 2가 그대로 지켜집니다. + 4. 같은 `base_version`의 편집 가운데 서로 겹치는 것이 있으면 겹친 편집만 `CodeEditRejected`로 돌아오고 나머지는 적용된 채 같은 단계에 남습니다. 사용자의 입력과 겹쳐 거절되는 규칙(38.3)은 그대로입니다. + 5. Rust의 `CodeEditorHandle::edit_group`은 편집마다 `EditCode` 하나를 차례로 쓰고, 부르지 않으면 아무것도 쓰지 않습니다. 시험 이름은 `fr38_`로 시작합니다: `fr38_host_edits_in_one_batch_are_one_undo_step`, `fr38_host_edits_in_two_batches_are_two_undo_steps`, `fr38_overlapping_group_edits_reject_only_the_overlap`, `fr38_edit_group_writes_one_edit_command_per_edit`. #### 38.9 추가 기능의 와이어 (소유자 승인 2026-10-25, 이슈 #30) From 42e4e8ffdc4a0f9b3afb4267d8cd49fe4390bb41 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:23:35 +0900 Subject: [PATCH 178/224] Docs: Add FR-21 section 21.3.2, Esc dismisses an open Dialog or Sheet (#476) Draft status. Added section only; 21.3.1 already says it for the centered Sheet, this extends it to every open Dialog and Sheet, adds the focus move on open, the rule that a focused control that uses Esc goes first, and the topmost-overlay rule. No wire change. Held locally until the previous SPEC PR (#499) is approved. --- docs/SPEC.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index c5f4f9a3..f8bb6c7d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1663,6 +1663,24 @@ Fluent의 `NavigationView`가 결정적입니다. 한 컴포넌트가 폭에 따 5. Compact 창에서는 같은 선언이 가장자리 시트로 그려집니다. 6. `Presentation`을 말하지 않은 시트는 지금과 같습니다. +#### 21.3.2 Esc가 열린 Dialog와 Sheet를 닫으려 합니다 (`Draft`, 이슈 #476) + +요청한 쪽: darkpyonix-ember(#502 감사). Esc로 시트가 닫히지 않고 닫기 버튼만 동작했습니다. 21.3.1은 가운데 시트의 Esc만 적었고, Dialog와 가장자리 시트는 적지 않았습니다. Rust API에는 `Key::Escape`도, 컨테이너의 `on_key_down`도 없어서 앱이 스스로 해결할 수 없으므로 Renderer가 합니다. 승인: 감독자 B(위임 권한). + +- **모든 열린 `Dialog`와 `Sheet`(표현 방식과 상관없이)에서, Esc는 `OnDismiss`를 정확히 한 번 보냅니다.** 21.3의 "사용자가 닫으려 했다"는 사건과 같은 것입니다. 닫을지는 여전히 앱이 `Open`으로 정합니다. 그래서 저장하지 않은 변경을 묻는 확인 같은 앱의 보호 장치는 그대로 동작합니다. +- **열릴 때 Renderer가 키보드 포커스를 오버레이 안으로 옮깁니다.** 안에 포커스를 받을 컨트롤이 없어도 Esc가 오버레이에 닿습니다. 닫히면 포커스는 연 쪽으로 돌아갑니다(21.3.1의 포커스 규칙과 같습니다). +- **안쪽 컨트롤이 먼저입니다.** 포커스가 있는 컨트롤이 Esc를 쓰는 중이면(IME 조합 취소, 코드 편집기의 고스트 텍스트와 자동 완성 닫기) 그 컨트롤이 Esc를 받고 오버레이는 닫으려 하지 않습니다. 같은 키를 두 번 처리하지 않습니다. +- **겹쳐 열린 오버레이는 맨 위의 것만** `OnDismiss`를 받습니다. +- 새 속성, 새 키, 새 이벤트는 없고 와이어 변경도 없습니다. + +수용 기준. +1. 열린 `Dialog`에 Esc를 보내면 `OnDismiss`가 한 번 가고, `Open`이 그대로이면 Dialog는 열려 있습니다(fr21_escape_dismisses_an_open_dialog_once). +2. `Presentation=Edge`와 `Centered`의 열린 `Sheet` 각각에서 같습니다(fr21_escape_dismisses_an_open_sheet_once). +3. 안에 포커스를 받을 컨트롤이 없는 오버레이도 열린 직후의 Esc로 닫으려 합니다(fr21_escape_reaches_an_overlay_with_no_focusable_content). +4. 포커스된 `TextField`가 IME 조합 중이면 Esc는 조합을 취소하고 `OnDismiss`는 가지 않습니다(fr21_escape_in_a_composing_text_field_does_not_dismiss). +5. 시트 위에 열린 `Dialog`에서 Esc는 `Dialog`의 `OnDismiss`만 한 번 보내고 시트의 것은 보내지 않습니다(fr21_escape_goes_to_the_topmost_overlay). +6. 닫히면 포커스가 오버레이를 연 컨트롤로 돌아갑니다(fr21_escape_returns_focus_to_the_opener). + #### 21.4 일시 메시지는 노드가 아닙니다 Snackbar를 위젯 태그로 만들면 Host가 **자기 소멸 타이머를 모델링해야 합니다.** "지금부터 4초 동안 열림"이라는 상태를 Rust가 들고, 4초 뒤에 워커가 신호를 보내 프레임을 돌려서 노드를 지워야 합니다. 4초짜리 애니메이션을 위해 VirtualDom이 도는 셈이고, D5가 스크롤 위치와 IME 조합을 Renderer에 둔 것과 정확히 같은 이유로 틀렸습니다. From 1721ef6bfc950ddb0203162ad9be2717c750daaa Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:38:14 +0900 Subject: [PATCH 179/224] Docs: Rewrite the FR-38 group edit as an atomic EditCodeGroup (#496) Replaces the earlier per-batch undo grouping after the consumer's requirements: all or nothing, one base_version check per group, one reply per group with the request_id, coordinates in the base_version document, change events one per edit with the version +1 each. Adds the scenario table, the measured behaviour of consecutive EditCode, and the wire numbers confirmed by the table owner (command 28, event 51, property 128). Added criteria only. --- docs/SPEC.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 5ffddc7d..dba81110 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2335,7 +2335,28 @@ Notification::new("세션이 끝났습니다") - Host는 편집 명령 `EditCode(request_id, base_version, range, text)`로 바꿉니다(서식 맞춤, 완성 수락, 원격 파일 다시 읽기). - Renderer는 `base_version`이 현재 버전보다 오래되었으면 그 사이의 변경을 거쳐 범위를 옮긴 뒤 적용합니다. 옮긴 범위가 사용자가 그 사이에 고친 곳과 겹치면 적용하지 않고 거절 이벤트(`CodeEditRejected`, 핸들러 `OnEditRejected`)를 보냅니다. Host가 사용자의 입력을 덮어쓰지 않습니다. - Host가 적용한 편집도 변경 이벤트로 다시 나갑니다. 그래서 Host와 그 너머(언어 서버, 확장 호스트)가 보는 버전이 하나로 이어집니다. -- **한 배치의 Host 편집은 실행 취소 한 단계입니다.** 같은 편집기에 대해 한 배치(Host의 한 프레임) 안에 도착한 `EditCode`는 사용자가 한 번의 실행 취소로 되돌릴 수 있는 한 단계로 묶입니다. 모두 같은 `base_version`에 대해 쓰인 것이면 서로 겹치지 않는 편집은 모두 적용됩니다("모두 바꾸기"). 새 명령과 새 레코드는 없습니다. Rust 쪽에는 `CodeEditorHandle::edit_group(base_version, 편집 목록)`이 있고, 편집마다 `EditCode` 하나를 차례로 씁니다. 편집마다 자기 `request_id`를 가지므로 겹쳐서 거절된 편집만 `CodeEditRejected`로 돌아오고, 나머지는 적용된 채 같은 단계에 남습니다. 변경 이벤트는 편집마다 하나씩 나가고 버전도 편집마다 하나씩 늘어납니다. 이 방식이라야 변경 이벤트를 차례로 적용해 Renderer의 버퍼를 재현한다는 기준 2가 그대로 지켜집니다. +- **묶음 편집 `EditCodeGroup`: 전부 적용하거나 하나도 적용하지 않습니다**(이슈 #496, Ember의 찾아 바꾸기 "모두 바꾸기"). 편집 여러 개를 명령 하나에 담아 보냅니다. 승인: 감독자 B(위임 권한), 요구: Ember 에디터 담당. + - **좌표는 `base_version` 문서 기준입니다.** 묶음 안의 모든 편집이 같은 문서(`base_version`의 문서)에 대한 범위이고, 앞의 편집이 뒤의 편집 좌표를 흔들지 않습니다. 순서는 상관없습니다. Renderer가 정렬하고 검증합니다. 편집끼리는 겹치면 안 됩니다. 맞닿는 것(앞 편집의 끝이 뒤 편집의 시작)은 겹침이 아닙니다. 같은 자리에 삽입이 둘이면 목록 순서대로 이어 붙습니다. + - **`base_version` 검사는 묶음당 한 번입니다.** `base_version`이 현재보다 오래되었으면 모든 범위를 `EditCode`와 같은 규칙으로 한꺼번에 옮깁니다. 하나라도 사용자가 그 사이에 고친 곳과 겹치면 묶음 전체를 거절합니다. + - **원자적입니다.** 하나라도 적용할 수 없으면 문서는 한 글자도 바뀌지 않습니다. 일부만 적용된 상태는 없습니다. + - **응답은 묶음당 정확히 하나, `CodeEditGroupResult`입니다.** 묶음의 `request_id`를 그대로 싣고, 적용(`Applied`)인지 거절(`Rejected`)인지 무효(`Invalid`)인지와 처음 문제가 된 편집의 순번을 알려 줍니다. 기존 `CodeEditRejected`는 `EditCode` 전용으로 그대로입니다. + - **변경 이벤트는 편집마다 하나, 버전은 편집마다 +1입니다.** Renderer는 문서 뒤쪽 편집부터 적용하므로 각 이벤트의 범위는 그 이벤트 직전 문서 기준입니다(기준 2가 그대로 지켜지고, `CodeChanged`를 `version == 직전 + 1`일 때만 받는 쪽도 틈 없이 받습니다). 이벤트들은 같은 프레임에서 `CodeEditGroupResult`보다 앞에 나갑니다. + - **실행 취소 한 단계입니다.** 묶음 전체가 한 번의 실행 취소로 되돌아가고, 다시 실행으로 모두 돌아옵니다. 사용자의 타이핑과 합쳐지지 않습니다. 묶음은 UI 스레드에서 명령 하나로 적용되므로 사용자 입력이 그 사이에 끼어들 수 없습니다. + - **기존 `EditCode`는 그대로입니다.** 지금의 동작은 이렇습니다. 같은 `base_version`으로 `EditCode`를 연달아 보내면 각각 그 이후의 모든 변경(앞서 적용된 같은 열의 편집 포함)에 대해 범위가 옮겨져 적용되고, 앞 편집의 변경과 겹치는 편집만 `CodeEditRejected`로 돌아오며 앞의 것은 적용된 채 남습니다. 편집마다 실행 취소 한 단계입니다. 원자적이지 않으므로 "모두 바꾸기"에는 `EditCodeGroup`을 씁니다. + - **Rust**: `Batch::edit_group(node_id, request_id, base_version, &[(CodeRange, &str)])`가 명령 하나를 씁니다. 에디터 핸들(`CodeEditorHandle`)은 이 저장소 밖(dioxus-compose)에서 이 호출을 감쌉니다. 편집이 없는 묶음은 아무것도 쓰지 않습니다. + + 상황별 결과: + + | 상황 | 문서 | 변경 이벤트 | `CodeEditGroupResult.status` | `ProtocolError` | + |---|---|---|---|---| + | 겹치지 않는 편집들, `base_version`이 현재 | 모두 적용 | 편집마다 하나, 뒤쪽 편집부터, 버전 +1씩 | `Applied` | 없음 | + | `base_version`이 오래됨, 사용자가 고친 곳과 안 겹침 | 범위를 옮겨 모두 적용 | 위와 같음 | `Applied` | 없음 | + | `base_version`이 오래됨, 한 편집이라도 사용자가 고친 곳과 겹침 | 변화 없음 | 없음 | `Rejected`(순번은 처음 겹친 편집) | 없음 | + | `base_version`이 Renderer가 기억하는 것보다 오래됨 | 변화 없음 | 없음 | `Rejected`(순번 0) | 없음 | + | 편집끼리 겹침 | 변화 없음 | 없음 | `Invalid`(순번은 뒤쪽 겹친 편집) | 한 번 | + | 범위가 문서 밖이거나 시작이 끝보다 뒤 | 변화 없음 | 없음 | `Invalid`(순번은 그 편집) | 한 번 | + | `base_version`이 현재 버전보다 앞섬 | 변화 없음 | 없음 | `Invalid`(순번 0) | 한 번 | + | 편집이 하나도 없음 | 변화 없음 | 없음 | `Applied`(`current_version`은 그대로) | 없음 | - 문서 전체 교체(파일 열기)는 버전 0부터 다시 시작합니다(38.1). #### 38.4 장식 @@ -2382,6 +2403,7 @@ Notification::new("세션이 끝났습니다") | 태그 | 명령 | 레코드 | |---|---|---| | 17 | `EditCode` | 40바이트: 머리 4, `node_id: u32`, `request_id: u32`(앱이 고르며 거절 이벤트가 그대로 돌려줌), `base_version: u32`, `start_line`, `start_column`, `end_line`, `end_column`(각 `u32`), `text: (offset, len)` | +| 28 | `EditCodeGroup` | 가변 길이: 머리 4, `node_id: u32`, `request_id: u32`, `base_version: u32`, `edits: (offset, len)`. 블롭은 `count: u32` 뒤에 편집 레코드 `count`개(24바이트: `start_line`, `start_column`, `end_line`, `end_column`(각 `u32`), `text: (offset, len)`)와 글자 영역이 놓이고, 레코드의 `offset`은 글자 영역의 시작 기준입니다(장식 블롭과 같은 방식). 번호 26, 27은 다른 항목이 예약했습니다. | - 이벤트, FR-36의 25 뒤에 덧붙입니다. 공통 머리 16바이트(`node_id`, `handler_id`) 뒤에 페이로드가 오고, 바이트 수는 머리를 포함한 레코드 길이입니다. @@ -2396,6 +2418,7 @@ Notification::new("세션이 끝났습니다") - CodeLens 클릭과 고스트 텍스트 수락은 `DecorationActivated`(이벤트 태그 30)입니다. 장식 블롭 안의 id에는 Host 쪽 리스너가 붙어 있지 않으므로, 편집기 노드의 리스너 하나(`OnDecorationClick`)로 받고 페이로드의 id로 어느 장식인지 가립니다. - 장식의 id는 앱이 고르는 `u64`이며 0은 "장식 없음"이라 쓰지 않습니다. - 다섯 이벤트의 페이로드는 지금 `EventPayloadType`에 없는 모양이라 다섯을 더합니다. `kind`, `severity`, `phase`는 닫힌 열거형이고 모르는 값은 `ProtocolError`입니다. 범위가 문서 밖이거나 시작이 끝보다 뒤인 장식, 스팬, 편집은 그 항목만 버리고 `ProtocolError`를 보내며, 프로세스는 죽지 않습니다. +- **묶음 편집의 이벤트와 속성(이슈 #496).** 이벤트 `CodeEditGroupResult`는 태그 51(48, 49는 `FocusGained`, `KeyUp`, 50은 `FileDialogResult`가 쓰므로 51입니다)이고 페이로드는 36바이트입니다: 머리 16바이트 뒤에 `request_id: u32`, `status: u32`(`Applied=1`, `Rejected=2`, `Invalid=3`, 모르는 값은 `ProtocolError`), `base_version: u32`, `current_version: u32`(적용되면 마지막 변경 뒤의 버전, 아니면 지금 문서의 버전), `index: u32`(처음 문제가 된 편집의 순번이고 `Applied`이면 적용한 편집의 수). 핸들러 속성 `OnEditGroupResult`는 속성 태그 128입니다(127 `ReportSize`가 마지막 사용분입니다). 세 번호는 번호표 소유자의 확인을 받았습니다. 스키마 서술 문자열 끝에 추가하므로(`commands=...,edit_code_group`, `events=...,CodeEditGroupResult`, `properties=...,on_edit_group_result`) **스키마 해시가 바뀝니다**. 다른 항목이 먼저 병합되면 번호는 그대로 두고 서술 문자열의 순서만 태그 순서에 맞춥니다. #### 38.7 수용 기준 @@ -2421,12 +2444,17 @@ Notification::new("세션이 끝났습니다") 4. 편집을 따라 움직이는 규칙은 기준 14와 같습니다. 앞에 줄을 끼우면 막대가 아래로 따라가고, 그 줄을 지우면 사라집니다. Host로 가는 이벤트는 없습니다. 5. 접근성. 변경 표시 막대가 있는 줄의 접근성 설명에 "추가됨", "수정됨", "삭제됨" 중 하나가 붙고(플랫폼 로케일의 낱말), 색만으로 구분하지 않습니다. 6. 시험 이름은 `fr38_`로 시작합니다. 폭 4dp와 위치는 픽셀 시험으로, 우선순위와 이동은 모델 시험으로, 기존 `Bar` 회귀는 기준 14의 시험이 그대로 통과하는 것으로 확인합니다. -17. 한 배치의 Host 편집은 실행 취소 한 단계입니다(이슈 #496, Ember의 찾아 바꾸기 "모두 바꾸기"). 승인: 감독자 B(위임 권한). 새 명령과 와이어 변경은 없습니다. - 1. 같은 편집기에 대한 `EditCode` 셋이 한 배치로 오면 실행 취소를 한 번 하면 셋이 모두 되돌아가고, 다시 실행을 한 번 하면 셋이 모두 돌아옵니다. 되돌릴 때의 변경 이벤트는 기존 규칙대로 바뀐 순서대로 하나씩 나갑니다. - 2. 두 배치로 나뉘어 온 `EditCode`는 실행 취소 두 단계입니다. 배치 하나에 `EditCode`가 하나뿐인 지금의 경우는 한 단계로 지금과 같습니다. - 3. 변경 이벤트는 편집마다 하나씩 나가고 버전은 편집마다 하나씩 늘어납니다. 변경 이벤트를 차례로 적용한 결과가 Renderer의 버퍼와 같다는 기준 2가 그대로 지켜집니다. - 4. 같은 `base_version`의 편집 가운데 서로 겹치는 것이 있으면 겹친 편집만 `CodeEditRejected`로 돌아오고 나머지는 적용된 채 같은 단계에 남습니다. 사용자의 입력과 겹쳐 거절되는 규칙(38.3)은 그대로입니다. - 5. Rust의 `CodeEditorHandle::edit_group`은 편집마다 `EditCode` 하나를 차례로 쓰고, 부르지 않으면 아무것도 쓰지 않습니다. 시험 이름은 `fr38_`로 시작합니다: `fr38_host_edits_in_one_batch_are_one_undo_step`, `fr38_host_edits_in_two_batches_are_two_undo_steps`, `fr38_overlapping_group_edits_reject_only_the_overlap`, `fr38_edit_group_writes_one_edit_command_per_edit`. +17. 묶음 편집 `EditCodeGroup`(이슈 #496). 승인: 감독자 B(위임 권한). + 1. 겹치지 않는 편집 셋을 `base_version`이 현재인 묶음 하나로 보내면 셋이 모두 적용되고 `CodeEditGroupResult`가 `request_id`와 함께 `Applied`로 정확히 한 번 갑니다(fr38_edit_group_applies_all_edits_and_replies_once). + 2. 변경 이벤트는 편집마다 하나이고, 문서 뒤쪽 편집부터 가며 버전이 하나씩 늘고, 그 이벤트들을 차례로 적용한 결과가 Renderer의 버퍼와 같습니다. 결과 이벤트는 그 뒤에 갑니다(fr38_edit_group_events_are_ordered_and_replayable). + 3. 묶음은 실행 취소 한 번에 모두 되돌아가고 다시 실행 한 번에 모두 돌아옵니다. 직전의 사용자 타이핑과 한 단계로 합쳐지지 않습니다(fr38_edit_group_is_one_undo_step). + 4. 편집끼리 겹치면 문서가 한 글자도 바뀌지 않고 `Invalid`와 `ProtocolError`가 한 번씩 갑니다(fr38_edit_group_with_overlapping_edits_changes_nothing). + 5. 범위가 문서 밖이거나 시작이 끝보다 뒤인 편집이 하나라도 있으면 나머지도 적용되지 않습니다(fr38_edit_group_with_a_bad_range_changes_nothing). + 6. `base_version`이 오래된 묶음은 사용자가 그 사이에 고친 곳과 겹치지 않으면 범위가 모두 옮겨져 적용되고, 한 편집이라도 겹치면 전체가 `Rejected`입니다(fr38_edit_group_with_a_stale_base_moves_or_rejects_all). + 7. Renderer가 기억하는 것보다 오래된 `base_version`은 전체가 `Rejected`입니다(fr38_edit_group_older_than_the_history_is_rejected). + 8. 기존 `EditCode`의 동작(개별 거절, 편집마다 실행 취소 한 단계)은 그대로입니다(fr38_edit_code_sequence_keeps_its_behaviour). + 9. 와이어: `EditCodeGroup`(명령 28)과 `CodeEditGroupResult`(이벤트 51)가 묶음 편집 수와 글자(서로게이트 쌍 포함)를 그대로 왕복하고, 프로토콜 벡터에 있습니다(fr38_edit_group_round_trips_on_the_wire). + 10. Rust의 `Batch::edit_group`은 편집이 없으면 아무것도 쓰지 않습니다(fr38_an_empty_edit_group_writes_nothing). #### 38.9 추가 기능의 와이어 (소유자 승인 2026-10-25, 이슈 #30) From cf6dd87fb857686b17a9aacc43792e5d3dfba3ab Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:39:41 +0900 Subject: [PATCH 180/224] Docs: FR-38 group edit, semantic errors answer only with the result, TooOld status, empty group rules (#496) Invalid no longer sends a ProtocolError: that is kept for a broken wire (record length, encoding), where the result follows it when the header can be read. A base older than the remembered changes is TooOld, separate from Rejected, because the app reacts differently. An empty group changes nothing and replies Applied with both versions equal to the document's. Added criteria only. --- docs/SPEC.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index dba81110..e930f060 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2339,7 +2339,7 @@ Notification::new("세션이 끝났습니다") - **좌표는 `base_version` 문서 기준입니다.** 묶음 안의 모든 편집이 같은 문서(`base_version`의 문서)에 대한 범위이고, 앞의 편집이 뒤의 편집 좌표를 흔들지 않습니다. 순서는 상관없습니다. Renderer가 정렬하고 검증합니다. 편집끼리는 겹치면 안 됩니다. 맞닿는 것(앞 편집의 끝이 뒤 편집의 시작)은 겹침이 아닙니다. 같은 자리에 삽입이 둘이면 목록 순서대로 이어 붙습니다. - **`base_version` 검사는 묶음당 한 번입니다.** `base_version`이 현재보다 오래되었으면 모든 범위를 `EditCode`와 같은 규칙으로 한꺼번에 옮깁니다. 하나라도 사용자가 그 사이에 고친 곳과 겹치면 묶음 전체를 거절합니다. - **원자적입니다.** 하나라도 적용할 수 없으면 문서는 한 글자도 바뀌지 않습니다. 일부만 적용된 상태는 없습니다. - - **응답은 묶음당 정확히 하나, `CodeEditGroupResult`입니다.** 묶음의 `request_id`를 그대로 싣고, 적용(`Applied`)인지 거절(`Rejected`)인지 무효(`Invalid`)인지와 처음 문제가 된 편집의 순번을 알려 줍니다. 기존 `CodeEditRejected`는 `EditCode` 전용으로 그대로입니다. + - **응답은 묶음당 정확히 하나, `CodeEditGroupResult`입니다.** 묶음의 `request_id`를 그대로 싣고, 적용(`Applied`), 거절(`Rejected`, 사용자가 그 사이에 고친 곳과 겹침), 너무 오래됨(`TooOld`, `base_version`이 Renderer가 기억하는 변경보다 오래되어 범위를 옮길 수 없음), 무효(`Invalid`, 묶음 자체가 말이 안 됨) 가운데 하나와, 처음 문제가 된 편집의 순번을 알려 줍니다. 앱의 대응이 다르므로(`Rejected`는 문서를 다시 읽고 다시 보내고, `TooOld`는 문서 전체를 다시 맞추는 것) 이유를 따로 필드로 두지 않고 상태 값으로 가립니다. 의미가 틀린 묶음(`Invalid`)에도 `ProtocolError`는 보내지 않습니다. `ProtocolError`(NFR-7)는 아래 표의 마지막 행처럼 와이어 자체가 깨진 경우에만 갑니다. 기존 `CodeEditRejected`는 `EditCode` 전용으로 그대로입니다. - **변경 이벤트는 편집마다 하나, 버전은 편집마다 +1입니다.** Renderer는 문서 뒤쪽 편집부터 적용하므로 각 이벤트의 범위는 그 이벤트 직전 문서 기준입니다(기준 2가 그대로 지켜지고, `CodeChanged`를 `version == 직전 + 1`일 때만 받는 쪽도 틈 없이 받습니다). 이벤트들은 같은 프레임에서 `CodeEditGroupResult`보다 앞에 나갑니다. - **실행 취소 한 단계입니다.** 묶음 전체가 한 번의 실행 취소로 되돌아가고, 다시 실행으로 모두 돌아옵니다. 사용자의 타이핑과 합쳐지지 않습니다. 묶음은 UI 스레드에서 명령 하나로 적용되므로 사용자 입력이 그 사이에 끼어들 수 없습니다. - **기존 `EditCode`는 그대로입니다.** 지금의 동작은 이렇습니다. 같은 `base_version`으로 `EditCode`를 연달아 보내면 각각 그 이후의 모든 변경(앞서 적용된 같은 열의 편집 포함)에 대해 범위가 옮겨져 적용되고, 앞 편집의 변경과 겹치는 편집만 `CodeEditRejected`로 돌아오며 앞의 것은 적용된 채 남습니다. 편집마다 실행 취소 한 단계입니다. 원자적이지 않으므로 "모두 바꾸기"에는 `EditCodeGroup`을 씁니다. @@ -2352,11 +2352,13 @@ Notification::new("세션이 끝났습니다") | 겹치지 않는 편집들, `base_version`이 현재 | 모두 적용 | 편집마다 하나, 뒤쪽 편집부터, 버전 +1씩 | `Applied` | 없음 | | `base_version`이 오래됨, 사용자가 고친 곳과 안 겹침 | 범위를 옮겨 모두 적용 | 위와 같음 | `Applied` | 없음 | | `base_version`이 오래됨, 한 편집이라도 사용자가 고친 곳과 겹침 | 변화 없음 | 없음 | `Rejected`(순번은 처음 겹친 편집) | 없음 | - | `base_version`이 Renderer가 기억하는 것보다 오래됨 | 변화 없음 | 없음 | `Rejected`(순번 0) | 없음 | - | 편집끼리 겹침 | 변화 없음 | 없음 | `Invalid`(순번은 뒤쪽 겹친 편집) | 한 번 | - | 범위가 문서 밖이거나 시작이 끝보다 뒤 | 변화 없음 | 없음 | `Invalid`(순번은 그 편집) | 한 번 | - | `base_version`이 현재 버전보다 앞섬 | 변화 없음 | 없음 | `Invalid`(순번 0) | 한 번 | - | 편집이 하나도 없음 | 변화 없음 | 없음 | `Applied`(`current_version`은 그대로) | 없음 | + | `base_version`이 Renderer가 기억하는 것보다 오래됨 | 변화 없음 | 없음 | `TooOld`(순번 0) | 없음 | + | 편집끼리 겹침 | 변화 없음 | 없음 | `Invalid`(순번은 뒤쪽 겹친 편집) | 없음 | + | 범위가 문서 밖이거나 시작이 끝보다 뒤 | 변화 없음 | 없음 | `Invalid`(순번은 그 편집) | 없음 | + | `base_version`이 현재 버전보다 앞섬 | 변화 없음 | 없음 | `Invalid`(순번 0) | 없음 | + | 편집이 하나도 없음 | 변화 없음, 실행 취소 단계도 늘지 않음, 버전 그대로 | 없음 | `Applied`(결과의 `base_version`과 `current_version`이 모두 지금 문서의 버전. 보낸 `base_version`은 보지 않습니다) | 없음 | + | 와이어가 깨짐(레코드 길이나 인코딩 오류). 머리의 `node_id`와 `request_id`를 읽을 수 있으면 | 변화 없음 | 없음 | 그 `request_id`로 `Invalid`(순번 0)를 `ProtocolError` 뒤에 한 번 | 한 번 | + | 와이어가 깨져서 머리조차 읽을 수 없음 | 변화 없음 | 없음 | 보낼 수 없음 | 한 번 | - 문서 전체 교체(파일 열기)는 버전 0부터 다시 시작합니다(38.1). #### 38.4 장식 @@ -2418,7 +2420,7 @@ Notification::new("세션이 끝났습니다") - CodeLens 클릭과 고스트 텍스트 수락은 `DecorationActivated`(이벤트 태그 30)입니다. 장식 블롭 안의 id에는 Host 쪽 리스너가 붙어 있지 않으므로, 편집기 노드의 리스너 하나(`OnDecorationClick`)로 받고 페이로드의 id로 어느 장식인지 가립니다. - 장식의 id는 앱이 고르는 `u64`이며 0은 "장식 없음"이라 쓰지 않습니다. - 다섯 이벤트의 페이로드는 지금 `EventPayloadType`에 없는 모양이라 다섯을 더합니다. `kind`, `severity`, `phase`는 닫힌 열거형이고 모르는 값은 `ProtocolError`입니다. 범위가 문서 밖이거나 시작이 끝보다 뒤인 장식, 스팬, 편집은 그 항목만 버리고 `ProtocolError`를 보내며, 프로세스는 죽지 않습니다. -- **묶음 편집의 이벤트와 속성(이슈 #496).** 이벤트 `CodeEditGroupResult`는 태그 51(48, 49는 `FocusGained`, `KeyUp`, 50은 `FileDialogResult`가 쓰므로 51입니다)이고 페이로드는 36바이트입니다: 머리 16바이트 뒤에 `request_id: u32`, `status: u32`(`Applied=1`, `Rejected=2`, `Invalid=3`, 모르는 값은 `ProtocolError`), `base_version: u32`, `current_version: u32`(적용되면 마지막 변경 뒤의 버전, 아니면 지금 문서의 버전), `index: u32`(처음 문제가 된 편집의 순번이고 `Applied`이면 적용한 편집의 수). 핸들러 속성 `OnEditGroupResult`는 속성 태그 128입니다(127 `ReportSize`가 마지막 사용분입니다). 세 번호는 번호표 소유자의 확인을 받았습니다. 스키마 서술 문자열 끝에 추가하므로(`commands=...,edit_code_group`, `events=...,CodeEditGroupResult`, `properties=...,on_edit_group_result`) **스키마 해시가 바뀝니다**. 다른 항목이 먼저 병합되면 번호는 그대로 두고 서술 문자열의 순서만 태그 순서에 맞춥니다. +- **묶음 편집의 이벤트와 속성(이슈 #496).** 이벤트 `CodeEditGroupResult`는 태그 51(48, 49는 `FocusGained`, `KeyUp`, 50은 `FileDialogResult`가 쓰므로 51입니다)이고 페이로드는 36바이트입니다: 머리 16바이트 뒤에 `request_id: u32`, `status: u32`(`Applied=1`, `Rejected=2`, `Invalid=3`, `TooOld=4`, 모르는 값은 `ProtocolError`), `base_version: u32`, `current_version: u32`(적용되면 마지막 변경 뒤의 버전, 아니면 지금 문서의 버전), `index: u32`(처음 문제가 된 편집의 순번이고 `Applied`이면 적용한 편집의 수). 핸들러 속성 `OnEditGroupResult`는 속성 태그 128입니다(127 `ReportSize`가 마지막 사용분입니다). 세 번호는 번호표 소유자의 확인을 받았습니다. 스키마 서술 문자열 끝에 추가하므로(`commands=...,edit_code_group`, `events=...,CodeEditGroupResult`, `properties=...,on_edit_group_result`) **스키마 해시가 바뀝니다**. 다른 항목이 먼저 병합되면 번호는 그대로 두고 서술 문자열의 순서만 태그 순서에 맞춥니다. #### 38.7 수용 기준 @@ -2448,13 +2450,15 @@ Notification::new("세션이 끝났습니다") 1. 겹치지 않는 편집 셋을 `base_version`이 현재인 묶음 하나로 보내면 셋이 모두 적용되고 `CodeEditGroupResult`가 `request_id`와 함께 `Applied`로 정확히 한 번 갑니다(fr38_edit_group_applies_all_edits_and_replies_once). 2. 변경 이벤트는 편집마다 하나이고, 문서 뒤쪽 편집부터 가며 버전이 하나씩 늘고, 그 이벤트들을 차례로 적용한 결과가 Renderer의 버퍼와 같습니다. 결과 이벤트는 그 뒤에 갑니다(fr38_edit_group_events_are_ordered_and_replayable). 3. 묶음은 실행 취소 한 번에 모두 되돌아가고 다시 실행 한 번에 모두 돌아옵니다. 직전의 사용자 타이핑과 한 단계로 합쳐지지 않습니다(fr38_edit_group_is_one_undo_step). - 4. 편집끼리 겹치면 문서가 한 글자도 바뀌지 않고 `Invalid`와 `ProtocolError`가 한 번씩 갑니다(fr38_edit_group_with_overlapping_edits_changes_nothing). + 4. 편집끼리 겹치면 문서가 한 글자도 바뀌지 않고 `Invalid`가 한 번 가며 `ProtocolError`는 가지 않습니다(fr38_edit_group_with_overlapping_edits_changes_nothing). 5. 범위가 문서 밖이거나 시작이 끝보다 뒤인 편집이 하나라도 있으면 나머지도 적용되지 않습니다(fr38_edit_group_with_a_bad_range_changes_nothing). 6. `base_version`이 오래된 묶음은 사용자가 그 사이에 고친 곳과 겹치지 않으면 범위가 모두 옮겨져 적용되고, 한 편집이라도 겹치면 전체가 `Rejected`입니다(fr38_edit_group_with_a_stale_base_moves_or_rejects_all). - 7. Renderer가 기억하는 것보다 오래된 `base_version`은 전체가 `Rejected`입니다(fr38_edit_group_older_than_the_history_is_rejected). + 7. Renderer가 기억하는 것보다 오래된 `base_version`은 전체가 `TooOld`입니다. 사용자와 겹쳐 거절되는 `Rejected`와 상태 값이 다릅니다(fr38_edit_group_older_than_the_history_is_too_old). 8. 기존 `EditCode`의 동작(개별 거절, 편집마다 실행 취소 한 단계)은 그대로입니다(fr38_edit_code_sequence_keeps_its_behaviour). 9. 와이어: `EditCodeGroup`(명령 28)과 `CodeEditGroupResult`(이벤트 51)가 묶음 편집 수와 글자(서로게이트 쌍 포함)를 그대로 왕복하고, 프로토콜 벡터에 있습니다(fr38_edit_group_round_trips_on_the_wire). 10. Rust의 `Batch::edit_group`은 편집이 없으면 아무것도 쓰지 않습니다(fr38_an_empty_edit_group_writes_nothing). + 11. 편집이 하나도 없는 `EditCodeGroup`이 Renderer에 닿으면 문서가 바뀌지 않고, 변경 이벤트가 0개이고, 실행 취소 단계가 늘지 않고, 버전이 그대로이며, `Applied` 결과의 `base_version`과 `current_version`이 같고 지금 문서의 버전입니다(fr38_an_empty_group_changes_nothing_and_replies_applied). + 12. 레코드 길이가 맞지 않는 묶음은 문서를 바꾸지 않고 `ProtocolError` 하나를 보내며, 머리를 읽을 수 있으면 같은 `request_id`로 `Invalid` 결과가 뒤따릅니다. 의미가 틀린 묶음(기준 4, 5)에는 `ProtocolError`가 없습니다(fr38_a_broken_group_record_is_a_protocol_error_and_a_semantic_error_is_not). #### 38.9 추가 기능의 와이어 (소유자 승인 2026-10-25, 이슈 #30) From 8a3e49283345a2996b1f4679d41b73a576a62162 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:27:17 +0900 Subject: [PATCH 181/224] Feat: Dismiss open Dialog and Sheet with Escape and restore focus (FR-21.3.2, #476) --- .../shared/CodeEditorSurface.kt | 2 + .../android-kotlin/shared/ComposeRustHost.kt | 6 +- .../android-kotlin/shared/HostButton.kt | 28 +- .../android-kotlin/shared/HostOverlay.kt | 123 ++++++- .../android-kotlin/shared/HostSheet.kt | 7 +- .../android-kotlin/shared/HostTextField.kt | 35 +- .../android-kotlin/shared/ModifierChain.kt | 8 + docs/SPEC.md | 14 +- .../desktop/src/renderer/CodeEditorSurface.kt | 2 + .../desktop/src/renderer/ComposeRustHost.kt | 6 +- renderer/desktop/src/renderer/HostButton.kt | 28 +- renderer/desktop/src/renderer/HostOverlay.kt | 123 ++++++- renderer/desktop/src/renderer/HostSheet.kt | 7 +- .../desktop/src/renderer/HostTextField.kt | 35 +- .../desktop/src/renderer/ModifierChain.kt | 8 + renderer/desktop/test/SheetTest.kt | 314 ++++++++++++++++++ 16 files changed, 683 insertions(+), 63 deletions(-) diff --git a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt index ad9054ea..37d69bd9 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt @@ -860,6 +860,7 @@ private fun FieldSurface( // reader itself reads the field's layout state, so what draws from it still follows. val layoutHolder = remember { arrayOfNulls<() -> TextLayoutResult?>(1) } val focus = remember { FocusRequester() } + val overlayFocusStack = dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack.current LaunchedEffect(focusRequest) { if (focusRequest > 0L) focus.requestFocus() } @@ -1009,6 +1010,7 @@ private fun FieldSurface( } } .onFocusChanged { focusState -> + overlayFocusStack.noteFocused(focus, focusState.hasFocus) model.focusChanged(focusState.hasFocus) callbacks.onOutput() } diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 9f2c2778..ed209fc9 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -615,6 +615,7 @@ private fun ComposeRustTree( // wherever focus is. Heard on the way back up from the focused node, so a text field // that wants the key keeps it. val sidebarShortcuts = remember(host) { dev.darkpyonix.composerust.foundation.SidebarShortcuts() } + val overlayFocusStack = remember(host, windowNodeId) { dev.darkpyonix.composerust.foundation.OverlayFocusStack() } val dragWindow = LocalWindowDrag.current val dragExclusion = remember { WindowDragExclusion() } val dragStrip = if (caption.fullSize && caption.height > 0.dp) Modifier.pointerInput(caption.height, dragWindow) { @@ -635,6 +636,7 @@ private fun ComposeRustTree( } else Modifier CompositionLocalProvider( dev.darkpyonix.composerust.foundation.LocalSidebarShortcuts provides sidebarShortcuts, + dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack provides overlayFocusStack, LocalWindowDragExclusion provides dragExclusion, LocalDesignTheme provides theme, LocalReduceTransparency provides reduceTransparency, @@ -655,7 +657,9 @@ private fun ComposeRustTree( modifier .then(measured) .then(dragStrip) - .onKeyEvent { event -> sidebarShortcuts.handle(event, platform) } + .onKeyEvent { event -> + overlayFocusStack.handleEscape(event) || sidebarShortcuts.handle(event, platform) + } .background(host.table.windowFill(pageRoots, theme)), ) { Box(Modifier.padding(top = pageTop, bottom = pageBottom)) { diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 0530caea..b1e52571 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout @@ -125,22 +128,27 @@ internal fun HostButton( // label instead of staying opaque behind it. The label is the exception: it is drawn // again outside the fade, in a colour that keeps its contrast with the faded fill. val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(node.id) { FocusRequester() } val clickable = if (handlerId == null) { available.then(modifier) } else { // The Host result is not used here: a click that reached a Button is never offered // to anything below it. Indication is null because the design system owns the // feedback, and a ripple is only one system's answer. - available.then(modifier).pressFeedback( - interactions, enabled, theme, style.shape, - node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, - node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, - ).clickable( - interactionSource = interactions, - indication = null, - enabled = enabled, - role = Role.Button, - ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } + available.then(modifier) + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } + .pressFeedback( + interactions, enabled, theme, style.shape, + node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, + node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, + ).clickable( + interactionSource = interactions, + indication = null, + enabled = enabled, + role = Role.Button, + ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } } // A Host that named its own Background has already had it applied to this node's diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index d3273868..43483ea3 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -1,6 +1,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -8,8 +9,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -17,6 +21,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -41,6 +54,101 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider +/** + * The open modal overlays of a window, in the order they were opened, and the control that + * held focus before each one opened. + * + * Escape asks to close only the topmost open overlay, and only after whatever holds focus + * inside it has declined the key. When the overlay closes, focus returns to the control that + * had it before the overlay opened. + */ +class OverlayFocusStack { + private class Entry(val nodeId: Int, var onEscape: () -> Unit, val opener: FocusRequester?) + + private val entries = mutableListOf() + private var currentFocused: FocusRequester? = null + + internal fun noteFocused(requester: FocusRequester, focused: Boolean) { + if (focused) { + currentFocused = requester + } else if (currentFocused === requester) { + currentFocused = null + } + } + + internal fun push(nodeId: Int, onEscape: () -> Unit): FocusRequester? { + entries.removeAll { it.nodeId == nodeId } + val opener = currentFocused + entries += Entry(nodeId, onEscape, opener) + return opener + } + + internal fun update(nodeId: Int, onEscape: () -> Unit) { + entries.firstOrNull { it.nodeId == nodeId }?.onEscape = onEscape + } + + internal fun pop(nodeId: Int) { + val index = entries.indexOfFirst { it.nodeId == nodeId } + if (index < 0) return + val removed = entries.removeAt(index) + val target = if (entries.isEmpty()) removed.opener else null + if (target != null) { + try { + target.requestFocus() + } catch (_: IllegalStateException) { + // The opener left the tree while the overlay was open. + } + } + } + + internal fun handleEscape(event: KeyEvent, fromNodeId: Int? = null): Boolean { + if (event.type != KeyEventType.KeyDown || event.key != Key.Escape) return false + val top = entries.lastOrNull() ?: return false + if (fromNodeId != null && top.nodeId != fromNodeId) return false + top.onEscape() + return true + } +} + +val LocalOverlayFocusStack = staticCompositionLocalOf { OverlayFocusStack() } + +/** + * Keeps [node]'s presence in the window's overlay stack and moves focus into [surfaceRequester] + * when the overlay opens, returning focus to the opener when it leaves. + */ +@Composable +internal fun ModalOverlayFocus( + nodeId: Int, + surfaceRequester: FocusRequester, + onEscape: () -> Unit, +) { + val stack = LocalOverlayFocusStack.current + SideEffect { stack.update(nodeId, onEscape) } + DisposableEffect(stack, nodeId) { + stack.push(nodeId, onEscape) + onDispose { stack.pop(nodeId) } + } + LaunchedEffect(nodeId) { + try { + surfaceRequester.requestFocus() + } catch (_: IllegalStateException) { + } + } +} + +/** Modifier that places an overlay's surface in the focus tree and catches an unconsumed Escape. */ +@Composable +internal fun Modifier.modalOverlaySurface( + nodeId: Int, + surfaceRequester: FocusRequester, +): Modifier { + val stack = LocalOverlayFocusStack.current + return this + .onKeyEvent { event -> stack.handleEscape(event, fromNodeId = nodeId) } + .focusRequester(surfaceRequester) + .focusable() +} + /** Whether an overlay is showing, and the way to change it without asking the Host. */ internal class OverlayOpen(val value: Boolean, val set: (Boolean) -> Unit) @@ -72,6 +180,9 @@ internal fun dismiss(node: Node, dispatcher: EventDispatcher) { * The scrim, the placement and the corner come from the design system, so the same records * are a Material dialog, a HIG alert or a Fluent content dialog. */ +/** Test tag of a dialog's surface inside its popup. */ +fun dialogTestTag(nodeId: Int): String = "${nodeTestTag(nodeId)}-dialog" + @Composable internal fun HostDialog( node: Node, @@ -83,19 +194,27 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = { open.set(false) dismiss(node, dispatcher) }, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( Modifier.fillMaxSize().background(style.scrim), contentAlignment = Alignment.Center, ) { - Column(modifier.containerDecoration(node, style, theme, overlay = true)) { + Column( + modifier + .testTag(dialogTestTag(node.id)) + .modalOverlaySurface(node.id, surfaceRequester) + .containerDecoration(node, style, theme, overlay = true), + ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } diff --git a/compose-rust/android-kotlin/shared/HostSheet.kt b/compose-rust/android-kotlin/shared/HostSheet.kt index 2bdd6d72..f5a12bb9 100644 --- a/compose-rust/android-kotlin/shared/HostSheet.kt +++ b/compose-rust/android-kotlin/shared/HostSheet.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -80,12 +81,15 @@ internal fun HostSheet( open.set(false) dismiss(node, dispatcher) } + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = close, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( Modifier .testTag(sheetScrimTestTag(node.id)) @@ -114,6 +118,7 @@ internal fun HostSheet( Box( Modifier .testTag(sheetTestTag(node.id)) + .modalOverlaySurface(node.id, surfaceRequester) .then(sized) .offset { if (bottom) { diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 0306bfdc..1367c47d 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -92,6 +92,7 @@ internal fun HostTextField( var focused by remember(nodeId) { mutableStateOf(false) } var reportedComposing by remember(nodeId) { mutableStateOf(false) } val focusRequester = remember(nodeId) { FocusRequester() } + val overlayFocusStack = LocalOverlayFocusStack.current val multiline = node.flag(PropertyKind.Multiline, default = false) val enabled = node.flag(PropertyKind.Enabled, default = true) @@ -193,6 +194,7 @@ internal fun HostTextField( val inputModifier = modifier .focusRequester(focusRequester) .onFocusChanged { state -> + overlayFocusStack.noteFocused(focusRequester, state.isFocused) if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) } @@ -206,22 +208,27 @@ internal fun HostTextField( // guard that keeps Enter away from Rust during a Korean composition. .onPreviewKeyEvent { event -> val composingNow = value.composition != null - val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) - if (consumedByHandler) { + if (composingNow && event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + applyValueChange(value.copy(composition = null)) true - } else if (!isDispatchableKeyDown(event, composingNow)) { - false - } else if (!shouldSubmitOnEnter( - composing = composingNow, - multiline = multiline, - shiftPressed = event.isShiftPressed, - hasSubmitHandler = submitHandler != null, - ) - ) { - false } else { - dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + val consumedByHandler = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) + if (consumedByHandler) { + true + } else if (!isDispatchableKeyDown(event, composingNow)) { + false + } else if (!shouldSubmitOnEnter( + composing = composingNow, + multiline = multiline, + shiftPressed = event.isShiftPressed, + hasSubmitHandler = submitHandler != null, + ) + ) { + false + } else { + dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + } } } diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 73540ffd..e98f2707 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -54,6 +54,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect @@ -797,7 +801,11 @@ internal fun Modifier.pressable( } val ink = theme.color(ColorRole.OnSurface) val ring = theme.color(ColorRole.Primary) + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(nodeId) { FocusRequester() } this + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback(source, enabled, theme, shape, pressColor, feedback) .hoverable(source, enabled) .clickable( diff --git a/docs/SPEC.md b/docs/SPEC.md index ce6b2951..61771355 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1663,7 +1663,7 @@ Fluent의 `NavigationView`가 결정적입니다. 한 컴포넌트가 폭에 따 5. Compact 창에서는 같은 선언이 가장자리 시트로 그려집니다. 6. `Presentation`을 말하지 않은 시트는 지금과 같습니다. -#### 21.3.2 Esc가 열린 Dialog와 Sheet를 닫으려 합니다 (`Draft`, 이슈 #476) +#### 21.3.2 Esc가 열린 Dialog와 Sheet를 닫으려 합니다 (`Done`, 이슈 #476) 요청한 쪽: darkpyonix-ember(#502 감사). Esc로 시트가 닫히지 않고 닫기 버튼만 동작했습니다. 21.3.1은 가운데 시트의 Esc만 적었고, Dialog와 가장자리 시트는 적지 않았습니다. Rust API에는 `Key::Escape`도, 컨테이너의 `on_key_down`도 없어서 앱이 스스로 해결할 수 없으므로 Renderer가 합니다. 승인: 감독자 B(위임 권한). @@ -1674,12 +1674,12 @@ Fluent의 `NavigationView`가 결정적입니다. 한 컴포넌트가 폭에 따 - 새 속성, 새 키, 새 이벤트는 없고 와이어 변경도 없습니다. 수용 기준. -1. 열린 `Dialog`에 Esc를 보내면 `OnDismiss`가 한 번 가고, `Open`이 그대로이면 Dialog는 열려 있습니다(fr21_escape_dismisses_an_open_dialog_once). -2. `Presentation=Edge`와 `Centered`의 열린 `Sheet` 각각에서 같습니다(fr21_escape_dismisses_an_open_sheet_once). -3. 안에 포커스를 받을 컨트롤이 없는 오버레이도 열린 직후의 Esc로 닫으려 합니다(fr21_escape_reaches_an_overlay_with_no_focusable_content). -4. 포커스된 `TextField`가 IME 조합 중이면 Esc는 조합을 취소하고 `OnDismiss`는 가지 않습니다(fr21_escape_in_a_composing_text_field_does_not_dismiss). -5. 시트 위에 열린 `Dialog`에서 Esc는 `Dialog`의 `OnDismiss`만 한 번 보내고 시트의 것은 보내지 않습니다(fr21_escape_goes_to_the_topmost_overlay). -6. 닫히면 포커스가 오버레이를 연 컨트롤로 돌아갑니다(fr21_escape_returns_focus_to_the_opener). +1. 열린 `Dialog`에 Esc를 보내면 `OnDismiss`가 한 번 가고, `Open`이 그대로이면 Dialog는 열려 있습니다(`fr21_escape_dismisses_an_open_dialog_once`). +2. `Presentation=Edge`와 `Centered`의 열린 `Sheet` 각각에서 같습니다(`fr21_escape_dismisses_an_open_sheet_once`). +3. 안에 포커스를 받을 컨트롤이 없는 오버레이도 열린 직후의 Esc로 닫으려 합니다(`fr21_escape_reaches_an_overlay_with_no_focusable_content`). +4. 포커스된 `TextField`가 IME 조합 중이면 Esc는 조합을 취소하고 `OnDismiss`는 가지 않습니다(`fr21_escape_in_a_composing_text_field_does_not_dismiss`). +5. 시트 위에 열린 `Dialog`에서 Esc는 `Dialog`의 `OnDismiss`만 한 번 보내고 시트의 것은 보내지 않습니다(`fr21_escape_goes_to_the_topmost_overlay`). +6. 닫히면 포커스가 오버레이를 연 컨트롤로 돌아갑니다(`fr21_escape_returns_focus_to_the_opener`). #### 21.4 일시 메시지는 노드가 아닙니다 diff --git a/renderer/desktop/src/renderer/CodeEditorSurface.kt b/renderer/desktop/src/renderer/CodeEditorSurface.kt index ad9054ea..37d69bd9 100644 --- a/renderer/desktop/src/renderer/CodeEditorSurface.kt +++ b/renderer/desktop/src/renderer/CodeEditorSurface.kt @@ -860,6 +860,7 @@ private fun FieldSurface( // reader itself reads the field's layout state, so what draws from it still follows. val layoutHolder = remember { arrayOfNulls<() -> TextLayoutResult?>(1) } val focus = remember { FocusRequester() } + val overlayFocusStack = dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack.current LaunchedEffect(focusRequest) { if (focusRequest > 0L) focus.requestFocus() } @@ -1009,6 +1010,7 @@ private fun FieldSurface( } } .onFocusChanged { focusState -> + overlayFocusStack.noteFocused(focus, focusState.hasFocus) model.focusChanged(focusState.hasFocus) callbacks.onOutput() } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 9f2c2778..ed209fc9 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -615,6 +615,7 @@ private fun ComposeRustTree( // wherever focus is. Heard on the way back up from the focused node, so a text field // that wants the key keeps it. val sidebarShortcuts = remember(host) { dev.darkpyonix.composerust.foundation.SidebarShortcuts() } + val overlayFocusStack = remember(host, windowNodeId) { dev.darkpyonix.composerust.foundation.OverlayFocusStack() } val dragWindow = LocalWindowDrag.current val dragExclusion = remember { WindowDragExclusion() } val dragStrip = if (caption.fullSize && caption.height > 0.dp) Modifier.pointerInput(caption.height, dragWindow) { @@ -635,6 +636,7 @@ private fun ComposeRustTree( } else Modifier CompositionLocalProvider( dev.darkpyonix.composerust.foundation.LocalSidebarShortcuts provides sidebarShortcuts, + dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack provides overlayFocusStack, LocalWindowDragExclusion provides dragExclusion, LocalDesignTheme provides theme, LocalReduceTransparency provides reduceTransparency, @@ -655,7 +657,9 @@ private fun ComposeRustTree( modifier .then(measured) .then(dragStrip) - .onKeyEvent { event -> sidebarShortcuts.handle(event, platform) } + .onKeyEvent { event -> + overlayFocusStack.handleEscape(event) || sidebarShortcuts.handle(event, platform) + } .background(host.table.windowFill(pageRoots, theme)), ) { Box(Modifier.padding(top = pageTop, bottom = pageBottom)) { diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 0530caea..b1e52571 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout @@ -125,22 +128,27 @@ internal fun HostButton( // label instead of staying opaque behind it. The label is the exception: it is drawn // again outside the fade, in a colour that keeps its contrast with the faded fill. val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(node.id) { FocusRequester() } val clickable = if (handlerId == null) { available.then(modifier) } else { // The Host result is not used here: a click that reached a Button is never offered // to anything below it. Indication is null because the design system owns the // feedback, and a ripple is only one system's answer. - available.then(modifier).pressFeedback( - interactions, enabled, theme, style.shape, - node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, - node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, - ).clickable( - interactionSource = interactions, - indication = null, - enabled = enabled, - role = Role.Button, - ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } + available.then(modifier) + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } + .pressFeedback( + interactions, enabled, theme, style.shape, + node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, + node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, + ).clickable( + interactionSource = interactions, + indication = null, + enabled = enabled, + role = Role.Button, + ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } } // A Host that named its own Background has already had it applied to this node's diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index d3273868..43483ea3 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -1,6 +1,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -8,8 +9,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -17,6 +21,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -41,6 +54,101 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider +/** + * The open modal overlays of a window, in the order they were opened, and the control that + * held focus before each one opened. + * + * Escape asks to close only the topmost open overlay, and only after whatever holds focus + * inside it has declined the key. When the overlay closes, focus returns to the control that + * had it before the overlay opened. + */ +class OverlayFocusStack { + private class Entry(val nodeId: Int, var onEscape: () -> Unit, val opener: FocusRequester?) + + private val entries = mutableListOf() + private var currentFocused: FocusRequester? = null + + internal fun noteFocused(requester: FocusRequester, focused: Boolean) { + if (focused) { + currentFocused = requester + } else if (currentFocused === requester) { + currentFocused = null + } + } + + internal fun push(nodeId: Int, onEscape: () -> Unit): FocusRequester? { + entries.removeAll { it.nodeId == nodeId } + val opener = currentFocused + entries += Entry(nodeId, onEscape, opener) + return opener + } + + internal fun update(nodeId: Int, onEscape: () -> Unit) { + entries.firstOrNull { it.nodeId == nodeId }?.onEscape = onEscape + } + + internal fun pop(nodeId: Int) { + val index = entries.indexOfFirst { it.nodeId == nodeId } + if (index < 0) return + val removed = entries.removeAt(index) + val target = if (entries.isEmpty()) removed.opener else null + if (target != null) { + try { + target.requestFocus() + } catch (_: IllegalStateException) { + // The opener left the tree while the overlay was open. + } + } + } + + internal fun handleEscape(event: KeyEvent, fromNodeId: Int? = null): Boolean { + if (event.type != KeyEventType.KeyDown || event.key != Key.Escape) return false + val top = entries.lastOrNull() ?: return false + if (fromNodeId != null && top.nodeId != fromNodeId) return false + top.onEscape() + return true + } +} + +val LocalOverlayFocusStack = staticCompositionLocalOf { OverlayFocusStack() } + +/** + * Keeps [node]'s presence in the window's overlay stack and moves focus into [surfaceRequester] + * when the overlay opens, returning focus to the opener when it leaves. + */ +@Composable +internal fun ModalOverlayFocus( + nodeId: Int, + surfaceRequester: FocusRequester, + onEscape: () -> Unit, +) { + val stack = LocalOverlayFocusStack.current + SideEffect { stack.update(nodeId, onEscape) } + DisposableEffect(stack, nodeId) { + stack.push(nodeId, onEscape) + onDispose { stack.pop(nodeId) } + } + LaunchedEffect(nodeId) { + try { + surfaceRequester.requestFocus() + } catch (_: IllegalStateException) { + } + } +} + +/** Modifier that places an overlay's surface in the focus tree and catches an unconsumed Escape. */ +@Composable +internal fun Modifier.modalOverlaySurface( + nodeId: Int, + surfaceRequester: FocusRequester, +): Modifier { + val stack = LocalOverlayFocusStack.current + return this + .onKeyEvent { event -> stack.handleEscape(event, fromNodeId = nodeId) } + .focusRequester(surfaceRequester) + .focusable() +} + /** Whether an overlay is showing, and the way to change it without asking the Host. */ internal class OverlayOpen(val value: Boolean, val set: (Boolean) -> Unit) @@ -72,6 +180,9 @@ internal fun dismiss(node: Node, dispatcher: EventDispatcher) { * The scrim, the placement and the corner come from the design system, so the same records * are a Material dialog, a HIG alert or a Fluent content dialog. */ +/** Test tag of a dialog's surface inside its popup. */ +fun dialogTestTag(nodeId: Int): String = "${nodeTestTag(nodeId)}-dialog" + @Composable internal fun HostDialog( node: Node, @@ -83,19 +194,27 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = { open.set(false) dismiss(node, dispatcher) }, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( Modifier.fillMaxSize().background(style.scrim), contentAlignment = Alignment.Center, ) { - Column(modifier.containerDecoration(node, style, theme, overlay = true)) { + Column( + modifier + .testTag(dialogTestTag(node.id)) + .modalOverlaySurface(node.id, surfaceRequester) + .containerDecoration(node, style, theme, overlay = true), + ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } diff --git a/renderer/desktop/src/renderer/HostSheet.kt b/renderer/desktop/src/renderer/HostSheet.kt index 2bdd6d72..f5a12bb9 100644 --- a/renderer/desktop/src/renderer/HostSheet.kt +++ b/renderer/desktop/src/renderer/HostSheet.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -80,12 +81,15 @@ internal fun HostSheet( open.set(false) dismiss(node, dispatcher) } + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = close, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( Modifier .testTag(sheetScrimTestTag(node.id)) @@ -114,6 +118,7 @@ internal fun HostSheet( Box( Modifier .testTag(sheetTestTag(node.id)) + .modalOverlaySurface(node.id, surfaceRequester) .then(sized) .offset { if (bottom) { diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 0306bfdc..1367c47d 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -92,6 +92,7 @@ internal fun HostTextField( var focused by remember(nodeId) { mutableStateOf(false) } var reportedComposing by remember(nodeId) { mutableStateOf(false) } val focusRequester = remember(nodeId) { FocusRequester() } + val overlayFocusStack = LocalOverlayFocusStack.current val multiline = node.flag(PropertyKind.Multiline, default = false) val enabled = node.flag(PropertyKind.Enabled, default = true) @@ -193,6 +194,7 @@ internal fun HostTextField( val inputModifier = modifier .focusRequester(focusRequester) .onFocusChanged { state -> + overlayFocusStack.noteFocused(focusRequester, state.isFocused) if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) } @@ -206,22 +208,27 @@ internal fun HostTextField( // guard that keeps Enter away from Rust during a Korean composition. .onPreviewKeyEvent { event -> val composingNow = value.composition != null - val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) - if (consumedByHandler) { + if (composingNow && event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + applyValueChange(value.copy(composition = null)) true - } else if (!isDispatchableKeyDown(event, composingNow)) { - false - } else if (!shouldSubmitOnEnter( - composing = composingNow, - multiline = multiline, - shiftPressed = event.isShiftPressed, - hasSubmitHandler = submitHandler != null, - ) - ) { - false } else { - dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + val consumedByHandler = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) + if (consumedByHandler) { + true + } else if (!isDispatchableKeyDown(event, composingNow)) { + false + } else if (!shouldSubmitOnEnter( + composing = composingNow, + multiline = multiline, + shiftPressed = event.isShiftPressed, + hasSubmitHandler = submitHandler != null, + ) + ) { + false + } else { + dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + } } } diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 73540ffd..e98f2707 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -54,6 +54,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect @@ -797,7 +801,11 @@ internal fun Modifier.pressable( } val ink = theme.color(ColorRole.OnSurface) val ring = theme.color(ColorRole.Primary) + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(nodeId) { FocusRequester() } this + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback(source, enabled, theme, shape, pressColor, feedback) .hoverable(source, enabled) .clickable( diff --git a/renderer/desktop/test/SheetTest.kt b/renderer/desktop/test/SheetTest.kt index 287b97a1..3235fe02 100644 --- a/renderer/desktop/test/SheetTest.kt +++ b/renderer/desktop/test/SheetTest.kt @@ -6,12 +6,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.swipeDown import androidx.compose.ui.unit.Density @@ -173,4 +180,311 @@ class SheetTest { ) assertNear(covered.right, sheet.right, "its trailing edge") } + + /** + * Pressing Escape on an open Dialog dispatches OnDismiss once, and leaves the Dialog + * open when the Host keeps Open true. + */ + @Test + fun fr21_escape_dismisses_an_open_dialog_once() = runComposeUiTest { + val dialog = 1 + val label = 2 + val handler = 61L + val connection = FakeHostConnection( + listOf( + Mutation.Create(dialog, WidgetKind.Dialog), + Mutation.SetProp(dialog, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(dialog, PropertyKind.OnDismiss, PropertyValue.Integer(handler)), + Mutation.Create(label, WidgetKind.Text), + Mutation.SetProp(label, PropertyKind.Text, PropertyValue.Text("unsaved changes")), + Mutation.Insert(dialog, label, 0), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(label)).assertTextEquals("unsaved changes") + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + val dismissals = connection.events.filterIsInstance() + assertEquals(listOf(HostEvent.Clicked(dialog, handler)), dismissals) + // The Host did not send Open=false, so the Dialog stays open. + onNodeWithTag(nodeTestTag(label)).assertTextEquals("unsaved changes") + } + + /** + * Pressing Escape on an open Sheet dispatches OnDismiss once in both narrow and wide + * windows. + */ + @Test + fun fr21_escape_dismisses_an_open_sheet_once() = runComposeUiTest { + for (windowWidth in listOf(500.dp, 1100.dp)) { + val connection = FakeHostConnection(sheetBatch(open = true)) + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(windowWidth, 800.dp), + ) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(CONTENT)).assertTextEquals("filter by") + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + val dismissals = connection.events.filterIsInstance() + assertEquals( + listOf(HostEvent.Clicked(SHEET, DISMISS_HANDLER)), + dismissals, + "width=$windowWidth", + ) + onNodeWithTag(nodeTestTag(CONTENT)).assertTextEquals("filter by") + } + } + + /** + * An overlay with no focusable controls inside it still receives Escape immediately + * after opening, even when a control on the page held focus before. + */ + @Test + fun fr21_escape_reaches_an_overlay_with_no_focusable_content() = runComposeUiTest { + val root = 10 + val opener = 11 + val sheet = 12 + val text = 13 + val connection = FakeHostConnection( + listOf( + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(opener, WidgetKind.Button), + Mutation.SetProp(opener, PropertyKind.Text, PropertyValue.Text("Settings")), + Mutation.SetProp(opener, PropertyKind.OnClick, PropertyValue.Integer(70L)), + Mutation.Insert(root, opener, 0), + Mutation.Create(sheet, WidgetKind.Sheet), + Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(false)), + Mutation.SetProp(sheet, PropertyKind.OnDismiss, PropertyValue.Integer(DISMISS_HANDLER)), + Mutation.Create(text, WidgetKind.Text), + Mutation.SetProp(text, PropertyKind.Text, PropertyValue.Text("Read-only info")), + Mutation.Insert(sheet, text, 0), + Mutation.Insert(root, sheet, 1), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(opener)).requestFocus() + waitForIdle() + + connection.scheduleFrame( + listOf(Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(true))), + ) + frames.request() + waitForIdle() + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + val dismissals = connection.events.filterIsInstance() + assertEquals(listOf(HostEvent.Clicked(sheet, DISMISS_HANDLER)), dismissals) + } + + /** + * When a focused TextField inside an overlay has an active IME composition, Escape + * cancels the composition and does not dispatch OnDismiss. + */ + @Test + fun fr21_escape_in_a_composing_text_field_does_not_dismiss() = runComposeUiTest { + val field = 20 + val compositionHandler = 81L + var editorUpdate: ((androidx.compose.ui.text.input.TextFieldValue) -> Unit)? = null + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = { update -> editorUpdate = update } + try { + val connection = FakeHostConnection( + listOf( + Mutation.Create(SHEET, WidgetKind.Sheet), + Mutation.SetProp(SHEET, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(SHEET, PropertyKind.OnDismiss, PropertyValue.Integer(DISMISS_HANDLER)), + Mutation.Create(field, WidgetKind.TextField), + Mutation.SetProp( + field, + PropertyKind.OnCompositionChange, + PropertyValue.Integer(compositionHandler), + ), + Mutation.Insert(SHEET, field, 0), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(field)).requestFocus() + waitForIdle() + + val update = requireNotNull(editorUpdate) { "TextField did not install value hook" } + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "한", + selection = androidx.compose.ui.text.TextRange(1), + composition = androidx.compose.ui.text.TextRange(0, 1), + ), + ) + } + waitForIdle() + + // First Escape cancels the active IME composition without dismissing the sheet. + onNodeWithTag(nodeTestTag(field)).performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + assertEquals( + emptyList(), + connection.events.filterIsInstance(), + "Escape during IME composition must not dismiss the overlay", + ) + assertEquals( + listOf( + HostEvent.CompositionChanged(field, compositionHandler, true), + HostEvent.CompositionChanged(field, compositionHandler, false), + ), + connection.events.filterIsInstance(), + ) + + // Once composition is gone, a subsequent Escape dismisses the sheet. + onNodeWithTag(nodeTestTag(field)).performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + assertEquals( + listOf(HostEvent.Clicked(SHEET, DISMISS_HANDLER)), + connection.events.filterIsInstance(), + ) + } finally { + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = null + } + } + + /** + * When a Dialog is open on top of a Sheet, Escape dispatches OnDismiss only for the + * topmost overlay (the Dialog), leaving the Sheet's handler untouched. + */ + @Test + fun fr21_escape_goes_to_the_topmost_overlay() = runComposeUiTest { + val root = 10 + val sheet = 11 + val sheetText = 12 + val dialog = 13 + val dialogText = 14 + val sheetDismiss = 91L + val dialogDismiss = 92L + val connection = FakeHostConnection( + listOf( + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(sheet, WidgetKind.Sheet), + Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(sheet, PropertyKind.OnDismiss, PropertyValue.Integer(sheetDismiss)), + Mutation.Create(sheetText, WidgetKind.Text), + Mutation.SetProp(sheetText, PropertyKind.Text, PropertyValue.Text("Project settings")), + Mutation.Insert(sheet, sheetText, 0), + Mutation.Insert(root, sheet, 0), + Mutation.Create(dialog, WidgetKind.Dialog), + Mutation.SetProp(dialog, PropertyKind.Open, PropertyValue.Bool(false)), + Mutation.SetProp(dialog, PropertyKind.OnDismiss, PropertyValue.Integer(dialogDismiss)), + Mutation.Create(dialogText, WidgetKind.Text), + Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Discard changes?")), + Mutation.Insert(dialog, dialogText, 0), + Mutation.Insert(root, dialog, 1), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + + connection.scheduleFrame( + listOf(Mutation.SetProp(dialog, PropertyKind.Open, PropertyValue.Bool(true))), + ) + frames.request() + waitForIdle() + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + assertEquals( + listOf(HostEvent.Clicked(dialog, dialogDismiss)), + connection.events.filterIsInstance(), + ) + } + + /** + * When an overlay closes, keyboard focus returns to the control that held focus before + * the overlay opened. + */ + @Test + fun fr21_escape_returns_focus_to_the_opener() = runComposeUiTest { + val root = 10 + val opener = 11 + val sheet = 12 + val sheetText = 13 + val connection = FakeHostConnection( + listOf( + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(opener, WidgetKind.TextField), + Mutation.Insert(root, opener, 0), + Mutation.Create(sheet, WidgetKind.Sheet), + Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(false)), + Mutation.SetProp(sheet, PropertyKind.OnDismiss, PropertyValue.Integer(DISMISS_HANDLER)), + Mutation.Create(sheetText, WidgetKind.Text), + Mutation.SetProp(sheetText, PropertyKind.Text, PropertyValue.Text("Details")), + Mutation.Insert(sheet, sheetText, 0), + Mutation.Insert(root, sheet, 1), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + + onNodeWithTag(nodeTestTag(opener)).requestFocus() + waitForIdle() + onNodeWithTag(nodeTestTag(opener)).assertIsFocused() + + // Open the sheet. + connection.scheduleFrame( + listOf(Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(true))), + ) + frames.request() + waitForIdle() + + // Escape asks the Host to dismiss; the Host closes the sheet by setting Open=false. + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + assertEquals( + listOf(HostEvent.Clicked(sheet, DISMISS_HANDLER)), + connection.events.filterIsInstance(), + ) + connection.scheduleFrame( + listOf(Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(false))), + ) + frames.request() + waitForIdle() + + onNodeWithTag(nodeTestTag(opener)).assertIsFocused() + } } From fcaae43762670d74afb56b17eb1216dd0a2a3fb3 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:27:04 +0900 Subject: [PATCH 182/224] Chore: Pin the window modules at the fork's macOS file dialog sheets (FR-72) WINDOW_REVISION moves to e3088cc, the merge of fork #183 (thisisthepy/compose-multiplatform-core-extended): FileDialogs in the common window module and MacosFileDialogs, NSOpenPanel and NSSavePanel as a sheet on the owning window. The only other change since 1b2884f is the fork's CI (#184). --- renderer/scripts/build-compose.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/renderer/scripts/build-compose.sh b/renderer/scripts/build-compose.sh index 1e117511..89b0cc94 100755 --- a/renderer/scripts/build-compose.sh +++ b/renderer/scripts/build-compose.sh @@ -40,8 +40,9 @@ REVISION="2eb80c12e7615ef80c7661ea63e0f20651f20a31" # for Snap Layouts (fork #172), the Windows wheel direction fix (fork #170) and the windows' own # accessibility bridges (the renderer hands them the scene's semantics). It replaces the # backport branch feat/windows-window-backport-57b4a0e, which carried those two fixes on an -# older base; multi-window work builds on `extended` itself. -WINDOW_REVISION="1b2884f1610371a9867d7969a2e2376e473654bd" +# older base; multi-window work builds on `extended` itself. It also holds the macOS file open +# and save sheets (fork #183, `MacosFileDialogs`), which the macOS renderer shows for FR-72. +WINDOW_REVISION="e3088cc0b01cb1d83d904dfa040a2e61d251d8e6" # The commit the Windows build takes. It was its own pin while the Windows Compose lived on a # branch apart from the Compose pin; the fork's `extended` now holds mingwX64 for every Compose UI # module, the skiko build for mingwX64 (extended/skiko/build-skiko-mingw.sh, which also compiles From 07fe2a5ba165b9c8dadcdf964576ff50d1b64b4a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:29:48 +0900 Subject: [PATCH 183/224] Feat: Show file dialogs as sheets on macOS (FR-72) The macOS renderer installs MacosFileDialogPlatform before the Host starts. It maps a request to the fork's MacosFileDialogs on the owning NSWindow (a Window node's platform window, or for node 0 the single window, or in a multi-window run the key or first window), keeps one set of sheets per window, cancels them when the window is removed or closes, and translates requests and answers. A request whose window is not on screen yet (the first batch, or the batch that created the window) is retried on the main queue for up to 5 seconds before it is answered Unavailable. Tests: fr72_macos_* in renderer/macos/test, against fake sheets. --- renderer/macos/src/MacosFileDialogPlatform.kt | 221 ++++++++++++++++++ renderer/macos/src/MacosMultiWindow.kt | 5 + renderer/macos/src/MacosRenderer.kt | 8 + renderer/macos/test/MacosFileDialogTest.kt | 204 ++++++++++++++++ 4 files changed, 438 insertions(+) create mode 100644 renderer/macos/src/MacosFileDialogPlatform.kt create mode 100644 renderer/macos/test/MacosFileDialogTest.kt diff --git a/renderer/macos/src/MacosFileDialogPlatform.kt b/renderer/macos/src/MacosFileDialogPlatform.kt new file mode 100644 index 00000000..f628125c --- /dev/null +++ b/renderer/macos/src/MacosFileDialogPlatform.kt @@ -0,0 +1,221 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.darkpyonix.composerust.ui.platform + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import org.thisisthepy.compose.window.FileDialogFilter as SheetFilter +import org.thisisthepy.compose.window.FileDialogKind as SheetKind +import org.thisisthepy.compose.window.FileDialogOutcome as SheetOutcome +import org.thisisthepy.compose.window.FileDialogRequest as SheetRequest +import org.thisisthepy.compose.window.macos.MacosFileDialogs +import platform.AppKit.NSApplication +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowWillCloseNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.darwin.DISPATCH_TIME_NOW +import platform.darwin.dispatch_after +import platform.darwin.dispatch_get_main_queue +import platform.darwin.dispatch_time +import kotlin.native.ref.WeakReference + +/** How long a request waits for its window to be on screen: this many tries, [WINDOW_WAIT_MILLIS] apart. */ +internal const val WINDOW_WAIT_ATTEMPTS = 100 + +/** The pause between two looks for a request's window. */ +internal const val WINDOW_WAIT_MILLIS = 50L + +/** + * The sheets of one window, as [SheetFileDialogPlatform] uses them: requests shown one after + * the other, and a way to end every one of them at once. + */ +internal interface WindowSheets { + /** Shows [request] without waiting for the user. [onResult] is called once, on the main thread. */ + fun show(request: SheetRequest, onResult: (SheetOutcome) -> Unit) + + /** Ends the open sheet and every waiting request with a cancel, each answered once. */ + fun cancelAll() +} + +/** + * The renderer's file dialogs on top of the fork's per-window sheets (FR-72). + * + * The fork shows a sheet and keeps one window's requests in order; the interpreter's + * [FileDialogCenter] keeps the protocol's rules. This sits between the two: it finds the + * window a request belongs to, keeps one [WindowSheets] for each window that has had one, and + * translates the request and its answer between the two vocabularies. + * + * A request can arrive before its window is on screen: the batch that asked for it may be the + * first one, which is applied before any window exists, or the one that created its `Window` + * node. So a request whose window is not there yet is looked at again, [attempts] times, + * through [later], and is answered as unavailable only when the window never comes. + * + * Generic in the window type so the rules can be tested without AppKit. [windowFor] turns the + * handle the centre resolved (and the node the request names) into a window, or null when + * there is none yet. [sheetsFor] makes the sheets of one window and is given the function to + * call when that window goes away. Everything is called on the main thread. + */ +internal class SheetFileDialogPlatform( + private val windowFor: (handle: Any?, node: Int) -> W?, + private val sheetsFor: (window: W, gone: () -> Unit) -> WindowSheets, + private val later: (() -> Unit) -> Unit, + private val attempts: Int = WINDOW_WAIT_ATTEMPTS, +) : FileDialogPlatform { + private val open = HashMap() + + /** How many windows have sheets kept for them now. */ + internal val windowCount: Int get() = open.size + + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) { + showWhenThere(window, request.windowNode, sheetRequestOf(request), onResult, attempts) + } + + private fun showWhenThere( + handle: Any?, + node: Int, + request: SheetRequest, + onResult: (FileDialogOutcome) -> Unit, + left: Int, + ) { + val target = windowFor(handle, node) + if (target == null) { + if (left <= 0) { + onResult(FileDialogOutcome.Unavailable) + } else { + later { showWhenThere(handle, node, request, onResult, left - 1) } + } + return + } + val sheets = open.getOrPut(target) { sheetsFor(target) { open.remove(target) } } + sheets.show(request) { outcome -> onResult(outcomeOf(outcome)) } + } + + override fun cancelWindow(window: Any?) { + // The implicit root window is never removed, and a handle that was null when the + // dialog started is ended by the window's own close notification instead. + if (window == null) return + val target = windowFor(window, NO_NODE) ?: return + open.remove(target)?.cancelAll() + } + + companion object { + /** Passed as the node when only a handle is known. */ + const val NO_NODE = -1 + } +} + +/** The request as the fork's sheets take it. The centre has already normalised it. */ +internal fun sheetRequestOf(spec: FileDialogSpec): SheetRequest = SheetRequest( + kind = when (spec.kind) { + FileDialogKind.OpenFile -> SheetKind.OpenFile + FileDialogKind.OpenFiles -> SheetKind.OpenFiles + FileDialogKind.OpenFolder -> SheetKind.OpenFolder + FileDialogKind.OpenFolders -> SheetKind.OpenFolders + FileDialogKind.Save -> SheetKind.Save + }, + title = spec.title, + directory = spec.directory, + name = spec.name, + filters = spec.filters.map { SheetFilter(it.label, it.extensions) }, + defaultFilter = spec.defaultFilter, +) + +/** The sheet's answer as the centre takes it. A filter index below zero means there was no filter. */ +internal fun outcomeOf(outcome: SheetOutcome): FileDialogOutcome = when (outcome) { + is SheetOutcome.Chosen -> FileDialogOutcome.Chosen.ofText( + outcome.paths, + outcome.filterIndex.takeIf { it >= 0 }, + ) + SheetOutcome.Cancelled -> FileDialogOutcome.Cancelled + SheetOutcome.Unavailable -> FileDialogOutcome.Unavailable +} + +/** + * The file dialogs of the macOS renderer: `NSOpenPanel` and `NSSavePanel` as sheets on the + * owning `NSWindow`, through the fork's [MacosFileDialogs]. + * + * Installed in [FileDialogs.platform] before the Host is made, because the interpreter's + * centre takes the platform when the node table is created. What the windows are is only + * known later, so it is handed in afterwards: [nodeWindow] gives the platform window of a + * `Window` node (the window manager's handle, read through the centre's resolver), and + * [rootWindow] the window of node 0, the implicit single window, or in a multi-window run the + * window in use. + */ +internal class MacosFileDialogPlatform : FileDialogPlatform { + /** Gives the platform window of a `Window` node, or null. */ + var nodeWindow: (Int) -> Any? = { null } + + /** Gives the window a request on node 0 is shown on, or null when there is none yet. */ + var rootWindow: () -> NSWindow? = { null } + + private val inner = SheetFileDialogPlatform( + windowFor = { handle, node -> + nativeWindowOf(handle) ?: when { + node > 0 -> nativeWindowOf(nodeWindow(node)) + node == 0 -> rootWindow() + else -> null + } + }, + sheetsFor = ::macosSheets, + later = ::onMainQueueLater, + ) + + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) = + inner.show(window, request, onResult) + + override fun cancelWindow(window: Any?) = inner.cancelWindow(window) +} + +private fun nativeWindowOf(handle: Any?): NSWindow? = when (handle) { + is MacosPlatformWindow -> handle.nativeWindow + is NSWindow -> handle + else -> null +} + +/** + * The window a multi-window run shows a node 0 request on: the key window when it is one of + * [windows], otherwise the first of them. + */ +internal fun keyOrFirstWindow(windows: List): NSWindow? { + val key = NSApplication.sharedApplication.keyWindow + return windows.firstOrNull { it === key } ?: windows.firstOrNull() +} + +/** + * One window's sheets. The window is held weakly, so a window that is closed is not kept alive + * by the dialogs it once showed, and its closing ends them: every open and waiting request is + * cancelled and the window is forgotten. + */ +private fun macosSheets(window: NSWindow, gone: () -> Unit): WindowSheets { + val ref = WeakReference(window) + val dialogs = MacosFileDialogs { ref.get() } + var observer: Any? = null + val sheets = object : WindowSheets { + override fun show(request: SheetRequest, onResult: (SheetOutcome) -> Unit) = + dialogs.show(request, onResult) + + override fun cancelAll() { + observer?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + observer = null + dialogs.cancelAll() + } + } + observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = NSWindowWillCloseNotification, + `object` = window, + queue = NSOperationQueue.mainQueue, + ) { _ -> + gone() + sheets.cancelAll() + } + return sheets +} + +/** Runs [block] on the main queue after [WINDOW_WAIT_MILLIS], once the run loop is turning. */ +private fun onMainQueueLater(block: () -> Unit) { + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, WINDOW_WAIT_MILLIS * 1_000_000L), + dispatch_get_main_queue(), + block, + ) +} diff --git a/renderer/macos/src/MacosMultiWindow.kt b/renderer/macos/src/MacosMultiWindow.kt index 69975f3b..1d86f29b 100644 --- a/renderer/macos/src/MacosMultiWindow.kt +++ b/renderer/macos/src/MacosMultiWindow.kt @@ -76,6 +76,11 @@ internal fun runWindows(application: NSApplication, host: ComposeRustHost, asked } val windows = MacosWindowBackend(scene) backend = windows + // A dialog on node 0 has no `Window` node of its own here, so it goes to the window in + // use, or the first one open (FR-72). + (FileDialogs.platform as? MacosFileDialogPlatform)?.rootWindow = { + keyOrFirstWindow(windows.windows.mapNotNull { (it as? MacosPlatformWindow)?.nativeWindow }) + } val manager = WindowManager(host, windows, onExit = { stopApplicationLoop() }) manager.attach() // A batch is one snapshot for every window: each is drawn again after it is applied, not diff --git a/renderer/macos/src/MacosRenderer.kt b/renderer/macos/src/MacosRenderer.kt index 355e184e..5af9a0c0 100644 --- a/renderer/macos/src/MacosRenderer.kt +++ b/renderer/macos/src/MacosRenderer.kt @@ -106,7 +106,13 @@ internal fun runRenderer(connection: () -> HostConnection): Int { application.windows.forEach { (it as? NSWindow)?.deminiaturize(null) } }, ) + // The file dialogs, before the Host exists for the same reason: the node table takes the + // platform when it is made, and the first batch may already ask for one. The windows they + // attach to are handed in once they exist (FR-72). + val fileDialogs = MacosFileDialogPlatform() + FileDialogs.platform = fileDialogs val host = ComposeRustHost(connection()) + fileDialogs.nodeWindow = { id -> host.table.fileDialogs.windowResolver(id) } host.start() // Quitting ends the process from inside the run loop, so `run` below never returns to // say so. What this application posted is taken back when the platform says it is @@ -155,6 +161,8 @@ internal fun runRenderer(connection: () -> HostConnection): Int { ), ) requestFrame = window::requestFrame + // The one window is where a dialog on node 0, the implicit window, is shown. + fileDialogs.rootWindow = { window.window } // The fork's window runs the scene's coroutines (the frame requests below among them, and // every delay) only inside a frame, and an idle window is not drawn. A request a Host // worker makes would wait for a frame nobody asks for, so each one is turned into a diff --git a/renderer/macos/test/MacosFileDialogTest.kt b/renderer/macos/test/MacosFileDialogTest.kt new file mode 100644 index 00000000..d48656e4 --- /dev/null +++ b/renderer/macos/test/MacosFileDialogTest.kt @@ -0,0 +1,204 @@ +package dev.darkpyonix.composerust.test + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.ui.platform.FileDialogFilter +import dev.darkpyonix.composerust.ui.platform.FileDialogOutcome +import dev.darkpyonix.composerust.ui.platform.FileDialogPath +import dev.darkpyonix.composerust.ui.platform.FileDialogSpec +import dev.darkpyonix.composerust.ui.platform.SheetFileDialogPlatform +import dev.darkpyonix.composerust.ui.platform.WindowSheets +import dev.darkpyonix.composerust.ui.platform.outcomeOf +import dev.darkpyonix.composerust.ui.platform.sheetRequestOf +import org.thisisthepy.compose.window.FileDialogKind as SheetKind +import org.thisisthepy.compose.window.FileDialogOutcome as SheetOutcome +import org.thisisthepy.compose.window.FileDialogRequest as SheetRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** One window's sheets that remember what they were asked and answer when the test says so. */ +private class FakeSheets(val window: String, val gone: () -> Unit) : WindowSheets { + class Shown(val request: SheetRequest, val onResult: (SheetOutcome) -> Unit) + + val shown = mutableListOf() + var cancelled = 0 + + override fun show(request: SheetRequest, onResult: (SheetOutcome) -> Unit) { + shown += Shown(request, onResult) + } + + override fun cancelAll() { + cancelled++ + shown.forEach { it.onResult(SheetOutcome.Cancelled) } + shown.clear() + } +} + +private fun spec( + request: Int = 1, + window: Int = 0, + kind: FileDialogKind = FileDialogKind.OpenFiles, + filters: List = emptyList(), + defaultFilter: Int = 0, + name: String = "", +) = FileDialogSpec( + request = request, + windowNode = window, + kind = kind, + title = "파일 고르기", + directory = "/Users/한글", + name = name, + filters = filters, + defaultFilter = defaultFilter, +) + +/** + * The macOS renderer's glue between the interpreter's file dialog centre and the fork's + * sheets (FR-72). The sheet itself is AppKit's and is checked by hand (criterion 12); what is + * checked here is which window a request goes to, what reaches the sheet, and what comes back. + */ +class MacosFileDialogTest { + /** Windows by node, the way the window manager's handles resolve; node 0 is "root". */ + private val windows = mutableMapOf(0 to "root") + private val made = mutableListOf() + private val queued = ArrayDeque<() -> Unit>() + + private fun platform(attempts: Int = 3) = SheetFileDialogPlatform( + windowFor = { handle, node -> (handle as? String) ?: windows[node] }, + sheetsFor = { window, gone -> FakeSheets(window, gone).also { made += it } }, + later = { queued.addLast(it) }, + attempts = attempts, + ) + + private fun runLater() { + while (queued.isNotEmpty()) queued.removeFirst()() + } + + private fun sheetsOf(window: String) = made.last { it.window == window } + + @Test + fun fr72_macos_request_reaches_the_sheet_unchanged() { + for ((kind, sheetKind) in listOf( + FileDialogKind.OpenFile to SheetKind.OpenFile, + FileDialogKind.OpenFiles to SheetKind.OpenFiles, + FileDialogKind.OpenFolder to SheetKind.OpenFolder, + FileDialogKind.OpenFolders to SheetKind.OpenFolders, + FileDialogKind.Save to SheetKind.Save, + )) { + val request = sheetRequestOf( + spec( + kind = kind, + filters = listOf(FileDialogFilter("이미지", listOf("png", "jpg")), FileDialogFilter("모두", emptyList())), + defaultFilter = 1, + name = "보고서.txt", + ), + ) + assertEquals(sheetKind, request.kind) + assertEquals("파일 고르기", request.title) + assertEquals("/Users/한글", request.directory) + assertEquals("보고서.txt", request.name) + assertEquals(listOf("이미지", "모두"), request.filters.map { it.label }) + assertEquals(listOf(listOf("png", "jpg"), emptyList()), request.filters.map { it.extensions }) + assertEquals(1, request.defaultFilter) + } + } + + @Test + fun fr72_macos_sheet_answers_become_the_centres_outcomes() { + val chosen = outcomeOf(SheetOutcome.Chosen(listOf("/Users/한글/a b.png", "/tmp/😀.jpg"), 1)) + assertIs(chosen) + assertEquals( + listOf("/Users/한글/a b.png", "/tmp/😀.jpg"), + chosen.paths.map { (it as FileDialogPath.Text).value }, + ) + assertEquals(1, chosen.filter) + + // A folder pick, or a dialog without filters, reports no filter. + val noFilter = outcomeOf(SheetOutcome.Chosen(listOf("/Users"), -1)) + assertIs(noFilter) + assertNull(noFilter.filter) + + assertSame(FileDialogOutcome.Cancelled, outcomeOf(SheetOutcome.Cancelled)) + assertSame(FileDialogOutcome.Unavailable, outcomeOf(SheetOutcome.Unavailable)) + } + + @Test + fun fr72_macos_every_request_gets_exactly_one_result() { + val platform = platform() + val answers = mutableListOf() + platform.show(null, spec(request = 1)) { answers += it } + sheetsOf("root").shown.single().onResult(SheetOutcome.Chosen(listOf("/a"), -1)) + assertEquals(1, answers.size) + assertIs(answers.single()) + } + + @Test + fun fr72_macos_dialog_waits_for_its_window_to_open() { + // The batch that asked for the dialog is applied before the window is on screen. + windows.clear() + val platform = platform() + val answers = mutableListOf() + platform.show(null, spec(request = 1, window = 7)) { answers += it } + assertTrue(made.isEmpty()) + assertEquals(1, queued.size) + + windows[7] = "seven" + runLater() + assertEquals(1, sheetsOf("seven").shown.size) + assertTrue(answers.isEmpty()) + } + + @Test + fun fr72_macos_window_that_never_opens_answers_unavailable() { + windows.clear() + val platform = platform(attempts = 3) + val answers = mutableListOf() + platform.show(null, spec(request = 1, window = 7)) { answers += it } + runLater() + assertEquals(listOf(FileDialogOutcome.Unavailable), answers) + assertTrue(made.isEmpty()) + } + + @Test + fun fr72_macos_each_window_has_its_own_sheets() { + windows[3] = "three" + val platform = platform() + platform.show(null, spec(request = 1, window = 3)) {} + platform.show(null, spec(request = 2, window = 3)) {} + platform.show(null, spec(request = 3, window = 0)) {} + // The handle the centre resolved wins over the node. + platform.show("handle", spec(request = 4, window = 3)) {} + + assertEquals(listOf("three", "root", "handle"), made.map { it.window }) + assertEquals(2, sheetsOf("three").shown.size) + assertEquals(3, platform.windowCount) + } + + @Test + fun fr72_macos_closing_the_owner_window_cancels_the_dialog() { + windows[3] = "three" + val platform = platform() + val answers = mutableListOf() + platform.show(null, spec(request = 1, window = 3)) { answers += it } + + // The node was removed: the centre names the handle it showed the dialog on. + platform.cancelWindow("three") + assertEquals(1, sheetsOf("three").cancelled) + assertEquals(listOf(FileDialogOutcome.Cancelled), answers) + assertEquals(0, platform.windowCount) + + // The window closed by itself: its sheets say so, and the window is forgotten. + platform.show(null, spec(request = 2, window = 3)) {} + assertEquals(1, platform.windowCount) + sheetsOf("three").gone() + assertEquals(0, platform.windowCount) + + // The implicit window is never removed, so a missing handle cancels nothing. + platform.show(null, spec(request = 3, window = 0)) {} + platform.cancelWindow(null) + assertEquals(0, sheetsOf("root").cancelled) + } +} From 2e52946ce38431652c62a8712c3fbc5c9e2120d4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:30:06 +0900 Subject: [PATCH 184/224] Docs: State which platforms show a file dialog after stage 1 (FR-72, #483) macOS shows the sheet; Windows, Linux, iOS, Android, the web and the GraalVM native image answer Unavailable until stages 2 and 3. The changelog says the same. --- CHANGELOG.md | 4 ++-- docs/SPEC.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ceca620..2a3f6c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## Unreleased -### Native file open and save dialogs (host side) +### Native file open and save dialogs -The Host can ask for a platform file dialog with `FileDialog` (open a file, files, a folder or folders, or save with a suggested name and folder, filters, a title and an initial folder) and receives one result per request: the chosen absolute paths, a cancel, or "unavailable". Every platform answers "unavailable" until its dialog is wired in; macOS follows ([#483](https://github.com/DarkPyonix/compose-rust/issues/483)). +The Host can ask for a platform file dialog with `FileDialog` (open a file, files, a folder or folders, or save with a suggested name and folder, filters, a title and an initial folder) and receives one result per request: the chosen absolute paths, a cancel, or "unavailable". On macOS the dialog is an `NSOpenPanel` or `NSSavePanel` sheet on the owning window. Windows, Linux, iOS, Android and the web answer "unavailable" until their dialogs are wired in ([#483](https://github.com/DarkPyonix/compose-rust/issues/483)). ## v0.0.6 diff --git a/docs/SPEC.md b/docs/SPEC.md index 58f081ff..e72bfc11 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -4001,6 +4001,7 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" | Web | 이 항목의 범위 밖 | 요청하면 `Unavailable` | - **구현은 단계로 나눕니다.** 1단계는 공통(스키마, codegen, Fake 시험, Kotlin 해석기)과 macOS, 2단계는 Windows, 3단계는 Linux 포털(X11 한정, GTK 폴백)입니다. 아직 구현되지 않은 플랫폼의 Renderer는 요청마다 즉시 `Unavailable`을 돌려줍니다. 지원하지 않는 플랫폼은 `Unavailable`이라는 규칙의 같은 경로입니다. +- **1단계가 들어간 지금의 상태(이슈 #483)**: macOS의 Kotlin/Native Renderer가 소유한 창에 시트를 띄웁니다(포크 `MacosFileDialogs`, `WINDOW_REVISION` e3088cc). Windows, Linux, iOS, Android, Web의 Renderer와 GraalVM 네이티브 이미지 Renderer는 모든 요청에 `Unavailable` 하나를 돌려줍니다. Windows는 2단계, Linux는 3단계에서 바뀝니다. - 이 표의 대상은 **데스크톱 셋**입니다. 모바일과 Web은 이슈 #483이 처음부터 뺐고, 뺀 것이 명시된 범위입니다. 그 플랫폼에서도 Host 코드는 갈라지지 않습니다. 같은 요청이 `Unavailable`을 받습니다(FR-27, FR-36의 `Unsupported`와 같은 규칙). - **플랫폼 대화상자 코드는 Compose 포크(`thisisthepy/compose-multiplatform-core-extended`)에 있고, 이 저장소는 Renderer가 그 호출을 부르는 접착과 명령·이벤트 해석만 가집니다**(19.8, INTENT D19). 포크가 하는 일은 창 핸들을 받아 대화상자를 띄우고 결과를 콜백으로 돌려주는 것까지입니다. - Linux의 포털은 샌드박스(Flatpak, Snap)에서 앱이 접근할 수 있는 경로를 돌려줍니다. 포털이 문서 포털의 경로(`/run/user//doc/...`)를 돌려주면 그것이 `paths`에 그대로 담깁니다. From ad628e6e8d4cd3357c24a7b5ed936d6397db000f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:41:38 +0900 Subject: [PATCH 185/224] Fix: Write the file dialog reasons out instead of citing the SPEC in code scripts/tests/no-spec-citations.test.sh failed on five comments the macOS file dialog change added. --- renderer/macos/src/MacosFileDialogPlatform.kt | 2 +- renderer/macos/src/MacosMultiWindow.kt | 2 +- renderer/macos/src/MacosRenderer.kt | 2 +- renderer/macos/test/MacosFileDialogTest.kt | 2 +- renderer/scripts/build-compose.sh | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/renderer/macos/src/MacosFileDialogPlatform.kt b/renderer/macos/src/MacosFileDialogPlatform.kt index f628125c..fc919481 100644 --- a/renderer/macos/src/MacosFileDialogPlatform.kt +++ b/renderer/macos/src/MacosFileDialogPlatform.kt @@ -38,7 +38,7 @@ internal interface WindowSheets { } /** - * The renderer's file dialogs on top of the fork's per-window sheets (FR-72). + * The renderer's file dialogs on top of the fork's per-window sheets. * * The fork shows a sheet and keeps one window's requests in order; the interpreter's * [FileDialogCenter] keeps the protocol's rules. This sits between the two: it finds the diff --git a/renderer/macos/src/MacosMultiWindow.kt b/renderer/macos/src/MacosMultiWindow.kt index 1d86f29b..ad3b819b 100644 --- a/renderer/macos/src/MacosMultiWindow.kt +++ b/renderer/macos/src/MacosMultiWindow.kt @@ -77,7 +77,7 @@ internal fun runWindows(application: NSApplication, host: ComposeRustHost, asked val windows = MacosWindowBackend(scene) backend = windows // A dialog on node 0 has no `Window` node of its own here, so it goes to the window in - // use, or the first one open (FR-72). + // use, or the first one open. (FileDialogs.platform as? MacosFileDialogPlatform)?.rootWindow = { keyOrFirstWindow(windows.windows.mapNotNull { (it as? MacosPlatformWindow)?.nativeWindow }) } diff --git a/renderer/macos/src/MacosRenderer.kt b/renderer/macos/src/MacosRenderer.kt index 5af9a0c0..773ff53d 100644 --- a/renderer/macos/src/MacosRenderer.kt +++ b/renderer/macos/src/MacosRenderer.kt @@ -108,7 +108,7 @@ internal fun runRenderer(connection: () -> HostConnection): Int { ) // The file dialogs, before the Host exists for the same reason: the node table takes the // platform when it is made, and the first batch may already ask for one. The windows they - // attach to are handed in once they exist (FR-72). + // attach to are handed in once they exist. val fileDialogs = MacosFileDialogPlatform() FileDialogs.platform = fileDialogs val host = ComposeRustHost(connection()) diff --git a/renderer/macos/test/MacosFileDialogTest.kt b/renderer/macos/test/MacosFileDialogTest.kt index d48656e4..8d2b59b1 100644 --- a/renderer/macos/test/MacosFileDialogTest.kt +++ b/renderer/macos/test/MacosFileDialogTest.kt @@ -57,7 +57,7 @@ private fun spec( /** * The macOS renderer's glue between the interpreter's file dialog centre and the fork's - * sheets (FR-72). The sheet itself is AppKit's and is checked by hand (criterion 12); what is + * sheets. The sheet itself is AppKit's and is checked by hand on a macOS build; what is * checked here is which window a request goes to, what reaches the sheet, and what comes back. */ class MacosFileDialogTest { diff --git a/renderer/scripts/build-compose.sh b/renderer/scripts/build-compose.sh index 89b0cc94..c726dce8 100755 --- a/renderer/scripts/build-compose.sh +++ b/renderer/scripts/build-compose.sh @@ -41,7 +41,8 @@ REVISION="2eb80c12e7615ef80c7661ea63e0f20651f20a31" # accessibility bridges (the renderer hands them the scene's semantics). It replaces the # backport branch feat/windows-window-backport-57b4a0e, which carried those two fixes on an # older base; multi-window work builds on `extended` itself. It also holds the macOS file open -# and save sheets (fork #183, `MacosFileDialogs`), which the macOS renderer shows for FR-72. +# and save sheets (fork #183, `MacosFileDialogs`), which the macOS renderer shows when the Host +# asks for a file dialog. WINDOW_REVISION="e3088cc0b01cb1d83d904dfa040a2e61d251d8e6" # The commit the Windows build takes. It was its own pin while the Windows Compose lived on a # branch apart from the Compose pin; the fork's `extended` now holds mingwX64 for every Compose UI From 55eb292e38fdaa5c9f5767af19135ae3a12e0074 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:06:30 +0900 Subject: [PATCH 186/224] Fix: Report the dialog result's filter as the position in the list the Host sent (FR-72) --- .../android-kotlin/shared/FileDialogHost.kt | 14 +++++++++++++- .../desktop/src/renderer/FileDialogHost.kt | 14 +++++++++++++- renderer/desktop/test/FileDialogTest.kt | 19 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/compose-rust/android-kotlin/shared/FileDialogHost.kt b/compose-rust/android-kotlin/shared/FileDialogHost.kt index 06702f11..4e1466bf 100644 --- a/compose-rust/android-kotlin/shared/FileDialogHost.kt +++ b/compose-rust/android-kotlin/shared/FileDialogHost.kt @@ -52,6 +52,12 @@ data class FileDialogSpec( val name: String, val filters: List, val defaultFilter: Int, + /** + * For each entry of [filters], its position in the list the Host sent. A filter dropped + * on the way makes this differ from the identity; the result's `filter` is mapped back + * through it. Empty means the identity. + */ + val sentPositions: List = emptyList(), ) /** @@ -287,7 +293,12 @@ class FileDialogCenter( handlerId = 0, request = spec.request, status = FileDialogStatus.Chosen, - filter = checked.filter?.takeIf { it in spec.filters.indices } ?: none, + // The platform answers with a position in the cleaned list; the Host is told the + // position in the list it sent. + filter = checked.filter + ?.takeIf { chosen -> chosen in spec.filters.indices } + ?.let { chosen -> spec.sentPositions.getOrElse(chosen) { chosen } } + ?: none, paths = checked.joined, ) is Checked.Ended -> HostEvent.FileDialogResult( @@ -360,6 +371,7 @@ class FileDialogCenter( // The default is a position in the list the Host sent, so it follows its filter // when an earlier one was dropped. defaultFilter = parsed.indexOfFirst { it.first == mutation.defaultFilter }.takeIf { it >= 0 } ?: 0, + sentPositions = parsed.map { it.first }, ) } diff --git a/renderer/desktop/src/renderer/FileDialogHost.kt b/renderer/desktop/src/renderer/FileDialogHost.kt index 06702f11..4e1466bf 100644 --- a/renderer/desktop/src/renderer/FileDialogHost.kt +++ b/renderer/desktop/src/renderer/FileDialogHost.kt @@ -52,6 +52,12 @@ data class FileDialogSpec( val name: String, val filters: List, val defaultFilter: Int, + /** + * For each entry of [filters], its position in the list the Host sent. A filter dropped + * on the way makes this differ from the identity; the result's `filter` is mapped back + * through it. Empty means the identity. + */ + val sentPositions: List = emptyList(), ) /** @@ -287,7 +293,12 @@ class FileDialogCenter( handlerId = 0, request = spec.request, status = FileDialogStatus.Chosen, - filter = checked.filter?.takeIf { it in spec.filters.indices } ?: none, + // The platform answers with a position in the cleaned list; the Host is told the + // position in the list it sent. + filter = checked.filter + ?.takeIf { chosen -> chosen in spec.filters.indices } + ?.let { chosen -> spec.sentPositions.getOrElse(chosen) { chosen } } + ?: none, paths = checked.joined, ) is Checked.Ended -> HostEvent.FileDialogResult( @@ -360,6 +371,7 @@ class FileDialogCenter( // The default is a position in the list the Host sent, so it follows its filter // when an earlier one was dropped. defaultFilter = parsed.indexOfFirst { it.first == mutation.defaultFilter }.takeIf { it >= 0 } ?: 0, + sentPositions = parsed.map { it.first }, ) } diff --git a/renderer/desktop/test/FileDialogTest.kt b/renderer/desktop/test/FileDialogTest.kt index 26c40200..918ab73c 100644 --- a/renderer/desktop/test/FileDialogTest.kt +++ b/renderer/desktop/test/FileDialogTest.kt @@ -194,6 +194,25 @@ class FileDialogTest { assertEquals("", platform.forRequest(5).spec.name, "an open dialog has no file name") } + @Test + fun fr72_result_filter_is_the_position_in_the_list_the_host_sent() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + // The filter at position 0 keeps no extension and is dropped, so "All" and "Txt" + // are positions 1 and 2 in what the Host sent and 0 and 1 in what the platform shows. + host.ask(1, filters = "Dots\u0000.\u0000All\u0000\u0000Txt\u0000txt") + host.ask(2, filters = "Dots\u0000.\u0000All\u0000\u0000Txt\u0000txt") + host.frame() + platform.forRequest(1).onResult(FileDialogOutcome.Chosen.ofText(listOf("/a.txt"), filter = 1)) + host.frame() + platform.forRequest(2).onResult(FileDialogOutcome.Chosen.ofText(listOf("/b"), filter = 0)) + host.frame() + + val results = connection.results().associateBy { it.request } + assertEquals(2, results.getValue(1).filter, "Txt was sent at position 2") + assertEquals(1, results.getValue(2).filter, "All was sent at position 1") + } + @Test fun fr72_paths_arrive_whole_utf8_and_a_non_utf8_path_answers_unavailable() { val platform = FakeFileDialogs() From bdd42323f368ec8ff21263869ff6d452725c3079 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:12:54 +0900 Subject: [PATCH 187/224] Fix: Spell the composable attribute #[Composable] in the tests added since #286 was opened --- compose-rust/tests/clickable_containers.rs | 2 +- compose-rust/tests/compose_memory.rs | 6 +++--- compose-rust/tests/fr53_windows.rs | 20 ++++++++++---------- compose-rust/tests/fr67_controls.rs | 6 +++--- compose-rust/tests/fr68_semantics.rs | 12 ++++++------ compose-rust/tests/fr72_file_dialog.rs | 2 +- compose-rust/tests/pointer_events.rs | 2 +- compose-rust/tests/zoom.rs | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/compose-rust/tests/clickable_containers.rs b/compose-rust/tests/clickable_containers.rs index 26c05cc4..6cf8cfd9 100644 --- a/compose-rust/tests/clickable_containers.rs +++ b/compose-rust/tests/clickable_containers.rs @@ -33,7 +33,7 @@ fn frame(host: &mut ComposeHost, tree: &mut Tree) { tree.apply(&batch); } -#[composable] +#[Composable] fn Clickables() { let count = remember(|| mutable_state_of(0)); Column().content(|| { diff --git a/compose-rust/tests/compose_memory.rs b/compose-rust/tests/compose_memory.rs index b073ac00..9c4c85fe 100644 --- a/compose-rust/tests/compose_memory.rs +++ b/compose-rust/tests/compose_memory.rs @@ -61,7 +61,7 @@ fn live_bytes() -> isize { LIVE.with(|live| live.get()) } -#[composable] +#[Composable] fn Busy(count: MutableState) { Column().content(|| { for row in 0..40 { @@ -99,7 +99,7 @@ fn nfr_17_live_memory_does_not_grow_with_the_frames_drawn() { ); } -#[composable] +#[Composable] fn Toggled(shown: MutableState) { Column().content(|| { if shown.get() { @@ -146,7 +146,7 @@ fn nfr_17_the_writer_keeps_nothing_for_nodes_that_were_removed() { ); } -#[composable] +#[Composable] fn Chat(count: MutableState, tail: MutableState) { LazyColumn().content(move |list| { let rows = count.get(); diff --git a/compose-rust/tests/fr53_windows.rs b/compose-rust/tests/fr53_windows.rs index bc7d64cf..053e30b1 100644 --- a/compose-rust/tests/fr53_windows.rs +++ b/compose-rust/tests/fr53_windows.rs @@ -54,7 +54,7 @@ fn event(node_id: u32, handler_id: u64, payload: EventPayload<'static>) -> HostE // ----- a single window is what it was ------------------------------------------------- -#[composable] +#[Composable] fn Nothing() {} /// An application launched the way it always was sends the window record it always sent, @@ -121,7 +121,7 @@ fn fr53_window_mode_rides_in_the_spare_bits_of_the_resizable_word() { // ----- windows share one composition --------------------------------------------------- -#[composable] +#[Composable] fn Shared() { let count = remember(|| mutable_state_of(0)); for name in ["a", "b"] { @@ -176,7 +176,7 @@ fn fr53_windows_share_state_in_one_batch() { static PANE_RUNS: [AtomicUsize; 2] = [AtomicUsize::new(0), AtomicUsize::new(0)]; -#[composable] +#[Composable] fn Pane(index: usize) { PANE_RUNS[index].fetch_add(1, Ordering::SeqCst); let size = current_window_size(); @@ -263,7 +263,7 @@ fn fr53_window_events_name_their_window() { static CLOSE_REQUESTS: AtomicUsize = AtomicUsize::new(0); static FOCUS_CHANGES: AtomicUsize = AtomicUsize::new(0); -#[composable] +#[Composable] fn Closable() { let open = remember(|| mutable_state_of(true)); if open.get() { @@ -420,7 +420,7 @@ fn fr53_last_window_closed_policy() { static REQUESTS: Mutex> = Mutex::new(Vec::new()); static REOPENS: AtomicUsize = AtomicUsize::new(0); -#[composable] +#[Composable] fn Listening() { on_window_requested(|request| { REQUESTS @@ -679,7 +679,7 @@ fn fr53_malformed_window_wire_is_a_protocol_error() { // ----- misplaced windows ------------------------------------------------------------------- -#[composable] +#[Composable] fn WindowInAColumn() { Column().content(|| { Window(|| {}).content(|| { @@ -765,12 +765,12 @@ fn fr53_wire_numbers_are_the_ones_the_spec_lists() { // ----- nothing outlives a window -------------------------------------------------------------- -#[composable] +#[Composable] fn Roaming() { Text(format!("{:?}", current_window_size().class)); } -#[composable] +#[Composable] fn Opener() { let open = remember(|| mutable_state_of(false)); let toggle = open.clone(); @@ -838,7 +838,7 @@ fn fr53_window_state_is_dropped_when_the_window_closes() { } } -#[composable] +#[Composable] fn ReportingWindow() { Window(|| {}).report_size(true).content(|| Nothing()); } @@ -874,7 +874,7 @@ fn fr20_8_window_report_size_writes_the_property() { ); } -#[composable] +#[Composable] fn TogglingWindow() { let on = remember(|| mutable_state_of(true)); let toggle = on.clone(); diff --git a/compose-rust/tests/fr67_controls.rs b/compose-rust/tests/fr67_controls.rs index c0dd3e72..06cec37f 100644 --- a/compose-rust/tests/fr67_controls.rs +++ b/compose-rust/tests/fr67_controls.rs @@ -17,7 +17,7 @@ fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { const INK: Paint = Paint::Literal(Color::rgb(0x1c1b19)); const SURFACE: Paint = Paint::Literal(Color::rgb(0xffffff)); -#[composable] +#[Composable] fn Plain() { Column().content(|| { Switch(true); @@ -26,7 +26,7 @@ fn Plain() { }); } -#[composable] +#[Composable] fn Styled() { Column().content(|| { Switch(true) @@ -97,7 +97,7 @@ fn fr67_the_checkbox_sends_its_container_check_and_side() { assert!(!checkbox.contains("TrackColor"), "{checkbox}"); } -#[composable] +#[Composable] fn Dropping() { let styled = remember(|| mutable_state_of(true)); Column().content(|| { diff --git a/compose-rust/tests/fr68_semantics.rs b/compose-rust/tests/fr68_semantics.rs index ae2f007a..65529c14 100644 --- a/compose-rust/tests/fr68_semantics.rs +++ b/compose-rust/tests/fr68_semantics.rs @@ -14,7 +14,7 @@ fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { tree } -#[composable] +#[Composable] fn Plain() { Column().content(|| { Box().modifier(Modifier.padding(4.0)).content(|| { @@ -25,7 +25,7 @@ fn Plain() { }); } -#[composable] +#[Composable] fn Named() { Column().content(|| { Box() @@ -49,7 +49,7 @@ fn Named() { }); } -#[composable] +#[Composable] fn Stated() { Column().content(|| { Box() @@ -117,7 +117,7 @@ fn fr68_the_widget_name_wins_over_the_modifier_label_and_the_icon() { assert!(!dump.contains("From the modifier"), "{dump}"); } -#[composable] +#[Composable] fn Renaming() { let named = remember(|| mutable_state_of(true)); Column().content(|| { @@ -177,7 +177,7 @@ fn fr68_a_widget_that_stops_naming_itself_sends_a_removal() { assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); } -#[composable] +#[Composable] fn Described() { let described = remember(|| mutable_state_of(true)); Column().content(|| { @@ -250,7 +250,7 @@ fn fr71_removing_semantics_restores_the_defaults() { assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); } -#[composable] +#[Composable] fn EveryNewRole() { Column().content(|| { for role in [ diff --git a/compose-rust/tests/fr72_file_dialog.rs b/compose-rust/tests/fr72_file_dialog.rs index 69bc9a1c..dc87188e 100644 --- a/compose-rust/tests/fr72_file_dialog.rs +++ b/compose-rust/tests/fr72_file_dialog.rs @@ -40,7 +40,7 @@ fn results() -> Vec<(&'static str, FileDialogResult)> { RESULTS.with_borrow(Clone::clone) } -#[composable] +#[Composable] fn Askers() { Button("choose").on_click(|| { FileDialog::open_files() diff --git a/compose-rust/tests/pointer_events.rs b/compose-rust/tests/pointer_events.rs index b681ae0d..ec600514 100644 --- a/compose-rust/tests/pointer_events.rs +++ b/compose-rust/tests/pointer_events.rs @@ -31,7 +31,7 @@ thread_local! { static SEEN: RefCell> = const { RefCell::new(Vec::new()) }; } -#[composable] +#[Composable] fn Screen() { Column().content(|| { // Subscribed to presses and moves. A press is consumed, a move is not. diff --git a/compose-rust/tests/zoom.rs b/compose-rust/tests/zoom.rs index 1dfa38c2..37d5ab61 100644 --- a/compose-rust/tests/zoom.rs +++ b/compose-rust/tests/zoom.rs @@ -12,7 +12,7 @@ use compose_rust::ui::*; use compose_rust::{ComposeHost, ZoomHandle, zoom}; use support::Tree; -#[composable] +#[Composable] fn App() { Button("in twice").on_click(|| { let handle = ZoomHandle::default(); From 52b1ab4fe81ed6e58e56941c6bf1636a0e446339 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:28:36 +0900 Subject: [PATCH 188/224] Fix: Refresh the generated files and Cargo.lock from the merged schema --- Cargo.lock | 74 +++++++++++++++++-- .../android-kotlin/shared/Protocol.gen.kt | 2 +- compose-rust/schema-hash.txt | 2 +- compose-rust/tests/vectors/vectors.json | 2 +- renderer/desktop/src/protocol/Protocol.gen.kt | 2 +- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40f42a25..22dd8a2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ name = "compose-rust-msix" version = "0.1.0" dependencies = [ "png", - "toml", + "toml 0.8.23", ] [[package]] @@ -213,7 +213,7 @@ name = "packager-linux" version = "0.0.0" dependencies = [ "serde", - "toml", + "toml 1.1.8+spec-1.1.0", ] [[package]] @@ -365,6 +365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -400,6 +401,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -472,6 +482,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "1.1.8+spec-1.1.0" @@ -480,11 +502,20 @@ checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -496,15 +527,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.3+spec-1.1.0" @@ -523,7 +574,7 @@ dependencies = [ "serde_json", "target-tuple", "termcolor", - "toml", + "toml 1.1.8+spec-1.1.0", ] [[package]] @@ -632,6 +683,15 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 30fb0d1e..a3a53b00 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -1334,7 +1334,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -152494910627624401L + const val SCHEMA_HASH: Long = 6134367089496884194L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index b8b14ff9..b543eb68 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0xfde23aafcdfc4a2f +0x5521a65ddb4f03e2 diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index a7b26362..c25568d0 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "18294249163081927215", + "schemaHashUnsigned": "6134367089496884194", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 30fb0d1e..a3a53b00 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -1334,7 +1334,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = -152494910627624401L + const val SCHEMA_HASH: Long = 6134367089496884194L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 From 363278358fdaa3be2746700b792f7f0da6cfeba6 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:29:45 +0900 Subject: [PATCH 189/224] Fix: Format the new key events code and state the call macro test without citing the SPEC --- compose-rust/src/codegen.rs | 4 +++- compose-rust/src/protocol.rs | 5 ++++- compose-rust/tests/call_macro.rs | 5 ++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 83a7896c..918a18c0 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -1232,7 +1232,9 @@ object Protocol { " if (event.metaKey) modifiers = modifiers or 0x08\n", ); output.push_str(" out.put(modifiers.toByte())\n"); - output.push_str(" out.put((if (event.repeat) 1 else 0).toByte())\n"); + output.push_str( + " out.put((if (event.repeat) 1 else 0).toByte())\n", + ); output.push_str(" out.putInt(event.code)\n"); output.push_str( " writeStringReference(out, recordLength, text!!)\n", diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 6130cbcf..260686a9 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -3253,7 +3253,10 @@ mod tests { // The text points past the end of the record. let mut past_end = bytes.clone(); past_end[28..32].copy_from_slice(&200_u32.to_le_bytes()); - assert_eq!(decode_event(&past_end), Err(ProtocolError::InvalidStringRange)); + assert_eq!( + decode_event(&past_end), + Err(ProtocolError::InvalidStringRange) + ); // The text starts inside the header, before the string area. let mut inside_header = bytes.clone(); inside_header[24..28].copy_from_slice(&8_u32.to_le_bytes()); diff --git a/compose-rust/tests/call_macro.rs b/compose-rust/tests/call_macro.rs index d38c652e..03ed1fa7 100644 --- a/compose-rust/tests/call_macro.rs +++ b/compose-rust/tests/call_macro.rs @@ -1,6 +1,5 @@ -//! The call macro `#[Composable]` generates next to every composable (FR-39.1.1 to -//! FR-39.1.3), through the public surface: functions written by an application and a Host -//! that composes them. +//! The call macro `#[Composable]` generates next to every composable, through the public +//! surface: functions written by an application and a Host that composes them. mod support; From f68dcbe8ee3bf35e7e8955244aa5f4be86b83cf9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:37:47 +0900 Subject: [PATCH 190/224] Fix: Copy the scroll state handles instead of cloning them --- compose-rust/src/ui/widgets.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index d6d38d9a..d8da241b 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -627,8 +627,8 @@ fn scroll_state_input( attrs.push(("determinate", OwnedAttr::Bool(animate))); attrs.push(("count", OwnedAttr::Int(revision))); } - let offset = state.offset.clone(); - let max_offset = state.max_offset.clone(); + let offset = state.offset; + let max_offset = state.max_offset; listeners.push(( "onrangerequest", EventCallback::Range(Rc::new(RefCell::new(move |range: crate::RangeRequest| { From 633bd0d46a21d0babe26b4194918fc54f6d9cb9f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:40:35 +0900 Subject: [PATCH 191/224] Fix: Spell the composable attribute #[Composable] in the state handle tests --- compose-rust/tests/state_handles.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/compose-rust/tests/state_handles.rs b/compose-rust/tests/state_handles.rs index 0e055132..74cbb7f1 100644 --- a/compose-rust/tests/state_handles.rs +++ b/compose-rust/tests/state_handles.rs @@ -52,7 +52,7 @@ fn frame(host: &mut ComposeHost, tree: &mut Tree) -> Vec { kinds(&batch) } -#[composable] +#[Composable] fn Child() { SITE_LINE.with(|line| line.set(line!() + 1)); let state = remember(|| { @@ -63,7 +63,7 @@ fn Child() { Text(format!("child {}", state.with(|tracked| tracked.0))); } -#[composable] +#[Composable] fn Gate(show: MutableState) { if show.get() { Child(); @@ -139,7 +139,7 @@ fn fr39_state_released_value_survives_a_recomposition_that_keeps_the_group() { assert_eq!(drops(), 1); } -#[composable] +#[Composable] fn Item(id: u32) { let state = remember(|| { let state = mutable_state_of(Tracked(id as i32)); @@ -149,7 +149,7 @@ fn Item(id: u32) { Text(format!("item {} {}", id, state.with(|tracked| tracked.0))); } -#[composable] +#[Composable] fn Items(order: MutableState>) { for id in order.get() { key(id, || Item(id)); @@ -275,7 +275,7 @@ fn fr39_state_worker_handle_is_send_and_sync() { assert_send_sync::>(); } -#[composable] +#[Composable] fn Reader(count: MutableState) { READER_RUNS.with(|runs| runs.set(runs.get() + 1)); Text(format!("{}", count.get())); From ed98b8911ba0e0d27e9c21db3d93538dbd977090 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:43:15 +0900 Subject: [PATCH 192/224] Fix: Give the semantics decode tests the ten modifier words Transform3D made the maximum --- compose-rust/src/protocol.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 260686a9..f87d19f1 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -2898,7 +2898,7 @@ mod tests { encoder.encode(&mutation).unwrap(); assert_eq!(decode_batch(encoder.finish().unwrap()).unwrap(), [mutation]); - let words = [SemanticsRole::Button as u64, 0x2000, 0, 0, 0, 0]; + let words = [SemanticsRole::Button as u64, 0x2000, 0, 0, 0, 0, 0, 0, 0, 0]; assert_eq!( decode_modifier(34, &words), Err(ProtocolError::InvalidModifier(34)) @@ -2950,13 +2950,13 @@ mod tests { #[test] fn nfr7_unknown_semantics_bit_is_a_protocol_error() { // Bit 13 is the first one above the mask. - let words = [SemanticsRole::Group as u64, 1 << 13, 0, 0, 0, 0]; + let words = [SemanticsRole::Group as u64, 1 << 13, 0, 0, 0, 0, 0, 0, 0, 0]; assert_eq!( decode_modifier(34, &words), Err(ProtocolError::InvalidModifier(34)) ); // The highest bit the mask allows still decodes. - let words = [SemanticsRole::Group as u64, 1 << 12, 0, 0, 0, 0]; + let words = [SemanticsRole::Group as u64, 1 << 12, 0, 0, 0, 0, 0, 0, 0, 0]; assert!(decode_modifier(34, &words).is_ok()); } From e8345500afe87da8e8861ccf5e287bd3008dee9c Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:46:33 +0900 Subject: [PATCH 193/224] Fix: Tags 28 to 30 are the unused ones now that Transform3D has 27 --- compose-rust/tests/html_elements.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose-rust/tests/html_elements.rs b/compose-rust/tests/html_elements.rs index 672f5f44..54a984d2 100644 --- a/compose-rust/tests/html_elements.rs +++ b/compose-rust/tests/html_elements.rs @@ -214,7 +214,7 @@ fn fr_matchparent_round_trips_with_sparse_tag() { assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 31); assert_eq!(bytes.len(), 40); assert_eq!(decode_batch(&bytes), Ok(vec![record])); - for tag in 27..31 { + for tag in 28..31 { assert_eq!(modifier_extra_words(tag), None); } } From 67a39b37f2bd04f6f12549aaaa9140d56d938ba9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:49:48 +0900 Subject: [PATCH 194/224] Fix: Read the animation events at their offsets after the longer KeyDown record --- compose-rust/tests/transform_animation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/transform_animation.rs b/compose-rust/tests/transform_animation.rs index 8a3f21c2..197df689 100644 --- a/compose-rust/tests/transform_animation.rs +++ b/compose-rust/tests/transform_animation.rs @@ -291,14 +291,14 @@ fn fr41_start_animation_round_trips() { ); let events = include_bytes!("vectors/events.bin"); assert!(matches!( - decode_event(&events[470..510]).unwrap().payload, + decode_event(&events[487..527]).unwrap().payload, EventPayload::AnimationEvent { kind: AnimationEventKind::End, .. } )); assert_eq!( - decode_event(&events[510..530]).unwrap().payload, + decode_event(&events[527..547]).unwrap().payload, EventPayload::ReducedMotionChanged(ReducedMotion::On) ); } From 300b1ec092f1cca0c22f8b85881f7cf86dd6362d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:01:17 +0900 Subject: [PATCH 195/224] Fix: Restore the modifier chain of RenderNode that the key handler merge dropped --- .../android-kotlin/shared/RenderNode.kt | 40 +++++++++++++++++++ renderer/desktop/src/renderer/RenderNode.kt | 40 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index 4f9a2ef4..d37a9a01 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -200,6 +200,46 @@ fun RenderNode( // focusable (a click, say) is a focus target, and a key event reaches only the handlers // outside it on the way down. Placed after it, a handler would see keys from focused // descendants only and never the keys pressed while the node itself has focus. + // The TextField wires its own key handling, because it has an editor to intercept and a + // composition to protect; everything else routes keys here. + val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val keyed = if ((keyDownHandler == null && keyUpHandler == null) || + node.widget == WidgetKind.TextField + ) { + profiled + } else { + // Plain containers and text are not focus targets on their own, so a node that + // listens for keys is made one, ahead of its modifiers like the handlers. Without it + // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. + // Widgets that are controls bring their own focus handling and are left alone. + val focusTarget = node.widget in KeyFocusTargets + profiled.hostKeyEvents(nodeId, keyDownHandler, keyUpHandler, dispatcher) + .let { if (focusTarget) it.focusable() else it } + } + val chain = keyed + .then( + node.modifiers.toComposeModifier( + nodeId, + dispatcher, + theme, + animated = { table.animations.animated(nodeId) }, + containerClickHandler = clickHandler, + containerEnabled = node.flag(PropertyKind.Enabled, default = true), + containerShape = if (clickHandler != null) node.pressedShape(theme) else null, + pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), + allowPress = node.widget != WidgetKind.TextField, + ), + ) + .testTag(nodeTestTag(nodeId)) + .accessibility( + node, + dispatcher, + clickHandler + ?: (node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + as? dev.darkpyonix.composerust.protocol.Modifier.Clickable)?.handlerId + ?: node.handler(PropertyKind.OnClick), + ) // Focus-gained and focus-lost for non-TextField nodes: `hasFocus` covers both the node // itself and any focusable descendant inside it. val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index 4f9a2ef4..d37a9a01 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -200,6 +200,46 @@ fun RenderNode( // focusable (a click, say) is a focus target, and a key event reaches only the handlers // outside it on the way down. Placed after it, a handler would see keys from focused // descendants only and never the keys pressed while the node itself has focus. + // The TextField wires its own key handling, because it has an editor to intercept and a + // composition to protect; everything else routes keys here. + val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val keyed = if ((keyDownHandler == null && keyUpHandler == null) || + node.widget == WidgetKind.TextField + ) { + profiled + } else { + // Plain containers and text are not focus targets on their own, so a node that + // listens for keys is made one, ahead of its modifiers like the handlers. Without it + // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. + // Widgets that are controls bring their own focus handling and are left alone. + val focusTarget = node.widget in KeyFocusTargets + profiled.hostKeyEvents(nodeId, keyDownHandler, keyUpHandler, dispatcher) + .let { if (focusTarget) it.focusable() else it } + } + val chain = keyed + .then( + node.modifiers.toComposeModifier( + nodeId, + dispatcher, + theme, + animated = { table.animations.animated(nodeId) }, + containerClickHandler = clickHandler, + containerEnabled = node.flag(PropertyKind.Enabled, default = true), + containerShape = if (clickHandler != null) node.pressedShape(theme) else null, + pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), + allowPress = node.widget != WidgetKind.TextField, + ), + ) + .testTag(nodeTestTag(nodeId)) + .accessibility( + node, + dispatcher, + clickHandler + ?: (node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + as? dev.darkpyonix.composerust.protocol.Modifier.Clickable)?.handlerId + ?: node.handler(PropertyKind.OnClick), + ) // Focus-gained and focus-lost for non-TextField nodes: `hasFocus` covers both the node // itself and any focusable descendant inside it. val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) From 78b35678b6c09c2fb840fed714658cdf3635a49a Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:21:27 +0900 Subject: [PATCH 196/224] Fix: Give the Linux window tests a Host of their own and the window size as bytes --- renderer/linux/test/LinuxMultiWindowTest.kt | 39 ++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/renderer/linux/test/LinuxMultiWindowTest.kt b/renderer/linux/test/LinuxMultiWindowTest.kt index d68a549d..95f55c6f 100644 --- a/renderer/linux/test/LinuxMultiWindowTest.kt +++ b/renderer/linux/test/LinuxMultiWindowTest.kt @@ -19,12 +19,12 @@ import dev.darkpyonix.composerust.protocol.Window import dev.darkpyonix.composerust.protocol.WindowMode import dev.darkpyonix.composerust.runtime.ComposeRustContent import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.HostConnection import dev.darkpyonix.composerust.runtime.LocalSystemDarkObserver import dev.darkpyonix.composerust.runtime.WindowDropTargets import dev.darkpyonix.composerust.runtime.Zoom import dev.darkpyonix.composerust.runtime.ZoomLevelStore import dev.darkpyonix.composerust.runtime.appZoomScale -import dev.darkpyonix.composerust.tooling.FakeHostConnection import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -185,7 +185,7 @@ class LinuxMultiWindowTest { ), PropertyKind.OnWindowDrag to PropertyValue.Integer(11L), ) + strip(1, 10, 44L) - val conn = FakeHostConnection(initial) + val conn = RecordingHost(initial) host = ComposeRustHost(conn) host.start() val manager = WindowManager(host, backend, onExit = {}, dropTargets = DropTargets.Registered) @@ -458,16 +458,16 @@ class LinuxMultiWindowTest { ), Mutation.Create(10, WidgetKind.Window), Mutation.SetProp(10, PropertyKind.WindowTitle, PropertyValue.Text("Window A")), - Mutation.SetProp(10, PropertyKind.WindowSize, PropertyValue.Pair(300f, 200f)), + Mutation.SetProp(10, PropertyKind.WindowSize, PropertyValue.Bytes(bytesOf(300f, 200f))), Mutation.Insert(0, 10, -1), Mutation.SetWindowTheme(10, Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Dark, false)), Mutation.Create(20, WidgetKind.Window), Mutation.SetProp(20, PropertyKind.WindowTitle, PropertyValue.Text("Window B")), - Mutation.SetProp(20, PropertyKind.WindowSize, PropertyValue.Pair(300f, 200f)), + Mutation.SetProp(20, PropertyKind.WindowSize, PropertyValue.Bytes(bytesOf(300f, 200f))), Mutation.Insert(0, 20, -1), Mutation.SetWindowTheme(20, Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), ) - val conn = FakeHostConnection(initial) + val conn = RecordingHost(initial) val host = ComposeRustHost(conn) host.start() val manager = WindowManager(host, backend, onExit = {}) @@ -518,3 +518,32 @@ class LinuxMultiWindowTest { } } } + +/** + * A Host that plays a fixed first batch, queues later batches for the next frame, and keeps + * the events it was sent. The linux module does not carry the desktop renderer's + * `FakeHostConnection`, so the tests that need one write what they need here. + */ +private class RecordingHost(private val initial: List) : HostConnection { + val events = mutableListOf() + private val frames = ArrayDeque>() + + fun scheduleFrame(mutations: List) { + frames.addLast(mutations) + } + + override fun init(onMutation: (Mutation) -> Unit) { + initial.forEach(onMutation) + } + + override fun dispatchEvent(event: HostEvent, onMutation: (Mutation) -> Unit): Long { + events += event + return 0 + } + + override fun renderFrame(frameTimeNanos: Long, onMutation: (Mutation) -> Unit) { + frames.removeFirstOrNull()?.forEach(onMutation) + } + + override fun shutdown() {} +} From 11c9478d8aa12660408b47bd7c65fa0f3b4228d0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:30:36 +0900 Subject: [PATCH 197/224] Fix: Allow the focus-gained and key-up events on pickers, and opt in to WeakReference in the macOS sheets --- renderer/desktop/test/PickerTest.kt | 2 ++ renderer/macos/src/MacosFileDialogPlatform.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/renderer/desktop/test/PickerTest.kt b/renderer/desktop/test/PickerTest.kt index 98fd8641..af675f50 100644 --- a/renderer/desktop/test/PickerTest.kt +++ b/renderer/desktop/test/PickerTest.kt @@ -209,7 +209,9 @@ class PickerTest { PropertyKind.OnValueChange, PropertyKind.OnSubmit, PropertyKind.OnFocusLost, + PropertyKind.OnFocusGained, PropertyKind.OnKeyDown, + PropertyKind.OnKeyUp, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, // The name assistive technology reads can be given to any node. diff --git a/renderer/macos/src/MacosFileDialogPlatform.kt b/renderer/macos/src/MacosFileDialogPlatform.kt index fc919481..2bcb85a0 100644 --- a/renderer/macos/src/MacosFileDialogPlatform.kt +++ b/renderer/macos/src/MacosFileDialogPlatform.kt @@ -186,6 +186,7 @@ internal fun keyOrFirstWindow(windows: List): NSWindow? { * by the dialogs it once showed, and its closing ends them: every open and waiting request is * cancelled and the window is forgotten. */ +@OptIn(kotlin.experimental.ExperimentalNativeApi::class) private fun macosSheets(window: NSWindow, gone: () -> Unit): WindowSheets { val ref = WeakReference(window) val dialogs = MacosFileDialogs { ref.get() } From 000b99a59642c9e9bb388f48003c54e3835cf568 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:01:46 +0900 Subject: [PATCH 198/224] Fix: Build the Flatpak against runtime 25.08, the newest one flathub offers --- .github/workflows/packager-linux.yml | 2 +- docs/SPEC.md | 2 +- scripts/packager-linux/FLATHUB.md | 5 ++++- scripts/packager-linux/src/flatpak.rs | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 085c96bd..574ad39f 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -46,7 +46,7 @@ concurrency: env: CARGO_TERM_COLOR: always CARGO_PROFILE_DEV_DEBUG: "0" - FLATPAK_RUNTIME: "26.08" + FLATPAK_RUNTIME: "25.08" # A screenshot for the test listings, pinned to a commit the way a store listing must be # (Ember's large icon; the images are stand-ins, the pinning and mirroring are real). SCREENSHOT_URL: https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/faa430a4b75ec45928623130ea2f874b628a7224/src-tauri/icons/128x128@2x.png diff --git a/docs/SPEC.md b/docs/SPEC.md index 417b94f5..d2c83f00 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1823,7 +1823,7 @@ tag 12, 32바이트: handler_id: u64, text: (offset, len), action: (offset, len) 6. `.deb`와 `.rpm`은 각각 `dpkg-deb --info`와 `rpm -qp --info`로 이름, 버전, 설명이 샘플 설정과 일치하는지 CI에서 확인합니다. 실제 설치와 실행은 해당 배포판에서 수동으로 확인합니다. 7. 세 OS의 직접 배포 패키지는 Velopack으로 만들고, 이전 버전에서 새 버전으로 가는 델타 패키지와 갱신 피드(nupkg 전체와 델타, `releases.{channel}.json`)를 함께 만듭니다. 패키지와 피드의 존재와 메타데이터는 CI에서 확인하고, 설치된 빌드가 델타로 갱신되어 다시 뜨는 것은 각 OS 환경에서 수동으로 확인합니다. Windows `.msi`는 `PerUser` 기본과 `PerMachine` 선택 설치가 각각 되는지도 Windows에서 수동으로 확인합니다. Linux `.AppImage`에는 zsync 갱신 정보가 없습니다. 8. macOS `.pkg`는 Mac App Store 제출 형식으로 만들며, 구조와 메타데이터는 `pkgutil --check-signature`와 `pkgutil --payload-files`로 CI에서 확인합니다. 스토어 제출과 승인은 Apple 계정이 있는 환경에서 수동으로만 확인할 수 있습니다. -9. Flatpak은 Flathub용 매니페스트로 만들고 `flatpak-builder`로 빌드되는 것을 CI에서 확인합니다. 설치와 실행은 수동으로 확인합니다. 패키지 메타데이터는 같은 입력에서 같은 출력을 만듭니다. 릴리스 날짜는 현재 시각이 아니라 `SOURCE_DATE_EPOCH` 같은 주어진 값(없으면 빌드 사실의 시각)에서 정해지고, 해석할 수 없는 값은 안전한 기본으로 떨어집니다(Flathub 제출 요건, fr22_6_9_release_dates_are_reproducible). Linux 애플리케이션 식별자는 Flatpak과 D-Bus 이름 규칙(점으로 나뉜 세 요소 이상, 요소는 숫자로 시작하지 않음, 앞쪽 요소에는 `-` 금지, 특수문자 금지)을 따르며 어긋나면 만들지 않습니다(fr22_6_9_application_ids_follow_flatpak_and_dbus_rules). +9. Flatpak은 Flathub용 매니페스트로 만들고(런타임은 `org.freedesktop.Platform`/`Sdk` 25.08입니다. 26.08은 Flathub의 appstream 목록에는 있으나 `flathub` 원격에 ref가 아직 없어 설치되지 않으므로, 설치되는 가장 새 런타임을 씁니다) `flatpak-builder`로 빌드되는 것을 CI에서 확인합니다. 설치와 실행은 수동으로 확인합니다. 패키지 메타데이터는 같은 입력에서 같은 출력을 만듭니다. 릴리스 날짜는 현재 시각이 아니라 `SOURCE_DATE_EPOCH` 같은 주어진 값(없으면 빌드 사실의 시각)에서 정해지고, 해석할 수 없는 값은 안전한 기본으로 떨어집니다(Flathub 제출 요건, fr22_6_9_release_dates_are_reproducible). Linux 애플리케이션 식별자는 Flatpak과 D-Bus 이름 규칙(점으로 나뉜 세 요소 이상, 요소는 숫자로 시작하지 않음, 앞쪽 요소에는 `-` 금지, 특수문자 금지)을 따르며 어긋나면 만들지 않습니다(fr22_6_9_application_ids_follow_flatpak_and_dbus_rules). 10. `.msi`는 `vpk pack --msi`로 만들며, 5번과 7번의 검사 대상입니다. 11. 템플릿과 메타데이터가 core-extended의 `extended/packaging/`에서 오고 그 커밋이 고정되어 있음을 CI가 확인합니다. 12. **실험 중:** ad-hoc 서명한 `.app`을 `.dmg`에서 설치한 Apple Silicon 기기가 Velopack 갱신을 받고, 갱신 뒤에도 실행됩니다(quarantine 속성 포함). Velopack 문서는 서명과 공증이 없는 macOS 앱은 실행되지 않는다고 하므로 확인이 필요하며, 프로브를 돌리는 중입니다. 결과가 나오기 전에는 이 항목을 통과로 보지 않습니다. diff --git a/scripts/packager-linux/FLATHUB.md b/scripts/packager-linux/FLATHUB.md index 93551623..62d7d680 100644 --- a/scripts/packager-linux/FLATHUB.md +++ b/scripts/packager-linux/FLATHUB.md @@ -53,7 +53,10 @@ entry, the metainfo and `icons/`. - [ ] `flatpak run dev.darkpyonix.Ember` opens the window. The workflow's calculator step shows how to do that under a virtual display. - [ ] `appstreamcli validate --no-net flathub-ember/dev.darkpyonix.Ember.metainfo.xml` passes. -- [ ] The runtime branch (default 26.08) is the newest one Flathub offers. +- [ ] The runtime branch (default 25.08) is the newest one Flathub offers. Flathub's appstream + listing already names 26.08, but its `flathub` remote has no `org.freedesktop.Sdk/x86_64/26.08` + ref yet (flatpak-builder: "No remote refs found"), so 25.08 is the newest installable one. + Move to 26.08 when `flatpak remote-ls flathub --runtime | grep 26.08` lists it. ## 4. Pull request (when the hold is lifted) diff --git a/scripts/packager-linux/src/flatpak.rs b/scripts/packager-linux/src/flatpak.rs index fe9ca373..cbf81711 100644 --- a/scripts/packager-linux/src/flatpak.rs +++ b/scripts/packager-linux/src/flatpak.rs @@ -22,7 +22,7 @@ use crate::icons::{Icon, IconKind}; use crate::metadata::AppMetadata; /// The runtime branch used when the application's file does not name one. -pub const DEFAULT_RUNTIME_VERSION: &str = "26.08"; +pub const DEFAULT_RUNTIME_VERSION: &str = "25.08"; /// Where a source comes from. #[derive(Debug, Clone, PartialEq, Eq)] From 8f7ae172c133030c7e5771133cd7b1171421f791 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:01:59 +0900 Subject: [PATCH 199/224] Fix: Check the samples against the head's own renderer when the release has another schema --- .github/workflows/test-samples.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index d3a37c7e..74b23731 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -103,9 +103,9 @@ jobs: # Does the release for this commit's crate version carry the renderer assets? Decides # whether the build downloads them or builds the renderer itself. # - # The renderer used is the one released for the crate version, so a pull request that - # changes the renderer's sources without raising the version is tested against the old - # release. Raise the version, or the assets are not found, for the new sources to be built. + # The renderer used is the one released for the crate version, unless the release carries + # another schema than this head (compared by schema-hash.txt): then the renderer is built + # from this head and the run says so in a notice. # The Windows static branches below take effect once the release carries a static # windows-x64 tarball; until then the Windows build still uses the DLL kit. resolve: @@ -132,8 +132,23 @@ jobs: # counts as not released. A gh failure is warned about, not hidden. result="$(.github/scripts/find-released-renderer.sh "$version")" echo "$result" - echo "$result" | grep '^released=' >> "$GITHUB_OUTPUT" released="$(sed -n 's/^released=//p' <<<"$result")" + # A release built from another schema can never agree with this head: the crate's + # build script refuses a renderer of a different schema. Say so, and build the + # renderer from this head instead, so the check stays meaningful on a head that + # changed the schema without moving the version yet. + if [[ "$released" == "true" ]]; then + work="$(mktemp -d)" + asset="compose-rust-renderer-v$version-linux-x64.tar.gz" + gh release download "v$version" -R "$GITHUB_REPOSITORY" --pattern "$asset" --dir "$work" + released_hash="$(tar -xzOf "$work/$asset" --wildcards '*schema-hash.txt' | tr -d '[:space:]')" + head_hash="$(tr -d '[:space:]' < compose-rust/schema-hash.txt)" + if [[ "$released_hash" != "$head_hash" ]]; then + echo "::notice title=Schema changed since v$version::the release carries schema $released_hash and this head $head_hash, so the samples are checked against the renderer built from this head" + released=false + fi + fi + echo "released=$released" >> "$GITHUB_OUTPUT" echo "crate $version, released renderer: $released" # The renderers macOS and Linux ship, from the same reusable workflow the release uses. From a0004f25ef889648ec3862e7f42417cd35386b1e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:39:23 +0900 Subject: [PATCH 200/224] Fix: Install the Flatpak runtimes in the system installation too, where the builder looks --- .github/workflows/packager-linux.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 574ad39f..45fa0db4 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -323,6 +323,12 @@ jobs: flatpak --user install --noninteractive flathub org.flatpak.Builder \ "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" + # The builder asks the host's flatpak whether the SDK is installed and looks in the + # system installation as well as the user one, so the runtimes go into both. + sudo flatpak --system remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + sudo flatpak --system install --noninteractive flathub \ + "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ + "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" echo "== what the SDK has for rewriting an executable's library names" flatpak run --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' rustup toolchain install stable --profile minimal && rustup default stable From 9d9e96ef80e3b788442c40ae0297668a3def4a56 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:19:19 +0900 Subject: [PATCH 201/224] Fix: Link UniformTypeIdentifiers for the macOS file dialogs and run the SDK probe in the user installation --- .github/workflows/packager-linux.yml | 2 +- compose-rust/build.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 45fa0db4..9d870b0b 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -330,7 +330,7 @@ jobs: "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" echo "== what the SDK has for rewriting an executable's library names" - flatpak run --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' + flatpak run --user --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' rustup toolchain install stable --profile minimal && rustup default stable cargo build --release -p packager-linux echo "PACKAGER_LINUX=$PWD/target/release/packager-linux" >> "$GITHUB_ENV" diff --git a/compose-rust/build.rs b/compose-rust/build.rs index 3d385f26..b14a38a2 100644 --- a/compose-rust/build.rs +++ b/compose-rust/build.rs @@ -286,6 +286,8 @@ fn static_link(target_os: &str) -> Option { // The notifications an application posts go through the system's own // notification centre. "UserNotifications", + // The file open and save sheets name the types a filter lets through. + "UniformTypeIdentifiers", ], libraries: &["c++", "z"], arm64_libraries: &[], From 3c079a04308df508734c216d335e4275b578f46b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:59:30 +0900 Subject: [PATCH 202/224] Fix: Build the Flatpaks with the host flatpak-builder, which sees the installed runtimes --- .github/workflows/packager-linux.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 9d870b0b..a5718beb 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -51,7 +51,10 @@ env: # (Ember's large icon; the images are stand-ins, the pinning and mirroring are real). SCREENSHOT_URL: https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/faa430a4b75ec45928623130ea2f874b628a7224/src-tauri/icons/128x128@2x.png # Flathub's builder and linter, run from the org.flatpak.Builder Flatpak. - FLATPAK_BUILDER: flatpak run --command=flatpak-builder org.flatpak.Builder + # The host's flatpak-builder: the one inside the org.flatpak.Builder sandbox does not find the + # runtimes installed for the user and its own install of them is refused ("No remote refs + # found"). The linter stays Flathub's, from the org.flatpak.Builder Flatpak. + FLATPAK_BUILDER: flatpak-builder FLATPAK_BUILDER_LINT: flatpak run --command=flatpak-builder-lint org.flatpak.Builder jobs: @@ -314,21 +317,15 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - # No flatpak-builder from apt: it is too old for the options Flathub requires. - # Flathub's own builder is installed below as the org.flatpak.Builder Flatpak. - sudo apt-get install -y --no-install-recommends flatpak elfutils dbus-daemon patchelf xvfb xauth imagemagick \ + # The builder is apt's (24.04 has 1.4.x, which has the options Flathub requires). The + # org.flatpak.Builder Flatpak below is only used for the linter. + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils dbus-daemon patchelf xvfb xauth imagemagick \ build-essential pkg-config libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true flatpak --user install --noninteractive flathub org.flatpak.Builder \ "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" - # The builder asks the host's flatpak whether the SDK is installed and looks in the - # system installation as well as the user one, so the runtimes go into both. - sudo flatpak --system remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo - sudo flatpak --system install --noninteractive flathub \ - "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ - "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" echo "== what the SDK has for rewriting an executable's library names" flatpak run --user --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' rustup toolchain install stable --profile minimal && rustup default stable From 740588aafb7a51e78adb439f42b45a6b880d58a5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:38:29 +0900 Subject: [PATCH 203/224] Fix: Let the Flathub builder sandbox see the user installation's runtimes --- .github/workflows/packager-linux.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index a5718beb..7d202fac 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -51,10 +51,9 @@ env: # (Ember's large icon; the images are stand-ins, the pinning and mirroring are real). SCREENSHOT_URL: https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/faa430a4b75ec45928623130ea2f874b628a7224/src-tauri/icons/128x128@2x.png # Flathub's builder and linter, run from the org.flatpak.Builder Flatpak. - # The host's flatpak-builder: the one inside the org.flatpak.Builder sandbox does not find the - # runtimes installed for the user and its own install of them is refused ("No remote refs - # found"). The linter stays Flathub's, from the org.flatpak.Builder Flatpak. - FLATPAK_BUILDER: flatpak-builder + # Flathub's builder, from the org.flatpak.Builder Flatpak; the install step adds the user + # installation to its sandbox so it sees the runtimes installed there. + FLATPAK_BUILDER: flatpak run --command=flatpak-builder org.flatpak.Builder FLATPAK_BUILDER_LINT: flatpak run --command=flatpak-builder-lint org.flatpak.Builder jobs: @@ -317,15 +316,18 @@ jobs: # bubblewrap, which builds and runs every sandbox, needs them. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo apt-get update - # The builder is apt's (24.04 has 1.4.x, which has the options Flathub requires). The - # org.flatpak.Builder Flatpak below is only used for the linter. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils dbus-daemon patchelf xvfb xauth imagemagick \ + # No flatpak-builder from apt: it is too old for the options Flathub requires. + # Flathub's own builder is installed below as the org.flatpak.Builder Flatpak. + sudo apt-get install -y --no-install-recommends flatpak elfutils dbus-daemon patchelf xvfb xauth imagemagick \ build-essential pkg-config libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true flatpak --user install --noninteractive flathub org.flatpak.Builder \ "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" + # Inside the builder's sandbox the user installation is not where the builder looks, so + # it asked flathub for the SDK that is already installed and was refused. + echo "FLATPAK_BUILDER=flatpak run --filesystem=$HOME/.local/share/flatpak --env=FLATPAK_USER_DIR=$HOME/.local/share/flatpak --command=flatpak-builder org.flatpak.Builder" >> "$GITHUB_ENV" echo "== what the SDK has for rewriting an executable's library names" flatpak run --user --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' rustup toolchain install stable --profile minimal && rustup default stable From d1525b9c0bae45d53f158267192f4f3a081c2c2b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:09:36 +0900 Subject: [PATCH 204/224] Fix: Run the 1.0.1 builder call under a session bus like the others --- .github/workflows/packager-linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml index 7d202fac..98be0555 100644 --- a/.github/workflows/packager-linux.yml +++ b/.github/workflows/packager-linux.yml @@ -378,7 +378,7 @@ jobs: --package packager-linux-demo --bin update-demo \ --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ --out work/flatpak-src - (cd work/flatpak-src && $FLATPAK_BUILDER \ + (cd work/flatpak-src && dbus-run-session -- $FLATPAK_BUILDER \ --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean \ --install-deps-from=flathub --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") flatpak --user update --noninteractive "$id" From fb45f87e043b25ffc232550e105ed50c054ddfd0 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:03:01 +0900 Subject: [PATCH 205/224] Fix: Give the sample build four hours, the Windows leg used up two --- .github/workflows/test-samples.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 74b23731..9b48a1e4 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -181,7 +181,9 @@ jobs: # failing does not stop the others' samples. if: ${{ !cancelled() && needs.requested.result == 'success' && needs.resolve.result == 'success' }} runs-on: ${{ matrix.runner }} - timeout-minutes: 120 + # The Windows leg compiles eleven samples one after another and used up the two hours + # with the renderer already built. + timeout-minutes: 240 strategy: fail-fast: false matrix: From 1c14be2135d4c89d21ad5ea1fa013943fbc15f2f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:35:27 +0900 Subject: [PATCH 206/224] Chore: bump version to 0.0.7 --- CHANGELOG.md | 61 +++++++++++++++++++++++++++++- Cargo.lock | 4 +- README.md | 2 +- compose-rust/Cargo.toml | 4 +- compose-rust/macros/Cargo.toml | 2 +- docs/guide/en/getting-started.html | 6 +-- docs/guide/ko/getting-started.html | 6 +-- docs/locales/README_ko.md | 2 +- 8 files changed, 73 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6fd9d60..5548001a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## v0.0.7 ### Native file open and save dialogs @@ -14,6 +14,65 @@ snake_case by convention only, and the widgets already use Compose's PascalCase lowercase `#[composable]` is gone, with no alias: the crate is before 1.0, so one release moves every use. Rename each `#[composable]` in your code to `#[Composable]`. +### Host-driven focus and IME composition notifications + +The Host can move input focus to a node, or clear it, with `Batch::request_focus(node_id)` (a new `RequestFocus` command, tag 25), and a `TextField` reports when an IME composition starts and ends with `.on_composition_change(impl FnMut(bool))` ([#410](https://github.com/DarkPyonix/compose-rust/issues/410), [#423](https://github.com/DarkPyonix/compose-rust/pull/423)). + +### `Button` text alignment, label size and a content slot + +`Button::text_align` and `Button::font_size` use the existing `TextAlign` and `FontSize` attributes, and a `Button` can take children that are drawn as a vertically centred row, so a button can be a list row with a left aligned label ([#425](https://github.com/DarkPyonix/compose-rust/issues/425), [#453](https://github.com/DarkPyonix/compose-rust/pull/453)). No schema change. + +### A weighted `Row` or `Column` child keeps room for its longest word + +A child with a weight on the main axis gets the larger of its share and its minimum content width, so a label in a narrow weighted cell is no longer broken into one glyph per line. Lazy lists and other lazy layouts keep the stock weight ([#427](https://github.com/DarkPyonix/compose-rust/issues/427), [#448](https://github.com/DarkPyonix/compose-rust/pull/448)). + +### Colours and size for `Switch`, `Slider` and `Checkbox` + +`Switch` has `track_color`, `thumb_color` and `size(width, height)`, `Slider` has `track_color`, `active_track_color` and `thumb_color`, and `Checkbox` has `container_color`, `check_color` and `size(side)` ([#426](https://github.com/DarkPyonix/compose-rust/issues/426), [#428](https://github.com/DarkPyonix/compose-rust/issues/428)). A value that is not set is not sent, and the design system draws that slot. On the wire these are the new `TrackColor` (121), `ThumbColor` (122), `ActiveTrackColor` (123), `ContainerColor` (124), `CheckColor` (125) and `ControlSize` (126) properties. The schema hash changes, so a renderer and a crate from before this change no longer agree. + +### A `WindowDrag(false)` child keeps its press out of a drag region on macOS + +Inside a region that is a window drag area, a node marked `WindowDrag(false)` (a button, a text field) now keeps its press: the press is not turned into a window drag ([#482](https://github.com/DarkPyonix/compose-rust/issues/482), [#458](https://github.com/DarkPyonix/compose-rust/pull/458)). + +### Change-marker bars in the code editor gutter + +A `Bar` gutter decoration whose colour is `DiffAdded`, `DiffModified` or `DiffRemoved` is drawn as a 4dp change-marker bar to the right of the line-number column, 12dp from the code. A line gets one record, so there is no wire change. When a line carries several, `DiffRemoved` wins over `DiffModified`, which wins over `DiffAdded`. Other `Dot`, `Bar` and `Glyph` decorations and an editor without bars are laid out as before. The editor's state description tells assistive technology which lines changed ([#477](https://github.com/DarkPyonix/compose-rust/pull/477)). + +### Accessibility modifier and `accessible_name` + +`Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. `Modifier::label` keeps each distinct name for the life of the program, at most 4096 of them; a name past that limit is left off, and debug builds print a warning once. + +### Node accessibility: roles, states and a click action + +The accessibility modifier grows, add-only ([#473](https://github.com/DarkPyonix/compose-rust/issues/473), [#489](https://github.com/DarkPyonix/compose-rust/pull/489)). There are 11 more roles (`TabList`, `Tree`, `TreeItem`, `Menu`, `MenuItem`, `TextBox`, `Heading`, `Navigation`, `List`, `ListItem`, `Group`), the states `expanded`, `disabled`, `focused` and `hidden` (a hidden node leaves the accessibility tree), and a `click` action that calls the node's own click handler, so an assistive tool or an automated audit can press a control without touching the screen. A disabled node, or one whose `enabled` is false, cannot be pressed this way. Roles that Compose has no name for (everything except `Heading` and the ones it already had) are carried as a short role word in the node's state description for now. + +Known limits: the `focus` action has a wire bit but is not exposed by the renderer yet (the node's focus requester is created after the accessibility modifier and only when a focus request exists), expanded and collapsed are a description and not an action, and the platform's own roles (macOS tab group, outline, menu) need a change in the Compose fork and are not part of this release. [#473](https://github.com/DarkPyonix/compose-rust/issues/473) stays open for them. + +### Window size reports for every whole-dp change, and a first report for tall phone-sized windows + +A window that sets `report_size` also receives `WindowSizeChanged` whenever its width or height, each rounded to the nearest whole dp, changes inside a size class, so a host that lays out from the exact size (an HTML and CSS host, for example) can reflow. Use `Window(on_close).report_size(true)` for a `Window` node and `Window::new().with_report_size(true)` for the implicit single window; a window that does not opt in behaves exactly as before. Each whole-dp change while the user drags an edge costs one call into the Host, so only a host that needs the exact size should turn it on ([#467](https://github.com/DarkPyonix/compose-rust/issues/467), [#470](https://github.com/DarkPyonix/compose-rust/pull/470), [#479](https://github.com/DarkPyonix/compose-rust/issues/479), [#489](https://github.com/DarkPyonix/compose-rust/pull/489)). + +Separately, a window that is narrow but tall (390 by 844 dp: Compact wide, Medium high) never sent its first size, so `current_window_size()` kept 0 by 0. The renderer now sends it. This needs no opt-in. + +### macOS safe area measured again after the window is shown + +On macOS the window buttons' safe area (`WindowSafeArea`) could stay `{0, 0, 0}` for a window that never resizes, because the caption was measured once before the title bar was laid out. The Compose fork's macOS window now measures it again after it is shown, a few times at short intervals, and the window modules are pinned at that fork commit ([#468](https://github.com/DarkPyonix/compose-rust/issues/468), [#470](https://github.com/DarkPyonix/compose-rust/pull/470), fork [#182](https://github.com/thisisthepy/compose-multiplatform-core-extended/pull/182)). Known limit: this has not been confirmed at a 390 by 844 window yet. An application should keep a fallback for a zero safe area until it has been. + +### Windows links the Kotlin/Native static renderer by default + +Windows targets now resolve and link the Kotlin/Native static renderer kit (`renderer/build/windows` in a checkout or `compose-rust-renderer-v-windows-x64.tar.gz` from a release) as `RendererKind::Static` by default, producing a single executable with no GraalVM DLLs beside it ([#332](https://github.com/DarkPyonix/compose-rust/issues/332), [#333](https://github.com/DarkPyonix/compose-rust/pull/333), [#465](https://github.com/DarkPyonix/compose-rust/pull/465)). `DXC_WINDOWS_NATIVE_LIB` and `COMPOSE_RUST_RENDERER_DIR` still override the path. + +### Schema hash and wire changes + +The schema hash changes in this release, so a renderer and a crate from before it no longer agree; upgrade both together. All changes are add-only: + +- `Modifier` tag 34 `Semantics { role, state }`; the property `AccessibleName` (119) carries the label. The `Semantics` role enum grows to 19 values and its state word to 13 bits (checked, selected, expanded, disabled, focused, hidden, and the click and focus action bits). +- Properties `TrackColor` (121), `ThumbColor` (122), `ActiveTrackColor` (123), `ContainerColor` (124), `CheckColor` (125) and `ControlSize` (126) for `Switch`, `Slider` and `Checkbox`. +- Property `ReportSize` (127) on a `Window` node. +- `SetWindow` flags word, bit 1: `report_size` for the implicit window. The record length is unchanged and a window that does not set it writes the same bytes as before. +- Command `RequestFocus` (tag 25) and the `OnCompositionChange` property with its event. +- `WINDOW_REVISION` moves to the fork commit that holds the macOS caption re-measure. + ## v0.0.6 ### Focus ring only for keyboard focus diff --git a/Cargo.lock b/Cargo.lock index 22dd8a2e..c012899d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "compose-rust" -version = "0.0.6" +version = "0.0.7" dependencies = [ "compose-rust-macros", "jni", @@ -52,7 +52,7 @@ dependencies = [ [[package]] name = "compose-rust-macros" -version = "0.0.6" +version = "0.0.7" dependencies = [ "compose-rust", "proc-macro2", diff --git a/README.md b/README.md index 4f79012e..a1f0a8d1 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ default, and `org.thisisthepy.compose.designsystem` for the contract both layers ### Using the crate ```bash -cargo add compose-rust@0.0.6 +cargo add compose-rust@0.0.7 ``` `cargo build` works out which renderer the target needs, downloads the release artifact for the diff --git a/compose-rust/Cargo.toml b/compose-rust/Cargo.toml index 343cea6a..233d7598 100644 --- a/compose-rust/Cargo.toml +++ b/compose-rust/Cargo.toml @@ -3,7 +3,7 @@ name = "compose-rust" # 0.0.x on purpose: the boundary works and so do the desktop and iOS renderers, but the # widget vocabulary is still being filled in and nothing here is stable yet. A number that # says so is more honest than one that implies a first usable release. -version = "0.0.6" +version = "0.0.7" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -62,7 +62,7 @@ windows-manifest = [] # crate and not the other way round. `scripts/tests/core-has-no-dioxus.test.sh` fails the # build if that changes. [dependencies] -compose-rust-macros = { path = "macros", version = "0.0.6" } +compose-rust-macros = { path = "macros", version = "0.0.7" } # The Android shims are generated against jni-rs. Nothing here is linked into a desktop, # iOS or web build, and the alternative bindings generators were rejected for their diff --git a/compose-rust/macros/Cargo.toml b/compose-rust/macros/Cargo.toml index 0a866b85..ce63a615 100644 --- a/compose-rust/macros/Cargo.toml +++ b/compose-rust/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "compose-rust-macros" -version = "0.0.6" +version = "0.0.7" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/docs/guide/en/getting-started.html b/docs/guide/en/getting-started.html index 8a97617a..d31574ab 100644 --- a/docs/guide/en/getting-started.html +++ b/docs/guide/en/getting-started.html @@ -78,13 +78,13 @@

Adding it to a project

shell
-
cargo add compose-rust@0.0.6
+
cargo add compose-rust@0.0.7
Cargo.toml, the line that command writes
[dependencies]
-compose-rust = "0.0.6"
+compose-rust = "0.0.7"

@@ -165,7 +165,7 @@

Your first application

- With compose-rust = "0.0.6" this program builds as it is. cargo run + With compose-rust = "0.0.7" this program builds as it is. cargo run downloads the renderer and opens the window.

diff --git a/docs/guide/ko/getting-started.html b/docs/guide/ko/getting-started.html index 890196ac..c17b67fb 100644 --- a/docs/guide/ko/getting-started.html +++ b/docs/guide/ko/getting-started.html @@ -77,13 +77,13 @@

프로젝트에 더하기

shell
-
cargo add compose-rust@0.0.6
+
cargo add compose-rust@0.0.7
Cargo.toml, 그 명령이 쓰는 줄
[dependencies]
-compose-rust = "0.0.6"
+compose-rust = "0.0.7"

@@ -163,7 +163,7 @@

첫 애플리케이션

- compose-rust = "0.0.6" 로는 이 프로그램이 그대로 빌드됩니다. cargo run + compose-rust = "0.0.7" 로는 이 프로그램이 그대로 빌드됩니다. cargo run 이 렌더러를 내려받고 창을 엽니다.

diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index b58398ef..efd706a7 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -201,7 +201,7 @@ Material 3, Apple HIG, Fluent, Liquid Glass를 비롯한 디자인 시스템 일 ### 크레이트 쓰기 ```bash -cargo add compose-rust@0.0.6 +cargo add compose-rust@0.0.7 ``` `cargo build`가 대상에 필요한 렌더러를 알아내고, 크레이트의 정확한 버전에 맞는 릴리스 아티팩트를 From 88b6d4833e926a140fe75667b79d627009b3c8c1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:31:45 +0900 Subject: [PATCH 207/224] Feat: allow explicit text field typography and icon button layout (FR-75, #507) --- .../android-kotlin/shared/HostButton.kt | 49 ++++++++--- .../android-kotlin/shared/HostTextField.kt | 40 ++++++--- .../android-kotlin/shared/ModifierChain.kt | 16 +++- .../android-kotlin/shared/RenderNode.kt | 1 + compose-rust/src/ui/widgets.rs | 58 ++++++++++++ compose-rust/tests/fr75_composer_tokens.rs | 60 +++++++++++++ docs/SPEC.md | 13 +++ renderer/desktop/src/renderer/HostButton.kt | 49 ++++++++--- .../desktop/src/renderer/HostTextField.kt | 40 ++++++--- .../desktop/src/renderer/ModifierChain.kt | 16 +++- renderer/desktop/src/renderer/RenderNode.kt | 1 + .../desktop/test/HostComposerTokensTest.kt | 88 +++++++++++++++++++ 12 files changed, 375 insertions(+), 56 deletions(-) create mode 100644 compose-rust/tests/fr75_composer_tokens.rs create mode 100644 renderer/desktop/test/HostComposerTokensTest.kt diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index b1e52571..81588068 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -48,6 +48,7 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.compositeOver import dev.darkpyonix.composerust.design.ensureContrast import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -130,6 +131,29 @@ internal fun HostButton( val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } val overlayFocusStack = LocalOverlayFocusStack.current val focusRequester = remember(node.id) { FocusRequester() } + val ownShape = node.modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + val shape = if (ownShape) node.modifiers.resolvedShape(theme) else style.shape + val hasExplicitSize = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Width || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + val setsOwnHeight = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + // Read before the decoration, because how much room the button needs depends on it. + val wordless = node.text(PropertyKind.Text).isEmpty() && + node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null + val labelPadH = if (wordless && hasExplicitSize) 0.dp else if (wordless) style.verticalPadding else style.horizontalPadding + val labelPadV = if (wordless && hasExplicitSize) 0.dp else style.verticalPadding + val clickable = if (handlerId == null) { available.then(modifier) } else { @@ -140,7 +164,7 @@ internal fun HostButton( .focusRequester(focusRequester) .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback( - interactions, enabled, theme, style.shape, + interactions, enabled, theme, shape, node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, ).clickable( @@ -160,30 +184,29 @@ internal fun HostButton( // pill, and asking for a variant instead hands the question to whichever design // system is active, which answers with its own accent. val ownFill = node.setsOwnBackground() - // Read before the decoration, because how much room the button needs depends on it. - val wordless = node.text(PropertyKind.Text).isEmpty() && - node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null val decorated = theme.rules - .elevation(clickable, elevation, style.shape, theme) - .clip(style.shape) - .then(if (ownFill) Modifier else Modifier.background(container, style.shape)) + .elevation(clickable, elevation, shape, theme) + .clip(shape) + .then(if (ownFill) Modifier else Modifier.background(container, shape)) .then( if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, borderColor, style.shape) + Modifier.border(style.borderWidth, borderColor, shape) } else { Modifier }, ) .topHighlight(style.topHighlight) - .defaultMinSize(minHeight = style.minHeight) + .let { + if (setsOwnHeight || (wordless && hasExplicitSize)) it else it.defaultMinSize(minHeight = style.minHeight) + } // A button with a glyph and no word gets the same room on both axes. The // horizontal padding is there to keep a word off the button's ends, and a button // with no word has nothing to keep off them: using it anyway makes every icon // control half as wide again as it needs to be, which two of them in a floating // group and one at the head of a composer both showed. .labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ) @@ -266,8 +289,8 @@ internal fun HostButton( } Box( Modifier.matchParentSize().labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ), contentAlignment = alignment, diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 008e1b18..a7ad4401 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -62,6 +62,10 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.node.HostText import dev.darkpyonix.composerust.design.LocalGlassDepth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier /** * The uncontrolled TextField. @@ -272,6 +276,12 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.Height || it is dev.darkpyonix.composerust.protocol.Modifier.Size } + val setsOwnHeight = node.modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.FillMaxHeight || + it is dev.darkpyonix.composerust.protocol.Modifier.Height || + it is dev.darkpyonix.composerust.protocol.Modifier.Size || + it is dev.darkpyonix.composerust.protocol.Modifier.RequiredSize + } // What the node named for itself replaces what the design system would have drawn // The system's frame is the default for a field that said nothing, and a field @@ -287,11 +297,6 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole || it is dev.darkpyonix.composerust.protocol.Modifier.CornerEach } - val ownRoom = node.modifiers.any { - it is dev.darkpyonix.composerust.protocol.Modifier.Padding || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingEach || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingRole - } val frameShape = if (ownShape) node.modifiers.resolvedShape(theme) else frame.shape BasicTextField( @@ -312,19 +317,14 @@ internal fun HostTextField( // could only put the caret in by hitting the words. .fillMaxWidth() .let { if (fillsHeight) it.fillMaxHeight() else it } - .defaultMinSize(minHeight = frame.minHeight) + .let { if (setsOwnHeight) it else it.defaultMinSize(minHeight = frame.minHeight) } .clip(frameShape) .let { if (ownFill) it else it.background(if (focused) frame.containerFocused else frame.container) } .let { if (ownLine) it else it.fieldBorder(frame.copy(shape = frameShape), focused) } .let { if (ownLine || ownFill) it else it.fieldUnderline(frame.underline, focused) } - .let { - if (ownRoom) it else it.padding( - horizontal = frame.horizontalPadding, - vertical = frame.verticalPadding, - ) - }, + .padding(node.contentPadding(theme, frame.horizontalPadding, frame.verticalPadding)), contentAlignment = if (multiline) Alignment.TopStart else Alignment.CenterStart, ) { if (value.text.isEmpty() && placeholder.isNotEmpty()) { @@ -436,3 +436,19 @@ private class FontChainTransformation(private val chain: FontChain) : VisualTran override fun hashCode(): Int = chain.hashCode() } + +internal fun Node.contentPadding( + theme: ResolvedTheme, + defaultHorizontal: Dp, + defaultVertical: Dp, +): PaddingValues { + val padEach = modifiers.filterIsInstance().lastOrNull() + if (padEach != null) { + return PaddingValues(padEach.start.dp, padEach.top.dp, padEach.end.dp, padEach.bottom.dp) + } + val pad = modifiers.filterIsInstance().lastOrNull() + if (pad != null) return PaddingValues(pad.value.dp) + val padRole = modifiers.filterIsInstance().lastOrNull() + if (padRole != null) return PaddingValues(theme.space(padRole.role)) + return PaddingValues(horizontal = defaultHorizontal, vertical = defaultVertical) +} diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 38ca7e20..7c32bc33 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -111,6 +111,7 @@ internal fun List.toComposeModifier( containerShape: Shape? = null, pressColor: Color = theme.color(ColorRole.OnSurface), allowPress: Boolean = true, + allowPadding: Boolean = true, ): Modifier { // The last Shape or ShapeRole in the list is what clips, what the border // follows and what the background fills, whatever their order in the chain. @@ -192,7 +193,8 @@ internal fun List.toComposeModifier( // Applied with the node's accessible name in one place, `accessibility`. is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain - is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) + is ProtocolModifier.Padding -> + if (allowPadding) attachPressableIfNeeded(chain).padding(value.value.dp) else chain is ProtocolModifier.MatchParentSize -> chain is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth() is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight() @@ -213,9 +215,17 @@ internal fun List.toComposeModifier( } is ProtocolModifier.PaddingEach -> - attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + } else { + chain + } is ProtocolModifier.PaddingRole -> - attachPressableIfNeeded(chain).padding(theme.space(value.role)) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(theme.space(value.role)) + } else { + chain + } is ProtocolModifier.Shape -> chain.clip(shape) is ProtocolModifier.ShapeRole -> chain.clip(shape) is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush -> diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index d37a9a01..78d2dc93 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -229,6 +229,7 @@ fun RenderNode( containerShape = if (clickHandler != null) node.pressedShape(theme) else null, pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), allowPress = node.widget != WidgetKind.TextField, + allowPadding = node.widget != WidgetKind.TextField, ), ) .testTag(nodeTestTag(nodeId)) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index d8da241b..a7d26af0 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -949,6 +949,11 @@ pub struct TextFieldScope { enabled: bool, multiline: bool, type_role: Option, + font_size: Option, + font_weight: Option, + line_height: Option, + letter_spacing: Option, + color: Option, font: FontRefs, on_value_change: Option>, on_submit: Option>, @@ -971,6 +976,11 @@ pub fn TextField() -> TextFieldScope { enabled: true, multiline: false, type_role: None, + font_size: None, + font_weight: None, + line_height: None, + letter_spacing: None, + color: None, font: FontRefs::default(), on_value_change: None, on_submit: None, @@ -1014,6 +1024,36 @@ impl TextFieldScope { self } + /// Explicit font size in sp. Overrides the type role's size. + pub fn font_size(mut self, sp: f32) -> Self { + self.font_size = Some(sp); + self + } + + /// Explicit font weight (e.g. 400, 500, 600, 700). Overrides the type role's weight. + pub fn font_weight(mut self, weight: u16) -> Self { + self.font_weight = Some(weight); + self + } + + /// Explicit line height in sp. + pub fn line_height(mut self, sp: f32) -> Self { + self.line_height = Some(sp); + self + } + + /// Explicit letter spacing in sp. + pub fn letter_spacing(mut self, sp: f32) -> Self { + self.letter_spacing = Some(sp); + self + } + + /// Explicit text colour. + pub fn color(mut self, paint: Paint) -> Self { + self.color = Some(paint); + self + } + /// The fonts to try for the field's text, in order. Taken only with `TypeRole::None`. pub fn font(mut self, font: FontRefs) -> Self { self.font = font; @@ -1064,6 +1104,9 @@ impl TextFieldScope { let text = std::mem::take(&mut self.text); let placeholder = std::mem::take(&mut self.placeholder); let (enabled, multiline, type_role) = (self.enabled, self.multiline, self.type_role); + let (font_size, font_weight, line_height) = + (self.font_size, self.font_weight, self.line_height); + let (letter_spacing, color) = (self.letter_spacing, self.color); let font = std::mem::take(&mut self.font); let key_down: EventCallback = match self.on_key_down.take() { Some(handler) => EventCallback::Key(Rc::new(RefCell::new(handler))), @@ -1106,6 +1149,21 @@ impl TextFieldScope { attrs.push(("enabled", OwnedAttr::Bool(enabled))); attrs.push(("multiline", OwnedAttr::Bool(multiline))); attrs.push(("type_role", role_or_zero(type_role))); + if let Some(font_size) = font_size { + attrs.push(("font_size", OwnedAttr::Float(font_size))); + } + if let Some(font_weight) = font_weight { + attrs.push(("font_weight", OwnedAttr::Int(font_weight as i64))); + } + if let Some(line_height) = line_height { + attrs.push(("line_height", OwnedAttr::Float(line_height))); + } + if let Some(letter_spacing) = letter_spacing { + attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing))); + } + if let Some(color) = color { + attrs.push(("color", super::paint(Some(color)))); + } attrs.push(("font", font_bytes(&font))); }, listeners, diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs new file mode 100644 index 00000000..18fac7d6 --- /dev/null +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -0,0 +1,60 @@ +//! FR-75: TextField typography and control over composer / icon button tokens. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::schema::PropertyKind; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +const CUSTOM_COLOR: Paint = Paint::Literal(Color::rgb(0x123456)); + +#[Composable] +fn StyledField() { + Column().content(|| { + TextField("custom") + .font_size(15.0) + .font_weight(600) + .line_height(22.0) + .letter_spacing(0.5) + .color(CUSTOM_COLOR); + }); +} + +#[Composable] +fn PlainField() { + Column().content(|| { + TextField("plain"); + }); +} + +#[test] +fn fr75_text_field_typography_round_trips() { + let tree = start(StyledField); + let dump = tree.dump(); + assert!(dump.contains("TextField"), "missing TextField: {dump}"); + assert!(dump.contains("FontSize=Float(15.0)"), "missing FontSize in {dump}"); + assert!(dump.contains("FontWeight=Integer(600)"), "missing FontWeight in {dump}"); + assert!(dump.contains("LineHeight=Float(22.0)"), "missing LineHeight in {dump}"); + assert!(dump.contains("LetterSpacing=Float(0.5)"), "missing LetterSpacing in {dump}"); + assert!(dump.contains("Color=Paint("), "missing Color in {dump}"); + assert!(dump.contains("1193046"), "missing Color rgb in {dump}"); +} + +#[test] +fn fr75_text_field_plain_does_not_send_typography_properties() { + let tree = start(PlainField); + let dump = tree.dump(); + assert!(dump.contains("TextField"), "missing TextField: {dump}"); + for prop in ["FontSize", "FontWeight", "LineHeight", "LetterSpacing", "Color"] { + assert!(!dump.contains(prop), "{prop} should not be sent for plain field: {dump}"); + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index d2c83f00..1b501b3a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -4122,6 +4122,19 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" 5. 클립보드를 쓸 수 없는 환경에서 명령은 아무 일도 하지 않고 프로세스는 계속 돕니다(시험 이름 fr73_unavailable_clipboard_is_silently_ignored). 6. Renderer의 어느 로그 줄에도 `text`의 내용이 들어가지 않습니다(시험 이름 fr73_clipboard_text_is_never_logged). +### FR-75 텍스트 필드 타이포그래피와 아이콘 버튼 레이아웃 제어 (`Draft`, 이슈 #507) + +요청한 쪽: darkpyonix-ember(#739, #740, `docs/DESIGN.md` §2, §4.1). Ember NFR-L3은 여러 OS에서 동일한 컴포저와 버튼 모양을 필요로 합니다. `HostTextField`는 타이포그래피와 내부 여백을 테마 기본 규칙(`theme.rules.field`)에서 파생하고, `HostButton`은 명시적 크기를 가진 아이콘 버튼에도 테마 버튼의 최소 높이와 여백을 강제 적용하여 전역 테마 변경 시 컴포저 플레이스홀더와 플러스 버튼의 위치와 크기가 달라지는 문제가 있었습니다. 이 항목은 텍스트 필드의 명시적 타이포그래피와 내용 여백 제어, 아이콘 버튼의 크기 존중 레이아웃을 제공합니다. + +- **TextField 타이포그래피**: `TextFieldScope`에 `font_size`, `font_weight`, `line_height`, `letter_spacing`, `color` 빌더 메서드를 제공하여 테마 기본값에 의존하지 않고 명시적 텍스트 스타일을 지정할 수 있습니다. +- **TextField 내용 여백과 높이**: 노드에 명시적 높이(`Modifier.height`, `size`, `fill_max_height`)가 지정된 경우 테마 최소 높이 강제를 건너뛰며, `Modifier.padding`이 지정된 경우 해당 여백을 필드 장식 상자 내부의 내용 여백으로 적용합니다. +- **아이콘 버튼 레이아웃 제어**: 텍스트 없이 아이콘만 가진 버튼(`wordless`)에 명시적 크기(`Modifier.size`, `width`, `height`)가 지정된 경우, 테마 최소 높이(`minHeight`) 강제를 건너뛰고 내부 여백을 0dp로 처리하여 지정된 크기를 유지합니다. 또한 `corner_radius` 등 자체 모양 지정 시 해당 모양을 클리핑 및 테두리에 적용합니다. + +수용 기준: +1. `TextField`가 명시적 타이포그래피 속성을 인코딩하여 렌더러로 전달합니다(시험 이름 fr75_text_field_typography_round_trips). +2. `TextField`에 명시적 여백이나 높이가 지정되면 렌더러가 지정된 여백과 높이를 적용합니다(시험 이름 fr75_text_field_honors_content_padding_and_height). +3. 명시적 크기를 가진 아이콘 버튼이 테마의 최소 높이에 의해 확장되지 않고 지정된 크기로 렌더링됩니다(시험 이름 fr75_icon_button_honors_explicit_size). + ### FR-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index b1e52571..81588068 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -48,6 +48,7 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.compositeOver import dev.darkpyonix.composerust.design.ensureContrast import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -130,6 +131,29 @@ internal fun HostButton( val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } val overlayFocusStack = LocalOverlayFocusStack.current val focusRequester = remember(node.id) { FocusRequester() } + val ownShape = node.modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + val shape = if (ownShape) node.modifiers.resolvedShape(theme) else style.shape + val hasExplicitSize = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Width || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + val setsOwnHeight = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + // Read before the decoration, because how much room the button needs depends on it. + val wordless = node.text(PropertyKind.Text).isEmpty() && + node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null + val labelPadH = if (wordless && hasExplicitSize) 0.dp else if (wordless) style.verticalPadding else style.horizontalPadding + val labelPadV = if (wordless && hasExplicitSize) 0.dp else style.verticalPadding + val clickable = if (handlerId == null) { available.then(modifier) } else { @@ -140,7 +164,7 @@ internal fun HostButton( .focusRequester(focusRequester) .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback( - interactions, enabled, theme, style.shape, + interactions, enabled, theme, shape, node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, ).clickable( @@ -160,30 +184,29 @@ internal fun HostButton( // pill, and asking for a variant instead hands the question to whichever design // system is active, which answers with its own accent. val ownFill = node.setsOwnBackground() - // Read before the decoration, because how much room the button needs depends on it. - val wordless = node.text(PropertyKind.Text).isEmpty() && - node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null val decorated = theme.rules - .elevation(clickable, elevation, style.shape, theme) - .clip(style.shape) - .then(if (ownFill) Modifier else Modifier.background(container, style.shape)) + .elevation(clickable, elevation, shape, theme) + .clip(shape) + .then(if (ownFill) Modifier else Modifier.background(container, shape)) .then( if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, borderColor, style.shape) + Modifier.border(style.borderWidth, borderColor, shape) } else { Modifier }, ) .topHighlight(style.topHighlight) - .defaultMinSize(minHeight = style.minHeight) + .let { + if (setsOwnHeight || (wordless && hasExplicitSize)) it else it.defaultMinSize(minHeight = style.minHeight) + } // A button with a glyph and no word gets the same room on both axes. The // horizontal padding is there to keep a word off the button's ends, and a button // with no word has nothing to keep off them: using it anyway makes every icon // control half as wide again as it needs to be, which two of them in a floating // group and one at the head of a composer both showed. .labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ) @@ -266,8 +289,8 @@ internal fun HostButton( } Box( Modifier.matchParentSize().labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ), contentAlignment = alignment, diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 008e1b18..a7ad4401 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -62,6 +62,10 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.node.HostText import dev.darkpyonix.composerust.design.LocalGlassDepth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier /** * The uncontrolled TextField. @@ -272,6 +276,12 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.Height || it is dev.darkpyonix.composerust.protocol.Modifier.Size } + val setsOwnHeight = node.modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.FillMaxHeight || + it is dev.darkpyonix.composerust.protocol.Modifier.Height || + it is dev.darkpyonix.composerust.protocol.Modifier.Size || + it is dev.darkpyonix.composerust.protocol.Modifier.RequiredSize + } // What the node named for itself replaces what the design system would have drawn // The system's frame is the default for a field that said nothing, and a field @@ -287,11 +297,6 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole || it is dev.darkpyonix.composerust.protocol.Modifier.CornerEach } - val ownRoom = node.modifiers.any { - it is dev.darkpyonix.composerust.protocol.Modifier.Padding || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingEach || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingRole - } val frameShape = if (ownShape) node.modifiers.resolvedShape(theme) else frame.shape BasicTextField( @@ -312,19 +317,14 @@ internal fun HostTextField( // could only put the caret in by hitting the words. .fillMaxWidth() .let { if (fillsHeight) it.fillMaxHeight() else it } - .defaultMinSize(minHeight = frame.minHeight) + .let { if (setsOwnHeight) it else it.defaultMinSize(minHeight = frame.minHeight) } .clip(frameShape) .let { if (ownFill) it else it.background(if (focused) frame.containerFocused else frame.container) } .let { if (ownLine) it else it.fieldBorder(frame.copy(shape = frameShape), focused) } .let { if (ownLine || ownFill) it else it.fieldUnderline(frame.underline, focused) } - .let { - if (ownRoom) it else it.padding( - horizontal = frame.horizontalPadding, - vertical = frame.verticalPadding, - ) - }, + .padding(node.contentPadding(theme, frame.horizontalPadding, frame.verticalPadding)), contentAlignment = if (multiline) Alignment.TopStart else Alignment.CenterStart, ) { if (value.text.isEmpty() && placeholder.isNotEmpty()) { @@ -436,3 +436,19 @@ private class FontChainTransformation(private val chain: FontChain) : VisualTran override fun hashCode(): Int = chain.hashCode() } + +internal fun Node.contentPadding( + theme: ResolvedTheme, + defaultHorizontal: Dp, + defaultVertical: Dp, +): PaddingValues { + val padEach = modifiers.filterIsInstance().lastOrNull() + if (padEach != null) { + return PaddingValues(padEach.start.dp, padEach.top.dp, padEach.end.dp, padEach.bottom.dp) + } + val pad = modifiers.filterIsInstance().lastOrNull() + if (pad != null) return PaddingValues(pad.value.dp) + val padRole = modifiers.filterIsInstance().lastOrNull() + if (padRole != null) return PaddingValues(theme.space(padRole.role)) + return PaddingValues(horizontal = defaultHorizontal, vertical = defaultVertical) +} diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 38ca7e20..7c32bc33 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -111,6 +111,7 @@ internal fun List.toComposeModifier( containerShape: Shape? = null, pressColor: Color = theme.color(ColorRole.OnSurface), allowPress: Boolean = true, + allowPadding: Boolean = true, ): Modifier { // The last Shape or ShapeRole in the list is what clips, what the border // follows and what the background fills, whatever their order in the chain. @@ -192,7 +193,8 @@ internal fun List.toComposeModifier( // Applied with the node's accessible name in one place, `accessibility`. is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain - is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) + is ProtocolModifier.Padding -> + if (allowPadding) attachPressableIfNeeded(chain).padding(value.value.dp) else chain is ProtocolModifier.MatchParentSize -> chain is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth() is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight() @@ -213,9 +215,17 @@ internal fun List.toComposeModifier( } is ProtocolModifier.PaddingEach -> - attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + } else { + chain + } is ProtocolModifier.PaddingRole -> - attachPressableIfNeeded(chain).padding(theme.space(value.role)) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(theme.space(value.role)) + } else { + chain + } is ProtocolModifier.Shape -> chain.clip(shape) is ProtocolModifier.ShapeRole -> chain.clip(shape) is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush -> diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index d37a9a01..78d2dc93 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -229,6 +229,7 @@ fun RenderNode( containerShape = if (clickHandler != null) node.pressedShape(theme) else null, pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), allowPress = node.widget != WidgetKind.TextField, + allowPadding = node.widget != WidgetKind.TextField, ), ) .testTag(nodeTestTag(nodeId)) diff --git a/renderer/desktop/test/HostComposerTokensTest.kt b/renderer/desktop/test/HostComposerTokensTest.kt new file mode 100644 index 00000000..648d657a --- /dev/null +++ b/renderer/desktop/test/HostComposerTokensTest.kt @@ -0,0 +1,88 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.IconRole +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * FR-75: TextField typography and control over composer and icon button tokens. + */ +@OptIn(ExperimentalTestApi::class) +class HostComposerTokensTest { + private val root = 1 + private val control = 2 + + @Test + fun fr75_text_field_honors_explicit_height_and_content_padding() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false)), + Mutation.Create(root, WidgetKind.Box), + Mutation.Create(control, WidgetKind.TextField), + Mutation.SetProp(control, PropertyKind.Text, PropertyValue.Text("hello")), + Mutation.SetModifier(control, 0, ProtocolModifier.Height(36f)), + Mutation.SetModifier(control, 1, ProtocolModifier.PaddingEach(12f, 6f, 12f, 6f)), + Mutation.Insert(root, control, 0), + ) + + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) + } + waitForIdle() + + val bounds = onNodeWithTag(nodeTestTag(control)).getBoundsInRoot() + val height = bounds.bottom - bounds.top + assertTrue( + abs(height.value - 36f) <= 1.5f, + "field with explicit height 36dp measured $height instead of adhering to height", + ) + } + + @Test + fun fr75_icon_button_honors_explicit_size() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false)), + Mutation.Create(root, WidgetKind.Box), + Mutation.Create(control, WidgetKind.Button), + Mutation.SetProp(control, PropertyKind.Text, PropertyValue.Text("")), + Mutation.SetProp(control, PropertyKind.Icon, PropertyValue.Integer(IconRole.Add.ordinal + 1L)), + Mutation.SetModifier(control, 0, ProtocolModifier.Size(32f, 32f)), + Mutation.SetModifier(control, 1, ProtocolModifier.CornerEach(8f, 8f, 8f, 8f)), + Mutation.Insert(root, control, 0), + ) + + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) + } + waitForIdle() + + val bounds = onNodeWithTag(nodeTestTag(control)).getBoundsInRoot() + val width = bounds.right - bounds.left + val height = bounds.bottom - bounds.top + assertTrue( + abs(width.value - 32f) <= 1.5f, + "wordless button with explicit size 32dp measured width $width", + ) + assertTrue( + abs(height.value - 32f) <= 1.5f, + "wordless button with explicit size 32dp measured height $height", + ) + } +} From 2301bf65674197f3bb1ce32c2e8bf6432737d186 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:36:10 +0900 Subject: [PATCH 208/224] Feat: allow theme overrides for overlay geometry and effects (FR-74, #506) --- .../android-kotlin/shared/ComposeRustHost.kt | 1 + .../android-kotlin/shared/DesignSystem.kt | 8 + .../android-kotlin/shared/HostContainer.kt | 50 ++++++- .../android-kotlin/shared/HostDropdown.kt | 31 +++- .../android-kotlin/shared/HostOverlay.kt | 9 +- .../android-kotlin/shared/HostSheet.kt | 74 ++++++++-- .../android-kotlin/shared/NodeTable.kt | 5 + .../android-kotlin/shared/Protocol.gen.kt | 40 ++++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/codegen.rs | 35 +++++ compose-rust/src/protocol.rs | 48 +++++- compose-rust/src/runtime/seam.rs | 8 + compose-rust/src/schema.rs | 103 ++++++++++++- compose-rust/src/ui/widgets.rs | 25 ++++ compose-rust/tests/fr74_overlay_tokens.rs | 67 +++++++++ compose-rust/tests/vectors/vectors.json | 2 +- docs/SPEC.md | 27 ++++ renderer/desktop/src/protocol/Protocol.gen.kt | 40 ++++- .../desktop/src/renderer/ComposeRustHost.kt | 1 + renderer/desktop/src/renderer/DesignSystem.kt | 8 + .../desktop/src/renderer/HostContainer.kt | 50 ++++++- renderer/desktop/src/renderer/HostDropdown.kt | 31 +++- renderer/desktop/src/renderer/HostOverlay.kt | 9 +- renderer/desktop/src/renderer/HostSheet.kt | 74 ++++++++-- renderer/desktop/src/renderer/NodeTable.kt | 5 + .../desktop/test/HostOverlayTokensTest.kt | 138 ++++++++++++++++++ 26 files changed, 842 insertions(+), 49 deletions(-) create mode 100644 compose-rust/tests/fr74_overlay_tokens.rs create mode 100644 renderer/desktop/test/HostOverlayTokensTest.kt diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 03c40c11..681dcea3 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -484,6 +484,7 @@ private fun ComposeRustTree( chainOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.chain }, brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush }, windowBackdrop = asked && platformBacksWindowWithMaterial(), + overlay = host.table.overlayTokens, ) // The animations the Host handed over are played here, on this side's frame clock, and // the Host is not called while they play unless one asked for events. The loop waits diff --git a/compose-rust/android-kotlin/shared/DesignSystem.kt b/compose-rust/android-kotlin/shared/DesignSystem.kt index 682b9586..754d2ac6 100644 --- a/compose-rust/android-kotlin/shared/DesignSystem.kt +++ b/compose-rust/android-kotlin/shared/DesignSystem.kt @@ -180,6 +180,11 @@ class ResolvedTheme( * given is enough to find the rest of the stack: see [chainFor]. */ val fontChains: List = emptyList(), + + /** + * Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + */ + val overlay: OverlayTokens? = null, ) { /** * The stack [family] is the first family of, or null where it is not one. @@ -1638,6 +1643,8 @@ fun resolveTheme( * family lacks in the families after it. */ chainOf: (Int) -> FontChain? = { null }, + /** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + overlay: OverlayTokens? = null, ): ResolvedTheme { val system = when { theme == null -> adaptiveSystem(platform, DesignSystem.Material3) @@ -1684,6 +1691,7 @@ fun resolveTheme( platform, application, chains, + overlay, ) } diff --git a/compose-rust/android-kotlin/shared/HostContainer.kt b/compose-rust/android-kotlin/shared/HostContainer.kt index 9914be33..116fe2e3 100644 --- a/compose-rust/android-kotlin/shared/HostContainer.kt +++ b/compose-rust/android-kotlin/shared/HostContainer.kt @@ -7,6 +7,7 @@ import dev.darkpyonix.composerust.protocol.TypeRole import dev.darkpyonix.composerust.ui.textStyle import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +46,20 @@ import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.weightOf import dev.darkpyonix.composerust.design.liftsOffThePage +/** True when the Host set a shape modifier of its own, which replaces the default shape. */ +internal fun Node.setsOwnShape(): Boolean = + modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + /** * The corner this container is drawn with: the one the Host asked for if it asked, and the * design system's corner for this kind of container otherwise. */ internal fun Node.containerShape(style: ContainerStyle, theme: ResolvedTheme): Shape = - if (modifiers.any { it is ProtocolModifier.Shape || it is ProtocolModifier.ShapeRole || it is ProtocolModifier.CornerEach }) { + if (setsOwnShape()) { modifiers.resolvedShape(theme) } else { style.shape @@ -98,12 +107,34 @@ internal fun Modifier.containerDecoration( theme: ResolvedTheme, /** True for a menu, a dialog or a tooltip: a sheet over the page rather than in it. */ overlay: Boolean = false, + role: ContainerRole? = null, ): Modifier { - val shape = node.containerShape(style, theme) - val raised = if (node.setsOwnElevation()) { + val shape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + else -> null + } ?: style.shape + } else { + style.shape + } + val effectiveElevation = if (node.setsOwnElevation()) { + 0.dp + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + else -> null + } ?: style.elevation + } else { + style.elevation + } + val raised = if (node.setsOwnElevation() || effectiveElevation.value <= 0f) { this } else { - theme.rules.elevation(this, style.elevation, shape, theme) + theme.rules.elevation(this, effectiveElevation, shape, theme) } val material = style.material // A Host that named its own Background has already had it applied, so painting the @@ -139,6 +170,17 @@ internal fun Modifier.containerDecoration( .then( if (node.setsOwnPadding()) { Modifier + } else if (overlay) { + val customPadding = when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + else -> null + } + if (customPadding != null) { + Modifier.padding(customPadding) + } else { + Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + } } else { Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) }, diff --git a/compose-rust/android-kotlin/shared/HostDropdown.kt b/compose-rust/android-kotlin/shared/HostDropdown.kt index 52b3f526..bbdcb641 100644 --- a/compose-rust/android-kotlin/shared/HostDropdown.kt +++ b/compose-rust/android-kotlin/shared/HostDropdown.kt @@ -196,6 +196,28 @@ private fun OpeningList( ) } if (open) { + val popupShape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else { + theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { androidx.compose.foundation.shape.RoundedCornerShape(it.dp) } ?: style.shape + } + val popupElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val popupRaised = if (node.setsOwnElevation() || popupElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, popupElevation, popupShape, theme) + } + val popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { + Modifier.padding(theme.overlay.popupPadding.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +227,11 @@ private fun OpeningList( Column( Modifier .testTag(dropdownPopupTestTag(node.id)) - .clip(style.shape) - .background(style.container, style.shape) - .border(style.borderWidth, style.borderColor, style.shape) - .padding(vertical = theme.space(SpaceRole.Xs)), + .then(popupRaised) + .clip(popupShape) + .background(style.container, popupShape) + .border(style.borderWidth, style.borderColor, popupShape) + .then(popupPadding), ) { options.forEachIndexed { index, childId -> key(childId) { diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index 43483ea3..d8e7ed49 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -194,6 +194,9 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } Popup( @@ -206,14 +209,14 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim), contentAlignment = Alignment.Center, ) { Column( modifier .testTag(dialogTestTag(node.id)) .modalOverlaySurface(node.id, surfaceRequester) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } @@ -265,7 +268,7 @@ internal fun HostMenu( Column( Modifier .testTag(menuPopupTestTag(node.id)) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Menu), ) { entries.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } diff --git a/compose-rust/android-kotlin/shared/HostSheet.kt b/compose-rust/android-kotlin/shared/HostSheet.kt index f5a12bb9..7f43ac61 100644 --- a/compose-rust/android-kotlin/shared/HostSheet.kt +++ b/compose-rust/android-kotlin/shared/HostSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.design.SheetEdge +import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.runtime.LocalWindowSizeClass @@ -41,6 +42,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import kotlin.math.roundToInt /** How far a sheet has to be pulled towards its edge before letting go closes it. */ @@ -84,6 +86,64 @@ internal fun HostSheet( val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + + val shape = if (node.setsOwnShape()) { + node.containerShape( + dev.darkpyonix.composerust.design.ContainerStyle( + container = style.container, + content = androidx.compose.ui.graphics.Color.Unspecified, + shape = style.shape, + elevation = style.elevation, + borderWidth = style.borderWidth, + borderColor = style.borderColor, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + separator = null, + scrim = style.scrim, + typeRole = dev.darkpyonix.composerust.protocol.TypeRole.Body, + ), + theme, + ) + } else { + theme.overlay?.sheetCornerRadius?.takeIf { it > 0 }?.let { + if (bottom) { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, it.dp, 0.dp, 0.dp) + } else { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, 0.dp, 0.dp, it.dp) + } + } ?: style.shape + } + + val sheetElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.sheetElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val raised = if (node.setsOwnElevation() || sheetElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, sheetElevation, shape, theme) + } + + val containerBg = if (node.setsOwnBackground()) Modifier else Modifier.background(style.container) + + val outline = if (style.borderWidth.value > 0f) { + Modifier.border(style.borderWidth, style.borderColor, shape) + } else { + Modifier + } + + val sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { + Modifier.padding(theme.overlay.sheetPadding.dp) + } else { + Modifier.padding(style.padding) + } + Popup( alignment = Alignment.Center, properties = PopupProperties(focusable = true, dismissOnBackPress = false), @@ -94,7 +154,7 @@ internal fun HostSheet( Modifier .testTag(sheetScrimTestTag(node.id)) .fillMaxSize() - .background(style.scrim) + .background(scrim) // Pressing what the sheet covers is the other way of asking to close it, // and it gets the same answer: one dismissal. A raw tap detector rather // than `clickable`, because a scrim is not a button and saying it is one @@ -107,11 +167,6 @@ internal fun HostSheet( } else { Modifier.fillMaxHeight().fillMaxWidth(style.widthFraction) } - val outline = if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, style.borderColor, style.shape) - } else { - Modifier - } // Two boxes rather than one: the outer is the sheet itself, which is what is // dragged and what the design system sized, and the inner carries whatever // Modifiers the Host put on the node, which belong inside its surface. @@ -127,8 +182,9 @@ internal fun HostSheet( IntOffset(travel.roundToInt(), 0) } } - .clip(style.shape) - .background(style.container) + .then(raised) + .clip(shape) + .then(containerBg) .then(outline) .draggable( orientation = if (bottom) Orientation.Vertical else Orientation.Horizontal, @@ -143,7 +199,7 @@ internal fun HostSheet( // swallowed here rather than reaching the scrim behind. .pointerInput(Unit) { detectTapGestures { } }, ) { - Column(modifier.fillMaxWidth().padding(style.padding)) { + Column(modifier.fillMaxWidth().then(sheetPadding)) { style.handle?.let { handle -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 1d4dab3f..3660b134 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -187,6 +187,10 @@ class NodeTable { var theme: Theme? by mutableStateOf(null) private set + /** Null until the Host sends an overlay tokens record. */ + var overlayTokens: OverlayTokens? by mutableStateOf(null) + private set + /** Null until the Host sends its first `SetWindow` record. */ var window: Window? by mutableStateOf(null) private set @@ -320,6 +324,7 @@ class NodeTable { fail(TableError.INVALID_PALETTE, "theme palette: $problem") } } + is Mutation.SetOverlayTokens -> overlayTokens = mutation.overlay // Read before there is a window to apply it to. The platform layer asks for // this out of the first batch and builds the window from it, so by the time // the batch is applied the window already looks the way it says. Keeping it diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index a3a53b00..93583d78 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Scrim, Progress } enum class Key { Enter, Other } @@ -195,6 +195,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -1279,6 +1293,9 @@ sealed interface Mutation { val name: String, val filters: String, ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } sealed interface HostEvent { @@ -1334,7 +1351,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 6134367089496884194L + const val SCHEMA_HASH: Long = 2307080319921313708L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1364,6 +1381,7 @@ object Protocol { private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1530,6 +1548,23 @@ object Protocol { readString(batch, base, available, offset + 40), ) } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the @@ -2283,6 +2318,7 @@ object Protocol { 125 -> PropertyKind.CheckColor 126 -> PropertyKind.ControlSize 127 -> PropertyKind.ReportSize + 128 -> PropertyKind.Scrim 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index b543eb68..03b6c45d 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x5521a65ddb4f03e2 +0x20046553d93457ac diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 918a18c0..da7e6dc6 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -148,6 +148,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -488,6 +502,9 @@ sealed interface Mutation { val name: String, val filters: String, ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } "#, @@ -626,6 +643,7 @@ object Protocol { private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -792,6 +810,23 @@ object Protocol { readString(batch, base, available, offset + 40), ) } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index f87d19f1..a60644c5 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -42,6 +42,10 @@ const TAG_SHOW_FILE_DIALOG: u16 = 26; /// `ShowFileDialog` after its header: the request, the window, the kind and the default /// filter, then four string references. 44 bytes, so the record is 48. const SHOW_FILE_DIALOG_PAYLOAD_LEN: usize = 4 + 4 + 2 + 2 + 4 * 8; +// 27 sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. +const TAG_SET_OVERLAY_TOKENS: u16 = 27; +/// 9 u16 fields (18 bytes) + 1 u16 reserved (2 bytes) + 1 u32 scrim (4 bytes) = 24 bytes. +const OVERLAY_TOKENS_PAYLOAD_LEN: usize = 24; /// The head of a `StartAnimation` record, before its keyframes. const ANIMATION_HEADER_LEN: usize = 52; /// A keyframe before its value: offset, timing, step position, flags and four numbers. @@ -268,6 +272,8 @@ pub enum Mutation<'a> { name: &'a str, filters: &'a str, }, + /// Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + SetOverlayTokens(crate::schema::OverlayTokens), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1507,6 +1513,20 @@ impl BatchEncoder { self.put_string_ref(name)?; self.put_string_ref(filters)?; } + Mutation::SetOverlayTokens(overlay) => { + self.begin_record(TAG_SET_OVERLAY_TOKENS, OVERLAY_TOKENS_PAYLOAD_LEN as u16); + self.put_u16(overlay.sheet_corner_radius); + self.put_u16(overlay.dialog_corner_radius); + self.put_u16(overlay.popup_corner_radius); + self.put_u16(overlay.sheet_elevation); + self.put_u16(overlay.popup_elevation); + self.put_u16(overlay.dialog_elevation); + self.put_u16(overlay.sheet_padding); + self.put_u16(overlay.dialog_padding); + self.put_u16(overlay.popup_padding); + self.put_u16(0); + self.put_u32(overlay.scrim); + } Mutation::SetWindow(window) => { self.begin_record(TAG_SET_WINDOW, 28); self.put_u16(window.chrome as u16); @@ -1827,6 +1847,7 @@ fn decode_theme(bytes: &[u8], payload: usize, records_len: usize) -> Result Result>, ProtocolError> { } } TAG_SHOW_FILE_DIALOG => return Err(ProtocolError::InvalidRecordLength), + TAG_SET_OVERLAY_TOKENS if len == 4 + OVERLAY_TOKENS_PAYLOAD_LEN => { + let sheet_corner_radius = read_u16(bytes, payload)?; + let dialog_corner_radius = read_u16(bytes, payload + 2)?; + let popup_corner_radius = read_u16(bytes, payload + 4)?; + let sheet_elevation = read_u16(bytes, payload + 6)?; + let popup_elevation = read_u16(bytes, payload + 8)?; + let dialog_elevation = read_u16(bytes, payload + 10)?; + let sheet_padding = read_u16(bytes, payload + 12)?; + let dialog_padding = read_u16(bytes, payload + 14)?; + let popup_padding = read_u16(bytes, payload + 16)?; + let scrim = read_u32(bytes, payload + 20)?; + Mutation::SetOverlayTokens(crate::schema::OverlayTokens { + sheet_corner_radius, + dialog_corner_radius, + popup_corner_radius, + sheet_elevation, + popup_elevation, + dialog_elevation, + sheet_padding, + dialog_padding, + popup_padding, + scrim, + }) + } + TAG_SET_OVERLAY_TOKENS => return Err(ProtocolError::InvalidRecordLength), TAG_SET_WINDOW if len == 32 => { let chrome = read_u16(bytes, payload)?; let title_bar = read_u16(bytes, payload + 2)?; @@ -2136,7 +2182,7 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { | TAG_START_ANIMATION | TAG_CONTROL_ANIMATION | TAG_SET_SELECTION..=TAG_SET_FOLDED - | TAG_SET_WINDOW_THEME..=TAG_REQUEST_FOCUS => { + | TAG_SET_WINDOW_THEME..=TAG_SET_OVERLAY_TOKENS => { return Err(ProtocolError::InvalidRecordLength); } other => return Err(ProtocolError::InvalidTag(other)), diff --git a/compose-rust/src/runtime/seam.rs b/compose-rust/src/runtime/seam.rs index ea62e8b6..cf9e7612 100644 --- a/compose-rust/src/runtime/seam.rs +++ b/compose-rust/src/runtime/seam.rs @@ -86,6 +86,14 @@ impl Batch { /// The root theme record. Written once per rebuild, never per frame. pub fn set_theme(&mut self, theme: Theme) { self.write(Mutation::SetTheme(theme)); + if let Some(overlay) = theme.overlay { + self.write(Mutation::SetOverlayTokens(*overlay)); + } + } + + /// Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + pub fn set_overlay_tokens(&mut self, tokens: crate::schema::OverlayTokens) { + self.write(Mutation::SetOverlayTokens(tokens)); } /// The root window record. Written once per rebuild, never per frame. diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index bfbe3f08..dd90e7ed 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -206,13 +206,13 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,on_focus_gained,on_key_up,accessible_name,track_color,thumb_color,active_track_color,container_color,check_color,control_size,report_size;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,on_focus_gained,on_key_up,accessible_name,track_color,thumb_color,active_track_color,container_color,check_color,control_size,report_size,scrim;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,Transform3D,MatchParentSize,WindowDrag,PressFeedback,Semantics;", "keys=Enter,Other;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen,FocusGained,KeyUp,FileDialogResult;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", - "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus,ShowFileDialog" + "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus,ShowFileDialog,SetOverlayTokens" ); const fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { @@ -1797,11 +1797,100 @@ pub struct Theme { /// platform. A palette built while the application runs is handed to `use_theme`, /// which owns it, so application code never has to give it that lifetime itself. pub palette: Option<&'static crate::palette::Palette>, + /// Application-level overlay theme tokens for Dialog, Menu, Dropdown, and Sheet. + pub overlay: Option<&'static OverlayTokens>, } /// How many type roles there are, which is how many font slots a theme carries. pub const TYPE_ROLE_COUNT: usize = 9; +/// Application-level overlay theme tokens for Dialog, Menu, Dropdown, and Sheet. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct OverlayTokens { + pub sheet_corner_radius: u16, + pub dialog_corner_radius: u16, + pub popup_corner_radius: u16, + pub sheet_elevation: u16, + pub popup_elevation: u16, + pub dialog_elevation: u16, + pub sheet_padding: u16, + pub dialog_padding: u16, + pub popup_padding: u16, + pub scrim: u32, +} + +impl OverlayTokens { + pub const fn new() -> Self { + Self { + sheet_corner_radius: 0, + dialog_corner_radius: 0, + popup_corner_radius: 0, + sheet_elevation: 0, + popup_elevation: 0, + dialog_elevation: 0, + sheet_padding: 0, + dialog_padding: 0, + popup_padding: 0, + scrim: 0, + } + } + + pub const fn sheet_corner_radius(mut self, radius: u16) -> Self { + self.sheet_corner_radius = radius; + self + } + + pub const fn dialog_corner_radius(mut self, radius: u16) -> Self { + self.dialog_corner_radius = radius; + self + } + + pub const fn popup_corner_radius(mut self, radius: u16) -> Self { + self.popup_corner_radius = radius; + self + } + + pub const fn sheet_elevation(mut self, elevation: u16) -> Self { + self.sheet_elevation = elevation; + self + } + + pub const fn popup_elevation(mut self, elevation: u16) -> Self { + self.popup_elevation = elevation; + self + } + + pub const fn dialog_elevation(mut self, elevation: u16) -> Self { + self.dialog_elevation = elevation; + self + } + + pub const fn sheet_padding(mut self, padding: u16) -> Self { + self.sheet_padding = padding; + self + } + + pub const fn dialog_padding(mut self, padding: u16) -> Self { + self.dialog_padding = padding; + self + } + + pub const fn popup_padding(mut self, padding: u16) -> Self { + self.popup_padding = padding; + self + } + + pub const fn scrim(mut self, color: Color) -> Self { + self.scrim = color.to_argb(); + self + } + + pub const fn scrim_argb(mut self, argb: u32) -> Self { + self.scrim = argb; + self + } +} + impl Theme { /// The same design system on every platform. pub const fn unified(design_system: DesignSystem) -> Self { @@ -1812,6 +1901,7 @@ impl Theme { adaptive: false, fonts: [0; TYPE_ROLE_COUNT], palette: None, + overlay: None, } } @@ -1824,6 +1914,7 @@ impl Theme { adaptive: true, fonts: [0; TYPE_ROLE_COUNT], palette: None, + overlay: None, } } @@ -1879,6 +1970,12 @@ impl Theme { self } + /// Overrides overlay geometry, spacing, elevation and scrim for Dialog, Menu, Dropdown and Sheet. + pub const fn with_overlay_tokens(mut self, overlay: &'static OverlayTokens) -> Self { + self.overlay = Some(overlay); + self + } + /// The font this role resolves to, or `None` for the system font. pub const fn font(&self, role: TypeRole) -> Option { if let TypeRole::None = role { @@ -3159,6 +3256,8 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // whole dp, changes, and not only when the size class does. A `Window` node's boolean; // absent is false. ReportSize = 127, + // Scrim colour for modal overlays (Dialog, Sheet, Menu), as a `Paint` bit value. + Scrim = 128, }); #[derive(Clone, Debug, PartialEq)] diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index d8da241b..213e21c3 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -2153,6 +2153,7 @@ pub struct ModalScope { modifier: Modifier, widget: WidgetKind, open: bool, + scrim: Option, on_dismiss: Option>, done: bool, } @@ -2164,6 +2165,7 @@ pub fn Dialog(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Dialog, open, + scrim: None, on_dismiss: None, done: false, } @@ -2176,6 +2178,7 @@ pub fn Sheet(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Sheet, open, + scrim: None, on_dismiss: None, done: false, } @@ -2187,6 +2190,12 @@ impl ModalScope { self } + /// Sets the modal scrim colour, overriding the design system default. + pub fn scrim(mut self, paint: impl Into) -> Self { + self.scrim = Some(paint.into()); + self + } + fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let (widget, open) = (self.widget, self.open); @@ -2195,12 +2204,16 @@ impl ModalScope { } else { widget_key("compose_rust::ui::Sheet") }; + let scrim = self.scrim; emit( key, widget, |attrs| { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("open", OwnedAttr::Bool(open))); + if let Some(scrim) = scrim { + attrs.push(("scrim", super::paint(Some(scrim)))); + } }, vec![("ondismiss", unit(self.on_dismiss.take()))], content, @@ -2214,6 +2227,7 @@ container!(ModalScope); pub struct MenuScope<'a> { modifier: Modifier, expanded: bool, + scrim: Option, on_dismiss: Option>, anchor: Option>, done: bool, @@ -2225,6 +2239,7 @@ pub fn Menu<'a>(expanded: bool) -> MenuScope<'a> { MenuScope { modifier: Modifier::new(), expanded, + scrim: None, on_dismiss: None, anchor: None, done: false, @@ -2237,6 +2252,12 @@ impl<'a> MenuScope<'a> { self } + /// Sets the popup scrim colour, overriding the design system default. + pub fn scrim(mut self, paint: impl Into) -> Self { + self.scrim = Some(paint.into()); + self + } + /// The widget the menu hangs off. It is the first child on the wire. pub fn anchor(mut self, anchor: impl FnOnce() + 'a) -> Self { self.anchor = Some(std::boxed::Box::new(anchor)); @@ -2247,12 +2268,16 @@ impl<'a> MenuScope<'a> { let modifier = self.modifier; let expanded = self.expanded; let anchor = self.anchor.take(); + let scrim = self.scrim; emit( widget_key("compose_rust::ui::Menu"), WidgetKind::Menu, |attrs| { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("open", OwnedAttr::Bool(expanded))); + if let Some(scrim) = scrim { + attrs.push(("scrim", super::paint(Some(scrim)))); + } }, vec![("ondismiss", unit(self.on_dismiss.take()))], || { diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs new file mode 100644 index 00000000..4d667668 --- /dev/null +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -0,0 +1,67 @@ +//! Overlay theme tokens and node geometry overrides (FR-74). + +use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; +use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; + +#[test] +fn fr74_overlay_tokens_command_round_trips() { + let tokens = OverlayTokens::new() + .sheet_corner_radius(24) + .dialog_corner_radius(16) + .popup_corner_radius(12) + .sheet_elevation(8) + .dialog_elevation(6) + .popup_elevation(4) + .sheet_padding(20) + .dialog_padding(16) + .popup_padding(8) + .scrim_argb(0x80000000); + + let mut encoder = BatchEncoder::new(); + encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); + let bytes = encoder.finish(); + + let decoded = decode_batch(&bytes).unwrap(); + assert_eq!(decoded.len(), 1); + match &decoded[0] { + Mutation::SetOverlayTokens(actual) => { + assert_eq!(*actual, tokens); + assert_eq!(actual.sheet_corner_radius, 24); + assert_eq!(actual.dialog_corner_radius, 16); + assert_eq!(actual.popup_corner_radius, 12); + assert_eq!(actual.sheet_elevation, 8); + assert_eq!(actual.dialog_elevation, 6); + assert_eq!(actual.popup_elevation, 4); + assert_eq!(actual.sheet_padding, 20); + assert_eq!(actual.dialog_padding, 16); + assert_eq!(actual.popup_padding, 8); + assert_eq!(actual.scrim, 0x80000000); + } + other => panic!("expected SetOverlayTokens, got {other:?}"), + } +} + +#[test] +fn fr74_theme_builder_attaches_overlay_tokens() { + static TOKENS: OverlayTokens = OverlayTokens { + sheet_corner_radius: 24, + dialog_corner_radius: 16, + popup_corner_radius: 12, + sheet_elevation: 8, + popup_elevation: 4, + dialog_elevation: 6, + sheet_padding: 20, + dialog_padding: 16, + popup_padding: 8, + scrim: 0x66000000, + }; + + let theme = Theme::unified(DesignSystem::Cupertino).with_overlay_tokens(&TOKENS); + assert_eq!(theme.overlay, Some(&TOKENS)); +} + +#[test] +fn fr74_property_kind_scrim_has_correct_wire_tag() { + assert_eq!(PropertyKind::Scrim as u16, 128); + assert_eq!(PropertyKind::from_name("scrim").unwrap(), PropertyKind::Scrim); +} diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index c25568d0..cf0dd8b1 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "6134367089496884194", + "schemaHashUnsigned": "2307080319921313708", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/docs/SPEC.md b/docs/SPEC.md index d2c83f00..e2f45acc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -4122,6 +4122,33 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" 5. 클립보드를 쓸 수 없는 환경에서 명령은 아무 일도 하지 않고 프로세스는 계속 돕니다(시험 이름 fr73_unavailable_clipboard_is_silently_ignored). 6. Renderer의 어느 로그 줄에도 `text`의 내용이 들어가지 않습니다(시험 이름 fr73_clipboard_text_is_never_logged). +### FR-74 오버레이 테마 토큰과 기하 구조 제어 (`Draft`, 이슈 #506) + +요청한 쪽: darkpyonix-ember(#739, #740, `docs/DESIGN.md` §2, §4.6). Ember는 모든 OS에서 24px 연속 곡률 모서리(`corner-xl`), 지정된 팝업과 시트 그림자, 여백, 반투명 배경(scrim)을 쓰는 단일 디자인을 필요로 합니다. `Theme::with_palette`는 색상 역할만 재정의하므로 툴킷의 `Dialog`, `Menu`, `Dropdown`, `Sheet`에 대한 모양(shape), 여백(padding), 그림자/고도(elevation), 스크림(scrim)을 앱 단위로 일괄 지정할 수 없습니다. 이 항목은 애플리케이션 레벨의 오버레이 테마 토큰과 개별 노드 속성을 제공하여 기본 동작을 해치지 않고 디자인을 맞출 수 있게 합니다. + +- **명령 `SetOverlayTokens`(명령 태그 27)**: 오버레이 기하 구조와 효과를 지정하는 고정 길이 레코드입니다. + - `sheet_corner_radius: u16`: 시트 모서리 반지름(dp, 0은 시스템 기본). + - `dialog_corner_radius: u16`: 대화상자 모서리 반지름(dp, 0은 시스템 기본). + - `popup_corner_radius: u16`: 메뉴/드롭다운 모서리 반지름(dp, 0은 시스템 기본). + - `sheet_elevation: u16`: 시트 고도(dp, 0은 시스템 기본). + - `popup_elevation: u16`: 팝업 고도(dp, 0은 시스템 기본). + - `dialog_elevation: u16`: 대화상자 고도(dp, 0은 시스템 기본). + - `sheet_padding: u16`: 시트 내용 여백(dp, 0은 시스템 기본). + - `dialog_padding: u16`: 대화상자 내용 여백(dp, 0은 시스템 기본). + - `scrim: u32`: ARGB 스크림 색상(0은 시스템 기본). +- **노드 레벨 우선순위**: + - `Sheet`는 노드가 자체 모양(`node.containerShape`), 자체 여백(`node.setsOwnPadding()`), 자체 배경(`node.setsOwnBackground()`), 자체 고도(`node.setsOwnElevation()`)를 지정하면 시스템 기본이나 테마 토큰보다 노드의 값을 우선 적용합니다. + - `PropertyKind::Scrim(128)`: 개별 `Sheet`, `Dialog`, `Menu` 노드에 지정 가능한 스크림 `Paint` 속성입니다. +- **Host API**: + - `OverlayTokens`: 빌더 패턴으로 각 필드를 설정하는 `Copy` 구조체입니다. + - `Theme::with_overlay_tokens(self, &'static OverlayTokens) -> Self`. + - `Sheet`, `Dialog`, `Menu` 빌더에 `.scrim(paint)` 지원. + +수용 기준: +1. `Theme::with_overlay_tokens`가 설정되면 `SetOverlayTokens`(명령 태그 27)가 인코딩되어 렌더러로 전달됩니다(시험 이름 fr74_overlay_tokens_command_round_trips). +2. `Sheet`가 테마의 `sheet_corner_radius`와 `scrim`을 반영하여 렌더링되고, 노드에 직접 지정한 모서리 반지름과 스크림이 테마 토큰보다 우선합니다(시험 이름 fr74_sheet_honors_overlay_tokens_and_own_modifiers). +3. `Dialog`와 `Menu`가 테마의 스크림과 모서리 설정을 반영하고, 개별 노드의 `scrim` 속성을 존중합니다(시험 이름 fr74_dialog_and_menu_honor_scrim_and_tokens). + ### FR-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index a3a53b00..93583d78 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Scrim, Progress } enum class Key { Enter, Other } @@ -195,6 +195,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -1279,6 +1293,9 @@ sealed interface Mutation { val name: String, val filters: String, ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } sealed interface HostEvent { @@ -1334,7 +1351,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 6134367089496884194L + const val SCHEMA_HASH: Long = 2307080319921313708L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1364,6 +1381,7 @@ object Protocol { private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1530,6 +1548,23 @@ object Protocol { readString(batch, base, available, offset + 40), ) } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the @@ -2283,6 +2318,7 @@ object Protocol { 125 -> PropertyKind.CheckColor 126 -> PropertyKind.ControlSize 127 -> PropertyKind.ReportSize + 128 -> PropertyKind.Scrim 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 03c40c11..681dcea3 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -484,6 +484,7 @@ private fun ComposeRustTree( chainOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.chain }, brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush }, windowBackdrop = asked && platformBacksWindowWithMaterial(), + overlay = host.table.overlayTokens, ) // The animations the Host handed over are played here, on this side's frame clock, and // the Host is not called while they play unless one asked for events. The loop waits diff --git a/renderer/desktop/src/renderer/DesignSystem.kt b/renderer/desktop/src/renderer/DesignSystem.kt index 682b9586..754d2ac6 100644 --- a/renderer/desktop/src/renderer/DesignSystem.kt +++ b/renderer/desktop/src/renderer/DesignSystem.kt @@ -180,6 +180,11 @@ class ResolvedTheme( * given is enough to find the rest of the stack: see [chainFor]. */ val fontChains: List = emptyList(), + + /** + * Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + */ + val overlay: OverlayTokens? = null, ) { /** * The stack [family] is the first family of, or null where it is not one. @@ -1638,6 +1643,8 @@ fun resolveTheme( * family lacks in the families after it. */ chainOf: (Int) -> FontChain? = { null }, + /** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + overlay: OverlayTokens? = null, ): ResolvedTheme { val system = when { theme == null -> adaptiveSystem(platform, DesignSystem.Material3) @@ -1684,6 +1691,7 @@ fun resolveTheme( platform, application, chains, + overlay, ) } diff --git a/renderer/desktop/src/renderer/HostContainer.kt b/renderer/desktop/src/renderer/HostContainer.kt index 9914be33..116fe2e3 100644 --- a/renderer/desktop/src/renderer/HostContainer.kt +++ b/renderer/desktop/src/renderer/HostContainer.kt @@ -7,6 +7,7 @@ import dev.darkpyonix.composerust.protocol.TypeRole import dev.darkpyonix.composerust.ui.textStyle import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +46,20 @@ import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.weightOf import dev.darkpyonix.composerust.design.liftsOffThePage +/** True when the Host set a shape modifier of its own, which replaces the default shape. */ +internal fun Node.setsOwnShape(): Boolean = + modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + /** * The corner this container is drawn with: the one the Host asked for if it asked, and the * design system's corner for this kind of container otherwise. */ internal fun Node.containerShape(style: ContainerStyle, theme: ResolvedTheme): Shape = - if (modifiers.any { it is ProtocolModifier.Shape || it is ProtocolModifier.ShapeRole || it is ProtocolModifier.CornerEach }) { + if (setsOwnShape()) { modifiers.resolvedShape(theme) } else { style.shape @@ -98,12 +107,34 @@ internal fun Modifier.containerDecoration( theme: ResolvedTheme, /** True for a menu, a dialog or a tooltip: a sheet over the page rather than in it. */ overlay: Boolean = false, + role: ContainerRole? = null, ): Modifier { - val shape = node.containerShape(style, theme) - val raised = if (node.setsOwnElevation()) { + val shape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + else -> null + } ?: style.shape + } else { + style.shape + } + val effectiveElevation = if (node.setsOwnElevation()) { + 0.dp + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + else -> null + } ?: style.elevation + } else { + style.elevation + } + val raised = if (node.setsOwnElevation() || effectiveElevation.value <= 0f) { this } else { - theme.rules.elevation(this, style.elevation, shape, theme) + theme.rules.elevation(this, effectiveElevation, shape, theme) } val material = style.material // A Host that named its own Background has already had it applied, so painting the @@ -139,6 +170,17 @@ internal fun Modifier.containerDecoration( .then( if (node.setsOwnPadding()) { Modifier + } else if (overlay) { + val customPadding = when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + else -> null + } + if (customPadding != null) { + Modifier.padding(customPadding) + } else { + Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + } } else { Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) }, diff --git a/renderer/desktop/src/renderer/HostDropdown.kt b/renderer/desktop/src/renderer/HostDropdown.kt index 52b3f526..bbdcb641 100644 --- a/renderer/desktop/src/renderer/HostDropdown.kt +++ b/renderer/desktop/src/renderer/HostDropdown.kt @@ -196,6 +196,28 @@ private fun OpeningList( ) } if (open) { + val popupShape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else { + theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { androidx.compose.foundation.shape.RoundedCornerShape(it.dp) } ?: style.shape + } + val popupElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val popupRaised = if (node.setsOwnElevation() || popupElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, popupElevation, popupShape, theme) + } + val popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { + Modifier.padding(theme.overlay.popupPadding.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +227,11 @@ private fun OpeningList( Column( Modifier .testTag(dropdownPopupTestTag(node.id)) - .clip(style.shape) - .background(style.container, style.shape) - .border(style.borderWidth, style.borderColor, style.shape) - .padding(vertical = theme.space(SpaceRole.Xs)), + .then(popupRaised) + .clip(popupShape) + .background(style.container, popupShape) + .border(style.borderWidth, style.borderColor, popupShape) + .then(popupPadding), ) { options.forEachIndexed { index, childId -> key(childId) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index 43483ea3..d8e7ed49 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -194,6 +194,9 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } Popup( @@ -206,14 +209,14 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim), contentAlignment = Alignment.Center, ) { Column( modifier .testTag(dialogTestTag(node.id)) .modalOverlaySurface(node.id, surfaceRequester) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } @@ -265,7 +268,7 @@ internal fun HostMenu( Column( Modifier .testTag(menuPopupTestTag(node.id)) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Menu), ) { entries.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } diff --git a/renderer/desktop/src/renderer/HostSheet.kt b/renderer/desktop/src/renderer/HostSheet.kt index f5a12bb9..7f43ac61 100644 --- a/renderer/desktop/src/renderer/HostSheet.kt +++ b/renderer/desktop/src/renderer/HostSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.design.SheetEdge +import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.runtime.LocalWindowSizeClass @@ -41,6 +42,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import kotlin.math.roundToInt /** How far a sheet has to be pulled towards its edge before letting go closes it. */ @@ -84,6 +86,64 @@ internal fun HostSheet( val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + + val shape = if (node.setsOwnShape()) { + node.containerShape( + dev.darkpyonix.composerust.design.ContainerStyle( + container = style.container, + content = androidx.compose.ui.graphics.Color.Unspecified, + shape = style.shape, + elevation = style.elevation, + borderWidth = style.borderWidth, + borderColor = style.borderColor, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + separator = null, + scrim = style.scrim, + typeRole = dev.darkpyonix.composerust.protocol.TypeRole.Body, + ), + theme, + ) + } else { + theme.overlay?.sheetCornerRadius?.takeIf { it > 0 }?.let { + if (bottom) { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, it.dp, 0.dp, 0.dp) + } else { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, 0.dp, 0.dp, it.dp) + } + } ?: style.shape + } + + val sheetElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.sheetElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val raised = if (node.setsOwnElevation() || sheetElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, sheetElevation, shape, theme) + } + + val containerBg = if (node.setsOwnBackground()) Modifier else Modifier.background(style.container) + + val outline = if (style.borderWidth.value > 0f) { + Modifier.border(style.borderWidth, style.borderColor, shape) + } else { + Modifier + } + + val sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { + Modifier.padding(theme.overlay.sheetPadding.dp) + } else { + Modifier.padding(style.padding) + } + Popup( alignment = Alignment.Center, properties = PopupProperties(focusable = true, dismissOnBackPress = false), @@ -94,7 +154,7 @@ internal fun HostSheet( Modifier .testTag(sheetScrimTestTag(node.id)) .fillMaxSize() - .background(style.scrim) + .background(scrim) // Pressing what the sheet covers is the other way of asking to close it, // and it gets the same answer: one dismissal. A raw tap detector rather // than `clickable`, because a scrim is not a button and saying it is one @@ -107,11 +167,6 @@ internal fun HostSheet( } else { Modifier.fillMaxHeight().fillMaxWidth(style.widthFraction) } - val outline = if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, style.borderColor, style.shape) - } else { - Modifier - } // Two boxes rather than one: the outer is the sheet itself, which is what is // dragged and what the design system sized, and the inner carries whatever // Modifiers the Host put on the node, which belong inside its surface. @@ -127,8 +182,9 @@ internal fun HostSheet( IntOffset(travel.roundToInt(), 0) } } - .clip(style.shape) - .background(style.container) + .then(raised) + .clip(shape) + .then(containerBg) .then(outline) .draggable( orientation = if (bottom) Orientation.Vertical else Orientation.Horizontal, @@ -143,7 +199,7 @@ internal fun HostSheet( // swallowed here rather than reaching the scrim behind. .pointerInput(Unit) { detectTapGestures { } }, ) { - Column(modifier.fillMaxWidth().padding(style.padding)) { + Column(modifier.fillMaxWidth().then(sheetPadding)) { style.handle?.let { handle -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 1d4dab3f..3660b134 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -187,6 +187,10 @@ class NodeTable { var theme: Theme? by mutableStateOf(null) private set + /** Null until the Host sends an overlay tokens record. */ + var overlayTokens: OverlayTokens? by mutableStateOf(null) + private set + /** Null until the Host sends its first `SetWindow` record. */ var window: Window? by mutableStateOf(null) private set @@ -320,6 +324,7 @@ class NodeTable { fail(TableError.INVALID_PALETTE, "theme palette: $problem") } } + is Mutation.SetOverlayTokens -> overlayTokens = mutation.overlay // Read before there is a window to apply it to. The platform layer asks for // this out of the first batch and builds the window from it, so by the time // the batch is applied the window already looks the way it says. Keeping it diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt new file mode 100644 index 00000000..3feba0d2 --- /dev/null +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -0,0 +1,138 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.foundation.dialogTestTag +import dev.darkpyonix.composerust.foundation.sheetTestTag +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalTestApi::class) +class HostOverlayTokensTest { + private val frames = FrameRequestSource() + + @Test + fun fr74_sheet_honors_overlay_tokens_and_own_modifiers() = runComposeUiTest { + val sheetId = 1 + val textId = 2 + val tokens = OverlayTokens( + sheetCornerRadius = 24, + sheetElevation = 8, + sheetPadding = 20, + scrim = 0x80000000L, + ) + val connection = FakeHostConnection( + listOf( + Mutation.SetTheme( + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false), + ), + Mutation.SetOverlayTokens(tokens), + Mutation.Create(sheetId, WidgetKind.Sheet), + Mutation.SetProp(sheetId, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.Create(textId, WidgetKind.Text), + Mutation.SetProp(textId, PropertyKind.Text, PropertyValue.Text("Settings")), + Mutation.Insert(sheetId, textId, 0), + ), + ) + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(500.dp, 800.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, connection.host.table.overlayTokens) + onNodeWithTag(nodeTestTag(textId)).assertTextEquals("Settings") + + // Overriding scrim on the node directly + connection.scheduleFrame( + listOf( + Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Paint(Paint.Rgb(0x40, 0x11, 0x22, 0x33))), + ), + ) + frames.request() + waitForIdle() + + val node = connection.host.table.node(sheetId) + assertNotNull(node) + assertEquals(Paint.Rgb(0x40, 0x11, 0x22, 0x33), node.paintProp(PropertyKind.Scrim)) + } + + @Test + fun fr74_dialog_and_menu_honor_scrim_and_tokens() = runComposeUiTest { + val root = 10 + val dialogId = 11 + val dialogText = 12 + val tokens = OverlayTokens( + dialogCornerRadius = 16, + popupCornerRadius = 12, + dialogElevation = 6, + popupElevation = 4, + dialogPadding = 16, + popupPadding = 8, + scrim = 0x66000000L, + ) + val connection = FakeHostConnection( + listOf( + Mutation.SetTheme( + Theme(DesignSystem.Cupertino, DesignSystem.Cupertino, ColorScheme.Light, adaptive = false), + ), + Mutation.SetOverlayTokens(tokens), + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(dialogId, WidgetKind.Dialog), + Mutation.SetProp(dialogId, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(dialogId, PropertyKind.Scrim, PropertyValue.Paint(Paint.Rgb(0x80, 0x00, 0x00, 0x00))), + Mutation.Create(dialogText, WidgetKind.Text), + Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Confirm")), + Mutation.Insert(dialogId, dialogText, 0), + Mutation.Insert(root, dialogId, 0), + ), + ) + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(800.dp, 600.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, connection.host.table.overlayTokens) + onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") + onNodeWithTag(dialogTestTag(dialogId)).assertExists() + } +} From ca5f24dfdad9d21320914a78d344c97d6b4abb79 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:20 +0900 Subject: [PATCH 209/224] Chore: format Rust files with rustfmt (FR-74) --- compose-rust/src/codegen.rs | 28 +++++++++++------------ compose-rust/src/protocol.rs | 25 ++++++-------------- compose-rust/src/ui/widgets.rs | 4 ++-- compose-rust/tests/fr74_overlay_tokens.rs | 7 ++++-- 4 files changed, 28 insertions(+), 36 deletions(-) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index da7e6dc6..c9878b41 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -1,23 +1,23 @@ //! Deterministic Kotlin protocol generation from the Rust schema. use crate::protocol::{ - BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, encode_event, -}; -use crate::schema::{ - ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, BoundaryOp, BoundaryParam, Color, ColorRole, - ColorScheme, DESIGN_SYSTEM_SCHEMA, DesignSystem, EVENT_SCHEMA, EventPayloadType, FieldSchema, - FieldSlot, FieldType, HOVER_PHASE_SCHEMA, KEY_SCHEMA, Key, MODIFIER_SCHEMA, - NOTIFICATION_PERMISSION_SCHEMA, NotificationImportance, NotificationPermission, - NotificationPresentation, PROPERTY_SCHEMA, PROTOCOL_VERSION, Paint, PropertyKind, - ROLE_ENUM_SCHEMA, SCHEMA_HASH, Selection, ShapeRole, SpaceRole, Theme, WEB_BATCH_BYTES, - WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, - WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE, WEB_RUST_REGION_BASE, WEB_START_SYMBOL, - WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, WidgetKind, + encode_event, BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, }; use crate::schema::{ AnimatedProperty, Animation, AnimationControl, AnimationEvents, ColorInterpolation, FillMode, Keyframe, KeyframeValue, PlayState, PlaybackDirection, StepPosition, Timing, TransformFunction, }; +use crate::schema::{ + BoundaryOp, BoundaryParam, Color, ColorRole, ColorScheme, DesignSystem, EventPayloadType, + FieldSchema, FieldSlot, FieldType, Key, NotificationImportance, NotificationPermission, + NotificationPresentation, Paint, PropertyKind, Selection, ShapeRole, SpaceRole, Theme, + WidgetKind, ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, DESIGN_SYSTEM_SCHEMA, EVENT_SCHEMA, + HOVER_PHASE_SCHEMA, KEY_SCHEMA, MODIFIER_SCHEMA, NOTIFICATION_PERMISSION_SCHEMA, + PROPERTY_SCHEMA, PROTOCOL_VERSION, ROLE_ENUM_SCHEMA, SCHEMA_HASH, WEB_BATCH_BYTES, + WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, + WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE, WEB_RUST_REGION_BASE, WEB_START_SYMBOL, + WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, +}; use crate::tokens::DESIGN_TOKENS; use crate::{EventPayload, Modifier}; use std::fmt::Write as _; @@ -3586,7 +3586,7 @@ fn lower_camel(name: &str) -> String { /// The drawing command vocabulary, mirrored into Kotlin with a decoder for the byte blob a /// `Canvas` node carries. fn write_draw_commands(output: &mut String) { - use crate::drawing::{COMMAND_LEN, DRAW_COMMAND_SCHEMA, DrawFieldType}; + use crate::drawing::{DrawFieldType, COMMAND_LEN, DRAW_COMMAND_SCHEMA}; output.push_str( "/**\n * One drawing command. Coordinates are dp from the Canvas's top-left corner.\n *\n \ @@ -3860,7 +3860,7 @@ object SpanRecords { /// that record's problem rather than the batch's. fn write_code_editor_records(output: &mut String) { use crate::code::{ - DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, RecordFieldType, RecordSchema, + RecordFieldType, RecordSchema, DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, SYNTAX_SPAN_RECORD, }; diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index a60644c5..388e5d10 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1,11 +1,11 @@ //! Fixed-layout little-endian boundary protocol. use crate::schema::{ - AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, Key, - MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, - NotificationImportance, NotificationPermission, NotificationPresentation, Paint, PropertyKind, - SEMANTICS_STATE_MASK, Selection, SemanticsRole, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, - WidgetKind, modifier_extra_words, + modifier_extra_words, AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, + Key, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, + NotificationPermission, NotificationPresentation, Paint, PropertyKind, Selection, + SemanticsRole, ShapeRole, SpaceRole, Theme, WidgetKind, MAX_MODIFIER_WORDS, + SEMANTICS_STATE_MASK, TYPE_ROLE_COUNT, }; use core::fmt; @@ -2584,18 +2584,7 @@ fn decode_paint(bits: u64) -> Result { } fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { - let [ - first, - second, - third, - fourth, - fifth, - sixth, - seventh, - eighth, - ninth, - tenth, - ] = *words; + let [first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, tenth] = *words; match tag { 0 => Ok(Modifier::Empty), 1 => Ok(Modifier::Padding(f32::from_bits(first as u32))), @@ -3902,8 +3891,8 @@ mod tests { fn fr65_text_field_on_composition_change_delivers_bool() { use std::cell::RefCell; use std::rc::Rc; - use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 213e21c3..cb192470 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -11,8 +11,8 @@ #![allow(non_snake_case)] use super::{ - Attrs, Modifier, Mods, dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, - role_or_zero, widget_key, + dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, role_or_zero, widget_key, + Attrs, Modifier, Mods, }; use crate::drawing::DrawList; use crate::fonts::FontRefs; diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 4d667668..9ec36b5d 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -1,6 +1,6 @@ //! Overlay theme tokens and node geometry overrides (FR-74). -use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; +use compose_rust::protocol::{decode_batch, BatchEncoder, Mutation}; use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; #[test] @@ -63,5 +63,8 @@ fn fr74_theme_builder_attaches_overlay_tokens() { #[test] fn fr74_property_kind_scrim_has_correct_wire_tag() { assert_eq!(PropertyKind::Scrim as u16, 128); - assert_eq!(PropertyKind::from_name("scrim").unwrap(), PropertyKind::Scrim); + assert_eq!( + PropertyKind::from_name("scrim").unwrap(), + PropertyKind::Scrim + ); } From b7c89ccf60f7cbbd460d10ce6045b1d655441201 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:39 +0900 Subject: [PATCH 210/224] Chore: format Rust files with rustfmt (FR-75) --- compose-rust/src/ui/widgets.rs | 4 +-- compose-rust/tests/fr75_composer_tokens.rs | 35 +++++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index a7d26af0..190cbbb3 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -11,8 +11,8 @@ #![allow(non_snake_case)] use super::{ - Attrs, Modifier, Mods, dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, - role_or_zero, widget_key, + dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, role_or_zero, widget_key, + Attrs, Modifier, Mods, }; use crate::drawing::DrawList; use crate::fonts::FontRefs; diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index 18fac7d6..51cddaa3 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -2,10 +2,10 @@ mod support; -use compose_rust::ComposeHost; use compose_rust::runtime::*; use compose_rust::schema::PropertyKind; use compose_rust::ui::*; +use compose_rust::ComposeHost; use support::Tree; fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { @@ -41,10 +41,22 @@ fn fr75_text_field_typography_round_trips() { let tree = start(StyledField); let dump = tree.dump(); assert!(dump.contains("TextField"), "missing TextField: {dump}"); - assert!(dump.contains("FontSize=Float(15.0)"), "missing FontSize in {dump}"); - assert!(dump.contains("FontWeight=Integer(600)"), "missing FontWeight in {dump}"); - assert!(dump.contains("LineHeight=Float(22.0)"), "missing LineHeight in {dump}"); - assert!(dump.contains("LetterSpacing=Float(0.5)"), "missing LetterSpacing in {dump}"); + assert!( + dump.contains("FontSize=Float(15.0)"), + "missing FontSize in {dump}" + ); + assert!( + dump.contains("FontWeight=Integer(600)"), + "missing FontWeight in {dump}" + ); + assert!( + dump.contains("LineHeight=Float(22.0)"), + "missing LineHeight in {dump}" + ); + assert!( + dump.contains("LetterSpacing=Float(0.5)"), + "missing LetterSpacing in {dump}" + ); assert!(dump.contains("Color=Paint("), "missing Color in {dump}"); assert!(dump.contains("1193046"), "missing Color rgb in {dump}"); } @@ -54,7 +66,16 @@ fn fr75_text_field_plain_does_not_send_typography_properties() { let tree = start(PlainField); let dump = tree.dump(); assert!(dump.contains("TextField"), "missing TextField: {dump}"); - for prop in ["FontSize", "FontWeight", "LineHeight", "LetterSpacing", "Color"] { - assert!(!dump.contains(prop), "{prop} should not be sent for plain field: {dump}"); + for prop in [ + "FontSize", + "FontWeight", + "LineHeight", + "LetterSpacing", + "Color", + ] { + assert!( + !dump.contains(prop), + "{prop} should not be sent for plain field: {dump}" + ); } } From 40f17705bce72a1f86711b89fa5c4e679c07d946 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:43:02 +0900 Subject: [PATCH 211/224] Fix: pass f64 to OwnedAttr::Float in TextFieldScope attrs (FR-75) --- compose-rust/src/ui/widgets.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 190cbbb3..4b08c4fe 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -1150,16 +1150,16 @@ impl TextFieldScope { attrs.push(("multiline", OwnedAttr::Bool(multiline))); attrs.push(("type_role", role_or_zero(type_role))); if let Some(font_size) = font_size { - attrs.push(("font_size", OwnedAttr::Float(font_size))); + attrs.push(("font_size", OwnedAttr::Float(font_size as f64))); } if let Some(font_weight) = font_weight { attrs.push(("font_weight", OwnedAttr::Int(font_weight as i64))); } if let Some(line_height) = line_height { - attrs.push(("line_height", OwnedAttr::Float(line_height))); + attrs.push(("line_height", OwnedAttr::Float(line_height as f64))); } if let Some(letter_spacing) = letter_spacing { - attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing))); + attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing as f64))); } if let Some(color) = color { attrs.push(("color", super::paint(Some(color)))); From b9daae0872dda0363b9081d6342fee90902e5f78 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:51:12 +0900 Subject: [PATCH 212/224] Fix: resolve rustfmt ordering and remove planning doc citation (FR-75) --- compose-rust/src/ui/widgets.rs | 4 ++-- compose-rust/tests/fr75_composer_tokens.rs | 2 +- renderer/desktop/test/HostComposerTokensTest.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 4b08c4fe..8b4643c1 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -11,8 +11,8 @@ #![allow(non_snake_case)] use super::{ - dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, role_or_zero, widget_key, - Attrs, Modifier, Mods, + Attrs, Modifier, Mods, dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, + role_or_zero, widget_key, }; use crate::drawing::DrawList; use crate::fonts::FontRefs; diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index 51cddaa3..bf6bccac 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -1,4 +1,4 @@ -//! FR-75: TextField typography and control over composer / icon button tokens. +//! TextField typography and control over composer / icon button tokens. mod support; diff --git a/renderer/desktop/test/HostComposerTokensTest.kt b/renderer/desktop/test/HostComposerTokensTest.kt index 648d657a..208bcf04 100644 --- a/renderer/desktop/test/HostComposerTokensTest.kt +++ b/renderer/desktop/test/HostComposerTokensTest.kt @@ -23,7 +23,7 @@ import kotlin.test.Test import kotlin.test.assertTrue /** - * FR-75: TextField typography and control over composer and icon button tokens. + * TextField typography and control over composer and icon button tokens. */ @OptIn(ExperimentalTestApi::class) class HostComposerTokensTest { From d7feb5cf715120e0037c79a6e9f1183fd4499427 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:52:29 +0900 Subject: [PATCH 213/224] Fix: resolve rustfmt ordering and remove planning doc citation (FR-74) --- compose-rust/src/codegen.rs | 28 +++++++++++------------ compose-rust/src/protocol.rs | 25 ++++++++++++++------ compose-rust/src/ui/widgets.rs | 4 ++-- compose-rust/tests/fr74_overlay_tokens.rs | 4 ++-- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index c9878b41..da7e6dc6 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -1,22 +1,22 @@ //! Deterministic Kotlin protocol generation from the Rust schema. use crate::protocol::{ - encode_event, BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, + BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, encode_event, }; use crate::schema::{ - AnimatedProperty, Animation, AnimationControl, AnimationEvents, ColorInterpolation, FillMode, - Keyframe, KeyframeValue, PlayState, PlaybackDirection, StepPosition, Timing, TransformFunction, -}; -use crate::schema::{ - BoundaryOp, BoundaryParam, Color, ColorRole, ColorScheme, DesignSystem, EventPayloadType, - FieldSchema, FieldSlot, FieldType, Key, NotificationImportance, NotificationPermission, - NotificationPresentation, Paint, PropertyKind, Selection, ShapeRole, SpaceRole, Theme, - WidgetKind, ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, DESIGN_SYSTEM_SCHEMA, EVENT_SCHEMA, - HOVER_PHASE_SCHEMA, KEY_SCHEMA, MODIFIER_SCHEMA, NOTIFICATION_PERMISSION_SCHEMA, - PROPERTY_SCHEMA, PROTOCOL_VERSION, ROLE_ENUM_SCHEMA, SCHEMA_HASH, WEB_BATCH_BYTES, + ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, BoundaryOp, BoundaryParam, Color, ColorRole, + ColorScheme, DESIGN_SYSTEM_SCHEMA, DesignSystem, EVENT_SCHEMA, EventPayloadType, FieldSchema, + FieldSlot, FieldType, HOVER_PHASE_SCHEMA, KEY_SCHEMA, Key, MODIFIER_SCHEMA, + NOTIFICATION_PERMISSION_SCHEMA, NotificationImportance, NotificationPermission, + NotificationPresentation, PROPERTY_SCHEMA, PROTOCOL_VERSION, Paint, PropertyKind, + ROLE_ENUM_SCHEMA, SCHEMA_HASH, Selection, ShapeRole, SpaceRole, Theme, WEB_BATCH_BYTES, WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE, WEB_RUST_REGION_BASE, WEB_START_SYMBOL, - WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, + WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, WidgetKind, +}; +use crate::schema::{ + AnimatedProperty, Animation, AnimationControl, AnimationEvents, ColorInterpolation, FillMode, + Keyframe, KeyframeValue, PlayState, PlaybackDirection, StepPosition, Timing, TransformFunction, }; use crate::tokens::DESIGN_TOKENS; use crate::{EventPayload, Modifier}; @@ -3586,7 +3586,7 @@ fn lower_camel(name: &str) -> String { /// The drawing command vocabulary, mirrored into Kotlin with a decoder for the byte blob a /// `Canvas` node carries. fn write_draw_commands(output: &mut String) { - use crate::drawing::{DrawFieldType, COMMAND_LEN, DRAW_COMMAND_SCHEMA}; + use crate::drawing::{COMMAND_LEN, DRAW_COMMAND_SCHEMA, DrawFieldType}; output.push_str( "/**\n * One drawing command. Coordinates are dp from the Canvas's top-left corner.\n *\n \ @@ -3860,7 +3860,7 @@ object SpanRecords { /// that record's problem rather than the batch's. fn write_code_editor_records(output: &mut String) { use crate::code::{ - RecordFieldType, RecordSchema, DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, + DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, RecordFieldType, RecordSchema, SYNTAX_SPAN_RECORD, }; diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 388e5d10..a60644c5 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1,11 +1,11 @@ //! Fixed-layout little-endian boundary protocol. use crate::schema::{ - modifier_extra_words, AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, - Key, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, - NotificationPermission, NotificationPresentation, Paint, PropertyKind, Selection, - SemanticsRole, ShapeRole, SpaceRole, Theme, WidgetKind, MAX_MODIFIER_WORDS, - SEMANTICS_STATE_MASK, TYPE_ROLE_COUNT, + AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, Key, + MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, + NotificationImportance, NotificationPermission, NotificationPresentation, Paint, PropertyKind, + SEMANTICS_STATE_MASK, Selection, SemanticsRole, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, + WidgetKind, modifier_extra_words, }; use core::fmt; @@ -2584,7 +2584,18 @@ fn decode_paint(bits: u64) -> Result { } fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { - let [first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, tenth] = *words; + let [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth, + tenth, + ] = *words; match tag { 0 => Ok(Modifier::Empty), 1 => Ok(Modifier::Padding(f32::from_bits(first as u32))), @@ -3891,8 +3902,8 @@ mod tests { fn fr65_text_field_on_composition_change_delivers_bool() { use std::cell::RefCell; use std::rc::Rc; - use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index cb192470..213e21c3 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -11,8 +11,8 @@ #![allow(non_snake_case)] use super::{ - dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, role_or_zero, widget_key, - Attrs, Modifier, Mods, + Attrs, Modifier, Mods, dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, + role_or_zero, widget_key, }; use crate::drawing::DrawList; use crate::fonts::FontRefs; diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 9ec36b5d..4b6e27bb 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -1,6 +1,6 @@ -//! Overlay theme tokens and node geometry overrides (FR-74). +//! Overlay theme tokens and node geometry overrides. -use compose_rust::protocol::{decode_batch, BatchEncoder, Mutation}; +use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; #[test] From 0a39fef2f4b81425299a5258805b47e78601b485 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:53:45 +0900 Subject: [PATCH 214/224] Fix: sort ComposeHost import in test (FR-75) --- compose-rust/tests/fr75_composer_tokens.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index bf6bccac..365efe7c 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -2,10 +2,10 @@ mod support; +use compose_rust::ComposeHost; use compose_rust::runtime::*; use compose_rust::schema::PropertyKind; use compose_rust::ui::*; -use compose_rust::ComposeHost; use support::Tree; fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { From adabb5950929de6d035f1e4acd8d12102bd4137f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:54:59 +0900 Subject: [PATCH 215/224] Fix: use BatchEncoder default and drop unused import (FR-74) --- compose-rust/tests/fr74_overlay_tokens.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 4b6e27bb..404581c7 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -1,7 +1,7 @@ //! Overlay theme tokens and node geometry overrides. use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; -use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; +use compose_rust::schema::{DesignSystem, OverlayTokens, PropertyKind, Theme}; #[test] fn fr74_overlay_tokens_command_round_trips() { @@ -17,7 +17,7 @@ fn fr74_overlay_tokens_command_round_trips() { .popup_padding(8) .scrim_argb(0x80000000); - let mut encoder = BatchEncoder::new(); + let mut encoder = BatchEncoder::default(); encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); let bytes = encoder.finish(); From fb0c2d177ed620d552e55a21e796d5cfc7350f0d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:56:12 +0900 Subject: [PATCH 216/224] Fix: use TextField text builder method in test (FR-75) --- compose-rust/tests/fr75_composer_tokens.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index 365efe7c..a5ef2fed 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -4,7 +4,6 @@ mod support; use compose_rust::ComposeHost; use compose_rust::runtime::*; -use compose_rust::schema::PropertyKind; use compose_rust::ui::*; use support::Tree; @@ -20,7 +19,8 @@ const CUSTOM_COLOR: Paint = Paint::Literal(Color::rgb(0x123456)); #[Composable] fn StyledField() { Column().content(|| { - TextField("custom") + TextField() + .text("custom") .font_size(15.0) .font_weight(600) .line_height(22.0) @@ -32,7 +32,7 @@ fn StyledField() { #[Composable] fn PlainField() { Column().content(|| { - TextField("plain"); + TextField().text("plain"); }); } From 5a970e5e9e3b2ad6c3f13c489db1342c21f29cc9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:57:05 +0900 Subject: [PATCH 217/224] Fix: unwrap encoder finish in test (FR-74) --- compose-rust/tests/fr74_overlay_tokens.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 404581c7..2b5e5d51 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -19,9 +19,9 @@ fn fr74_overlay_tokens_command_round_trips() { let mut encoder = BatchEncoder::default(); encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); - let bytes = encoder.finish(); + let bytes = encoder.finish().unwrap(); - let decoded = decode_batch(&bytes).unwrap(); + let decoded = decode_batch(bytes).unwrap(); assert_eq!(decoded.len(), 1); match &decoded[0] { Mutation::SetOverlayTokens(actual) => { From ff06fc2afceef668065bca82eb2ec222e5922f9d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:59:22 +0900 Subject: [PATCH 218/224] Fix: assert paint bits integer in typography test (FR-75) --- compose-rust/tests/fr75_composer_tokens.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index a5ef2fed..1147195d 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -57,8 +57,11 @@ fn fr75_text_field_typography_round_trips() { dump.contains("LetterSpacing=Float(0.5)"), "missing LetterSpacing in {dump}" ); - assert!(dump.contains("Color=Paint("), "missing Color in {dump}"); - assert!(dump.contains("1193046"), "missing Color rgb in {dump}"); + assert!(dump.contains("Color=Integer("), "missing Color in {dump}"); + assert!( + dump.contains(&format!("Color=Integer({})", CUSTOM_COLOR.to_bits() as i64)), + "missing Color in {dump}" + ); } #[test] From 13f25933d0782b6678428f400e32acd958fd4986 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:01:24 +0900 Subject: [PATCH 219/224] Fix: add PropertyKind.Scrim arm to NodeTable supportsProperty (FR-74) --- compose-rust/android-kotlin/shared/NodeTable.kt | 5 +++++ renderer/desktop/src/renderer/NodeTable.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 3660b134..bbc1f56e 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -979,6 +979,11 @@ class NodeTable { widget == WidgetKind.Menu || // A sheet is an overlay on the same terms as a dialog. widget == WidgetKind.Sheet + + // A custom scrim colour for modal and popup overlays. + PropertyKind.Scrim -> widget == WidgetKind.Dialog || + widget == WidgetKind.Menu || + widget == WidgetKind.Sheet // A Dropdown's selection is a position in its own children, the same thing // a tab strip's selection is. PropertyKind.SelectedIndex -> diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 3660b134..bbc1f56e 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -979,6 +979,11 @@ class NodeTable { widget == WidgetKind.Menu || // A sheet is an overlay on the same terms as a dialog. widget == WidgetKind.Sheet + + // A custom scrim colour for modal and popup overlays. + PropertyKind.Scrim -> widget == WidgetKind.Dialog || + widget == WidgetKind.Menu || + widget == WidgetKind.Sheet // A Dropdown's selection is a position in its own children, the same thing // a tab strip's selection is. PropertyKind.SelectedIndex -> From b7a2eb5bd2889a9b71ad7ca49e21f3cb5e36d349 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:19 +0900 Subject: [PATCH 220/224] Fix: import androidx.compose.ui.unit.dp in HostButton (FR-75) --- compose-rust/android-kotlin/shared/HostButton.kt | 1 + renderer/desktop/src/renderer/HostButton.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 81588068..430e9728 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 81588068..430e9728 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected From 9f017bf61d1a11a8d359890cddb0eefe8541a1fa Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:16:43 +0900 Subject: [PATCH 221/224] Fix: import OverlayTokens and avoid shadowing in HostSheet and HostDropdown (FR-74) --- compose-rust/android-kotlin/shared/DesignSystem.kt | 1 + compose-rust/android-kotlin/shared/HostDropdown.kt | 5 +++-- compose-rust/android-kotlin/shared/HostSheet.kt | 5 +++-- compose-rust/android-kotlin/shared/NodeTable.kt | 1 + renderer/desktop/src/renderer/DesignSystem.kt | 1 + renderer/desktop/src/renderer/HostDropdown.kt | 5 +++-- renderer/desktop/src/renderer/HostSheet.kt | 5 +++-- renderer/desktop/src/renderer/NodeTable.kt | 1 + 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/compose-rust/android-kotlin/shared/DesignSystem.kt b/compose-rust/android-kotlin/shared/DesignSystem.kt index 754d2ac6..f603e80f 100644 --- a/compose-rust/android-kotlin/shared/DesignSystem.kt +++ b/compose-rust/android-kotlin/shared/DesignSystem.kt @@ -22,6 +22,7 @@ import dev.darkpyonix.composerust.protocol.DesignTokens import androidx.compose.animation.core.FiniteAnimationSpec import dev.darkpyonix.composerust.protocol.MaterialRole import dev.darkpyonix.composerust.protocol.MotionRole +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.protocol.SpaceRole diff --git a/compose-rust/android-kotlin/shared/HostDropdown.kt b/compose-rust/android-kotlin/shared/HostDropdown.kt index bbdcb641..dc1fcdd9 100644 --- a/compose-rust/android-kotlin/shared/HostDropdown.kt +++ b/compose-rust/android-kotlin/shared/HostDropdown.kt @@ -211,10 +211,11 @@ private fun OpeningList( } else { theme.rules.elevation(Modifier, popupElevation, popupShape, theme) } + val explicitPopupPad = theme.overlay?.popupPadding val popupPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { - Modifier.padding(theme.overlay.popupPadding.dp) + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) } else { Modifier.padding(vertical = theme.space(SpaceRole.Xs)) } diff --git a/compose-rust/android-kotlin/shared/HostSheet.kt b/compose-rust/android-kotlin/shared/HostSheet.kt index 7f43ac61..74cd7dce 100644 --- a/compose-rust/android-kotlin/shared/HostSheet.kt +++ b/compose-rust/android-kotlin/shared/HostSheet.kt @@ -136,10 +136,11 @@ internal fun HostSheet( Modifier } + val explicitSheetPad = theme.overlay?.sheetPadding val sheetPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { - Modifier.padding(theme.overlay.sheetPadding.dp) + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) } else { Modifier.padding(style.padding) } diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index bbc1f56e..e74429f5 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import dev.darkpyonix.composerust.foundation.ChromeInsets import dev.darkpyonix.composerust.foundation.MessageQueue import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme diff --git a/renderer/desktop/src/renderer/DesignSystem.kt b/renderer/desktop/src/renderer/DesignSystem.kt index 754d2ac6..f603e80f 100644 --- a/renderer/desktop/src/renderer/DesignSystem.kt +++ b/renderer/desktop/src/renderer/DesignSystem.kt @@ -22,6 +22,7 @@ import dev.darkpyonix.composerust.protocol.DesignTokens import androidx.compose.animation.core.FiniteAnimationSpec import dev.darkpyonix.composerust.protocol.MaterialRole import dev.darkpyonix.composerust.protocol.MotionRole +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.protocol.SpaceRole diff --git a/renderer/desktop/src/renderer/HostDropdown.kt b/renderer/desktop/src/renderer/HostDropdown.kt index bbdcb641..dc1fcdd9 100644 --- a/renderer/desktop/src/renderer/HostDropdown.kt +++ b/renderer/desktop/src/renderer/HostDropdown.kt @@ -211,10 +211,11 @@ private fun OpeningList( } else { theme.rules.elevation(Modifier, popupElevation, popupShape, theme) } + val explicitPopupPad = theme.overlay?.popupPadding val popupPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { - Modifier.padding(theme.overlay.popupPadding.dp) + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) } else { Modifier.padding(vertical = theme.space(SpaceRole.Xs)) } diff --git a/renderer/desktop/src/renderer/HostSheet.kt b/renderer/desktop/src/renderer/HostSheet.kt index 7f43ac61..74cd7dce 100644 --- a/renderer/desktop/src/renderer/HostSheet.kt +++ b/renderer/desktop/src/renderer/HostSheet.kt @@ -136,10 +136,11 @@ internal fun HostSheet( Modifier } + val explicitSheetPad = theme.overlay?.sheetPadding val sheetPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { - Modifier.padding(theme.overlay.sheetPadding.dp) + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) } else { Modifier.padding(style.padding) } diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index bbc1f56e..e74429f5 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import dev.darkpyonix.composerust.foundation.ChromeInsets import dev.darkpyonix.composerust.foundation.MessageQueue import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme From 8ea8d6a035de3178bc174e86ef81637a4640bfc9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:35:04 +0900 Subject: [PATCH 222/224] Fix: import paintProp in HostOverlay and use ContainerRole.Menu in HostContainer --- compose-rust/android-kotlin/shared/HostContainer.kt | 6 +++--- compose-rust/android-kotlin/shared/HostOverlay.kt | 1 + renderer/desktop/src/renderer/HostContainer.kt | 6 +++--- renderer/desktop/src/renderer/HostOverlay.kt | 1 + 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostContainer.kt b/compose-rust/android-kotlin/shared/HostContainer.kt index 116fe2e3..bafe5709 100644 --- a/compose-rust/android-kotlin/shared/HostContainer.kt +++ b/compose-rust/android-kotlin/shared/HostContainer.kt @@ -114,7 +114,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } else -> null } ?: style.shape } else { @@ -125,7 +125,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } else -> null } ?: style.elevation } else { @@ -173,7 +173,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { val customPadding = when (role) { ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } else -> null } if (customPadding != null) { diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index d8e7ed49..67fa8d27 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -50,6 +50,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider diff --git a/renderer/desktop/src/renderer/HostContainer.kt b/renderer/desktop/src/renderer/HostContainer.kt index 116fe2e3..bafe5709 100644 --- a/renderer/desktop/src/renderer/HostContainer.kt +++ b/renderer/desktop/src/renderer/HostContainer.kt @@ -114,7 +114,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } else -> null } ?: style.shape } else { @@ -125,7 +125,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } else -> null } ?: style.elevation } else { @@ -173,7 +173,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { val customPadding = when (role) { ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } else -> null } if (customPadding != null) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index d8e7ed49..67fa8d27 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -50,6 +50,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider From f71fccbaca1b41c98c7b0953b7540dbe39ead4cd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:59:31 +0900 Subject: [PATCH 223/224] Fix: handle SetOverlayTokens in ProtocolVectorsTest and fix HostOverlayTokensTest --- .../desktop/test/HostOverlayTokensTest.kt | 24 ++++++++++++------- renderer/desktop/test/ProtocolVectorsTest.kt | 1 + 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt index 3feba0d2..d8232eb7 100644 --- a/renderer/desktop/test/HostOverlayTokensTest.kt +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -22,9 +22,11 @@ import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme import dev.darkpyonix.composerust.protocol.WidgetKind import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.tooling.FakeHostConnection import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests import kotlin.test.Test @@ -58,34 +60,37 @@ class HostOverlayTokensTest { Mutation.Insert(sheetId, textId, 0), ), ) + lateinit var host: ComposeRustHost setContent { CompositionLocalProvider( LocalFrameRequests provides frames, LocalDensity provides Density(1f), ) { + host = rememberComposeRustHost(connection) ComposeRustContent( - rememberComposeRustHost(connection), + host, Modifier.requiredSize(500.dp, 800.dp), ) } } waitForIdle() - assertEquals(tokens, connection.host.table.overlayTokens) + assertEquals(tokens, host.table.overlayTokens) onNodeWithTag(nodeTestTag(textId)).assertTextEquals("Settings") // Overriding scrim on the node directly + val scrimBits = (2L shl 32) or (0x40112233L and 0xFFFFFFFFL) connection.scheduleFrame( listOf( - Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Paint(Paint.Rgb(0x40, 0x11, 0x22, 0x33))), + Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), ), ) frames.request() waitForIdle() - val node = connection.host.table.node(sheetId) + val node = host.table.node(sheetId) assertNotNull(node) - assertEquals(Paint.Rgb(0x40, 0x11, 0x22, 0x33), node.paintProp(PropertyKind.Scrim)) + assertEquals(Paint.Literal(0x40112233), node.paintProp(PropertyKind.Scrim)) } @Test @@ -102,6 +107,7 @@ class HostOverlayTokensTest { popupPadding = 8, scrim = 0x66000000L, ) + val scrimBits = (2L shl 32) or (0x80000000L and 0xFFFFFFFFL) val connection = FakeHostConnection( listOf( Mutation.SetTheme( @@ -111,27 +117,29 @@ class HostOverlayTokensTest { Mutation.Create(root, WidgetKind.Column), Mutation.Create(dialogId, WidgetKind.Dialog), Mutation.SetProp(dialogId, PropertyKind.Open, PropertyValue.Bool(true)), - Mutation.SetProp(dialogId, PropertyKind.Scrim, PropertyValue.Paint(Paint.Rgb(0x80, 0x00, 0x00, 0x00))), + Mutation.SetProp(dialogId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), Mutation.Create(dialogText, WidgetKind.Text), Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Confirm")), Mutation.Insert(dialogId, dialogText, 0), Mutation.Insert(root, dialogId, 0), ), ) + lateinit var host: ComposeRustHost setContent { CompositionLocalProvider( LocalFrameRequests provides frames, LocalDensity provides Density(1f), ) { + host = rememberComposeRustHost(connection) ComposeRustContent( - rememberComposeRustHost(connection), + host, Modifier.requiredSize(800.dp, 600.dp), ) } } waitForIdle() - assertEquals(tokens, connection.host.table.overlayTokens) + assertEquals(tokens, host.table.overlayTokens) onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") onNodeWithTag(dialogTestTag(dialogId)).assertExists() } diff --git a/renderer/desktop/test/ProtocolVectorsTest.kt b/renderer/desktop/test/ProtocolVectorsTest.kt index 98f70f56..1418fa11 100644 --- a/renderer/desktop/test/ProtocolVectorsTest.kt +++ b/renderer/desktop/test/ProtocolVectorsTest.kt @@ -101,6 +101,7 @@ class ProtocolVectorsTest { is Mutation.AppendText -> mutation.nodeId !in created // The theme applies to the tree, not to a node, so it is never a bad record. is Mutation.SetTheme -> false + is Mutation.SetOverlayTokens -> false // Nor does the window: it is about the frame around the tree. is Mutation.SetWindow -> false // A window's theme and bringing one forward name a node the vector created. From 00274b74b6f8c89a9add53ccb8fef754ac50407f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:22:18 +0900 Subject: [PATCH 224/224] Fix: tag dialog container box with dialogTestTag --- compose-rust/android-kotlin/shared/HostOverlay.kt | 3 +-- renderer/desktop/src/renderer/HostOverlay.kt | 3 +-- renderer/desktop/test/HostOverlayTokensTest.kt | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index 67fa8d27..834c0246 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -210,12 +210,11 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(scrim), + Modifier.fillMaxSize().background(scrim).testTag(dialogTestTag(node.id)), contentAlignment = Alignment.Center, ) { Column( modifier - .testTag(dialogTestTag(node.id)) .modalOverlaySurface(node.id, surfaceRequester) .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), ) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index 67fa8d27..834c0246 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -210,12 +210,11 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(scrim), + Modifier.fillMaxSize().background(scrim).testTag(dialogTestTag(node.id)), contentAlignment = Alignment.Center, ) { Column( modifier - .testTag(dialogTestTag(node.id)) .modalOverlaySurface(node.id, surfaceRequester) .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), ) { diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt index d8232eb7..49dfd4df 100644 --- a/renderer/desktop/test/HostOverlayTokensTest.kt +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -142,5 +142,8 @@ class HostOverlayTokensTest { assertEquals(tokens, host.table.overlayTokens) onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") onNodeWithTag(dialogTestTag(dialogId)).assertExists() + val dialogNode = host.table.node(dialogId) + assertNotNull(dialogNode) + assertEquals(Paint.Literal(0x80000000.toInt()), dialogNode.paintProp(PropertyKind.Scrim)) } }