Evan's Blog
EvanZhu0721 / 公开仓库

SBMS

镜像已同步

SBMS complete the calculation rules for the default logical desktop topology relationship and the physical dimensions of the displays in the Windows multi-screen collaboration system.

上游 GitHub
READ-ONLY MIRROR

README、源码与 Release 已保存到本站;源码视图固定到 commit a37d6c4b。

main/ root / src
a37d6c4b· 固定源码快照
src/config.rs81.2 KB
1use std::env;
2use std::error::Error;
3use std::ffi::OsStr;
4use std::fmt::{Display, Formatter};
5use std::fs::{self, File, OpenOptions};
6use std::io::Write;
7use std::os::windows::ffi::OsStrExt;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use serde::{Deserialize, Serialize};
12use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_ABANDONED, WAIT_FAILED, WAIT_OBJECT_0};
13use windows::Win32::Storage::FileSystem::{
14 MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, REPLACEFILE_WRITE_THROUGH,
15 ReplaceFileW,
16};
17use windows::Win32::System::Threading::{
18 CreateMutexW, INFINITE, ReleaseMutex, WaitForSingleObject,
19};
20use windows::core::{PCWSTR, w};
21
22use crate::geometry::{AspectRatio, Rotation, SizingRequest};
23use crate::limits::{
24 MAX_OUTPUTS, MAX_REFRESH_MILLIHZ, MAX_VIRTUAL_DIMENSION, MILLIMETERS_PER_INCH,
25 MIN_REFRESH_MILLIHZ, valid_physical_millimeters, valid_refresh_millihz,
26};
27
28const CONFIG_VERSION: u32 = 2;
29const LEGACY_CONFIG_VERSION: u32 = 1;
30const CONFIG_PROFILES_VERSION: u32 = 1;
31pub const MAX_CONFIG_PROFILES: usize = 3;
32const DISPLAY_OVERRIDES_VERSION: u32 = 1;
33const CONFIG_DIRECTORY: &str = "SBMS";
34const CONFIG_FILE: &str = "config-v2.json";
35const CONFIG_PROFILES_FILE: &str = "config-profiles-v1.json";
36const LEGACY_CONFIG_FILE: &str = "config-v1.json";
37const DISPLAY_OVERRIDES_FILE: &str = "display-overrides-v1.json";
38static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ReferenceSource {
43 Display(String),
44 Manual,
45}
46
47#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct AppConfig {
50 pub version: u32,
51 pub groups: Vec<GroupConfig>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub selected_group_id: Option<u32>,
54}
55
56#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
57#[serde(deny_unknown_fields)]
58pub struct GroupConfig {
59 pub id: u32,
60 pub route: GroupRouteConfig,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub reference_source: Option<ReferenceSource>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub sizing: Option<SizingRequest>,
65}
66
67#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
68#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
69pub enum GroupRouteConfig {
70 Mirror {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 target_id: Option<String>,
73 },
74 StreamOnly {
75 screen: StreamScreenConfig,
76 },
77}
78
79#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
80#[serde(deny_unknown_fields)]
81pub struct StreamScreenConfig {
82 pub width: u32,
83 pub height: u32,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub diagonal_inches: Option<f64>,
86 pub refresh_millihz: u32,
87 pub aspect_ratio: AspectRatio,
88 #[serde(default)]
89 pub rotation: Rotation,
90}
91
92#[derive(Clone, Debug)]
93pub struct ConfigStore {
94 path: PathBuf,
95 legacy_path: Option<PathBuf>,
96}
97
98#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
99#[serde(deny_unknown_fields)]
100pub struct ConfigProfile {
101 pub id: String,
102 pub revision: u64,
103 pub config: AppConfig,
104}
105
106#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
107#[serde(deny_unknown_fields)]
108pub struct ConfigProfiles {
109 pub version: u32,
110 pub active_profile: String,
111 #[serde(default, skip_serializing_if = "is_false")]
112 pub launch_intro_disabled: bool,
113 pub profiles: Vec<ConfigProfile>,
114}
115
116fn is_false(value: &bool) -> bool {
117 !*value
118}
119
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct ProfileRevision {
122 pub id: String,
123 pub revision: u64,
124}
125
126#[derive(Clone, Debug, PartialEq)]
127pub struct ProfileSnapshot {
128 pub profile: ProfileRevision,
129 pub config: AppConfig,
130}
131
132#[derive(Clone, Debug, PartialEq)]
133pub struct ProfileDeleteOutcome {
134 pub active: ProfileSnapshot,
135 pub active_changed: bool,
136}
137
138#[derive(Clone, Debug)]
139pub struct ConfigProfileStore {
140 path: PathBuf,
141 legacy_config_path: PathBuf,
142}
143
144struct CrossProcessConfigGuard(HANDLE);
145
146#[derive(Clone, Debug, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct LegacyAppConfig {
149 version: u32,
150 #[serde(default)]
151 target_id: Option<String>,
152 #[serde(default)]
153 reference_source: Option<ReferenceSource>,
154 #[serde(default)]
155 sizing: Option<SizingRequest>,
156}
157
158#[derive(Deserialize)]
159struct VersionHeader {
160 version: u32,
161}
162
163#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
164#[serde(deny_unknown_fields)]
165pub struct DisplayOverride {
166 pub display_id: String,
167 pub diagonal_inches: f64,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub aspect_ratio: Option<AspectRatio>,
170}
171
172#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
173#[serde(deny_unknown_fields)]
174pub struct DisplayOverrides {
175 pub version: u32,
176 #[serde(default)]
177 pub displays: Vec<DisplayOverride>,
178}
179
180#[derive(Clone, Debug)]
181pub struct DisplayOverrideStore {
182 path: PathBuf,
183}
184
185#[derive(Clone, Debug, PartialEq)]
186pub struct DisplayOverrideLoadOutcome {
187 pub overrides: DisplayOverrides,
188 pub warning: Option<String>,
189}
190
191#[derive(Clone, Debug, PartialEq)]
192pub struct LoadOutcome {
193 pub config: AppConfig,
194 pub warning: Option<String>,
195}
196
197#[derive(Debug)]
198pub struct ConfigError(String);
199
200impl ConfigError {
201 pub fn new(message: impl Into<String>) -> Self {
202 Self(message.into())
203 }
204}
205
206impl Display for ConfigError {
207 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
208 formatter.write_str(&self.0)
209 }
210}
211
212impl Error for ConfigError {}
213
214impl Default for AppConfig {
215 fn default() -> Self {
216 Self {
217 version: CONFIG_VERSION,
218 groups: vec![GroupConfig::default()],
219 selected_group_id: Some(0),
220 }
221 }
222}
223
224impl Default for GroupConfig {
225 fn default() -> Self {
226 Self {
227 id: 0,
228 route: GroupRouteConfig::Mirror { target_id: None },
229 reference_source: None,
230 sizing: None,
231 }
232 }
233}
234
235impl AppConfig {
236 pub fn group(&self, id: u32) -> Option<&GroupConfig> {
237 self.groups.iter().find(|group| group.id == id)
238 }
239
240 pub fn group_mut(&mut self, id: u32) -> Option<&mut GroupConfig> {
241 self.groups.iter_mut().find(|group| group.id == id)
242 }
243
244 fn validate(&self) -> Result<(), ConfigError> {
245 self.validate_with_sizing(true)
246 }
247
248 fn validate_with_sizing(&self, validate_sizing: bool) -> Result<(), ConfigError> {
249 if self.version != CONFIG_VERSION {
250 return Err(ConfigError(format!(
251 "refusing unsupported config version {}",
252 self.version
253 )));
254 }
255 if self.groups.is_empty() {
256 return Err(ConfigError(
257 "configuration must contain at least one mapping group".into(),
258 ));
259 }
260 if self.groups.len() > MAX_OUTPUTS {
261 return Err(ConfigError(format!(
262 "configuration may contain at most {MAX_OUTPUTS} mapping groups"
263 )));
264 }
265
266 for (index, group) in self.groups.iter().enumerate() {
267 group.validate(validate_sizing)?;
268 if self.groups[..index]
269 .iter()
270 .any(|other| other.id == group.id)
271 {
272 return Err(ConfigError(format!(
273 "mapping group id {} is duplicated",
274 group.id
275 )));
276 }
277 if let GroupRouteConfig::Mirror {
278 target_id: Some(target),
279 } = &group.route
280 && self.groups[..index].iter().any(|other| {
281 matches!(
282 &other.route,
283 GroupRouteConfig::Mirror {
284 target_id: Some(other_target)
285 } if other_target.eq_ignore_ascii_case(target)
286 )
287 })
288 {
289 return Err(ConfigError(format!(
290 "mirror target {target} belongs to more than one mapping group"
291 )));
292 }
293 }
294
295 if let Some(selected) = self.selected_group_id
296 && !self.groups.iter().any(|group| group.id == selected)
297 {
298 return Err(ConfigError(format!(
299 "selected mapping group id {selected} does not exist"
300 )));
301 }
302 Ok(())
303 }
304}
305
306impl GroupConfig {
307 fn validate(&self, validate_sizing: bool) -> Result<(), ConfigError> {
308 if self.id >= MAX_OUTPUTS as u32 {
309 return Err(ConfigError(format!(
310 "mapping group id {} must be between 0 and {}",
311 self.id,
312 MAX_OUTPUTS - 1
313 )));
314 }
315 match &self.route {
316 GroupRouteConfig::Mirror {
317 target_id: Some(target),
318 } if target.trim().is_empty() => {
319 return Err(ConfigError(format!(
320 "mapping group {} has an empty target id",
321 self.id
322 )));
323 }
324 GroupRouteConfig::StreamOnly { screen } => screen.validate(self.id)?,
325 _ => {}
326 }
327 if matches!(
328 self.reference_source.as_ref(),
329 Some(ReferenceSource::Display(id)) if id.trim().is_empty()
330 ) {
331 return Err(ConfigError(format!(
332 "mapping group {} has an empty reference display id",
333 self.id
334 )));
335 }
336 if validate_sizing && let Some(sizing) = self.sizing {
337 sizing.calculate().map_err(|error| {
338 ConfigError(format!(
339 "mapping group {} has invalid sizing parameters: {error}",
340 self.id
341 ))
342 })?;
343 }
344 Ok(())
345 }
346}
347
348impl StreamScreenConfig {
349 fn validate(self, group_id: u32) -> Result<(), ConfigError> {
350 if self.width == 0
351 || self.height == 0
352 || self.width > MAX_VIRTUAL_DIMENSION
353 || self.height > MAX_VIRTUAL_DIMENSION
354 {
355 return Err(ConfigError(format!(
356 "mapping group {group_id} stream dimensions must be between 1 and {MAX_VIRTUAL_DIMENSION}"
357 )));
358 }
359 if !valid_refresh_millihz(self.refresh_millihz) {
360 return Err(ConfigError(format!(
361 "mapping group {group_id} stream refresh must be between {MIN_REFRESH_MILLIHZ} and {MAX_REFRESH_MILLIHZ} millihertz"
362 )));
363 }
364 if self.aspect_ratio.width == 0 || self.aspect_ratio.height == 0 {
365 return Err(ConfigError(format!(
366 "mapping group {group_id} stream aspect ratio dimensions must be positive"
367 )));
368 }
369 if let Some(diagonal_inches) = self.diagonal_inches {
370 let diagonal_mm = diagonal_inches * MILLIMETERS_PER_INCH;
371 if !valid_physical_millimeters(diagonal_mm) {
372 return Err(ConfigError(format!(
373 "mapping group {group_id} stream diagonal must be between 10 and 10000 millimetres"
374 )));
375 }
376 }
377 Ok(())
378 }
379}
380
381impl LegacyAppConfig {
382 fn validate(&self) -> Result<(), ConfigError> {
383 if self.version != LEGACY_CONFIG_VERSION {
384 return Err(ConfigError(format!(
385 "unsupported legacy config version {}",
386 self.version
387 )));
388 }
389 if self
390 .target_id
391 .as_ref()
392 .is_some_and(|target| target.trim().is_empty())
393 {
394 return Err(ConfigError("legacy target id cannot be empty".into()));
395 }
396 if matches!(
397 self.reference_source.as_ref(),
398 Some(ReferenceSource::Display(id)) if id.trim().is_empty()
399 ) {
400 return Err(ConfigError(
401 "legacy reference display id cannot be empty".into(),
402 ));
403 }
404 if let Some(sizing) = self.sizing {
405 sizing.calculate().map_err(|error| {
406 ConfigError(format!("legacy sizing parameters are invalid: {error}"))
407 })?;
408 }
409 Ok(())
410 }
411}
412
413impl From<LegacyAppConfig> for AppConfig {
414 fn from(legacy: LegacyAppConfig) -> Self {
415 Self {
416 version: CONFIG_VERSION,
417 groups: vec![GroupConfig {
418 id: 0,
419 route: GroupRouteConfig::Mirror {
420 target_id: legacy.target_id,
421 },
422 reference_source: legacy.reference_source,
423 sizing: legacy.sizing,
424 }],
425 selected_group_id: Some(0),
426 }
427 }
428}
429
430fn invalid_config(path: &Path, error: impl Display) -> LoadOutcome {
431 LoadOutcome {
432 config: AppConfig::default(),
433 warning: Some(format!(
434 "{} is invalid and was left unchanged: {error}",
435 path.display()
436 )),
437 }
438}
439
440fn remove_if_present(path: &Path) -> Result<(), ConfigError> {
441 match fs::remove_file(path) {
442 Ok(()) => Ok(()),
443 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
444 Err(error) => Err(ConfigError(format!(
445 "could not remove {}: {error}",
446 path.display()
447 ))),
448 }
449}
450
451impl Default for DisplayOverrides {
452 fn default() -> Self {
453 Self {
454 version: DISPLAY_OVERRIDES_VERSION,
455 displays: Vec::new(),
456 }
457 }
458}
459
460impl DisplayOverrides {
461 pub fn override_for(&self, display_id: &str) -> Option<&DisplayOverride> {
462 self.displays
463 .iter()
464 .find(|entry| entry.display_id.eq_ignore_ascii_case(display_id))
465 }
466
467 pub fn upsert(
468 &mut self,
469 display_id: String,
470 diagonal_inches: f64,
471 aspect_ratio: Option<AspectRatio>,
472 ) -> Result<(), ConfigError> {
473 validate_override(&display_id, diagonal_inches, aspect_ratio)?;
474 if let Some(entry) = self
475 .displays
476 .iter_mut()
477 .find(|entry| entry.display_id.eq_ignore_ascii_case(&display_id))
478 {
479 entry.display_id = display_id;
480 entry.diagonal_inches = diagonal_inches;
481 entry.aspect_ratio = aspect_ratio;
482 } else {
483 self.displays.push(DisplayOverride {
484 display_id,
485 diagonal_inches,
486 aspect_ratio,
487 });
488 }
489 self.displays
490 .sort_by_key(|entry| entry.display_id.to_ascii_lowercase());
491 Ok(())
492 }
493
494 pub fn remove(&mut self, display_id: &str) -> bool {
495 let before = self.displays.len();
496 self.displays
497 .retain(|entry| !entry.display_id.eq_ignore_ascii_case(display_id));
498 self.displays.len() != before
499 }
500
501 fn validate(&self) -> Result<(), ConfigError> {
502 if self.version != DISPLAY_OVERRIDES_VERSION {
503 return Err(ConfigError(format!(
504 "unsupported display override version {}",
505 self.version
506 )));
507 }
508 if self.displays.len() > 256 {
509 return Err(ConfigError("too many display overrides".into()));
510 }
511 for (index, entry) in self.displays.iter().enumerate() {
512 validate_override(&entry.display_id, entry.diagonal_inches, entry.aspect_ratio)?;
513 if self.displays[..index]
514 .iter()
515 .any(|other| other.display_id.eq_ignore_ascii_case(&entry.display_id))
516 {
517 return Err(ConfigError(format!(
518 "duplicate display override id {}",
519 entry.display_id
520 )));
521 }
522 }
523 Ok(())
524 }
525}
526
527fn validate_override(
528 display_id: &str,
529 diagonal_inches: f64,
530 aspect_ratio: Option<AspectRatio>,
531) -> Result<(), ConfigError> {
532 if display_id.trim().is_empty() {
533 return Err(ConfigError("display override id cannot be empty".into()));
534 }
535 let diagonal_mm = diagonal_inches * MILLIMETERS_PER_INCH;
536 if !valid_physical_millimeters(diagonal_mm) {
537 return Err(ConfigError(
538 "display diagonal must be between 10 and 10000 millimetres".into(),
539 ));
540 }
541 if aspect_ratio.is_some_and(|ratio| ratio.width == 0 || ratio.height == 0) {
542 return Err(ConfigError(
543 "display override aspect ratio dimensions must be positive".into(),
544 ));
545 }
546 Ok(())
547}
548
549impl ConfigStore {
550 pub fn default_path() -> Result<PathBuf, ConfigError> {
551 local_config_path(CONFIG_FILE)
552 }
553
554 pub fn default_store() -> Result<Self, ConfigError> {
555 let path = Self::default_path()?;
556 let legacy_path = path
557 .parent()
558 .expect("default config path has a parent")
559 .join(LEGACY_CONFIG_FILE);
560 Ok(Self {
561 path,
562 legacy_path: Some(legacy_path),
563 })
564 }
565
566 pub fn new(path: PathBuf) -> Self {
567 Self {
568 path,
569 legacy_path: None,
570 }
571 }
572
573 #[cfg(test)]
574 fn with_legacy_path(path: PathBuf, legacy_path: PathBuf) -> Self {
575 Self {
576 path,
577 legacy_path: Some(legacy_path),
578 }
579 }
580
581 pub fn path(&self) -> &Path {
582 &self.path
583 }
584
585 pub fn load(&self) -> Result<LoadOutcome, ConfigError> {
586 let bytes = match fs::read(&self.path) {
587 Ok(bytes) => bytes,
588 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
589 return self.load_legacy();
590 }
591 Err(error) => {
592 return Err(ConfigError(format!(
593 "could not read {}: {error}",
594 self.path.display()
595 )));
596 }
597 };
598
599 let version = match serde_json::from_slice::<VersionHeader>(&bytes) {
600 Ok(header) => header.version,
601 Err(error) => return Ok(invalid_config(&self.path, error)),
602 };
603 if version != CONFIG_VERSION {
604 return Ok(LoadOutcome {
605 config: AppConfig::default(),
606 warning: Some(format!(
607 "{} uses unsupported config version {} and was left unchanged",
608 self.path.display(),
609 version
610 )),
611 });
612 }
613
614 let config = match serde_json::from_slice::<AppConfig>(&bytes) {
615 Ok(config) => config,
616 Err(error) => return Ok(invalid_config(&self.path, error)),
617 };
618 if let Err(error) = config.validate() {
619 return Ok(LoadOutcome {
620 config: AppConfig::default(),
621 warning: Some(format!(
622 "{} is invalid and was left unchanged: {error}",
623 self.path.display()
624 )),
625 });
626 }
627 Ok(LoadOutcome {
628 config,
629 warning: None,
630 })
631 }
632
633 pub fn load_strict(&self) -> Result<AppConfig, ConfigError> {
634 let bytes = fs::read(&self.path).map_err(|error| {
635 ConfigError(format!("could not read {}: {error}", self.path.display()))
636 })?;
637 let config = serde_json::from_slice::<AppConfig>(&bytes).map_err(|error| {
638 ConfigError(format!(
639 "{} is not a valid SBMS configuration: {error}",
640 self.path.display()
641 ))
642 })?;
643 config.validate()?;
644 Ok(config)
645 }
646
647 pub fn save(&self, config: &AppConfig) -> Result<(), ConfigError> {
648 config.validate()?;
649 save_json_atomically(&self.path, config, "config", "config")
650 }
651
652 pub fn save_new(&self, config: &AppConfig) -> Result<(), ConfigError> {
653 config.validate()?;
654 save_json_atomically_new(&self.path, config, "config", "config")
655 }
656
657 pub fn reset(&self) -> Result<(), ConfigError> {
658 remove_if_present(&self.path)?;
659 if let Some(legacy_path) = &self.legacy_path {
660 remove_if_present(legacy_path)?;
661 }
662 Ok(())
663 }
664
665 fn load_legacy(&self) -> Result<LoadOutcome, ConfigError> {
666 let Some(legacy_path) = &self.legacy_path else {
667 return Ok(LoadOutcome {
668 config: AppConfig::default(),
669 warning: None,
670 });
671 };
672 let bytes = match fs::read(legacy_path) {
673 Ok(bytes) => bytes,
674 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
675 return Ok(LoadOutcome {
676 config: AppConfig::default(),
677 warning: None,
678 });
679 }
680 Err(error) => {
681 return Err(ConfigError(format!(
682 "could not read {}: {error}",
683 legacy_path.display()
684 )));
685 }
686 };
687
688 let version = match serde_json::from_slice::<VersionHeader>(&bytes) {
689 Ok(header) => header.version,
690 Err(error) => return Ok(invalid_config(legacy_path, error)),
691 };
692 if version != LEGACY_CONFIG_VERSION {
693 return Ok(LoadOutcome {
694 config: AppConfig::default(),
695 warning: Some(format!(
696 "{} uses unsupported config version {} and was left unchanged",
697 legacy_path.display(),
698 version
699 )),
700 });
701 }
702 let legacy = match serde_json::from_slice::<LegacyAppConfig>(&bytes) {
703 Ok(config) => config,
704 Err(error) => return Ok(invalid_config(legacy_path, error)),
705 };
706 if let Err(error) = legacy.validate() {
707 return Ok(LoadOutcome {
708 config: AppConfig::default(),
709 warning: Some(format!(
710 "{} is invalid and was left unchanged: {error}",
711 legacy_path.display()
712 )),
713 });
714 }
715 let config = AppConfig::from(legacy);
716 self.save(&config).map_err(|error| {
717 ConfigError(format!(
718 "could not migrate {} to {}: {error}",
719 legacy_path.display(),
720 self.path.display()
721 ))
722 })?;
723 Ok(LoadOutcome {
724 config,
725 warning: None,
726 })
727 }
728}
729
730impl ConfigProfiles {
731 fn validate(&self) -> Result<(), ConfigError> {
732 if self.version != CONFIG_PROFILES_VERSION {
733 return Err(ConfigError(format!(
734 "unsupported config profiles version {}",
735 self.version
736 )));
737 }
738 if self.profiles.is_empty() {
739 return Err(ConfigError(
740 "config profiles must contain at least one profile".into(),
741 ));
742 }
743 if self.profiles.len() > MAX_CONFIG_PROFILES {
744 return Err(ConfigError(format!(
745 "config profiles may contain at most {MAX_CONFIG_PROFILES} profiles"
746 )));
747 }
748 validate_profile_id(&self.active_profile)?;
749 for (index, profile) in self.profiles.iter().enumerate() {
750 validate_profile_id(&profile.id)?;
751 if profile.revision == 0 {
752 return Err(ConfigError(format!(
753 "config profile {} has an invalid zero revision",
754 profile.id
755 )));
756 }
757 if self.profiles[..index]
758 .iter()
759 .any(|other| other.id.eq_ignore_ascii_case(&profile.id))
760 {
761 return Err(ConfigError(format!(
762 "config profile id {} is duplicated",
763 profile.id
764 )));
765 }
766 profile.config.validate()?;
767 }
768 if !self
769 .profiles
770 .iter()
771 .any(|profile| profile.id.eq_ignore_ascii_case(&self.active_profile))
772 {
773 return Err(ConfigError(format!(
774 "active config profile {} does not exist",
775 self.active_profile
776 )));
777 }
778 Ok(())
779 }
780
781 fn profile(&self, id: &str) -> Option<&ConfigProfile> {
782 self.profiles
783 .iter()
784 .find(|profile| profile.id.eq_ignore_ascii_case(id))
785 }
786
787 fn profile_mut(&mut self, id: &str) -> Option<&mut ConfigProfile> {
788 self.profiles
789 .iter_mut()
790 .find(|profile| profile.id.eq_ignore_ascii_case(id))
791 }
792
793 fn active(&self) -> &ConfigProfile {
794 self.profile(&self.active_profile)
795 .expect("validated config profiles contain the active profile")
796 }
797}
798
799impl ConfigProfileStore {
800 pub fn default_path() -> Result<PathBuf, ConfigError> {
801 local_config_path(CONFIG_PROFILES_FILE)
802 }
803
804 pub fn default_store() -> Result<Self, ConfigError> {
805 Ok(Self {
806 path: Self::default_path()?,
807 legacy_config_path: ConfigStore::default_path()?,
808 })
809 }
810
811 pub fn new(path: PathBuf, legacy_config_path: PathBuf) -> Self {
812 Self {
813 path,
814 legacy_config_path,
815 }
816 }
817
818 pub fn path(&self) -> &Path {
819 &self.path
820 }
821
822 pub fn list(&self) -> Result<ConfigProfiles, ConfigError> {
823 let _guard = CrossProcessConfigGuard::acquire()?;
824 self.load_or_initialize_locked()
825 }
826
827 pub fn load_active(&self) -> Result<ProfileSnapshot, ConfigError> {
828 let _guard = CrossProcessConfigGuard::acquire()?;
829 let profiles = self.load_or_initialize_locked()?;
830 Ok(snapshot(profiles.active()))
831 }
832
833 pub fn set_launch_intro_disabled(&self, disabled: bool) -> Result<(), ConfigError> {
834 let _guard = CrossProcessConfigGuard::acquire()?;
835 let mut profiles = self.load_or_initialize_locked()?;
836 profiles.launch_intro_disabled = disabled;
837 self.save_locked(&profiles)
838 }
839
840 pub fn load_profile(&self, id: &str) -> Result<ProfileSnapshot, ConfigError> {
841 validate_profile_id(id)?;
842 let _guard = CrossProcessConfigGuard::acquire()?;
843 let profiles = self.load_or_initialize_locked()?;
844 profiles
845 .profile(id)
846 .map(snapshot)
847 .ok_or_else(|| ConfigError(format!("config profile {id} does not exist")))
848 }
849
850 pub fn update_active(
851 &self,
852 update: impl FnOnce(&mut AppConfig) -> Result<(), ConfigError>,
853 ) -> Result<ProfileSnapshot, ConfigError> {
854 let _guard = CrossProcessConfigGuard::acquire()?;
855 let mut profiles = self.load_or_initialize_locked()?;
856 let active_id = profiles.active_profile.clone();
857 let profile = profiles
858 .profile_mut(&active_id)
859 .expect("validated config profiles contain the active profile");
860 update(&mut profile.config)?;
861 profile.config.validate()?;
862 profile.revision = next_revision(profile.revision)?;
863 let result = snapshot(profile);
864 self.save_locked(&profiles)?;
865 Ok(result)
866 }
867
868 pub fn save_active_if_revision(
869 &self,
870 expected: &ProfileRevision,
871 config: &AppConfig,
872 ) -> Result<ProfileSnapshot, ConfigError> {
873 self.save_active_if_revision_inner(expected, config, true)
874 }
875
876 fn save_active_if_revision_inner(
877 &self,
878 expected: &ProfileRevision,
879 config: &AppConfig,
880 validate_sizing: bool,
881 ) -> Result<ProfileSnapshot, ConfigError> {
882 config.validate_with_sizing(validate_sizing)?;
883 let _guard = CrossProcessConfigGuard::acquire()?;
884 let mut profiles = self.load_or_initialize_locked()?;
885 if !profiles.active_profile.eq_ignore_ascii_case(&expected.id) {
886 return Err(ConfigError(format!(
887 "active config profile changed from {} to {}; reload before saving",
888 expected.id, profiles.active_profile
889 )));
890 }
891 let profile = profiles
892 .profile_mut(&expected.id)
893 .expect("validated config profiles contain the active profile");
894 if profile.revision != expected.revision {
895 return Err(ConfigError(format!(
896 "config profile {} changed from revision {} to {}; reload before saving",
897 profile.id, expected.revision, profile.revision
898 )));
899 }
900 profile.config = config.clone();
901 profile.revision = next_revision(profile.revision)?;
902 let result = snapshot(profile);
903 self.save_locked(&profiles)?;
904 Ok(result)
905 }
906
907 pub fn save_profile(
908 &self,
909 id: &str,
910 config: &AppConfig,
911 replace: bool,
912 activate: bool,
913 ) -> Result<(ProfileSnapshot, bool), ConfigError> {
914 validate_profile_id(id)?;
915 config.validate()?;
916 let _guard = CrossProcessConfigGuard::acquire()?;
917 let mut profiles = self.load_or_initialize_locked()?;
918 let profile_index = profiles
919 .profiles
920 .iter()
921 .position(|profile| profile.id.eq_ignore_ascii_case(id));
922 let index = if let Some(index) = profile_index {
923 if !replace {
924 return Err(ConfigError(format!(
925 "config profile {} already exists; use --replace to overwrite it",
926 profiles.profiles[index].id
927 )));
928 }
929 profiles.profiles[index].config = config.clone();
930 profiles.profiles[index].revision = next_revision(profiles.profiles[index].revision)?;
931 index
932 } else {
933 if profiles.profiles.len() >= MAX_CONFIG_PROFILES {
934 return Err(ConfigError(format!(
935 "cannot create config profile {id}; the {MAX_CONFIG_PROFILES}-profile limit is reached"
936 )));
937 }
938 profiles.profiles.push(ConfigProfile {
939 id: id.into(),
940 revision: 1,
941 config: config.clone(),
942 });
943 profiles.profiles.len() - 1
944 };
945 if activate {
946 profiles.active_profile = profiles.profiles[index].id.clone();
947 }
948 let result = snapshot(&profiles.profiles[index]);
949 let is_active = profiles
950 .active_profile
951 .eq_ignore_ascii_case(&result.profile.id);
952 self.save_locked(&profiles)?;
953 Ok((result, is_active))
954 }
955
956 pub fn save_active_as(&self, id: &str) -> Result<ProfileSnapshot, ConfigError> {
957 validate_profile_id(id)?;
958 let _guard = CrossProcessConfigGuard::acquire()?;
959 let mut profiles = self.load_or_initialize_locked()?;
960 if profiles.active_profile.eq_ignore_ascii_case(id) {
961 return Ok(snapshot(profiles.active()));
962 }
963 let config = profiles.active().config.clone();
964 if let Some(profile) = profiles.profile_mut(id) {
965 profile.config = config;
966 profile.revision = next_revision(profile.revision)?;
967 let result = snapshot(profile);
968 self.save_locked(&profiles)?;
969 return Ok(result);
970 }
971 if profiles.profiles.len() >= MAX_CONFIG_PROFILES {
972 return Err(ConfigError(format!(
973 "cannot create config profile {id}; the {MAX_CONFIG_PROFILES}-profile limit is reached"
974 )));
975 }
976 profiles.profiles.push(ConfigProfile {
977 id: id.into(),
978 revision: 1,
979 config,
980 });
981 let result = snapshot(profiles.profiles.last().expect("profile was pushed"));
982 self.save_locked(&profiles)?;
983 Ok(result)
984 }
985
986 pub fn duplicate_active(
987 &self,
988 id: &str,
989 activate: bool,
990 ) -> Result<(ProfileSnapshot, bool), ConfigError> {
991 validate_profile_id(id)?;
992 let _guard = CrossProcessConfigGuard::acquire()?;
993 let mut profiles = self.load_or_initialize_locked()?;
994 if profiles.profile(id).is_some() {
995 return Err(ConfigError(format!("config profile {id} already exists")));
996 }
997 if profiles.profiles.len() >= MAX_CONFIG_PROFILES {
998 return Err(ConfigError(format!(
999 "cannot create config profile {id}; the {MAX_CONFIG_PROFILES}-profile limit is reached"
1000 )));
1001 }
1002 let config = profiles.active().config.clone();
1003 profiles.profiles.push(ConfigProfile {
1004 id: id.into(),
1005 revision: 1,
1006 config,
1007 });
1008 let index = profiles.profiles.len() - 1;
1009 if activate {
1010 profiles.active_profile = profiles.profiles[index].id.clone();
1011 }
1012 let result = snapshot(&profiles.profiles[index]);
1013 let is_active = profiles
1014 .active_profile
1015 .eq_ignore_ascii_case(&result.profile.id);
1016 self.save_locked(&profiles)?;
1017 Ok((result, is_active))
1018 }
1019
1020 pub fn rename(
1021 &self,
1022 old_id: &str,
1023 new_id: &str,
1024 ) -> Result<(ProfileSnapshot, bool), ConfigError> {
1025 validate_profile_id(old_id)?;
1026 validate_profile_id(new_id)?;
1027 let _guard = CrossProcessConfigGuard::acquire()?;
1028 let mut profiles = self.load_or_initialize_locked()?;
1029 let index = profiles
1030 .profiles
1031 .iter()
1032 .position(|profile| profile.id.eq_ignore_ascii_case(old_id))
1033 .ok_or_else(|| ConfigError(format!("config profile {old_id} does not exist")))?;
1034 if profiles
1035 .profiles
1036 .iter()
1037 .enumerate()
1038 .any(|(other_index, profile)| {
1039 other_index != index && profile.id.eq_ignore_ascii_case(new_id)
1040 })
1041 {
1042 return Err(ConfigError(format!(
1043 "config profile {new_id} already exists"
1044 )));
1045 }
1046 if profiles.profiles[index].id == new_id {
1047 let result = snapshot(&profiles.profiles[index]);
1048 let is_active = profiles
1049 .active_profile
1050 .eq_ignore_ascii_case(&result.profile.id);
1051 return Ok((result, is_active));
1052 }
1053 let was_active = profiles
1054 .active_profile
1055 .eq_ignore_ascii_case(&profiles.profiles[index].id);
1056 profiles.profiles[index].id = new_id.into();
1057 profiles.profiles[index].revision = next_revision(profiles.profiles[index].revision)?;
1058 if was_active {
1059 profiles.active_profile = profiles.profiles[index].id.clone();
1060 }
1061 let result = snapshot(&profiles.profiles[index]);
1062 self.save_locked(&profiles)?;
1063 Ok((result, was_active))
1064 }
1065
1066 pub fn activate(&self, id: &str) -> Result<ProfileSnapshot, ConfigError> {
1067 validate_profile_id(id)?;
1068 let _guard = CrossProcessConfigGuard::acquire()?;
1069 let mut profiles = self.load_or_initialize_locked()?;
1070 let profile = profiles
1071 .profile(id)
1072 .ok_or_else(|| ConfigError(format!("config profile {id} does not exist")))?;
1073 let result = snapshot(profile);
1074 profiles.active_profile = result.profile.id.clone();
1075 self.save_locked(&profiles)?;
1076 Ok(result)
1077 }
1078
1079 pub fn delete(&self, id: &str) -> Result<(), ConfigError> {
1080 validate_profile_id(id)?;
1081 let _guard = CrossProcessConfigGuard::acquire()?;
1082 let mut profiles = self.load_or_initialize_locked()?;
1083 if profiles.active_profile.eq_ignore_ascii_case(id) {
1084 return Err(ConfigError(format!(
1085 "cannot delete active config profile {}",
1086 profiles.active_profile
1087 )));
1088 }
1089 let before = profiles.profiles.len();
1090 profiles
1091 .profiles
1092 .retain(|profile| !profile.id.eq_ignore_ascii_case(id));
1093 if profiles.profiles.len() == before {
1094 return Err(ConfigError(format!("config profile {id} does not exist")));
1095 }
1096 self.save_locked(&profiles)
1097 }
1098
1099 pub fn delete_with_fallback(&self, id: &str) -> Result<ProfileDeleteOutcome, ConfigError> {
1100 validate_profile_id(id)?;
1101 let _guard = CrossProcessConfigGuard::acquire()?;
1102 let mut profiles = self.load_or_initialize_locked()?;
1103 let index = profiles
1104 .profiles
1105 .iter()
1106 .position(|profile| profile.id.eq_ignore_ascii_case(id))
1107 .ok_or_else(|| ConfigError(format!("config profile {id} does not exist")))?;
1108 if profiles.profiles.len() == 1 {
1109 return Err(ConfigError("cannot delete the last config profile".into()));
1110 }
1111 let active_changed = profiles
1112 .active_profile
1113 .eq_ignore_ascii_case(&profiles.profiles[index].id);
1114 profiles.profiles.remove(index);
1115 if active_changed {
1116 let fallback_index = index.saturating_sub(1).min(profiles.profiles.len() - 1);
1117 profiles.active_profile = profiles.profiles[fallback_index].id.clone();
1118 }
1119 let active = snapshot(profiles.active());
1120 self.save_locked(&profiles)?;
1121 Ok(ProfileDeleteOutcome {
1122 active,
1123 active_changed,
1124 })
1125 }
1126
1127 pub fn reset_active(&self) -> Result<ProfileSnapshot, ConfigError> {
1128 let _guard = CrossProcessConfigGuard::acquire()?;
1129 let mut profiles = if self.path.exists() {
1130 self.load_or_initialize_locked()?
1131 } else {
1132 ConfigProfiles {
1133 version: CONFIG_PROFILES_VERSION,
1134 active_profile: "default".into(),
1135 launch_intro_disabled: false,
1136 profiles: vec![ConfigProfile {
1137 id: "default".into(),
1138 revision: 1,
1139 config: AppConfig::default(),
1140 }],
1141 }
1142 };
1143 let active_id = profiles.active_profile.clone();
1144 let profile = profiles
1145 .profile_mut(&active_id)
1146 .expect("validated config profiles contain the active profile");
1147 profile.config = AppConfig::default();
1148 profile.revision = next_revision(profile.revision)?;
1149 let result = snapshot(profile);
1150 self.save_locked(&profiles)?;
1151 Ok(result)
1152 }
1153
1154 fn load_or_initialize_locked(&self) -> Result<ConfigProfiles, ConfigError> {
1155 match fs::read(&self.path) {
1156 Ok(bytes) => {
1157 let profiles =
1158 serde_json::from_slice::<ConfigProfiles>(&bytes).map_err(|error| {
1159 ConfigError(format!(
1160 "{} is not a valid SBMS config profile store: {error}",
1161 self.path.display()
1162 ))
1163 })?;
1164 profiles.validate()?;
1165 Ok(profiles)
1166 }
1167 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1168 let legacy_store = ConfigStore {
1169 path: self.legacy_config_path.clone(),
1170 legacy_path: Some(
1171 self.legacy_config_path
1172 .parent()
1173 .unwrap_or_else(|| Path::new("."))
1174 .join(LEGACY_CONFIG_FILE),
1175 ),
1176 };
1177 let config = match legacy_store.load() {
1178 Ok(outcome) => {
1179 if let Some(warning) = outcome.warning {
1180 return Err(ConfigError(format!(
1181 "cannot initialize config profiles: {warning}"
1182 )));
1183 }
1184 outcome.config
1185 }
1186 Err(error) => return Err(error),
1187 };
1188 let profiles = ConfigProfiles {
1189 version: CONFIG_PROFILES_VERSION,
1190 active_profile: "default".into(),
1191 launch_intro_disabled: false,
1192 profiles: vec![ConfigProfile {
1193 id: "default".into(),
1194 revision: 1,
1195 config,
1196 }],
1197 };
1198 self.save_locked(&profiles)?;
1199 Ok(profiles)
1200 }
1201 Err(error) => Err(ConfigError(format!(
1202 "could not read {}: {error}",
1203 self.path.display()
1204 ))),
1205 }
1206 }
1207
1208 fn save_locked(&self, profiles: &ConfigProfiles) -> Result<(), ConfigError> {
1209 profiles.validate()?;
1210 save_json_atomically(&self.path, profiles, "config profiles", "config profiles")
1211 }
1212}
1213
1214impl CrossProcessConfigGuard {
1215 fn acquire() -> Result<Self, ConfigError> {
1216 let handle = unsafe { CreateMutexW(None, false, w!("Local\\SBMSConfigProfiles-v1")) }
1217 .map_err(|error| {
1218 ConfigError(format!("could not create config profiles lock: {error}"))
1219 })?;
1220 let wait = unsafe { WaitForSingleObject(handle, INFINITE) };
1221 if wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED {
1222 return Ok(Self(handle));
1223 }
1224 unsafe {
1225 let _ = CloseHandle(handle);
1226 }
1227 if wait == WAIT_FAILED {
1228 Err(ConfigError(format!(
1229 "could not acquire config profiles lock: {}",
1230 std::io::Error::last_os_error()
1231 )))
1232 } else {
1233 Err(ConfigError(format!(
1234 "unexpected config profiles lock wait result: {}",
1235 wait.0
1236 )))
1237 }
1238 }
1239}
1240
1241impl Drop for CrossProcessConfigGuard {
1242 fn drop(&mut self) {
1243 unsafe {
1244 let _ = ReleaseMutex(self.0);
1245 let _ = CloseHandle(self.0);
1246 }
1247 }
1248}
1249
1250fn snapshot(profile: &ConfigProfile) -> ProfileSnapshot {
1251 ProfileSnapshot {
1252 profile: ProfileRevision {
1253 id: profile.id.clone(),
1254 revision: profile.revision,
1255 },
1256 config: profile.config.clone(),
1257 }
1258}
1259
1260fn validate_profile_id(id: &str) -> Result<(), ConfigError> {
1261 if id.is_empty()
1262 || id.len() > 32
1263 || !id
1264 .bytes()
1265 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
1266 {
1267 return Err(ConfigError(
1268 "config profile id must contain 1-32 ASCII letters, digits, underscores, or hyphens"
1269 .into(),
1270 ));
1271 }
1272 Ok(())
1273}
1274
1275fn next_revision(revision: u64) -> Result<u64, ConfigError> {
1276 revision
1277 .checked_add(1)
1278 .ok_or_else(|| ConfigError("config profile revision overflow".into()))
1279}
1280
1281impl DisplayOverrideStore {
1282 pub fn default_path() -> Result<PathBuf, ConfigError> {
1283 local_config_path(DISPLAY_OVERRIDES_FILE)
1284 }
1285
1286 pub fn default_store() -> Result<Self, ConfigError> {
1287 Ok(Self::new(Self::default_path()?))
1288 }
1289
1290 pub fn new(path: PathBuf) -> Self {
1291 Self { path }
1292 }
1293
1294 pub fn load(&self) -> Result<DisplayOverrideLoadOutcome, ConfigError> {
1295 let bytes = match fs::read(&self.path) {
1296 Ok(bytes) => bytes,
1297 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1298 return Ok(DisplayOverrideLoadOutcome {
1299 overrides: DisplayOverrides::default(),
1300 warning: None,
1301 });
1302 }
1303 Err(error) => {
1304 return Err(ConfigError(format!(
1305 "could not read {}: {error}",
1306 self.path.display()
1307 )));
1308 }
1309 };
1310 let overrides = match serde_json::from_slice::<DisplayOverrides>(&bytes) {
1311 Ok(overrides) => overrides,
1312 Err(error) => {
1313 return Ok(DisplayOverrideLoadOutcome {
1314 overrides: DisplayOverrides::default(),
1315 warning: Some(format!(
1316 "{} is invalid and was left unchanged: {error}",
1317 self.path.display()
1318 )),
1319 });
1320 }
1321 };
1322 if let Err(error) = overrides.validate() {
1323 return Ok(DisplayOverrideLoadOutcome {
1324 overrides: DisplayOverrides::default(),
1325 warning: Some(format!(
1326 "{} is invalid and was left unchanged: {error}",
1327 self.path.display()
1328 )),
1329 });
1330 }
1331 Ok(DisplayOverrideLoadOutcome {
1332 overrides,
1333 warning: None,
1334 })
1335 }
1336
1337 pub fn save(&self, overrides: &DisplayOverrides) -> Result<(), ConfigError> {
1338 overrides.validate()?;
1339 save_json_atomically(
1340 &self.path,
1341 overrides,
1342 "display override",
1343 "display overrides",
1344 )
1345 }
1346}
1347
1348fn local_config_path(file_name: &str) -> Result<PathBuf, ConfigError> {
1349 let local_app_data = env::var_os("LOCALAPPDATA")
1350 .filter(|value| !value.is_empty())
1351 .ok_or_else(|| ConfigError("LOCALAPPDATA is not available".into()))?;
1352 Ok(PathBuf::from(local_app_data)
1353 .join(CONFIG_DIRECTORY)
1354 .join(file_name))
1355}
1356
1357fn save_json_atomically<T: Serialize>(
1358 destination: &Path,
1359 value: &T,
1360 path_kind: &str,
1361 artifact_name: &str,
1362) -> Result<(), ConfigError> {
1363 save_json_atomically_inner(destination, value, path_kind, artifact_name, true)
1364}
1365
1366fn save_json_atomically_new<T: Serialize>(
1367 destination: &Path,
1368 value: &T,
1369 path_kind: &str,
1370 artifact_name: &str,
1371) -> Result<(), ConfigError> {
1372 save_json_atomically_inner(destination, value, path_kind, artifact_name, false)
1373}
1374
1375fn save_json_atomically_inner<T: Serialize>(
1376 destination: &Path,
1377 value: &T,
1378 path_kind: &str,
1379 artifact_name: &str,
1380 replace: bool,
1381) -> Result<(), ConfigError> {
1382 let parent = destination
1383 .parent()
1384 .ok_or_else(|| ConfigError(format!("{path_kind} path has no parent directory")))?;
1385 fs::create_dir_all(parent)
1386 .map_err(|error| ConfigError(format!("could not create {}: {error}", parent.display())))?;
1387 let (temporary_path, mut temporary) = create_temporary(parent, destination)?;
1388 let result = (|| {
1389 let bytes = serde_json::to_vec_pretty(value).map_err(|error| {
1390 ConfigError(format!("could not serialize {artifact_name}: {error}"))
1391 })?;
1392 temporary
1393 .write_all(&bytes)
1394 .and_then(|_| temporary.write_all(b"\n"))
1395 .and_then(|_| temporary.sync_all())
1396 .map_err(|error| {
1397 ConfigError(format!(
1398 "could not write temporary {artifact_name} {}: {error}",
1399 temporary_path.display()
1400 ))
1401 })?;
1402 drop(temporary);
1403 if replace {
1404 atomic_replace(&temporary_path, destination)
1405 } else {
1406 atomic_install_new(&temporary_path, destination)
1407 }
1408 })();
1409 if result.is_err() {
1410 let _ = fs::remove_file(&temporary_path);
1411 }
1412 result
1413}
1414
1415fn atomic_install_new(temporary: &Path, destination: &Path) -> Result<(), ConfigError> {
1416 let temporary_wide = wide(temporary.as_os_str());
1417 let destination_wide = wide(destination.as_os_str());
1418 unsafe {
1419 MoveFileExW(
1420 PCWSTR(temporary_wide.as_ptr()),
1421 PCWSTR(destination_wide.as_ptr()),
1422 MOVEFILE_WRITE_THROUGH,
1423 )
1424 }
1425 .map_err(|error| {
1426 ConfigError(format!(
1427 "could not atomically create {}: {error}",
1428 destination.display()
1429 ))
1430 })
1431}
1432
1433fn create_temporary(parent: &Path, destination: &Path) -> Result<(PathBuf, File), ConfigError> {
1434 let file_name = destination
1435 .file_name()
1436 .and_then(OsStr::to_str)
1437 .unwrap_or("config.json");
1438 for _ in 0..32 {
1439 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1440 let path = parent.join(format!(
1441 ".{file_name}.{}.{}.tmp",
1442 std::process::id(),
1443 sequence
1444 ));
1445 match OpenOptions::new().write(true).create_new(true).open(&path) {
1446 Ok(file) => return Ok((path, file)),
1447 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1448 Err(error) => {
1449 return Err(ConfigError(format!(
1450 "could not create temporary config {}: {error}",
1451 path.display()
1452 )));
1453 }
1454 }
1455 }
1456 Err(ConfigError(
1457 "could not allocate a unique temporary config file".into(),
1458 ))
1459}
1460
1461fn atomic_replace(temporary: &Path, destination: &Path) -> Result<(), ConfigError> {
1462 let temporary_wide = wide(temporary.as_os_str());
1463 let destination_wide = wide(destination.as_os_str());
1464 let temporary_pcwstr = PCWSTR(temporary_wide.as_ptr());
1465 let destination_pcwstr = PCWSTR(destination_wide.as_ptr());
1466 let result = if destination.exists() {
1467 unsafe {
1468 ReplaceFileW(
1469 destination_pcwstr,
1470 temporary_pcwstr,
1471 PCWSTR::null(),
1472 REPLACEFILE_WRITE_THROUGH,
1473 None,
1474 None,
1475 )
1476 }
1477 } else {
1478 unsafe {
1479 MoveFileExW(
1480 temporary_pcwstr,
1481 destination_pcwstr,
1482 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
1483 )
1484 }
1485 };
1486 result.map_err(|error| {
1487 ConfigError(format!(
1488 "could not atomically replace {}: {error}",
1489 destination.display()
1490 ))
1491 })
1492}
1493
1494fn wide(value: &OsStr) -> Vec<u16> {
1495 value.encode_wide().chain(Some(0)).collect()
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use crate::geometry::{DisplayGeometry, PhysicalMeasurement, PixelSize, SizingStrategy};
1502
1503 fn test_store(name: &str) -> ConfigStore {
1504 let path = env::temp_dir().join(format!(
1505 "sbms-config-test-{}-{}-{}.json",
1506 std::process::id(),
1507 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed),
1508 name
1509 ));
1510 ConfigStore::new(path)
1511 }
1512
1513 fn test_migration_store(name: &str) -> ConfigStore {
1514 let root = env::temp_dir().join(format!(
1515 "sbms-config-migration-test-{}-{}-{name}",
1516 std::process::id(),
1517 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed),
1518 ));
1519 ConfigStore::with_legacy_path(
1520 root.with_extension("v2.json"),
1521 root.with_extension("v1.json"),
1522 )
1523 }
1524
1525 fn test_sizing() -> SizingRequest {
1526 SizingRequest {
1527 reference: DisplayGeometry {
1528 native_pixels: PixelSize {
1529 width: 2560,
1530 height: 1440,
1531 },
1532 physical: PhysicalMeasurement::DiagonalMm(685.8),
1533 aspect_ratio: Some(AspectRatio {
1534 width: 16,
1535 height: 9,
1536 }),
1537 rotation: Rotation::Deg0,
1538 },
1539 target: DisplayGeometry {
1540 native_pixels: PixelSize {
1541 width: 3840,
1542 height: 2160,
1543 },
1544 physical: PhysicalMeasurement::DiagonalMm(1219.2),
1545 aspect_ratio: Some(AspectRatio {
1546 width: 16,
1547 height: 9,
1548 }),
1549 rotation: Rotation::Deg0,
1550 },
1551 strategy: SizingStrategy::MatchPhysicalSize,
1552 alignment: 2,
1553 preferred_refresh_millihz: Some(120_000),
1554 }
1555 }
1556
1557 fn stream_config(refresh_millihz: u32) -> AppConfig {
1558 let mut config = AppConfig::default();
1559 config.groups[0].route = GroupRouteConfig::StreamOnly {
1560 screen: StreamScreenConfig {
1561 width: 1920,
1562 height: 1080,
1563 diagonal_inches: Some(24.0),
1564 refresh_millihz,
1565 aspect_ratio: AspectRatio {
1566 width: 16,
1567 height: 9,
1568 },
1569 rotation: Rotation::Deg0,
1570 },
1571 };
1572 config
1573 }
1574
1575 fn test_override_store(name: &str) -> DisplayOverrideStore {
1576 let path = env::temp_dir().join(format!(
1577 "sbms-display-override-test-{}-{}-{}.json",
1578 std::process::id(),
1579 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed),
1580 name
1581 ));
1582 DisplayOverrideStore::new(path)
1583 }
1584
1585 #[test]
1586 fn missing_config_loads_defaults() {
1587 let store = test_store("missing");
1588 let outcome = store.load().unwrap();
1589 assert_eq!(outcome.config, AppConfig::default());
1590 assert_eq!(outcome.warning, None);
1591 }
1592
1593 #[test]
1594 fn round_trip_replaces_existing_file() {
1595 let store = test_store("round-trip");
1596 let mut config = AppConfig {
1597 version: CONFIG_VERSION,
1598 groups: vec![
1599 GroupConfig {
1600 id: 0,
1601 route: GroupRouteConfig::Mirror {
1602 target_id: Some("stable-display-id".into()),
1603 },
1604 reference_source: Some(ReferenceSource::Display("reference-display-id".into())),
1605 sizing: Some(test_sizing()),
1606 },
1607 GroupConfig {
1608 id: 3,
1609 route: GroupRouteConfig::StreamOnly {
1610 screen: StreamScreenConfig {
1611 width: 3840,
1612 height: 2160,
1613 diagonal_inches: Some(48.0),
1614 refresh_millihz: 120_000,
1615 aspect_ratio: AspectRatio {
1616 width: 16,
1617 height: 9,
1618 },
1619 rotation: Rotation::Deg90,
1620 },
1621 },
1622 reference_source: Some(ReferenceSource::Manual),
1623 sizing: Some(test_sizing()),
1624 },
1625 ],
1626 selected_group_id: Some(3),
1627 };
1628 store.save(&config).unwrap();
1629 assert_eq!(store.load().unwrap().config, config);
1630
1631 let GroupRouteConfig::Mirror { target_id } = &mut config.groups[0].route else {
1632 unreachable!()
1633 };
1634 *target_id = Some("replacement-id".into());
1635 store.save(&config).unwrap();
1636 assert_eq!(store.load().unwrap().config, config);
1637 store.reset().unwrap();
1638 }
1639
1640 #[test]
1641 fn empty_reference_display_id_is_rejected() {
1642 let store = test_store("empty-reference");
1643 let mut config = AppConfig::default();
1644 config.groups[0].reference_source = Some(ReferenceSource::Display(" ".into()));
1645 assert!(store.save(&config).is_err());
1646 }
1647
1648 #[test]
1649 fn malformed_config_is_preserved_and_reported() {
1650 let store = test_store("malformed");
1651 fs::write(store.path(), b"{not json").unwrap();
1652 let before = fs::read(store.path()).unwrap();
1653 let outcome = store.load().unwrap();
1654 assert_eq!(outcome.config, AppConfig::default());
1655 assert!(outcome.warning.is_some());
1656 assert_eq!(fs::read(store.path()).unwrap(), before);
1657 store.reset().unwrap();
1658 }
1659
1660 #[test]
1661 fn unsupported_version_is_preserved_and_reported() {
1662 let store = test_store("future");
1663 fs::write(store.path(), br#"{"version":99,"groups":[]}"#).unwrap();
1664 let before = fs::read(store.path()).unwrap();
1665 let outcome = store.load().unwrap();
1666 assert_eq!(outcome.config, AppConfig::default());
1667 assert!(
1668 outcome
1669 .warning
1670 .unwrap()
1671 .contains("unsupported config version")
1672 );
1673 assert_eq!(fs::read(store.path()).unwrap(), before);
1674 store.reset().unwrap();
1675 }
1676
1677 #[test]
1678 fn legacy_v1_migrates_to_output_one_and_is_preserved() {
1679 let store = test_migration_store("legacy");
1680 let legacy = LegacyAppConfig {
1681 version: LEGACY_CONFIG_VERSION,
1682 target_id: Some("legacy-target".into()),
1683 reference_source: Some(ReferenceSource::Display("legacy-reference".into())),
1684 sizing: Some(test_sizing()),
1685 };
1686 let legacy_path = store.legacy_path.as_ref().unwrap();
1687 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
1688 "version": legacy.version,
1689 "target_id": legacy.target_id,
1690 "reference_source": legacy.reference_source,
1691 "sizing": legacy.sizing,
1692 }))
1693 .unwrap();
1694 fs::write(legacy_path, &bytes).unwrap();
1695
1696 let outcome = store.load().unwrap();
1697 assert_eq!(outcome.warning, None);
1698 assert_eq!(outcome.config.version, CONFIG_VERSION);
1699 assert_eq!(outcome.config.groups.len(), 1);
1700 assert_eq!(outcome.config.groups[0].id, 0);
1701 assert_eq!(
1702 outcome.config.groups[0].route,
1703 GroupRouteConfig::Mirror {
1704 target_id: Some("legacy-target".into())
1705 }
1706 );
1707 assert_eq!(outcome.config.groups[0].sizing, Some(test_sizing()));
1708 assert_eq!(outcome.config.selected_group_id, Some(0));
1709 assert_eq!(fs::read(legacy_path).unwrap(), bytes);
1710 assert!(store.path().is_file());
1711 store.reset().unwrap();
1712 }
1713
1714 #[test]
1715 fn legacy_v1_rounded_scale_fixture_migrates_every_sizing_field() {
1716 let store = test_migration_store("legacy-rounded-scale");
1717 let legacy_path = store.legacy_path.as_ref().unwrap();
1718 let bytes = br#"{
1719 "version": 1,
1720 "target_id": "\\\\?\\DISPLAY#LHC907B#UID1028",
1721 "reference_source": {
1722 "display": "\\\\?\\DISPLAY#GTR1106#UID1024"
1723 },
1724 "sizing": {
1725 "reference": {
1726 "native_pixels": {
1727 "width": 5120,
1728 "height": 2880
1729 },
1730 "physical": {
1731 "dimensions_mm": {
1732 "width": 597.0,
1733 "height": 336.0
1734 }
1735 },
1736 "rotation": "deg0"
1737 },
1738 "target": {
1739 "native_pixels": {
1740 "width": 2560,
1741 "height": 1440
1742 },
1743 "physical": {
1744 "dimensions_mm": {
1745 "width": 541.0,
1746 "height": 303.0
1747 }
1748 },
1749 "rotation": "deg180"
1750 },
1751 "strategy": "rounded_scale",
1752 "alignment": 2,
1753 "preferred_refresh_millihz": 240000
1754 }
1755}"#;
1756 fs::write(legacy_path, bytes).unwrap();
1757
1758 let outcome = store.load().unwrap();
1759 assert_eq!(outcome.warning, None);
1760 assert_eq!(outcome.config.version, CONFIG_VERSION);
1761 assert_eq!(outcome.config.groups.len(), 1);
1762 assert_eq!(outcome.config.selected_group_id, Some(0));
1763
1764 let group = &outcome.config.groups[0];
1765 assert_eq!(group.id, 0);
1766 assert_eq!(
1767 group.route,
1768 GroupRouteConfig::Mirror {
1769 target_id: Some(r"\\?\DISPLAY#LHC907B#UID1028".into())
1770 }
1771 );
1772 assert_eq!(
1773 group.reference_source,
1774 Some(ReferenceSource::Display(
1775 r"\\?\DISPLAY#GTR1106#UID1024".into()
1776 ))
1777 );
1778
1779 let sizing = group.sizing.unwrap();
1780 assert_eq!(
1781 sizing.reference.native_pixels,
1782 PixelSize {
1783 width: 5120,
1784 height: 2880
1785 }
1786 );
1787 assert_eq!(
1788 sizing.reference.physical,
1789 PhysicalMeasurement::DimensionsMm {
1790 width: 597.0,
1791 height: 336.0
1792 }
1793 );
1794 assert_eq!(sizing.reference.aspect_ratio, None);
1795 assert_eq!(sizing.reference.rotation, Rotation::Deg0);
1796 assert_eq!(
1797 sizing.target.native_pixels,
1798 PixelSize {
1799 width: 2560,
1800 height: 1440
1801 }
1802 );
1803 assert_eq!(
1804 sizing.target.physical,
1805 PhysicalMeasurement::DimensionsMm {
1806 width: 541.0,
1807 height: 303.0
1808 }
1809 );
1810 assert_eq!(sizing.target.aspect_ratio, None);
1811 assert_eq!(sizing.target.rotation, Rotation::Deg180);
1812 assert_eq!(sizing.strategy, SizingStrategy::RoundedScale);
1813 assert_eq!(sizing.alignment, 2);
1814 assert_eq!(sizing.preferred_refresh_millihz, Some(240_000));
1815
1816 assert_eq!(fs::read(legacy_path).unwrap(), bytes);
1817 assert!(store.path().is_file());
1818 assert_eq!(store.load().unwrap().config, outcome.config);
1819 store.reset().unwrap();
1820 }
1821
1822 #[test]
1823 fn empty_legacy_config_migrates_to_unconfigured_output_one() {
1824 let store = test_migration_store("legacy-empty");
1825 let legacy_path = store.legacy_path.as_ref().unwrap();
1826 fs::write(
1827 legacy_path,
1828 br#"{"version":1,"target_id":null,"reference_source":null,"sizing":null}"#,
1829 )
1830 .unwrap();
1831 assert_eq!(store.load().unwrap().config, AppConfig::default());
1832 assert!(legacy_path.is_file());
1833 assert!(store.path().is_file());
1834 store.reset().unwrap();
1835 }
1836
1837 #[test]
1838 fn malformed_legacy_is_preserved_without_creating_v2() {
1839 let store = test_migration_store("legacy-malformed");
1840 let legacy_path = store.legacy_path.as_ref().unwrap();
1841 let bytes = b"{not json";
1842 fs::write(legacy_path, bytes).unwrap();
1843 let outcome = store.load().unwrap();
1844 assert!(outcome.warning.is_some());
1845 assert_eq!(fs::read(legacy_path).unwrap(), bytes);
1846 assert!(!store.path().exists());
1847 store.reset().unwrap();
1848 }
1849
1850 #[test]
1851 fn future_legacy_is_preserved_without_creating_v2() {
1852 let store = test_migration_store("legacy-future");
1853 let legacy_path = store.legacy_path.as_ref().unwrap();
1854 let bytes = br#"{"version":9,"target_id":"future"}"#;
1855 fs::write(legacy_path, bytes).unwrap();
1856 let outcome = store.load().unwrap();
1857 assert!(
1858 outcome
1859 .warning
1860 .as_deref()
1861 .is_some_and(|warning| warning.contains("unsupported config version 9"))
1862 );
1863 assert_eq!(fs::read(legacy_path).unwrap(), bytes);
1864 assert!(!store.path().exists());
1865 store.reset().unwrap();
1866 }
1867
1868 #[test]
1869 fn failed_migration_preserves_legacy_and_creates_no_v2() {
1870 let blocker = env::temp_dir().join(format!(
1871 "sbms-config-blocker-{}-{}",
1872 std::process::id(),
1873 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
1874 ));
1875 let legacy_path = env::temp_dir().join(format!(
1876 "sbms-config-legacy-{}-{}.json",
1877 std::process::id(),
1878 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
1879 ));
1880 fs::write(&blocker, b"not a directory").unwrap();
1881 let bytes = br#"{"version":1,"target_id":"legacy"}"#;
1882 fs::write(&legacy_path, bytes).unwrap();
1883 let store = ConfigStore::with_legacy_path(blocker.join(CONFIG_FILE), legacy_path.clone());
1884
1885 assert!(store.load().is_err());
1886 assert_eq!(fs::read(&legacy_path).unwrap(), bytes);
1887 assert!(!store.path().exists());
1888
1889 fs::remove_file(legacy_path).unwrap();
1890 fs::remove_file(blocker).unwrap();
1891 }
1892
1893 #[test]
1894 fn existing_malformed_v2_does_not_fall_back_to_legacy() {
1895 let store = test_migration_store("v2-malformed");
1896 let legacy_path = store.legacy_path.as_ref().unwrap();
1897 fs::write(
1898 legacy_path,
1899 br#"{"version":1,"target_id":"legacy","reference_source":null,"sizing":null}"#,
1900 )
1901 .unwrap();
1902 let current = b"{broken v2";
1903 fs::write(store.path(), current).unwrap();
1904 let outcome = store.load().unwrap();
1905 assert!(outcome.warning.is_some());
1906 assert_eq!(outcome.config, AppConfig::default());
1907 assert_eq!(fs::read(store.path()).unwrap(), current);
1908 store.reset().unwrap();
1909 }
1910
1911 #[test]
1912 fn existing_future_v2_does_not_fall_back_to_legacy() {
1913 let store = test_migration_store("v2-future");
1914 fs::write(
1915 store.legacy_path.as_ref().unwrap(),
1916 br#"{"version":1,"target_id":"legacy"}"#,
1917 )
1918 .unwrap();
1919 fs::write(store.path(), br#"{"version":3,"groups":[]}"#).unwrap();
1920 let outcome = store.load().unwrap();
1921 assert!(
1922 outcome
1923 .warning
1924 .as_deref()
1925 .is_some_and(|warning| warning.contains("unsupported config version 3"))
1926 );
1927 assert_eq!(outcome.config, AppConfig::default());
1928 store.reset().unwrap();
1929 }
1930
1931 #[test]
1932 fn reset_removes_current_and_legacy_configs() {
1933 let store = test_migration_store("reset");
1934 fs::write(store.path(), b"current").unwrap();
1935 fs::write(store.legacy_path.as_ref().unwrap(), b"legacy").unwrap();
1936 store.reset().unwrap();
1937 assert!(!store.path().exists());
1938 assert!(!store.legacy_path.as_ref().unwrap().exists());
1939 }
1940
1941 #[test]
1942 fn group_validation_rejects_invalid_collections() {
1943 let store = test_store("invalid-groups");
1944 let mut empty = AppConfig::default();
1945 empty.groups.clear();
1946 assert!(store.save(&empty).is_err());
1947
1948 let mut duplicate = AppConfig::default();
1949 duplicate.groups.push(GroupConfig::default());
1950 assert!(store.save(&duplicate).is_err());
1951
1952 let missing_selection = AppConfig {
1953 selected_group_id: Some(7),
1954 ..AppConfig::default()
1955 };
1956 assert!(store.save(&missing_selection).is_err());
1957
1958 let too_many = AppConfig {
1959 groups: (0..=MAX_OUTPUTS as u32)
1960 .map(|id| GroupConfig {
1961 id,
1962 ..GroupConfig::default()
1963 })
1964 .collect(),
1965 selected_group_id: Some(0),
1966 ..AppConfig::default()
1967 };
1968 assert!(store.save(&too_many).is_err());
1969 }
1970
1971 #[test]
1972 fn exactly_sixteen_groups_round_trip_in_tab_order() {
1973 let store = test_store("sixteen-groups");
1974 let order = [12, 4, 0, 15, 7, 2, 10, 6, 1, 14, 5, 9, 3, 13, 8, 11];
1975 let config = AppConfig {
1976 version: CONFIG_VERSION,
1977 groups: order
1978 .into_iter()
1979 .map(|id| GroupConfig {
1980 id,
1981 ..GroupConfig::default()
1982 })
1983 .collect(),
1984 selected_group_id: Some(15),
1985 };
1986 store.save(&config).unwrap();
1987 let loaded = store.load().unwrap().config;
1988 assert_eq!(
1989 loaded
1990 .groups
1991 .iter()
1992 .map(|group| group.id)
1993 .collect::<Vec<_>>(),
1994 order
1995 );
1996 store.reset().unwrap();
1997 }
1998
1999 #[test]
2000 fn duplicate_mirror_targets_are_case_insensitive() {
2001 let store = test_store("duplicate-target");
2002 let mut config = AppConfig::default();
2003 config.groups[0].route = GroupRouteConfig::Mirror {
2004 target_id: Some("DISPLAY-A".into()),
2005 };
2006 config.groups.push(GroupConfig {
2007 id: 1,
2008 route: GroupRouteConfig::Mirror {
2009 target_id: Some("display-a".into()),
2010 },
2011 ..GroupConfig::default()
2012 });
2013 assert!(store.save(&config).is_err());
2014 }
2015
2016 #[test]
2017 fn invalid_stream_parameters_are_rejected() {
2018 let store = test_store("invalid-stream");
2019 let mut config = AppConfig::default();
2020 config.groups[0].route = GroupRouteConfig::StreamOnly {
2021 screen: StreamScreenConfig {
2022 width: 0,
2023 height: 2160,
2024 diagonal_inches: Some(f64::NAN),
2025 refresh_millihz: 0,
2026 aspect_ratio: AspectRatio {
2027 width: 0,
2028 height: 9,
2029 },
2030 rotation: Rotation::Deg0,
2031 },
2032 };
2033 assert!(store.save(&config).is_err());
2034 }
2035
2036 #[test]
2037 fn stream_refresh_boundaries_round_trip() {
2038 for refresh_millihz in [MIN_REFRESH_MILLIHZ, MAX_REFRESH_MILLIHZ] {
2039 let store = test_store(&format!("refresh-{refresh_millihz}"));
2040 let config = stream_config(refresh_millihz);
2041 store.save(&config).unwrap();
2042 let outcome = store.load().unwrap();
2043 assert_eq!(outcome.warning, None);
2044 assert_eq!(outcome.config, config);
2045 store.reset().unwrap();
2046 }
2047 }
2048
2049 #[test]
2050 fn sub_hertz_stream_refresh_is_rejected_on_save_and_load() {
2051 let store = test_store("sub-hertz-refresh");
2052 let config = stream_config(MIN_REFRESH_MILLIHZ - 1);
2053 assert!(store.save(&config).is_err());
2054
2055 let bytes = serde_json::to_vec_pretty(&config).unwrap();
2056 fs::write(store.path(), &bytes).unwrap();
2057 let outcome = store.load().unwrap();
2058 assert_eq!(outcome.config, AppConfig::default());
2059 assert!(
2060 outcome
2061 .warning
2062 .as_deref()
2063 .is_some_and(|warning| warning.contains("stream refresh"))
2064 );
2065 assert_eq!(fs::read(store.path()).unwrap(), bytes);
2066 store.reset().unwrap();
2067 }
2068
2069 #[test]
2070 fn display_override_round_trip_and_case_insensitive_lookup() {
2071 let store = test_override_store("round-trip");
2072 let mut overrides = DisplayOverrides::default();
2073 overrides
2074 .upsert(
2075 r"\\?\DISPLAY#ABC".into(),
2076 24.0,
2077 Some(AspectRatio {
2078 width: 16,
2079 height: 9,
2080 }),
2081 )
2082 .unwrap();
2083 store.save(&overrides).unwrap();
2084 let loaded = store.load().unwrap();
2085 assert_eq!(loaded.warning, None);
2086 assert_eq!(
2087 loaded
2088 .overrides
2089 .override_for(r"\\?\display#abc")
2090 .map(|entry| (entry.diagonal_inches, entry.aspect_ratio)),
2091 Some((
2092 24.0,
2093 Some(AspectRatio {
2094 width: 16,
2095 height: 9
2096 })
2097 ))
2098 );
2099 fs::remove_file(store.path).unwrap();
2100 }
2101
2102 #[test]
2103 fn invalid_display_override_is_preserved_and_reported() {
2104 let store = test_override_store("invalid");
2105 fs::write(
2106 &store.path,
2107 br#"{"version":1,"displays":[{"display_id":"x","diagonal_inches":0}]}"#,
2108 )
2109 .unwrap();
2110 let before = fs::read(&store.path).unwrap();
2111 let outcome = store.load().unwrap();
2112 assert_eq!(outcome.overrides, DisplayOverrides::default());
2113 assert!(outcome.warning.is_some());
2114 assert_eq!(fs::read(&store.path).unwrap(), before);
2115 fs::remove_file(store.path).unwrap();
2116 }
2117
2118 #[test]
2119 fn display_override_upsert_and_remove_are_deterministic() {
2120 let mut overrides = DisplayOverrides::default();
2121 overrides.upsert("B".into(), 27.0, None).unwrap();
2122 overrides.upsert("a".into(), 24.0, None).unwrap();
2123 overrides.upsert("A".into(), 25.0, None).unwrap();
2124 assert_eq!(overrides.displays.len(), 2);
2125 assert_eq!(overrides.displays[0].display_id, "A");
2126 assert_eq!(
2127 overrides
2128 .override_for("a")
2129 .map(|entry| entry.diagonal_inches),
2130 Some(25.0)
2131 );
2132 assert!(overrides.remove("b"));
2133 assert!(!overrides.remove("missing"));
2134 }
2135
2136 #[test]
2137 fn legacy_display_override_without_aspect_ratio_keeps_native_behavior() {
2138 let store = test_override_store("legacy-aspect");
2139 fs::write(
2140 &store.path,
2141 br#"{"version":1,"displays":[{"display_id":"legacy","diagonal_inches":24.0}]}"#,
2142 )
2143 .unwrap();
2144 let outcome = store.load().unwrap();
2145 let display_override = outcome.overrides.override_for("legacy").unwrap();
2146 assert_eq!(display_override.diagonal_inches, 24.0);
2147 assert_eq!(display_override.aspect_ratio, None);
2148 fs::remove_file(store.path).unwrap();
2149 }
2150
2151 #[test]
2152 fn zero_display_override_aspect_ratio_is_preserved_and_reported() {
2153 let store = test_override_store("zero-aspect");
2154 fs::write(
2155 &store.path,
2156 br#"{"version":1,"displays":[{"display_id":"x","diagonal_inches":24.0,"aspect_ratio":{"width":0,"height":9}}]}"#,
2157 )
2158 .unwrap();
2159 let before = fs::read(&store.path).unwrap();
2160 let outcome = store.load().unwrap();
2161 assert!(outcome.warning.is_some());
2162 assert_eq!(fs::read(&store.path).unwrap(), before);
2163 fs::remove_file(store.path).unwrap();
2164 }
2165
2166 #[test]
2167 fn group_lookup_and_mutation_preserve_collection_state() {
2168 let mut config = AppConfig {
2169 version: CONFIG_VERSION,
2170 groups: vec![
2171 GroupConfig::default(),
2172 GroupConfig {
2173 id: 3,
2174 route: GroupRouteConfig::StreamOnly {
2175 screen: StreamScreenConfig {
2176 width: 1920,
2177 height: 1080,
2178 diagonal_inches: None,
2179 refresh_millihz: 60_000,
2180 aspect_ratio: AspectRatio {
2181 width: 16,
2182 height: 9,
2183 },
2184 rotation: Rotation::Deg0,
2185 },
2186 },
2187 reference_source: None,
2188 sizing: None,
2189 },
2190 ],
2191 selected_group_id: Some(3),
2192 };
2193 let other_group = config.group(3).unwrap().clone();
2194
2195 config.group_mut(0).unwrap().route = GroupRouteConfig::Mirror {
2196 target_id: Some("replacement-target".into()),
2197 };
2198
2199 assert_eq!(config.groups.len(), 2);
2200 assert_eq!(config.groups[0].id, 0);
2201 assert_eq!(config.groups[1], other_group);
2202 assert_eq!(config.selected_group_id, Some(3));
2203 assert!(config.group(99).is_none());
2204 assert!(config.group_mut(99).is_none());
2205 }
2206
2207 #[test]
2208 fn failed_atomic_save_removes_temporary_file() {
2209 let root = env::temp_dir().join(format!(
2210 "sbms-config-atomic-failure-{}-{}",
2211 std::process::id(),
2212 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
2213 ));
2214 let destination = root.join(CONFIG_FILE);
2215 fs::create_dir_all(&destination).unwrap();
2216 let store = ConfigStore::new(destination);
2217
2218 assert!(store.save(&AppConfig::default()).is_err());
2219 let temporary_prefix = format!(".{CONFIG_FILE}.");
2220 assert!(
2221 fs::read_dir(&root)
2222 .unwrap()
2223 .filter_map(Result::ok)
2224 .all(|entry| !entry
2225 .file_name()
2226 .to_string_lossy()
2227 .starts_with(&temporary_prefix))
2228 );
2229
2230 fs::remove_dir_all(root).unwrap();
2231 }
2232
2233 fn test_profile_store(name: &str) -> (PathBuf, ConfigProfileStore) {
2234 let root = env::temp_dir().join(format!(
2235 "sbms-config-profiles-{name}-{}-{}",
2236 std::process::id(),
2237 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
2238 ));
2239 let _ = fs::remove_dir_all(&root);
2240 let store =
2241 ConfigProfileStore::new(root.join(CONFIG_PROFILES_FILE), root.join(CONFIG_FILE));
2242 (root, store)
2243 }
2244
2245 fn config_with_target(target: &str) -> AppConfig {
2246 let mut config = AppConfig::default();
2247 config.group_mut(0).unwrap().route = GroupRouteConfig::Mirror {
2248 target_id: Some(target.into()),
2249 };
2250 config
2251 }
2252
2253 #[test]
2254 fn profile_store_migrates_existing_config_without_removing_it() {
2255 let (root, profiles) = test_profile_store("migration");
2256 let legacy = ConfigStore::new(root.join(CONFIG_FILE));
2257 let expected = config_with_target("legacy-target");
2258 legacy.save(&expected).unwrap();
2259 let original = fs::read(legacy.path()).unwrap();
2260
2261 let snapshot = profiles.load_active().unwrap();
2262
2263 assert_eq!(snapshot.profile.id, "default");
2264 assert_eq!(snapshot.profile.revision, 1);
2265 assert_eq!(snapshot.config, expected);
2266 assert_eq!(fs::read(legacy.path()).unwrap(), original);
2267 assert!(profiles.path().is_file());
2268 fs::remove_dir_all(root).unwrap();
2269 }
2270
2271 #[test]
2272 fn launch_intro_preference_is_global_and_does_not_touch_profile_revision() {
2273 let (root, profiles) = test_profile_store("launch-intro-preference");
2274 let before = profiles.load_active().unwrap();
2275 assert!(!profiles.list().unwrap().launch_intro_disabled);
2276
2277 profiles.set_launch_intro_disabled(true).unwrap();
2278
2279 let stored = profiles.list().unwrap();
2280 let after = profiles.load_active().unwrap();
2281 assert!(stored.launch_intro_disabled);
2282 assert_eq!(after, before);
2283
2284 profiles.set_launch_intro_disabled(false).unwrap();
2285 assert!(!profiles.list().unwrap().launch_intro_disabled);
2286 fs::remove_dir_all(root).unwrap();
2287 }
2288
2289 #[test]
2290 fn profile_store_enforces_three_profile_limit() {
2291 let (root, profiles) = test_profile_store("limit");
2292 profiles.load_active().unwrap();
2293 profiles
2294 .save_profile("second", &config_with_target("second"), false, false)
2295 .unwrap();
2296 profiles
2297 .save_profile("third", &config_with_target("third"), false, false)
2298 .unwrap();
2299
2300 let error = profiles
2301 .save_profile("fourth", &AppConfig::default(), false, false)
2302 .unwrap_err();
2303
2304 assert!(error.to_string().contains("3-profile limit"));
2305 assert_eq!(profiles.list().unwrap().profiles.len(), 3);
2306 fs::remove_dir_all(root).unwrap();
2307 }
2308
2309 #[test]
2310 fn duplicate_active_can_activate_and_respects_conflicts_and_limit() {
2311 let (root, profiles) = test_profile_store("duplicate-active");
2312 let default = profiles.load_active().unwrap();
2313 assert!(profiles.duplicate_active("DEFAULT", false).is_err());
2314
2315 let (gaming, gaming_active) = profiles.duplicate_active("gaming", true).unwrap();
2316 assert!(gaming_active);
2317 assert_eq!(gaming.config, default.config);
2318 assert_eq!(profiles.load_active().unwrap(), gaming);
2319
2320 profiles.duplicate_active("streaming", false).unwrap();
2321 let error = profiles.duplicate_active("fourth", false).unwrap_err();
2322 assert!(error.to_string().contains("3-profile limit"));
2323 assert_eq!(profiles.list().unwrap().profiles.len(), 3);
2324 fs::remove_dir_all(root).unwrap();
2325 }
2326
2327 #[test]
2328 fn profile_rename_is_atomic_for_active_case_changes_and_full_stores() {
2329 let (root, profiles) = test_profile_store("rename");
2330 let original = profiles.load_active().unwrap();
2331 profiles.duplicate_active("gaming", false).unwrap();
2332 profiles.duplicate_active("streaming", false).unwrap();
2333
2334 let (case_changed, case_changed_active) = profiles.rename("default", "DEFAULT").unwrap();
2335 assert!(case_changed_active);
2336 assert_eq!(case_changed.profile.id, "DEFAULT");
2337 assert_eq!(case_changed.profile.revision, original.profile.revision + 1);
2338 assert_eq!(case_changed.config, original.config);
2339
2340 let (renamed, renamed_active) = profiles.rename("DEFAULT", "primary").unwrap();
2341 assert!(renamed_active);
2342 assert_eq!(profiles.load_active().unwrap(), renamed);
2343 assert!(profiles.load_profile("default").is_err());
2344
2345 let error = profiles.rename("streaming", "GAMING").unwrap_err();
2346 assert!(error.to_string().contains("already exists"));
2347 assert_eq!(profiles.list().unwrap().profiles.len(), 3);
2348 fs::remove_dir_all(root).unwrap();
2349 }
2350
2351 #[test]
2352 fn deleting_active_profile_selects_fallback_and_rejects_last_profile() {
2353 let (root, profiles) = test_profile_store("delete-with-fallback");
2354 profiles.load_active().unwrap();
2355 profiles.duplicate_active("gaming", true).unwrap();
2356 profiles.duplicate_active("streaming", false).unwrap();
2357
2358 let deleted_active = profiles.delete_with_fallback("gaming").unwrap();
2359 assert!(deleted_active.active_changed);
2360 assert_eq!(deleted_active.active.profile.id, "default");
2361 assert_eq!(profiles.load_active().unwrap(), deleted_active.active);
2362
2363 let deleted_inactive = profiles.delete_with_fallback("streaming").unwrap();
2364 assert!(!deleted_inactive.active_changed);
2365 assert_eq!(deleted_inactive.active.profile.id, "default");
2366
2367 let error = profiles.delete_with_fallback("default").unwrap_err();
2368 assert!(error.to_string().contains("last config profile"));
2369 assert_eq!(profiles.list().unwrap().profiles.len(), 1);
2370 fs::remove_dir_all(root).unwrap();
2371 }
2372
2373 #[test]
2374 fn profile_activation_persists_and_active_profile_cannot_be_deleted() {
2375 let (root, profiles) = test_profile_store("activate");
2376 profiles.load_active().unwrap();
2377 profiles
2378 .save_profile("gaming", &config_with_target("gaming"), false, false)
2379 .unwrap();
2380
2381 let active = profiles.activate("gaming").unwrap();
2382
2383 assert_eq!(active.profile.id, "gaming");
2384 assert_eq!(profiles.load_active().unwrap().config, active.config);
2385 assert!(profiles.delete("gaming").is_err());
2386 profiles.delete("default").unwrap();
2387 assert_eq!(profiles.list().unwrap().profiles.len(), 1);
2388 fs::remove_dir_all(root).unwrap();
2389 }
2390
2391 #[test]
2392 fn stale_profile_revision_cannot_overwrite_reloaded_config() {
2393 let (root, profiles) = test_profile_store("revision");
2394 let stale = profiles.load_active().unwrap();
2395 let updated = profiles
2396 .update_active(|config| {
2397 *config = config_with_target("new-target");
2398 Ok(())
2399 })
2400 .unwrap();
2401
2402 let error = profiles
2403 .save_active_if_revision(&stale.profile, &config_with_target("stale-target"))
2404 .unwrap_err();
2405
2406 assert!(error.to_string().contains("changed from revision"));
2407 assert_eq!(profiles.load_active().unwrap(), updated);
2408 fs::remove_dir_all(root).unwrap();
2409 }
2410
2411 #[test]
2412 fn profile_ids_are_bounded_and_case_insensitively_unique() {
2413 let (root, profiles) = test_profile_store("ids");
2414 profiles.load_active().unwrap();
2415 assert!(
2416 profiles
2417 .save_profile("bad name", &AppConfig::default(), false, false)
2418 .is_err()
2419 );
2420 assert!(
2421 profiles
2422 .save_profile("DEFAULT", &AppConfig::default(), false, false)
2423 .is_err()
2424 );
2425 fs::remove_dir_all(root).unwrap();
2426 }
2427}
2428