Skip to main content

max / alloy

Seed the screens that are lit, not only the built-in panel detect_panel filtered connectors on built_in(), so a lid-closed install seeded a stanza for the panel that is off and nothing for the monitor in use. detect_outputs takes the connectors the kernel reports as lit and falls back to the connected built-in panel when nothing is, which is what a text console with no CRTC bound produces and what the old rule always returned. A monitor merely plugged into a machine being installed is connected and not enabled, so the reasoning the old filter defended holds. Both fw13 EDIDs are captured into testdata, so the multi-output sysfs path has real hardware behind it instead of the Mock. The sway-JSON path still does not, and cannot until Alloy runs there.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 01:29 UTC
Signed with PGP, not checked
Commit: 5000e1a32362f71fd449d3670c81e07ad0500a6b
Parent: c2e9ee1
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