Skip to main content

max / alloy

Content-address monitor identity, key sway stanzas by connector `alloy display` wrote the `make model serial` triple into sway stanzas for external outputs, because a triple survives a replug into a different port and a connector name does not. Right about the problem, wrong about the fix, in two ways this now removes rather than guards: The triple is vendor text in a file sway parses at login. A quote or a newline in an EDID model string was a malformed line in that file, which `a9b496e` fixed by falling back to the connector when the triple carried one. Writing the connector always means the hazard cannot occur, and the quoting function is gone with it -- `is_one_word` is what remains, a debug tripwire at the one place that would notice vendor text finding its way back into a stanza. And the triple is not unique. Two identical serial-less monitors produce the same one, sway merges the stanzas, and the last wins for both. That is problem a4d249f1, filed as needing a matched pair of monitors to decide; two connectors are always two stanzas, so it is answered without buying hardware. What replaces the portability the triple bought is `monitors.rs`: a SHA-256 of the same three fields truncated to 64 bits, a table from that to where the monitor was last seen, and a reconcile that moves the settings to the port it is on now. Reproducible from `swaymsg -t get_outputs` and nothing else, so deleting the table forgets settings and breaks nothing. Rows unseen for a year are dropped, because the ruling allowed expiry by age and ruled out silent accumulation. The accepted cost, stated on the module: a triple works with Alloy uninstalled, and connector-keyed stanzas plus a reconcile need something to run after a replug. A config that is silently wrong for the user is worse than one that needs a program to stay right. ONE CORRECTION TO THE PLAN. The reconcile was to ride `usr/bin/alloy-session` beside `alloy theme apply`. It cannot: that wrapper runs before `exec sway` and this needs a compositor to ask. It runs from the shipped sway config as an `exec` line instead, which fires at the first moment `swaymsg -t get_outputs` can answer, and `alloy display --reconcile` covers the mid-session replug. Named monitor sets -- "desk", "docked" -- are the other half of the same decision and are deliberately not here. That half must not ship on single-output evidence, and there is still no multi-output capture anywhere in this project. GO 05cfd0d5 carries it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 20:23 UTC
Signed with PGP, not checked
Commit: f9711caebe546d3cdbff64b5a6d4ba6e225f1704
Parent: d2cb208
6 files changed, +678 insertions, -127 deletions
M Cargo.lock +1
@@ -31,6 +31,7 @@
31 31 "serde",
32 32 "serde_json",
33 33 "sha-crypt",
34 + "sha2 0.10.9",
34 35 "toml",
35 36 "toml_edit",
36 37 ]
@@ -22,6 +22,10 @@
22 22 toml = "1.1"
23 23 makeover.workspace = true
24 24 sha-crypt = "0.6.0"
25 + # Content-addressed monitor identity (`monitors.rs`). Already in the tree under
26 + # sha-crypt, so this is a direct name for a dependency that was there anyway,
27 + # and it is the same primitive audiofiles content-addresses with.
28 + sha2 = "0.10"
25 29 getrandom = "0.4.3"
26 30 toml_edit = "0.25.13"
27 31 # The console's settings store, and the only reason SQLite is here at all: two
@@ -21,17 +21,36 @@
21 21 //! layer, no second syntax, nothing to keep in agreement, and the line the log
22 22 //! pane shows is a line the user can paste into their own sway config.
23 23 //!
24 - //! ## Which is why the quotes are ours to write
24 + //! ## And there is nothing to quote, since 2026-08-06
25 25 //!
26 26 //! swaymsg does not pass argv through: its `main` ends at
27 27 //! `join_args(argv + optind, argc - optind)`, and sway's `join_args` is a plain
28 28 //! space join with no quoting of its own (read in sway 1.11's `swaymsg/main.c`
29 29 //! and `common/stringop.c`). The joined string is then split by the same parser
30 30 //! that reads a config file. So an identifier containing spaces —
31 - //! `BOE NV122WUM-N42 Unknown` is one — has to carry its quotes *in the string*,
32 - //! or sway sees three words where one was meant. Both consumers need the same
33 - //! quoting for the same reason, which is the one-string claim holding rather
34 - //! than an exception to it. See [`quote`].
31 + //! `BOE NV122WUM-N42 Unknown` was one — had to carry its quotes *in the string*.
32 + //!
33 + //! Every identifier this file writes is now a connector name, which is one word
34 + //! and never needs them. That is not a simplification for its own sake: the
35 + //! quoting existed to carry vendor-supplied EDID text into a config sway parses,
36 + //! and it is that text being written at all that was the hazard. See
37 + //! [`Output::identifier`] for the change and [`crate::monitors`] for what
38 + //! replaces the portability the triple bought. [`is_one_word`] is what is left
39 + //! of the quoting, as a tripwire.
40 + //!
41 + //! # Monitors are addressed by content, ports are addressed by name
42 + //!
43 + //! Stanzas are keyed by connector, and a connector is a fact about a cable. What
44 + //! keeps a monitor's settings attached to the monitor is [`crate::monitors`]: a
45 + //! hash of its `make model serial`, a table from that hash to where it was last
46 + //! seen, and [`reconcile`], which moves the settings when the two disagree.
47 + //! `alloy display --reconcile` runs it, and the shipped sway config runs it at
48 + //! every login.
49 + //!
50 + //! The reconcile is in the sway config rather than in `usr/bin/alloy-session`,
51 + //! beside `alloy theme apply`, which is where the ruling put it and where it
52 + //! cannot work: the wrapper runs before `exec sway`, and this needs a
53 + //! compositor to ask.
35 54 //!
36 55 //! # sway is the persistence layer; there is no kanshi
37 56 //!
@@ -108,6 +127,7 @@
108 127 use serde::Deserialize;
109 128
110 129 use crate::cli::{CommandLog, Effect, Invocation};
130 + use crate::monitors;
111 131 use crate::shell::{Flow, View, block_title};
112 132
113 133 /// The file this verb owns, relative to a home directory.
@@ -244,37 +264,48 @@
244 264 .any(|prefix| name.starts_with(prefix))
245 265 }
246 266
247 - /// The name a config stanza should match this output by.
267 + /// The name a config stanza matches this output by: the connector, always.
248 268 ///
249 - /// The `make model serial` triple for external outputs, because it survives
250 - /// being replugged into a different port and the connector name does not.
251 - /// The connector name for the built-in panel, because the triple buys
252 - /// nothing there — a laptop panel does not move — and because a serial of
253 - /// `Unknown` makes the triple ambiguous rather than portable: two identical
254 - /// serial-less panels produce the same one.
269 + /// This used to be the `make model serial` triple for external outputs, on
270 + /// the reasoning that a triple survives being replugged into a different
271 + /// port and a connector name does not. That reasoning was right about the
272 + /// problem and wrong about the fix, and both halves of why are worth keeping:
255 273 ///
256 - /// An external output with no make and no model falls back to the connector
257 - /// name for the same reason. `Unknown Unknown Unknown` matches every such
258 - /// output at once, which is the one identifier that could apply a stanza to
259 - /// hardware it was never written for.
274 + /// - **The triple is vendor text in a file sway parses.** A quote or a
275 + /// newline in an EDID model string became a malformed line in the config
276 + /// loaded at login, which `alloy@a9b496e` fixed by falling back to the
277 + /// connector when the triple carried one. Emitting the connector always
278 + /// removes the hazard instead of guarding it.
279 + /// - **The triple is not unique.** Two identical monitors with no serial
280 + /// produce the same one, sway merges the stanzas, and the last wins for
281 + /// both (GO problem `a4d249f1`). Two connectors are always two stanzas.
260 282 ///
261 - /// A triple carrying a character sway's quoting cannot hold falls back the
262 - /// same way. See [`safely_quotable`]: the make and model come from EDID,
263 - /// which is vendor-supplied text, and [`config_file`] writes the result into
264 - /// a file sway loads at login. Handling it here rather than in [`quote`] is
265 - /// deliberate — every caller of `quote` is handed an identifier this method
266 - /// produced, so refusing to build an unquotable one makes the property
267 - /// structural instead of something each writer has to remember.
283 + /// What replaces the portability the triple bought is [`crate::monitors`]:
284 + /// a table keyed by a hash of the same three fields, and a reconcile that
285 + /// moves the settings to the port the monitor is on now. The cost is that
286 + /// the config is only correct if something runs after a replug; the ruling
287 + /// that accepted it is recorded on that module.
268 288 pub(crate) fn identifier(&self) -> String {
289 + self.name.clone()
290 + }
291 +
292 + /// This output's content-addressed identity, or `None` when there is no
293 + /// identity to address.
294 + ///
295 + /// Make or model has to say something. Serial alone is not enough: it is the
296 + /// field most often `Unknown`, and a hash of nothing but a serial is an
297 + /// identity built from the two fields that were missing. A panel with
298 + /// neither is remembered by nothing, which is correct — there is no fact
299 + /// about it that would survive a replug.
300 + ///
301 + /// The built-in panel is excluded for a different reason: it cannot move, so
302 + /// an entry for it would be a row that can never be wrong and never be
303 + /// useful.
304 + pub(crate) fn fingerprint(&self) -> Option<String> {
269 305 if self.built_in() || !self.identifiable() {
270 - return self.name.clone();
271 - }
272 - let triple = format!("{} {} {}", self.make, self.model, self.serial);
273 - if safely_quotable(&triple) {
274 - triple
275 - } else {
276 - self.name.clone()
306 + return None;
277 307 }
308 + Some(monitors::fingerprint(&self.make, &self.model, &self.serial))
278 309 }
279 310
280 311 /// Whether the triple says anything specific about this output.
@@ -380,20 +411,33 @@
380 411
381 412 impl Directive {
382 413 fn new(output: &Output, property: &'static str, value: impl Into<String>) -> Self {
414 + Self::at(output.identifier(), property, value)
415 + }
416 +
417 + /// A directive aimed at a connector by name, for the reconcile: it moves a
418 + /// remembered setting onto whatever port the monitor turned up on, and that
419 + /// port is a string rather than an `Output` field it can borrow.
420 + fn at(identifier: String, property: &'static str, value: impl Into<String>) -> Self {
421 + debug_assert!(
422 + is_one_word(&identifier),
423 + "a stanza identifier must be a connector name and nothing else: {identifier}"
424 + );
383 425 Self {
384 - identifier: output.identifier(),
426 + identifier,
385 427 property,
386 428 value: value.into(),
387 429 }
388 430 }
389 431
390 - /// The instruction, quoted for sway's parser.
432 + /// The instruction, as sway's parser reads it.
433 + ///
434 + /// No quoting. The identifier is a connector name, which is one word by
435 + /// construction; see [`is_one_word`] for what used to be here and why it is
436 + /// gone.
391 437 pub(crate) fn words(&self) -> String {
392 438 format!(
393 439 "output {} {} {}",
394 - quote(&self.identifier),
395 - self.property,
396 - self.value
440 + self.identifier, self.property, self.value
397 441 )
398 442 }
399 443
@@ -406,18 +450,11 @@
406 450 /// quotes an argument containing spaces and the line comes out as
407 451 /// `swaymsg 'output eDP-1 scale 1.25'`.
408 452 ///
409 - /// There are two layers of quoting here and each strips its own. The double
410 - /// quotes around a triple identifier are sway's, and they have to survive the
411 - /// shell; the single quotes the log adds are the shell's, and they do not
412 - /// reach sway. A pasted `swaymsg output '"Example Co PA279CV S1"' scale 2` is
413 - /// therefore the same instruction, which is the property the pane advertises.
453 + /// The two-layers-of-quoting note that stood here is retired with the triple
454 + /// identifier: there is one layer now, the shell's, and it never has
455 + /// anything to do.
414 456 pub(crate) fn invocation(&self) -> Invocation {
415 - Invocation::new("swaymsg").args([
416 - "output",
417 - &quote(&self.identifier),
418 - self.property,
419 - &self.value,
420 - ])
457 + Invocation::new("swaymsg").args(["output", &self.identifier, self.property, &self.value])
421 458 }
422 459
423 460 /// The line that puts it back at the next login. The same words.
@@ -426,51 +463,30 @@
426 463 }
427 464 }
428 465
429 - /// Quote an identifier if sway's parser would otherwise split it.
466 + /// Whether an identifier is one sway's parser reads as a single word.
430 467 ///
431 - /// The `make model serial` triple is three words, and sway's config parser
432 - /// splits on whitespace before it looks for an output. A connector name never
433 - /// needs this, and quoting it anyway would make the log pane's line noisier than
434 - /// the one a person would have typed.
435 - fn quote(identifier: &str) -> String {
436 - debug_assert!(
437 - safely_quotable(identifier),
438 - "an unquotable identifier reached quote(); Output::identifier should have \
439 - fallen back to the connector name"
440 - );
441 - if identifier.contains(char::is_whitespace) {
442 - format!("\"{identifier}\"")
443 - } else {
444 - identifier.to_string()
445 - }
446 - }
447 -
448 - /// Whether sway's quoting can carry this identifier without being broken by it.
468 + /// A connector name always is: DRM's vocabulary is `eDP-1`, `DP-3`,
469 + /// `HDMI-A-1`, and nothing in it needs quoting. This is the assertion that it
470 + /// stayed that way rather than a quoting function, and it exists because the
471 + /// quoting function it replaced was removed for a reason worth not undoing.
449 472 ///
450 - /// [`quote`] wraps in double quotes and escapes nothing, so a `"` inside the
451 - /// string closes it early and the rest of the line becomes stray tokens. That
452 - /// text is not ours: make and model come from EDID, and [`config_file`] writes
453 - /// the line into `~/.config/sway/config.d/50-display.conf`, which
454 - /// `templates/etc/skel/.config/sway/config.in` includes. A malformed line is
455 - /// therefore in the config sway loads at login, not confined to one output's
456 - /// settings. Same class as [`refuse`], which exists because a per-user config
457 - /// survives the reboot that would otherwise recover the session.
473 + /// `quote` used to wrap the `make model serial` triple in double quotes and
474 + /// escape nothing, so a `"` inside a vendor's model string closed it early and
475 + /// the rest of the line became stray tokens in the config sway loads at login.
476 + /// `alloy@a9b496e` fixed that by falling back to the connector name when the
477 + /// triple carried a quote, a backslash or a control character. Since
478 + /// 2026-08-06 the connector name is all that is ever written, so the fallback
479 + /// has nothing to fall back from and the escaping question does not arise.
458 480 ///
459 - /// Three things are refused, and a newline is the worst of them: it would end
460 - /// the directive and start a second one, which is injection rather than
461 - /// corruption. A backslash is refused because whether sway honours escapes
462 - /// inside a quoted string is exactly what cannot be checked from a machine with
463 - /// no sway on it, and a trailing one would eat the closing quote if it does.
464 - ///
465 - /// The alternative fix was to escape rather than refuse, and it was not taken
466 - /// for that reason: it is correct only under an assumption about sway's parser
467 - /// that this file has already been bitten by twice. Falling back costs
468 - /// replug-portability for one monitor and is right whichever way the parser
469 - /// behaves.
470 - fn safely_quotable(identifier: &str) -> bool {
471 - !identifier
472 - .chars()
473 - .any(|c| c == '"' || c == '\\' || c.is_control())
481 + /// Kept as a debug assertion rather than deleted outright: the property is now
482 + /// structural, and a structural property is worth a tripwire at the one place
483 + /// that would notice if a future change quietly put vendor text back into a
484 + /// stanza.
485 + fn is_one_word(identifier: &str) -> bool {
486 + !identifier.is_empty()
487 + && !identifier
488 + .chars()
489 + .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c.is_control())
474 490 }
475 491
476 492 /// Why a change must not be made, or `None` when it may be.
@@ -560,11 +576,24 @@
560 576 );
561 577 for output in outputs {
562 578 out.push('\n');
579 + // The comment is where the monitor is named, now that the directive
580 + // below it is a connector and says nothing about which screen that is.
581 + // The fingerprint rides along so the file and the monitor table can be
582 + // read against each other without running anything.
563 583 let described = output.description();
564 - if described.is_empty() {
565 - let _ = writeln!(out, "# {}", output.name);
566 - } else {
567 - let _ = writeln!(out, "# {} ({described})", output.name);
584 + match (described.is_empty(), output.fingerprint()) {
585 + (true, None) => {
586 + let _ = writeln!(out, "# {}", output.name);
587 + }
588 + (true, Some(id)) => {
589 + let _ = writeln!(out, "# {} [{id}]", output.name);
590 + }
591 + (false, None) => {
592 + let _ = writeln!(out, "# {} ({described})", output.name);
593 + }
594 + (false, Some(id)) => {
595 + let _ = writeln!(out, "# {} ({described}) [{id}]", output.name);
596 + }
568 597 }
569 598 for directive in output.persisted() {
570 599 out.push_str(&directive.line());
@@ -574,6 +603,164 @@
574 603 out
575 604 }
576 605
606 + // ---- reconcile: settings follow the monitor, not the port ----
607 +
608 + /// What one monitor's settings should be after a reconcile, and why.
609 + ///
610 + /// Returned rather than applied so the decision is testable without a sway, a
611 + /// home directory or a clock. Everything below this line that touches the world
612 + /// is in [`reconcile`].
613 + #[derive(Debug, Clone, PartialEq)]
614 + pub(crate) struct Move {
615 + pub fingerprint: String,
616 + /// Where the monitor was last seen.
617 + pub from: String,
618 + /// Where it is now.
619 + pub to: String,
620 + pub scale: f64,
621 + pub transform: String,
622 + pub enabled: bool,
623 + }
624 +
625 + impl Move {
626 + /// The instructions that put the remembered settings on the new connector.
627 + pub(crate) fn directives(&self) -> Vec<Directive> {
628 + let mut directives = vec![Directive::at(
629 + self.to.clone(),
630 + "scale",
631 + spell_scale(self.scale),
632 + )];
633 + if !self.transform.is_empty() && self.transform != "normal" {
634 + directives.push(Directive::at(
635 + self.to.clone(),
636 + "transform",
637 + self.transform.clone(),
638 + ));
639 + }
640 + if !self.enabled {
641 + directives.push(Directive::at(self.to.clone(), "enable", "false"));
642 + }
643 + directives
644 + }
645 + }
646 +
647 + /// Work out which monitors moved, given what is attached and what is remembered.
648 + ///
649 + /// Pure, and the whole of the design's logic. A monitor is "moved" when its
650 + /// fingerprint is in the table under a different connector than the one it is on
651 + /// now; anything unknown, unchanged, or unidentifiable is left alone.
652 + ///
653 + /// A monitor that moved and whose settings already match what is remembered
654 + /// still counts as a move, because the *table* is wrong either way and the
655 + /// caller is what fixes it. Emitting the directives again costs one swaymsg per
656 + /// property and is how the surface stays idempotent.
657 + pub(crate) fn moves(outputs: &[Output], registry: &monitors::Registry) -> Vec<Move> {
658 + outputs
659 + .iter()
660 + .filter_map(|output| {
661 + let fingerprint = output.fingerprint()?;
662 + let from = registry.moved_from(&fingerprint, &output.name)?;
663 + let remembered = registry.get(&fingerprint)?;
664 + Some(Move {
665 + fingerprint: fingerprint.clone(),
666 + from: from.to_string(),
667 + to: output.name.clone(),
668 + scale: remembered.scale,
669 + transform: remembered.transform.clone(),
670 + enabled: remembered.enabled,
671 + })
672 + })
673 + .collect()
674 + }
675 +
676 + /// Record what is attached now, so a later run can notice it moved.
677 + ///
678 + /// Called after a write and after a reconcile, which are the two moments the
679 + /// console knows both what is attached and what its settings are. An output
680 + /// with no fingerprint is skipped rather than stored under a placeholder: see
681 + /// [`Output::fingerprint`].
682 + pub(crate) fn remember(outputs: &[Output], registry: &mut monitors::Registry, now: u64) {
683 + for output in outputs {
684 + let Some(fingerprint) = output.fingerprint() else {
685 + continue;
686 + };
687 + registry.remember(
688 + fingerprint,
689 + monitors::Remembered {
690 + connector: output.name.clone(),
691 + scale: output.scale,
692 + transform: output.transform.clone(),
693 + enabled: output.active,
694 + last_seen: now,
695 + description: output.description(),
696 + },
697 + );
698 + }
699 + registry.expire(now);
700 + }
701 +
702 + /// The whole reconcile, for the session wrapper and for `alloy display
703 + /// --reconcile`.
704 + ///
705 + /// Prints rather than draws: it runs from `usr/bin/alloy-session` before there
706 + /// is a session to draw into, beside `alloy theme apply` and for the same
707 + /// reason. Failure is reported and not fatal, on the rule that file already
708 + /// follows: a login must not be lost to bookkeeping.
709 + pub(crate) fn reconcile(verbose: bool) {
710 + let mut log = CommandLog::new();
711 + let backend = detect();
712 + let outputs = match backend.list(&mut log) {
713 + Ok(outputs) => outputs,
714 + Err(err) => {
715 + eprintln!("alloy display: cannot read outputs: {err}");
716 + return;
717 + }
718 + };
719 +
720 + let mut registry = monitors::load();
721 + let moved = moves(&outputs, &registry);
722 +
723 + for one in &moved {
724 + println!(
725 + "alloy display: {} moved from {} to {}",
726 + one.fingerprint, one.from, one.to
727 + );
728 + for directive in one.directives() {
729 + if let Err(err) = directive.invocation().run(&mut log) {
730 + eprintln!("alloy display: {}: {err}", directive.words());
731 + }
732 + }
733 + }
734 +
735 + // Re-read after applying, so what is recorded is what sway ended up with
736 + // rather than what it was asked for. A refused scale would otherwise be
737 + // remembered as though it had taken.
738 + let settled = if moved.is_empty() {
739 + outputs
740 + } else {
741 + backend.list(&mut log).unwrap_or_default()
742 + };
743 +
744 + // Through `Effect::apply` rather than `fs::write`, so the reconcile writes
745 + // the file the same way the `w` key does, mode included.
746 + if let Some(effect) = (!settled.is_empty())
747 + .then(|| write_effect(&settled))
748 + .flatten()
749 + && let Err(err) = effect.apply(&mut log)
750 + {
751 + eprintln!("alloy display: {err}");
752 + }
753 +
754 + remember(&settled, &mut registry, monitors::now());
755 + if let Err(err) = monitors::save(&registry) {
756 + eprintln!("alloy display: cannot write the monitor table: {err}");
757 + }
758 +
759 + if verbose && moved.is_empty() {
760 + println!("alloy display: nothing moved");
761 + }
762 + }
763 +
577 764 // ---- the panel, before there is a sway to ask ----
578 765
579 766 /// Where the kernel describes the connectors it found.
@@ -1345,18 +1532,15 @@
1345 1532 }
1346 1533 }
1347 1534
1348 - // The identifier rule: the triple survives a replug into a different port,
1349 - // so external outputs get it. The built-in panel does not move, and its
1350 - // triple is ambiguous anyway on a serial of "Unknown".
1535 + // The identifier rule since 2026-08-06: the connector, always, for every
1536 + // output. The triple it replaced was vendor text in a file sway parses and
1537 + // was not unique across identical serial-less monitors; `monitors.rs` holds
1538 + // the reasoning and the replacement.
1351 1539 #[test]
1352 - fn external_outputs_are_matched_by_the_triple() {
1540 + fn every_output_is_matched_by_its_connector() {
1353 1541 let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
1354 - assert_eq!(monitor.identifier(), "Example Co PA279CV K8LMQS032990");
1355 - assert_eq!(
1356 - fw12().identifier(),
1357 - "eDP-1",
1358 - "the panel keeps its connector"
1359 - );
1542 + assert_eq!(monitor.identifier(), "DP-3");
1543 + assert_eq!(fw12().identifier(), "eDP-1");
1360 1544 }
1361 1545
1362 1546 #[test]
@@ -1370,79 +1554,187 @@
1370 1554 assert!(!external("DP-3", "Example Co", "PA279CV", "S1").built_in());
1371 1555 }
1372 1556
1373 - /// EDID is vendor-supplied text and the identifier it feeds is written into
1374 - /// a file sway loads at login. A make carrying a double quote would close
1375 - /// sway's quoting early and leave a line the compositor cannot parse, so the
1376 - /// triple is abandoned for the connector name instead.
1557 + /// The injection hazard, gone structurally rather than escaped. Each of
1558 + /// these used to be a case `identifier` had to detect and fall back from;
1559 + /// now none of them can reach a stanza by any route, because no EDID text
1560 + /// is written at all.
1377 1561 #[test]
1378 - fn a_quote_in_the_edid_falls_back_to_the_connector() {
1379 - let hostile = external("DP-1", "Ex\"Co", "PA279CV", "S1");
1380 - assert_eq!(hostile.identifier(), "DP-1");
1381 - // The bad text must not survive into the line by any route.
1382 - assert!(!hostile.identifier().contains('"'));
1383 - }
1384 -
1385 - /// The worst case is not a broken line but a second directive. A newline in
1386 - /// make or model would end the `output` stanza and start whatever followed
1387 - /// it, which is injection rather than corruption.
1388 - #[test]
1389 - fn a_newline_in_the_edid_cannot_start_a_second_directive() {
1390 - let injected = external("DP-2", "Ex\noutput * scale 3", "PA279CV", "S1");
1391 - assert_eq!(injected.identifier(), "DP-2");
1392 - }
1393 -
1394 - /// A backslash is refused because whether sway honours escapes inside a
1395 - /// quoted string cannot be checked from a machine with no sway, and a
Lines truncated
@@ -16,6 +16,7 @@
16 16 mod image;
17 17 mod install;
18 18 mod mesh;
19 + mod monitors;
19 20 mod net;
20 21 mod pkg;
21 22 mod profile;
@@ -90,7 +91,21 @@
90 91 surface: Option<BluetoothSurface>,
91 92 },
92 93 /// Displays sway is driving: scale, rotation, and which are on
93 - Display,
94 + Display {
95 + /// Move each monitor's settings to the port it is on now, and exit
96 + ///
97 + /// Draws nothing. `usr/bin/alloy-session` runs it at every login beside
98 + /// `theme apply`, so a monitor replugged while logged out is correct by
99 + /// the time a desktop appears; typed by hand it covers the mid-session
100 + /// replug. Stanzas are keyed by connector name (see `monitors.rs`), so
101 + /// without this a moved monitor keeps the settings of whatever was
102 + /// previously in that port.
103 + #[arg(long)]
104 + reconcile: bool,
105 + /// Say so even when nothing moved
106 + #[arg(long)]
107 + verbose: bool,
108 + },
94 109 // `tail` stays as an alias: docs/CONSOLE.md named the verb that way, and
95 110 // the muscle memory is worth more than the tidiness of a single name.
96 111 // Deliberately a plain comment, not a doc comment — clap turns those into
@@ -259,7 +274,7 @@
259 274 // Refused as well as hidden, and through the same table, so the two
260 275 // cannot drift. See `profile::is_unavailable`.
261 276 let verb = match &cli.command {
262 - Command::Display => "display",
277 + Command::Display { .. } => "display",
263 278 Command::Status { .. } => "status",
264 279 Command::Bluetooth { .. } => "bluetooth",
265 280 _ => "",
@@ -268,6 +283,19 @@
268 283 anyhow::bail!("{}", profile::unavailable(verb));
269 284 }
270 285
286 + // Before the theme load, for the same reason `theme apply` is: it runs from
287 + // the session wrapper at every login, draws nothing, and needs no palette.
288 + // A machine whose theme directory will not load must still end up with its
289 + // monitors on the right ports.
290 + if let Command::Display {
291 + reconcile: true,
292 + verbose,
293 + } = &cli.command
294 + {
295 + display::reconcile(*verbose);
296 + return Ok(());
297 + }
298 +
271 299 // Before the theme load, and that ordering is load-bearing rather than
272 300 // tidy. `theme::load` is a hard error when nothing is on the search path,
273 301 // by design — there is no built-in fallback palette. `theme apply` runs from
@@ -328,7 +356,7 @@
328 356 let mut view = bluetooth::BluetoothView::new(tab, &mut log);
329 357 shell::run(&theme, &mut view, &mut log)
330 358 }
331 - Command::Display => {
359 + Command::Display { .. } => {
332 360 let mut view = display::DisplayView::new(&mut log);
333 361 shell::run(&theme, &mut view, &mut log)
334 362 }
@@ -124,6 +124,22 @@
124 124 # So the server has nothing to start it but the compositor.
125 125 exec swayosd-server
126 126
127 + # Monitors follow their settings between ports.
128 + #
129 + # `~/.config/sway/config.d/50-display.conf` keys every stanza by connector name,
130 + # so a monitor moved from DP-1 to DP-2 arrives with whatever was written for the
131 + # port rather than for the screen. This reads the EDID of what is attached,
132 + # matches it against Alloy's own table, and moves the settings across. See
133 + # `crates/alloy/src/monitors.rs` for why the connector is what gets written.
134 + #
135 + # Here rather than in `usr/bin/alloy-session`, which is where `alloy theme
136 + # apply` runs and would be the obvious home: this needs a compositor to ask, and
137 + # the wrapper runs before there is one. `exec` fires once sway is up, which is
138 + # the earliest moment `swaymsg -t get_outputs` can answer.
139 + #
140 + # Draws nothing and prints only when something moved.
141 + exec alloy display --reconcile
142 +
127 143 # The first-boot offer: a mesh for the machines, a sync for the files
128 144 # (docs/CONTINUITY.md). --if-first-boot is what makes this a one-time event:
129 145 # the console records that it asked, and every later login runs this line and
@@ -1,0 +1,336 @@
1 + //! Which monitor is which, independently of the port it is plugged into.
2 + //!
3 + //! Alloy's own bookkeeping, and the first use of content addressing outside
4 + //! `audiofiles`. `display.rs` writes sway stanzas keyed by connector name, and a
5 + //! connector name is a fact about a cable rather than about a monitor: unplug a
6 + //! screen from DP-1 and put it in DP-2 and every setting written for it stops
7 + //! matching. The `make model serial` triple sway also accepts survives that, and
8 + //! it brought two problems of its own, which is why this file exists instead.
9 + //!
10 + //! # Why not just write the triple
11 + //!
12 + //! **It is vendor text in a file sway parses.** The triple is EDID strings, and
13 + //! `alloy display` writes them into `~/.config/sway/config.d/50-display.conf`.
14 + //! A quote or a newline in a model string is a malformed line in the config
15 + //! loaded at login, which is why `display.rs` grew a quote-and-fall-back path in
16 + //! `alloy@a9b496e`. Under this design nothing vendor-supplied is written at all,
17 + //! so the hazard is gone structurally rather than guarded against.
18 + //!
19 + //! **And the triple is not unique.** Two identical monitors with no serial
20 + //! produce the same `Example Co PA279CV Unknown`, sway merges the two stanzas,
21 + //! and the last one wins for both. That is GO problem `a4d249f1`, filed as
22 + //! needing a matched pair of monitors to decide. Keying stanzas by connector
23 + //! answers it without one: two panels on two ports are two stanzas whatever
24 + //! their EDID says.
25 + //!
26 + //! # The cost, taken knowingly
27 + //!
28 + //! A triple in a sway config works with Alloy uninstalled: a static line sway
29 + //! resolves on its own, forever. Connector-keyed stanzas plus a reconcile step
30 + //! mean the file is only correct if something runs after a replug. That converts
31 + //! a self-contained config into one with a live dependency on Alloy, which cuts
32 + //! against the instinct `install.rs` follows in taking `bootc install to-disk`
33 + //! rather than owning what it does not have to.
34 + //!
35 + //! Taken anyway (Max, 2026-08-06), because a config that is silently wrong for
36 + //! the user is worse than one that needs a program to stay right.
37 + //!
38 + //! # What is here and what is not
39 + //!
40 + //! Here: the fingerprint, the table, expiry, and the reconcile that moves a
41 + //! monitor's settings to the port it is on now. Not here: named monitor sets
42 + //! ("desk", "docked"), which the same decision asks for and which explicitly
43 + //! must not ship on single-output evidence. No multi-output capture exists
44 + //! anywhere in this project yet, and a layout feature written against one
45 + //! screen is a UI written against nothing. GO task `05cfd0d5` carries that half.
46 + //!
47 + //! <!-- wiki: alloy-console -->
48 +
49 + use std::collections::BTreeMap;
50 + use std::path::PathBuf;
51 + use std::time::{SystemTime, UNIX_EPOCH};
52 +
53 + use serde::{Deserialize, Serialize};
54 + use sha2::{Digest, Sha256};
55 +
56 + /// Alloy's own state for this, beside the mode file the theme row writes.
57 + ///
58 + /// Not in `~/.config/sway/`: that directory is sway's config, this is a table
59 + /// only Alloy reads, and putting bookkeeping where a user edits stanzas invites
60 + /// hand edits that the next reconcile overwrites.
61 + pub(crate) const FILE: &str = ".config/alloy/monitors.toml";
62 +
63 + /// How long an unplugged monitor is remembered.
64 + ///
65 + /// A year. The two failure modes are asymmetric and neither is severe, so the
66 + /// number is chosen to fail in the harmless direction: forgetting too early
67 + /// costs one monitor's settings, which is a keypress to set again, while never
68 + /// forgetting means a table that grows for the life of the machine and keeps
69 + /// hardware that was sold years ago. A year is longer than any plausible gap
70 + /// between using a monitor and using it again, and short enough that the table
71 + /// tracks the desk rather than its history.
72 + ///
73 + /// The decision this records is that expiry exists at all. The 2026-08-06
74 + /// ruling asked for exactly that and left the period open: "expiry by age is
75 + /// fine, silent accumulation is not".
76 + const FORGET_AFTER: u64 = 365 * 24 * 60 * 60;
77 +
78 + /// How much of the digest is kept.
79 + ///
80 + /// Sixteen hex characters, 64 bits. The full 64 would be unreadable in a file a
81 + /// person may open to see what Alloy thinks is attached, and the collision
82 + /// question is not close: the population is the monitors one machine has ever
83 + /// had plugged in, call it tens, against 2^64. The truncation is stated rather
84 + /// than assumed because a content address that silently keeps 64 bits of 256
85 + /// invites someone to compare it against a full SHA-256 taken elsewhere and
86 + /// conclude the two disagree.
87 + const FINGERPRINT_HEX: usize = 16;
88 +
89 + /// The content address of a monitor's identity.
90 + ///
91 + /// SHA-256 over make, model and serial exactly as sway reports them, joined by
92 + /// a unit separator. The separator is what keeps `("AB", "C")` and `("A", "BC")`
93 + /// from hashing alike, and it is a byte no EDID string can contain, so it cannot
94 + /// be smuggled in as data.
95 + ///
96 + /// Reproducible from `swaymsg -t get_outputs` and nothing else: no file, no
97 + /// counter, no clock. That is the property that makes it an address rather than
98 + /// an id, and it is why the table below can be deleted with no consequence
99 + /// beyond forgetting settings.
100 + pub(crate) fn fingerprint(make: &str, model: &str, serial: &str) -> String {
101 + let mut hasher = Sha256::new();
102 + hasher.update(make.as_bytes());
103 + hasher.update([0x1f]);
104 + hasher.update(model.as_bytes());
105 + hasher.update([0x1f]);
106 + hasher.update(serial.as_bytes());
107 + let digest = hasher.finalize();
108 + // Two hex characters per byte, so half as many bytes as the width asked
109 + // for. Formatting the whole digest and truncating the string would work and
110 + // would hash 32 bytes to throw 24 away.
111 + let mut hex = String::with_capacity(FINGERPRINT_HEX);
112 + for byte in digest.iter().take(FINGERPRINT_HEX / 2) {
113 + use std::fmt::Write as _;
114 + let _ = write!(hex, "{byte:02x}");
115 + }
116 + hex
117 + }
118 +
119 + /// What is remembered about one monitor.
120 + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
121 + pub(crate) struct Remembered {
122 + /// The connector it was last seen on. The field the whole design exists for:
123 + /// when it disagrees with where the monitor is now, the stanzas move.
124 + pub connector: String,
125 + pub scale: f64,
126 + #[serde(default)]
127 + pub transform: String,
128 + #[serde(default = "yes")]
129 + pub enabled: bool,
130 + /// Unix seconds. Not a date string: nothing in this tree formats one, and
131 + /// `stale.rs` already reasons in epoch seconds, so a second convention would
132 + /// be a second thing to get wrong. This file is Alloy's bookkeeping and not
133 + /// a config anyone is asked to read.
134 + pub last_seen: u64,
135 + /// Make and model, for whoever opens the file. Written, never read: it is a
136 + /// label on a row, and the fingerprint is the identity.
137 + #[serde(default)]
138 + pub description: String,
139 + }
140 +
141 + fn yes() -> bool {
142 + true
143 + }
144 +
145 + /// Fingerprint to what is remembered about it.
146 + #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
147 + pub(crate) struct Registry {
148 + /// `BTreeMap` rather than `HashMap` so the file is stable between writes.
149 + /// A table that reorders itself on every login is a table nobody can diff.
150 + #[serde(default)]
151 + pub monitors: BTreeMap<String, Remembered>,
152 + }
153 +
154 + impl Registry {
155 + /// Parse, treating a damaged file as an empty table.
156 + ///
157 + /// Forgiving on purpose. The worst case of a bad parse is that one login
158 + /// forgets where the monitors were, and the worst case of a hard error is a
159 + /// session that will not start because a bookkeeping file has a stray
160 + /// character in it. The reconcile rides the session wrapper, so the second
161 + /// one is a machine you cannot log into.
162 + pub(crate) fn parse(raw: &str) -> Self {
163 + toml::from_str(raw).unwrap_or_default()
164 + }
165 +
166 + pub(crate) fn to_toml(&self) -> String {
167 + let body = toml::to_string_pretty(self).unwrap_or_default();
168 + format!(
169 + "# Generated by Alloy. Which monitor is which, keyed by a hash of\n\
170 + # the make, model and serial sway reports, so settings follow a\n\
171 + # screen when it moves between ports. Rewritten whole, so hand\n\
172 + # edits are lost; `last_seen` is Unix seconds and rows unseen for a\n\
173 + # year are dropped. Deleting this file forgets monitor settings and\n\
174 + # breaks nothing else.\n\n{body}"
175 + )
176 + }
177 +
178 + /// Drop what has not been seen in [`FORGET_AFTER`].
179 + pub(crate) fn expire(&mut self, now: u64) {
180 + self.monitors
181 + .retain(|_, entry| now.saturating_sub(entry.last_seen) < FORGET_AFTER);
182 + }
183 +
184 + /// Record a monitor as attached here, now.
185 + pub(crate) fn remember(&mut self, fingerprint: String, entry: Remembered) {
186 + self.monitors.insert(fingerprint, entry);
187 + }
188 +
189 + /// Where a monitor was last seen, if it is known and has moved.
190 + ///
191 + /// `None` covers both "never seen" and "still on the same port", which are
192 + /// the two cases with nothing to do. A caller that needs to tell them apart
193 + /// should look the fingerprint up directly.
194 + pub(crate) fn moved_from(&self, fingerprint: &str, connector: &str) -> Option<&str> {
195 + let entry = self.monitors.get(fingerprint)?;
196 + (entry.connector != connector).then_some(entry.connector.as_str())
197 + }
198 +
199 + pub(crate) fn get(&self, fingerprint: &str) -> Option<&Remembered> {
200 + self.monitors.get(fingerprint)
201 + }
202 + }
203 +
204 + pub(crate) fn now() -> u64 {
205 + SystemTime::now()
206 + .duration_since(UNIX_EPOCH)
207 + .map_or(0, |since| since.as_secs())
208 + }
209 +
210 + /// The table's path, or `None` with no home to put it in.
211 + pub(crate) fn path() -> Option<PathBuf> {
212 + // `$HOME` rather than `$XDG_CONFIG_HOME`, matching `display.rs`: the two
213 + // files are written by the same action and a machine where they landed in
214 + // different directories would be worse than either choice.
215 + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(FILE))
216 + }
217 +
218 + /// Read the table, or an empty one when there is no file yet.
219 + pub(crate) fn load() -> Registry {
220 + path()
221 + .and_then(|path| std::fs::read_to_string(path).ok())
222 + .map_or_else(Registry::default, |raw| Registry::parse(&raw))
223 + }
224 +
225 + /// Write the table, reporting why not rather than failing silently.
226 + pub(crate) fn save(registry: &Registry) -> anyhow::Result<PathBuf> {
227 + let path = path().ok_or_else(|| anyhow::anyhow!("no HOME to write the monitor table into"))?;
228 + if let Some(parent) = path.parent() {
229 + std::fs::create_dir_all(parent)?;
230 + }
231 + std::fs::write(&path, registry.to_toml())?;
232 + Ok(path)
233 + }
234 +
235 + #[cfg(test)]
236 + mod tests {
237 + use super::*;
238 +
239 + fn entry(connector: &str, last_seen: u64) -> Remembered {
240 + Remembered {
241 + connector: connector.to_string(),
242 + scale: 1.5,
243 + transform: String::new(),
244 + enabled: true,
245 + last_seen,
246 + description: "Example Co PA279CV".to_string(),
247 + }
248 + }
249 +
250 + /// The property that makes it an address: same identity in, same string
251 + /// out, from nothing but the EDID fields.
252 + #[test]
253 + fn the_fingerprint_is_a_function_of_the_triple_alone() {
254 + let one = fingerprint("Example Co", "PA279CV", "S1");
255 + assert_eq!(one, fingerprint("Example Co", "PA279CV", "S1"));
256 + assert_ne!(one, fingerprint("Example Co", "PA279CV", "S2"));
257 + assert_eq!(one.len(), FINGERPRINT_HEX);
258 + assert!(one.chars().all(|c| c.is_ascii_hexdigit()));
259 + }
260 +
261 + /// The separator is not decoration. Without it a make ending where a model
262 + /// begins hashes the same as the shift of it, and two different monitors
263 + /// share one identity.
264 + #[test]
265 + fn field_boundaries_survive_the_hash() {
266 + assert_ne!(
267 + fingerprint("AB", "C", "D"),
268 + fingerprint("A", "BC", "D"),
269 + "the unit separator should keep these apart"
270 + );
271 + }
272 +
273 + /// Nothing vendor-supplied survives into the output. This is the injection
274 + /// hazard disappearing structurally rather than being escaped.
275 + #[test]
276 + fn a_hostile_model_string_cannot_reach_a_config_file() {
277 + let hostile = fingerprint("Example", "\"\nexec rm -rf /\n", "S1");
278 + assert!(
279 + hostile.chars().all(|c| c.is_ascii_hexdigit()),
280 + "a fingerprint is hex and nothing else: {hostile}"
281 + );
282 + }
283 +
284 + /// Two identical serial-less monitors hash alike, and that is correct: they
285 + /// are the same identity by every fact available. What keeps their settings
286 + /// apart is that stanzas are keyed by connector, which is the answer to
287 + /// problem `a4d249f1`.
288 + #[test]
289 + fn identical_serialless_monitors_share_a_fingerprint() {
290 + assert_eq!(
291 + fingerprint("Example Co", "PA279CV", "Unknown"),
292 + fingerprint("Example Co", "PA279CV", "Unknown")
293 + );
294 + }
295 +
296 + #[test]
297 + fn a_move_is_noticed_and_a_stay_is_not() {
298 + let mut registry = Registry::default();
299 + registry.remember("abc".to_string(), entry("DP-1", 100));
300 + assert_eq!(registry.moved_from("abc", "DP-2"), Some("DP-1"));
301 + assert_eq!(registry.moved_from("abc", "DP-1"), None);
302 + assert_eq!(registry.moved_from("unknown", "DP-1"), None);
303 + }
304 +
305 + /// Silent accumulation is the thing the ruling ruled out.
306 + #[test]
307 + fn rows_older_than_a_year_are_dropped() {
308 + let mut registry = Registry::default();
309 + registry.remember("old".to_string(), entry("DP-1", 0));
310 + registry.remember("recent".to_string(), entry("DP-2", FORGET_AFTER));
311 + registry.expire(FORGET_AFTER + 1);
312 +
313 + assert!(registry.get("old").is_none(), "a year-old row should go");
314 + assert!(registry.get("recent").is_some(), "a fresh row should stay");
315 + }
316 +
317 + /// A round trip through the file, including the header, which is part of
318 + /// the file rather than a comment on it.
319 + #[test]
320 + fn the_table_round_trips_through_toml() {
321 + let mut registry = Registry::default();
322 + registry.remember("abc".to_string(), entry("DP-1", 1_700_000_000));
323 + let rendered = registry.to_toml();
324 +
325 + assert!(rendered.starts_with("# Generated by Alloy"), "{rendered}");
326 + assert_eq!(Registry::parse(&rendered), registry);
327 + }
328 +
329 + /// A damaged file must not stop a login. The reconcile runs from the
330 + /// session wrapper, so a hard error here is a machine nobody can get into.
331 + #[test]
332 + fn a_damaged_table_reads_as_an_empty_one() {
333 + assert_eq!(Registry::parse("this is not toml {{{"), Registry::default());
334 + assert_eq!(Registry::parse(""), Registry::default());
335 + }
336 + }