| 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 |
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 |
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 |
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 |
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 |
|
- |
"e(&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 |
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 |
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 |
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, ®istry);
|
|
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(®istry) {
|
|
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 |
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 |
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
|