max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+323 insertions,
-72 deletions
| @@ -29,11 +29,13 @@ | |||
| 29 | 29 | ||
| 30 | 30 | Two things follow. | |
| 31 | 31 | ||
| 32 | - | `detect_panel` filters on `built_in()`, so the installer seeds the laptop panel and says nothing about the BenQ. That is correct: an external monitor is a `reconcile` and `s`-key concern, and the monitor table exists for exactly this. But it means a fw13 install finishing lid-closed on the desk comes up with a seeded stanza for a panel that is off and nothing for the panel being used. | |
| 32 | + | `detect_panel` filtered on `built_in()`, so the installer seeded the laptop panel and said nothing about the BenQ. A fw13 install finishing lid-closed on the desk therefore came up with a stanza for a panel that is off and nothing for the screen being used. | |
| 33 | + | ||
| 34 | + | **Fixed 2026-08-11.** `detect_outputs` seeds the connectors the kernel reports lit, falling back to the connected built-in panel when nothing is lit, which is what a text-console install with no CRTC bound produces and what the old rule always returned. The reasoning the built-in filter was defending survives the change: a monitor merely plugged into a machine being installed is connected and not enabled, so it is still not seeded. Run against this machine's own sysfs as it stands today, the generator now proposes `output DP-3 scale 1` and nothing for the dark panel. | |
| 33 | 35 | ||
| 34 | 36 | And 1.0x on a 163 PPI 28 inch panel is the first case where the ladder's rounding is visibly load-bearing rather than incidental. It may well be right at desk distance, where the FW12's arm's-length argument does not apply. It is worth an eye during the boot test, and it is a hint that viewing distance is the term the PPI rule leaves out. | |
| 35 | 37 | ||
| 36 | - | Also unverified and worth naming: `display.rs` says outright that the multi-output parsing path has no real hardware behind it, only the `Mock`. fw13 with the BenQ attached is the first machine that can exercise it. | |
| 38 | + | Two layers had no real hardware behind them, only the `Mock`, and only one of them can be closed from here. The sysfs layer the installer reads is closed: both EDIDs are captured byte for byte in `crates/alloy/testdata/`, and the seeding rule is tested against a two-connector machine in each lid state. The sway-JSON layer is not, and cannot be until Alloy runs on fw13 under sway with the BenQ attached, which is what `reads_this_machines_real_outputs` is waiting for. | |
| 37 | 39 | ||
| 38 | 40 | ## Graphics | |
| 39 | 41 |
| @@ -79,9 +79,9 @@ | |||
| 79 | 79 | //! | |
| 80 | 80 | //! **The installer seeds that file, from the same generator.** A fresh machine | |
| 81 | 81 | //! has no console session behind it, so without a seed the first boot renders | |
| 82 | - | //! at 1.0 whatever the panel is. [`detect_panel`] reads the built-in panel out | |
| 82 | + | //! at 1.0 whatever the panel is. [`detect_outputs`] reads the lit screens out | |
| 83 | 83 | //! of sysfs — where the installer runs there is no compositor to ask — and | |
| 84 | - | //! `install.rs` writes [`config_file`] of it into the new home. One generator, | |
| 84 | + | //! `install.rs` writes [`config_file`] of them into the new home. One generator, | |
| 85 | 85 | //! so a seeded file and a console-written one are the same file, and the first | |
| 86 | 86 | //! press of `s` in `alloy display` overwrites it without a special case. | |
| 87 | 87 | //! | |
| @@ -568,7 +568,7 @@ | |||
| 568 | 568 | /// is not the same thing as a generated artifact. | |
| 569 | 569 | pub(crate) fn config_file(outputs: &[Output]) -> String { | |
| 570 | 570 | let mut out = String::from( | |
| 571 | - | "# Generated by Alloy: seeded at install from the panel this machine\n\ | |
| 571 | + | "# Generated by Alloy: seeded at install from the screens this machine\n\ | |
| 572 | 572 | # reports, and rewritten whole by `alloy display`, so\n\ | |
| 573 | 573 | # hand edits here are lost. Put your own output config in another file\n\ | |
| 574 | 574 | # in this directory: sway merges every stanza that matches an output,\n\ | |
| @@ -848,17 +848,31 @@ | |||
| 848 | 848 | (width != 0 && height != 0).then_some((width, height)) | |
| 849 | 849 | } | |
| 850 | 850 | ||
| 851 | - | /// Read one connector directory as an output, if it is a panel worth seeding. | |
| 851 | + | /// One connector as sysfs describes it: what it is, and whether it is lit. | |
| 852 | 852 | /// | |
| 853 | - | /// `None` for everything else: a disconnected connector, an external port, and | |
| 854 | - | /// a panel whose EDID does not say how big it is. Each of those is a machine | |
| 855 | - | /// this cannot answer for, and a guessed scale is worse than none — an install | |
| 856 | - | /// that seeds nothing comes up at 1.0, which is legible everywhere and one | |
| 857 | - | /// keypress from correct. | |
| 858 | - | fn panel_at(dir: &std::path::Path) -> Option<Output> { | |
| 853 | + | /// The two halves come from different files and only the first is an `Output`. | |
| 854 | + | /// `enabled` is not a property of the output the console persists — it is how | |
| 855 | + | /// [`detect_outputs`] tells the screen being used from the one that merely has | |
| 856 | + | /// a cable in it. | |
| 857 | + | struct Detected { | |
| 858 | + | output: Output, | |
| 859 | + | enabled: bool, | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | /// Read one connector directory as an output, if it is worth seeding. | |
| 863 | + | /// | |
| 864 | + | /// `None` for a disconnected connector and for one whose EDID does not say how | |
| 865 | + | /// big it is. Each of those is a machine this cannot answer for, and a guessed | |
| 866 | + | /// scale is worse than none — an install that seeds nothing comes up at 1.0, | |
| 867 | + | /// which is legible everywhere and one keypress from correct. | |
| 868 | + | /// | |
| 869 | + | /// Not filtered to the built-in panel here, which it was until 2026-08-11. See | |
| 870 | + | /// [`detect_outputs`] for what replaced the filter and why the reasoning behind | |
| 871 | + | /// it survives. | |
| 872 | + | fn output_at(dir: &std::path::Path) -> Option<Detected> { | |
| 859 | 873 | // `card1-eDP-1` is one card and one connector; sway names the second half. | |
| 860 | 874 | let name = dir.file_name()?.to_str()?.split_once('-')?.1.to_string(); | |
| 861 | - | let panel = Output { | |
| 875 | + | let output = Output { | |
| 862 | 876 | name, | |
| 863 | 877 | make: String::new(), | |
| 864 | 878 | model: String::new(), | |
| @@ -872,35 +886,89 @@ | |||
| 872 | 886 | current_mode: None, | |
| 873 | 887 | modes: Vec::new(), | |
| 874 | 888 | }; | |
| 875 | - | if !panel.built_in() { | |
| 876 | - | return None; | |
| 877 | - | } | |
| 878 | 889 | if std::fs::read_to_string(dir.join("status")).ok()?.trim() != "connected" { | |
| 879 | 890 | return None; | |
| 880 | 891 | } | |
| 881 | 892 | ||
| 882 | 893 | let (pixels_wide, _) = first_mode(&std::fs::read_to_string(dir.join("modes")).ok()?)?; | |
| 883 | 894 | let (millimetres_wide, _) = panel_millimetres(&std::fs::read(dir.join("edid")).ok()?)?; | |
| 884 | - | Some(Output { | |
| 885 | - | scale: scale_for(pixels_wide, millimetres_wide)?, | |
| 886 | - | ..panel | |
| 895 | + | Some(Detected { | |
| 896 | + | output: Output { | |
| 897 | + | scale: scale_for(pixels_wide, millimetres_wide)?, | |
| 898 | + | ..output | |
| 899 | + | }, | |
| 900 | + | // A connector with no `enabled` file reads as not lit rather than as an | |
| 901 | + | // error: the fallback below is what covers that, and it is the same | |
| 902 | + | // answer this gave before the file was ever read. | |
| 903 | + | enabled: std::fs::read_to_string(dir.join("enabled")) | |
| 904 | + | .is_ok_and(|state| state.trim() == "enabled"), | |
| 887 | 905 | }) | |
| 888 | 906 | } | |
| 889 | 907 | ||
| 890 | - | /// The built-in panel of the machine this is running on, if it has one. | |
| 908 | + | /// The screens of the machine this is running on, in the order they are spelled. | |
| 891 | 909 | /// | |
| 892 | 910 | /// For the installer, which runs on the target hardware and before any | |
| 893 | 911 | /// compositor: `swaymsg` has nobody to ask there, and the answer is in sysfs | |
| 894 | - | /// either way. Connectors are read in name order so a machine with two panels | |
| 895 | - | /// (none has been seen) seeds the lower-numbered one rather than whichever the | |
| 896 | - | /// directory listing happened to yield first. | |
| 897 | - | pub(crate) fn detect_panel() -> Option<Output> { | |
| 898 | - | let mut connectors: Vec<PathBuf> = std::fs::read_dir(DRM) | |
| 899 | - | .ok()? | |
| 912 | + | /// either way. | |
| 913 | + | /// | |
| 914 | + | /// **The rule is what is lit, with the panel as the fallback.** This used to | |
| 915 | + | /// return the built-in panel and nothing else, and the reason recorded for that | |
| 916 | + | /// was sound: an external monitor plugged in during an install is not the | |
| 917 | + | /// machine's screen, and seeding the scale of hardware about to be unplugged | |
| 918 | + | /// configures a machine that will not exist. What the rule missed is the | |
| 919 | + | /// opposite arrangement, measured on fw13 (`docs/HARDWARE-FW13.md`): lid closed | |
| 920 | + | /// on a desk, `eDP-1` connected but `enabled=disabled`, everything being looked | |
| 921 | + | /// at coming off `DP-3`. The old rule seeded the panel that is off and said | |
| 922 | + | /// nothing about the screen in use, so the first boot came up at 1.0 on the only | |
| 923 | + | /// display anyone could see. | |
| 924 | + | /// | |
| 925 | + | /// So: seed the connectors the kernel reports as lit, which is the transient | |
| 926 | + | /// monitor's answer as much as it is the closed lid's — a monitor nobody is | |
| 927 | + | /// running the install on is connected, not enabled. When nothing reports lit, | |
| 928 | + | /// fall back to the connected built-in panel, which is exactly what this | |
| 929 | + | /// returned before and what a text-console install with no CRTC bound produces. | |
| 930 | + | /// | |
| 931 | + | /// Connectors are read in name order, and the built-in panel is spelled first | |
| 932 | + | /// when it is among them: the file reads as the machine does, and a machine with | |
| 933 | + | /// two panels (none has been seen) is deterministic rather than at the mercy of | |
| 934 | + | /// the directory listing. | |
| 935 | + | pub(crate) fn detect_outputs() -> Vec<Output> { | |
| 936 | + | detect_outputs_in(std::path::Path::new(DRM)) | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | /// [`detect_outputs`] against a given sysfs root, which is the whole of it. | |
| 940 | + | /// | |
| 941 | + | /// Split out for the tests: the rule this implements is about a machine with | |
| 942 | + | /// several connectors in particular states, and the one thing no test can do is | |
| 943 | + | /// arrange that under the real `/sys`. | |
| 944 | + | fn detect_outputs_in(drm: &std::path::Path) -> Vec<Output> { | |
| 945 | + | let Ok(entries) = std::fs::read_dir(drm) else { | |
| 946 | + | return Vec::new(); | |
| 947 | + | }; | |
| 948 | + | let mut connectors: Vec<PathBuf> = entries | |
| 900 | 949 | .filter_map(|entry| Some(entry.ok()?.path())) | |
| 901 | 950 | .collect(); | |
| 902 | 951 | connectors.sort(); | |
| 903 | - | connectors.iter().find_map(|dir| panel_at(dir)) | |
| 952 | + | ||
| 953 | + | let detected: Vec<Detected> = connectors.iter().filter_map(|dir| output_at(dir)).collect(); | |
| 954 | + | let mut outputs: Vec<Output> = if detected.iter().any(|screen| screen.enabled) { | |
| 955 | + | detected | |
| 956 | + | .into_iter() | |
| 957 | + | .filter(|screen| screen.enabled) | |
| 958 | + | .map(|screen| screen.output) | |
| 959 | + | .collect() | |
| 960 | + | } else { | |
| 961 | + | detected | |
| 962 | + | .into_iter() | |
| 963 | + | .map(|screen| screen.output) | |
| 964 | + | .filter(Output::built_in) | |
| 965 | + | .take(1) | |
| 966 | + | .collect() | |
| 967 | + | }; | |
| 968 | + | // Stable sort, so the name order the connectors were read in survives among | |
| 969 | + | // the externals. | |
| 970 | + | outputs.sort_by_key(|output| !output.built_in()); | |
| 971 | + | outputs | |
| 904 | 972 | } | |
| 905 | 973 | ||
| 906 | 974 | /// Where the file lives, or `None` on a machine with no `$HOME`. | |
| @@ -2079,10 +2147,30 @@ | |||
| 2079 | 2147 | } | |
| 2080 | 2148 | ||
| 2081 | 2149 | /// A connector directory as the kernel lays one out. | |
| 2150 | + | /// | |
| 2151 | + | /// No `enabled` file: the connectors that need one are built by | |
| 2152 | + | /// [`lit_connector`], and its absence is itself the case the fallback in | |
| 2153 | + | /// [`detect_outputs`] covers. | |
| 2082 | 2154 | fn connector(name: &str, status: &str, modes: &str, edid: Option<Vec<u8>>) -> PathBuf { | |
| 2083 | - | let dir = std::env::temp_dir() | |
| 2084 | - | .join("alloy-display-connectors") | |
| 2085 | - | .join(name); | |
| 2155 | + | connector_in( | |
| 2156 | + | &std::env::temp_dir().join("alloy-display-connectors"), | |
| 2157 | + | name, | |
| 2158 | + | status, | |
| 2159 | + | modes, | |
| 2160 | + | edid, | |
| 2161 | + | ) | |
| 2162 | + | } | |
| 2163 | + | ||
| 2164 | + | /// The same, under a caller-chosen root, so one test can lay out a machine | |
| 2165 | + | /// with several connectors instead of one connector at a time. | |
| 2166 | + | fn connector_in( | |
| 2167 | + | root: &std::path::Path, | |
| 2168 | + | name: &str, | |
| 2169 | + | status: &str, | |
| 2170 | + | modes: &str, | |
| 2171 | + | edid: Option<Vec<u8>>, | |
| 2172 | + | ) -> PathBuf { | |
| 2173 | + | let dir = root.join(name); | |
| 2086 | 2174 | let _ = std::fs::remove_dir_all(&dir); | |
| 2087 | 2175 | std::fs::create_dir_all(&dir).unwrap(); | |
| 2088 | 2176 | std::fs::write(dir.join("status"), format!("{status}\n")).unwrap(); | |
| @@ -2093,6 +2181,29 @@ | |||
| 2093 | 2181 | dir | |
| 2094 | 2182 | } | |
| 2095 | 2183 | ||
| 2184 | + | /// A connector with the kernel's `enabled` file written too. | |
| 2185 | + | fn lit_connector( | |
| 2186 | + | root: &std::path::Path, | |
| 2187 | + | name: &str, | |
| 2188 | + | modes: &str, | |
| 2189 | + | edid: &[u8], | |
| 2190 | + | enabled: bool, | |
| 2191 | + | ) -> PathBuf { | |
| 2192 | + | let dir = connector_in(root, name, "connected", modes, Some(edid.to_vec())); | |
| 2193 | + | let state = if enabled { "enabled" } else { "disabled" }; | |
| 2194 | + | std::fs::write(dir.join("enabled"), format!("{state}\n")).unwrap(); | |
| 2195 | + | dir | |
| 2196 | + | } | |
| 2197 | + | ||
| 2198 | + | /// This machine's own EDIDs, captured 2026-08-11 from `/sys/class/drm`. | |
| 2199 | + | /// | |
| 2200 | + | /// The BOE NE135A1M-NY1 panel and the BenQ RD280U on `DP-3`, byte for byte. | |
| 2201 | + | /// They are here because the multi-output path had nothing behind it but | |
| 2202 | + | /// `Mock`, and a hand-built EDID cannot show that two real ones disagree | |
| 2203 | + | /// about anything. | |
| 2204 | + | const FW13_PANEL_EDID: &[u8] = include_bytes!("../testdata/fw13-edp-1.edid"); | |
| 2205 | + | const FW13_MONITOR_EDID: &[u8] = include_bytes!("../testdata/fw13-dp-3.edid"); | |
| 2206 | + | ||
| 2096 | 2207 | #[test] | |
| 2097 | 2208 | fn a_connected_panel_reads_as_an_output_the_generator_accepts() { | |
| 2098 | 2209 | let dir = connector( | |
| @@ -2101,7 +2212,9 @@ | |||
| 2101 | 2212 | "1920x1200\n", | |
| 2102 | 2213 | Some(edid((26, 16), Some((263, 164)))), | |
| 2103 | 2214 | ); | |
| 2104 | - | let panel = panel_at(&dir).expect("a connected panel with a size"); | |
| 2215 | + | let panel = output_at(&dir) | |
| 2216 | + | .expect("a connected panel with a size") | |
| 2217 | + | .output; | |
| 2105 | 2218 | assert_eq!(panel.name, "eDP-1"); | |
| 2106 | 2219 | assert_eq!(panel.identifier(), "eDP-1"); | |
| 2107 | 2220 | assert!((panel.scale - 1.25).abs() < f64::EPSILON); | |
| @@ -2114,24 +2227,29 @@ | |||
| 2114 | 2227 | assert!(!file.contains("enable"), "{file}"); | |
| 2115 | 2228 | } | |
| 2116 | 2229 | ||
| 2117 | - | // Only the built-in panel. An external monitor plugged in during an install | |
| 2118 | - | // is not the machine's screen, and seeding its scale into the new home would | |
| 2119 | - | // configure hardware that is about to be unplugged. | |
| 2230 | + | // An external connector reads as an output like any other. Which of them | |
| 2231 | + | // gets seeded is [`detect_outputs`]'s question and not this function's; that | |
| 2232 | + | // filter used to live here, and putting it here is what made a lid-closed | |
| 2233 | + | // install unable to describe the screen it was being run on. | |
| 2120 | 2234 | #[test] | |
| 2121 | - | fn an_external_connector_is_not_a_panel() { | |
| 2235 | + | fn an_external_connector_reads_as_an_output_too() { | |
| 2122 | 2236 | let dir = connector( | |
| 2123 | 2237 | "card1-DP-1", | |
| 2124 | 2238 | "connected", | |
| 2125 | 2239 | "2560x1440\n", | |
| 2126 | 2240 | Some(edid((60, 34), None)), | |
| 2127 | 2241 | ); | |
| 2128 | - | assert!(panel_at(&dir).is_none()); | |
| 2242 | + | let monitor = output_at(&dir) | |
| 2243 | + | .expect("a connected monitor with a size") | |
| 2244 | + | .output; | |
| 2245 | + | assert_eq!(monitor.identifier(), "DP-1"); | |
| 2246 | + | assert!(!monitor.built_in()); | |
| 2129 | 2247 | } | |
| 2130 | 2248 | ||
| 2131 | 2249 | #[test] | |
| 2132 | 2250 | fn a_connector_with_nothing_on_it_is_skipped() { | |
| 2133 | 2251 | let dir = connector("card1-eDP-2", "disconnected", "", None); | |
| 2134 | - | assert!(panel_at(&dir).is_none()); | |
| 2252 | + | assert!(output_at(&dir).is_none()); | |
| 2135 | 2253 | } | |
| 2136 | 2254 | ||
| 2137 | 2255 | // No EDID is the ordinary case on a connector the kernel has not read one | |
| @@ -2139,14 +2257,112 @@ | |||
| 2139 | 2257 | #[test] | |
| 2140 | 2258 | fn a_panel_with_no_edid_seeds_nothing() { | |
| 2141 | 2259 | let dir = connector("card1-eDP-3", "connected", "1920x1200\n", None); | |
| 2142 | - | assert!(panel_at(&dir).is_none()); | |
| 2260 | + | assert!(output_at(&dir).is_none()); | |
| 2143 | 2261 | } | |
| 2144 | 2262 | ||
| 2145 | - | /// Read this machine's real panel, the way an install would. | |
| 2263 | + | /// A sysfs root of this machine's own connectors, in a chosen lid state. | |
| 2264 | + | /// | |
| 2265 | + | /// `eDP-1` and `DP-3` carry the real EDIDs; the six dark DisplayPort | |
| 2266 | + | /// connectors this machine also has are left out, since a disconnected | |
| 2267 | + | /// connector is already covered above and eight of them would say the same | |
| 2268 | + | /// thing seven more times. | |
| 2269 | + | fn fw13_sysfs(case: &str, lid_open: bool) -> PathBuf { | |
| 2270 | + | let root = std::env::temp_dir() | |
| 2271 | + | .join("alloy-display-machines") | |
| 2272 | + | .join(case); | |
| 2273 | + | let _ = std::fs::remove_dir_all(&root); | |
| 2274 | + | std::fs::create_dir_all(&root).unwrap(); | |
| 2275 | + | lit_connector( | |
| 2276 | + | &root, | |
| 2277 | + | "card1-eDP-1", | |
| 2278 | + | "2880x1920\n", | |
| 2279 | + | FW13_PANEL_EDID, | |
| 2280 | + | lid_open, | |
| 2281 | + | ); | |
| 2282 | + | lit_connector(&root, "card1-DP-3", "3840x2560\n", FW13_MONITOR_EDID, true); | |
| 2283 | + | root | |
| 2284 | + | } | |
| 2285 | + | ||
| 2286 | + | // THE LID-CLOSED DESK INSTALL, which is the case this rule exists for. Both | |
| 2287 | + | // connectors say `connected`; only the monitor is lit. Seeding the panel | |
| 2288 | + | // alone left the screen in use at 1.0 with no console session to fix it. | |
| 2289 | + | #[test] | |
| 2290 | + | fn a_dark_panel_does_not_displace_the_monitor_being_used() { | |
| 2291 | + | let outputs = detect_outputs_in(&fw13_sysfs("lid-closed", false)); | |
| 2292 | + | let names: Vec<&str> = outputs.iter().map(|output| output.name.as_str()).collect(); | |
| 2293 | + | assert_eq!(names, ["DP-3"]); | |
| 2294 | + | assert!( | |
| 2295 | + | (outputs[0].scale - 1.0).abs() < f64::EPSILON, | |
| 2296 | + | "{outputs:#?}" | |
| 2297 | + | ); | |
| 2298 | + | } | |
| 2299 | + | ||
| 2300 | + | // Lid open on the same desk: both are lit, both are seeded, and the panel | |
| 2301 | + | // is spelled first. The two real EDIDs disagree by three quarters of a rung, | |
| 2302 | + | // which is the disagreement no hand-built fixture was showing. | |
| 2303 | + | #[test] | |
| 2304 | + | fn both_lit_screens_are_seeded_and_the_panel_leads() { | |
| 2305 | + | let outputs = detect_outputs_in(&fw13_sysfs("lid-open", true)); | |
| 2306 | + | let names: Vec<&str> = outputs.iter().map(|output| output.name.as_str()).collect(); | |
| 2307 | + | assert_eq!(names, ["eDP-1", "DP-3"]); | |
| 2308 | + | ||
| 2309 | + | let file = config_file(&outputs); | |
| 2310 | + | assert!(file.contains("output eDP-1 scale 1.75"), "{file}"); | |
| 2311 | + | assert!(file.contains("output DP-3 scale 1"), "{file}"); | |
| 2312 | + | } | |
| 2313 | + | ||
| 2314 | + | // A text-console install with no CRTC bound reports nothing lit. That is the | |
| 2315 | + | // pre-2026-08-11 world, and the answer there is the one it always gave: the | |
| 2316 | + | // built-in panel, and no stanza for a monitor nobody is looking at. | |
| 2317 | + | #[test] | |
| 2318 | + | fn with_nothing_lit_the_built_in_panel_is_still_the_answer() { | |
| 2319 | + | let root = std::env::temp_dir().join("alloy-display-machines/console"); | |
| 2320 | + | let _ = std::fs::remove_dir_all(&root); | |
| 2321 | + | std::fs::create_dir_all(&root).unwrap(); | |
| 2322 | + | connector_in( | |
| 2323 | + | &root, | |
| 2324 | + | "card1-eDP-1", | |
| 2325 | + | "connected", | |
| 2326 | + | "2880x1920\n", | |
| 2327 | + | Some(FW13_PANEL_EDID.to_vec()), | |
| 2328 | + | ); | |
| 2329 | + | connector_in( | |
| 2330 | + | &root, | |
| 2331 | + | "card1-DP-3", | |
| 2332 | + | "connected", | |
| 2333 | + | "3840x2560\n", | |
| 2334 | + | Some(FW13_MONITOR_EDID.to_vec()), | |
| 2335 | + | ); | |
| 2336 | + | ||
| 2337 | + | let outputs = detect_outputs_in(&root); | |
| 2338 | + | let names: Vec<&str> = outputs.iter().map(|output| output.name.as_str()).collect(); | |
| 2339 | + | assert_eq!(names, ["eDP-1"]); | |
| 2340 | + | } | |
| 2341 | + | ||
| 2342 | + | // A desktop: no panel, nothing lit in the console. Seeding nothing is the | |
| 2343 | + | // right answer, and it is the one an empty `/sys` gives too. | |
| 2344 | + | #[test] | |
| 2345 | + | fn a_machine_with_no_lit_screen_and_no_panel_seeds_nothing() { | |
| 2346 | + | let root = std::env::temp_dir().join("alloy-display-machines/desktop"); | |
| 2347 | + | let _ = std::fs::remove_dir_all(&root); | |
| 2348 | + | std::fs::create_dir_all(&root).unwrap(); | |
| 2349 | + | connector_in( | |
| 2350 | + | &root, | |
| 2351 | + | "card1-DP-3", | |
| 2352 | + | "connected", | |
| 2353 | + | "3840x2560\n", | |
| 2354 | + | Some(FW13_MONITOR_EDID.to_vec()), | |
| 2355 | + | ); | |
| 2356 | + | ||
| 2357 | + | assert!(detect_outputs_in(&root).is_empty()); | |
| 2358 | + | assert!(detect_outputs_in(std::path::Path::new("/nonexistent-drm")).is_empty()); | |
| 2359 | + | } | |
| 2360 | + | ||
| 2361 | + | /// Read this machine's real screens, the way an install would. | |
| 2146 | 2362 | /// | |
| 2147 | 2363 | /// Ignored by default because it asserts about hardware: it passes on a | |
| 2148 | - | /// laptop and says nothing on a desktop or in a container. Unlike the sway | |
| 2149 | - | /// test below it needs no session, so it runs anywhere with a panel. | |
| 2364 | + | /// machine with a screen this can describe and says nothing in a container. | |
| 2365 | + | /// Unlike the sway test below it needs no session. | |
| 2150 | 2366 | /// | |
| 2151 | 2367 | /// Run on fw13 (Framework 13, 2880x1920, EDID 285x190mm) on 2026-07-30 it | |
| 2152 | 2368 | /// reads 257 PPI and seeds 1.75, which is the second panel the rule has been | |
| @@ -2154,11 +2370,14 @@ | |||
| 2154 | 2370 | /// an Alloy install, and the seed has still never been written by a real | |
| 2155 | 2371 | /// installer run. | |
| 2156 | 2372 | #[test] | |
| 2157 | - | #[ignore = "requires a machine with a built-in panel"] | |
| 2158 | - | fn reads_this_machines_real_panel() { | |
| 2159 | - | let panel = detect_panel().expect("a laptop has a built-in panel"); | |
| 2160 | - | println!("{}", config_file(std::slice::from_ref(&panel))); | |
| 2161 | - | assert!(SCALES.contains(&panel.scale), "{}", panel.scale); | |
| 2373 | + | #[ignore = "requires a machine with a screen it can describe"] | |
| 2374 | + | fn reads_this_machines_real_screens() { | |
| 2375 | + | let outputs = detect_outputs(); | |
| 2376 | + | assert!(!outputs.is_empty(), "this machine has a screen"); | |
| 2377 | + | println!("{}", config_file(&outputs)); | |
| 2378 | + | for output in &outputs { | |
| 2379 | + | assert!(SCALES.contains(&output.scale), "{}", output.scale); | |
| 2380 | + | } | |
| 2162 | 2381 | } | |
| 2163 | 2382 | ||
| 2164 | 2383 | /// Read this machine's real outputs. |
| @@ -1743,7 +1743,7 @@ | |||
| 1743 | 1743 | let password = choices.password.to_string(); | |
| 1744 | 1744 | let pubkey = choices.pubkey.map(str::to_string); | |
| 1745 | 1745 | let locate_timezone = choices.locate_timezone; | |
| 1746 | - | let panel = crate::display::detect_panel(); | |
| 1746 | + | let screens = crate::display::detect_outputs(); | |
| 1747 | 1747 | let choice = choices.encryption.cloned(); | |
| 1748 | 1748 | let intact = Arc::clone(intact); | |
| 1749 | 1749 | ||
| @@ -1776,7 +1776,7 @@ | |||
| 1776 | 1776 | &password, | |
| 1777 | 1777 | &HomeSeeds { | |
| 1778 | 1778 | pubkey: pubkey.as_deref(), | |
| 1779 | - | panel: panel.as_ref(), | |
| 1779 | + | screens: &screens, | |
| 1780 | 1780 | }, | |
| 1781 | 1781 | locate_timezone, | |
| 1782 | 1782 | encryption.as_ref(), | |
| @@ -1808,15 +1808,17 @@ | |||
| 1808 | 1808 | /// Grouped because they are the same kind of thing and go to the same place: | |
| 1809 | 1809 | /// both are optional, both are written under the home, and both have to precede | |
| 1810 | 1810 | /// the recursive chown that makes it the user's. Neither is an answer the wizard | |
| 1811 | - | /// insists on — a key is optional by the minting decision, and a panel is | |
| 1811 | + | /// insists on — a key is optional by the minting decision, and a screen is | |
| 1812 | 1812 | /// something the machine either reports or does not. | |
| 1813 | 1813 | #[derive(Debug, Default)] | |
| 1814 | 1814 | struct HomeSeeds<'a> { | |
| 1815 | 1815 | /// The SSH public key to authorize, if one was given. | |
| 1816 | 1816 | pubkey: Option<&'a str>, | |
| 1817 | - | /// The built-in panel, if this machine has one it can describe. See | |
| 1818 | - | /// [`crate::display::detect_panel`]. | |
| 1819 | - | panel: Option<&'a crate::display::Output>, | |
| 1817 | + | /// The screens this machine can describe, empty when it can describe none. | |
| 1818 | + | /// Plural because a lid-closed install is looking at an external monitor and | |
| 1819 | + | /// seeding only the built-in panel configures the screen nobody can see. See | |
| 1820 | + | /// [`crate::display::detect_outputs`]. | |
| 1821 | + | screens: &'a [crate::display::Output], | |
| 1820 | 1822 | } | |
| 1821 | 1823 | ||
| 1822 | 1824 | /// The commands that configure an already-deployed system. | |
| @@ -2022,17 +2024,17 @@ | |||
| 2022 | 2024 | ]); | |
| 2023 | 2025 | } | |
| 2024 | 2026 | ||
| 2025 | - | // The scale the panel wants, before anyone has logged in to set it. | |
| 2027 | + | // The scale each screen wants, before anyone has logged in to set it. | |
| 2026 | 2028 | // | |
| 2027 | 2029 | // Same placement argument as the key above: the recursive chown below | |
| 2028 | 2030 | // covers whatever is under the home, so writing here costs no second place | |
| 2029 | 2031 | // that has to know the numeric ids. | |
| 2030 | 2032 | // | |
| 2031 | 2033 | // Passed in rather than detected here, because detection reads this | |
| 2032 | - | // machine's sysfs and a plan that changed shape with the panel of whoever | |
| 2033 | - | // ran the tests would be untestable. [`crate::display::detect_panel`] is | |
| 2034 | + | // machine's sysfs and a plan that changed shape with the screens of whoever | |
| 2035 | + | // ran the tests would be untestable. [`crate::display::detect_outputs`] is | |
| 2034 | 2036 | // called once, where the plan is built. | |
| 2035 | - | if let Some(panel) = seeds.panel { | |
| 2037 | + | if !seeds.screens.is_empty() { | |
| 2036 | 2038 | let file = format!("{home}/{}", crate::display::FILE); | |
| 2037 | 2039 | let dir = file | |
| 2038 | 2040 | .rsplit_once('/') | |
| @@ -2044,9 +2046,11 @@ | |||
| 2044 | 2046 | // hand over a whole file, and Secret is the only stdin it takes. | |
| 2045 | 2047 | // Nothing here is secret, and the run screen showing the config it | |
| 2046 | 2048 | // wrote is wanted. | |
| 2047 | - | Stage::Run(Invocation::new("tee").arg(&file).stdin(Secret::new( | |
| 2048 | - | crate::display::config_file(std::slice::from_ref(panel)), | |
| 2049 | - | ))), | |
| 2049 | + | Stage::Run( | |
| 2050 | + | Invocation::new("tee") | |
| 2051 | + | .arg(&file) | |
| 2052 | + | .stdin(Secret::new(crate::display::config_file(seeds.screens))), | |
| 2053 | + | ), | |
| 2050 | 2054 | ]); | |
| 2051 | 2055 | } | |
| 2052 | 2056 | ||
| @@ -2584,11 +2588,11 @@ | |||
| 2584 | 2588 | let locate_timezone = choices.locate_timezone; | |
| 2585 | 2589 | let encryption = choices.encryption.cloned(); | |
| 2586 | 2590 | let encrypt = encryption.is_some(); | |
| 2587 | - | // Read here, once, on the machine being installed. The panel does not | |
| 2588 | - | // change while the install runs, and reading it before the disk is touched | |
| 2591 | + | // Read here, once, on the machine being installed. The screens do not | |
| 2592 | + | // change while the install runs, and reading them before the disk is touched | |
| 2589 | 2593 | // means a machine whose EDID cannot be read is one where nothing was seeded | |
| 2590 | 2594 | // rather than one where a stage failed after the deploy. | |
| 2591 | - | let panel = crate::display::detect_panel(); | |
| 2595 | + | let screens = crate::display::detect_outputs(); | |
| 2592 | 2596 | ||
| 2593 | 2597 | let mut stages = vec![ | |
| 2594 | 2598 | // --wipe is explicit rather than implied by the confirm the user just | |
| @@ -2690,7 +2694,7 @@ | |||
| 2690 | 2694 | &password, | |
| 2691 | 2695 | &HomeSeeds { | |
| 2692 | 2696 | pubkey: pubkey.as_deref(), | |
| 2693 | - | panel: panel.as_ref(), | |
| 2697 | + | screens: &screens, | |
| 2694 | 2698 | }, | |
| 2695 | 2699 | locate_timezone, | |
| 2696 | 2700 | encryption.as_ref(), | |
| @@ -7173,10 +7177,11 @@ | |||
| 7173 | 7177 | assert!(!joined.contains(".ssh"), "{joined}"); | |
| 7174 | 7178 | } | |
| 7175 | 7179 | ||
| 7176 | - | /// A panel as [`crate::display::detect_panel`] would return one. | |
| 7177 | - | fn panel() -> crate::display::Output { | |
| 7180 | + | /// A screen as [`crate::display::detect_outputs`] would return one: a | |
| 7181 | + | /// connector, a scale, and nothing sysfs cannot say. | |
| 7182 | + | fn screen(name: &str, scale: f64) -> crate::display::Output { | |
| 7178 | 7183 | crate::display::Output { | |
| 7179 | - | name: "eDP-1".into(), | |
| 7184 | + | name: name.into(), | |
| 7180 | 7185 | make: String::new(), | |
| 7181 | 7186 | model: String::new(), | |
| 7182 | 7187 | serial: String::new(), | |
| @@ -7184,20 +7189,20 @@ | |||
| 7184 | 7189 | dpms: true, | |
| 7185 | 7190 | focused: false, | |
| 7186 | 7191 | rect: crate::display::Rectangle::default(), | |
| 7187 | - | scale: 1.25, | |
| 7192 | + | scale, | |
| 7188 | 7193 | transform: "normal".into(), | |
| 7189 | 7194 | current_mode: None, | |
| 7190 | 7195 | modes: Vec::new(), | |
| 7191 | 7196 | } | |
| 7192 | 7197 | } | |
| 7193 | 7198 | ||
| 7194 | - | fn plan_with_panel(panel: Option<&crate::display::Output>) -> Vec<String> { | |
| 7199 | + | fn plan_with_screens(screens: &[crate::display::Output]) -> Vec<String> { | |
| 7195 | 7200 | configure_plan( | |
| 7196 | 7201 | "workshop", | |
| 7197 | 7202 | "max", | |
| 7198 | 7203 | "hunter2", | |
| 7199 | 7204 | &HomeSeeds { | |
| 7200 | - | panel, | |
| 7205 | + | screens, | |
| 7201 | 7206 | ..HomeSeeds::default() | |
| 7202 | 7207 | }, | |
| 7203 | 7208 | false, | |
| @@ -7215,7 +7220,7 @@ | |||
| 7215 | 7220 | // not create one, since `alloy display` is what owns this path. | |
| 7216 | 7221 | #[test] | |
| 7217 | 7222 | fn a_detected_panel_seeds_the_per_user_display_config() { | |
| 7218 | - | let shown = plan_with_panel(Some(&panel())); | |
| 7223 | + | let shown = plan_with_screens(&[screen("eDP-1", 1.25)]); | |
| 7219 | 7224 | let joined = shown.join("\n"); | |
| 7220 | 7225 | assert!(joined.contains(crate::display::FILE), "{joined}"); | |
| 7221 | 7226 | ||
| @@ -7236,7 +7241,7 @@ | |||
| 7236 | 7241 | // `alloy display` rewrites this exact file. | |
| 7237 | 7242 | #[test] | |
| 7238 | 7243 | fn the_seed_is_written_before_the_recursive_chown_that_owns_it() { | |
| 7239 | - | let shown = plan_with_panel(Some(&panel())); | |
| 7244 | + | let shown = plan_with_screens(&[screen("eDP-1", 1.25)]); | |
| 7240 | 7245 | let wrote = shown | |
| 7241 | 7246 | .iter() | |
| 7242 | 7247 | .position(|line| line.contains(crate::display::FILE)) | |
| @@ -7255,11 +7260,36 @@ | |||
| 7255 | 7260 | // regenerates whole. | |
| 7256 | 7261 | #[test] | |
| 7257 | 7262 | fn no_detected_panel_seeds_nothing() { | |
| 7258 | - | let joined = plan_with_panel(None).join("\n"); | |
| 7263 | + | let joined = plan_with_screens(&[]).join("\n"); | |
| 7259 | 7264 | assert!(!joined.contains("50-display.conf"), "{joined}"); | |
| 7260 | 7265 | assert!(!joined.contains("sway"), "{joined}"); | |
| 7261 | 7266 | } | |
| 7262 | 7267 | ||
| 7268 | + | // The lid-closed desk install: what is lit is an external monitor and there | |
| 7269 | + | // is no built-in panel in the list at all. The seed still has to happen, | |
| 7270 | + | // which is what the built-in filter used to make impossible. Measured on | |
| 7271 | + | // fw13, `docs/HARDWARE-FW13.md`. | |
| 7272 | + | // | |
| 7273 | + | // What lands in the file is `display.rs`'s to test: the stanza travels as a | |
| 7274 | + | // [`Secret`] on stdin and the plan display withholds it, deliberately. | |
| 7275 | + | #[test] | |
| 7276 | + | fn a_machine_with_no_built_in_panel_still_seeds_its_screen() { | |
| 7277 | + | let joined = plan_with_screens(&[screen("DP-3", 1.0)]).join("\n"); | |
| 7278 | + | assert!(joined.contains(crate::display::FILE), "{joined}"); | |
| 7279 | + | } | |
| 7280 | + | ||
| 7281 | + | // One `tee`, one file. The console regenerates this file whole, so two | |
| 7282 | + | // writes would leave the first one's stanza live until it did. | |
| 7283 | + | #[test] | |
| 7284 | + | fn the_screens_are_seeded_in_a_single_write() { | |
| 7285 | + | let shown = plan_with_screens(&[screen("eDP-1", 1.75), screen("DP-3", 1.0)]); | |
| 7286 | + | let writes = shown | |
| 7287 | + | .iter() | |
| 7288 | + | .filter(|line| line.contains(crate::display::FILE)) | |
| 7289 | + | .count(); | |
| 7290 | + | assert_eq!(writes, 1, "{shown:#?}"); | |
| 7291 | + | } | |
| 7292 | + | ||
| 7263 | 7293 | // A field left as whitespace is a field nobody filled in, and it must not | |
| 7264 | 7294 | // become a blank line in authorized_keys. | |
| 7265 | 7295 | #[test] |
Binary file
Binary file