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.
文件
../ bininputuiconfig.rs81.2 KBcontrol.rs4.1 KBcontroller.rs43.6 KBdiagnostics.rs24.2 KBdisplay.rs29.8 KBgeometry.rs21.4 KBgpu_renderer.rs37.4 KBinput.rs79.7 KBlaunch_broker.rs10.3 KBlib.rs350 Blimits.rs714 Bmain.rs24.4 KBmapping.rs46.5 KBnetwork.rs9.2 KBrenderer.rs20.6 KBsession_gate.rs14.9 KBsunshine.rs12.2 KBui.rs68.5 KBvirtual_display.rs7.2 KBwin32_flyout.rs3.4 KBwin32_tray.rs22.3 KBwindow_migration.rs43.8 KBsrc/config.rs81.2 KB
1
use std::env;2
use std::error::Error;3
use std::ffi::OsStr;4
use std::fmt::{Display, Formatter};5
use std::fs::{self, File, OpenOptions};6
use std::io::Write;7
use std::os::windows::ffi::OsStrExt;8
use std::path::{Path, PathBuf};9
use std::sync::atomic::{AtomicU64, Ordering};10
11
use serde::{Deserialize, Serialize};12
use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_ABANDONED, WAIT_FAILED, WAIT_OBJECT_0};13
use windows::Win32::Storage::FileSystem::{14
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, REPLACEFILE_WRITE_THROUGH,15
ReplaceFileW,16
};17
use windows::Win32::System::Threading::{18
CreateMutexW, INFINITE, ReleaseMutex, WaitForSingleObject,19
};20
use windows::core::{PCWSTR, w};21
22
use crate::geometry::{AspectRatio, Rotation, SizingRequest};23
use 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
28
const CONFIG_VERSION: u32 = 2;29
const LEGACY_CONFIG_VERSION: u32 = 1;30
const CONFIG_PROFILES_VERSION: u32 = 1;31
pub const MAX_CONFIG_PROFILES: usize = 3;32
const DISPLAY_OVERRIDES_VERSION: u32 = 1;33
const CONFIG_DIRECTORY: &str = "SBMS";34
const CONFIG_FILE: &str = "config-v2.json";35
const CONFIG_PROFILES_FILE: &str = "config-profiles-v1.json";36
const LEGACY_CONFIG_FILE: &str = "config-v1.json";37
const DISPLAY_OVERRIDES_FILE: &str = "display-overrides-v1.json";38
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);39
40
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]41
#[serde(rename_all = "snake_case")]42
pub enum ReferenceSource {43
Display(String),44
Manual,45
}46
47
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]48
#[serde(deny_unknown_fields)]49
pub 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)]58
pub 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)]69
pub 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)]81
pub 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)]93
pub struct ConfigStore {94
path: PathBuf,95
legacy_path: Option<PathBuf>,96
}97
98
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]99
#[serde(deny_unknown_fields)]100
pub 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)]108
pub 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
116
fn is_false(value: &bool) -> bool {117
!*value118
}119
120
#[derive(Clone, Debug, Eq, PartialEq)]121
pub struct ProfileRevision {122
pub id: String,123
pub revision: u64,124
}125
126
#[derive(Clone, Debug, PartialEq)]127
pub struct ProfileSnapshot {128
pub profile: ProfileRevision,129
pub config: AppConfig,130
}131
132
#[derive(Clone, Debug, PartialEq)]133
pub struct ProfileDeleteOutcome {134
pub active: ProfileSnapshot,135
pub active_changed: bool,136
}137
138
#[derive(Clone, Debug)]139
pub struct ConfigProfileStore {140
path: PathBuf,141
legacy_config_path: PathBuf,142
}143
144
struct CrossProcessConfigGuard(HANDLE);145
146
#[derive(Clone, Debug, Deserialize)]147
#[serde(deny_unknown_fields)]148
struct 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)]159
struct VersionHeader {160
version: u32,161
}162
163
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]164
#[serde(deny_unknown_fields)]165
pub 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)]174
pub struct DisplayOverrides {175
pub version: u32,176
#[serde(default)]177
pub displays: Vec<DisplayOverride>,178
}179
180
#[derive(Clone, Debug)]181
pub struct DisplayOverrideStore {182
path: PathBuf,183
}184
185
#[derive(Clone, Debug, PartialEq)]186
pub struct DisplayOverrideLoadOutcome {187
pub overrides: DisplayOverrides,188
pub warning: Option<String>,189
}190
191
#[derive(Clone, Debug, PartialEq)]192
pub struct LoadOutcome {193
pub config: AppConfig,194
pub warning: Option<String>,195
}196
197
#[derive(Debug)]198
pub struct ConfigError(String);199
200
impl ConfigError {201
pub fn new(message: impl Into<String>) -> Self {202
Self(message.into())203
}204
}205
206
impl Display for ConfigError {207
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {208
formatter.write_str(&self.0)209
}210
}211
212
impl Error for ConfigError {}213
214
impl 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
224
impl 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
235
impl 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.version253
)));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.id275
)));276
}277
if let GroupRouteConfig::Mirror {278
target_id: Some(target),279
} = &group.route280
&& 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_id296
&& !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
306
impl 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 - 1313
)));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.id322
)));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.id334
)));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.id341
))342
})?;343
}344
Ok(())345
}346
}347
348
impl StreamScreenConfig {349
fn validate(self, group_id: u32) -> Result<(), ConfigError> {350
if self.width == 0351
|| self.height == 0352
|| self.width > MAX_VIRTUAL_DIMENSION353
|| self.height > MAX_VIRTUAL_DIMENSION354
{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
381
impl 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.version387
)));388
}389
if self390
.target_id391
.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
413
impl 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
430
fn 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
440
fn 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
451
impl Default for DisplayOverrides {452
fn default() -> Self {453
Self {454
version: DISPLAY_OVERRIDES_VERSION,455
displays: Vec::new(),456
}457
}458
}459
460
impl DisplayOverrides {461
pub fn override_for(&self, display_id: &str) -> Option<&DisplayOverride> {462
self.displays463
.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) = self475
.displays476
.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.displays490
.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.displays497
.retain(|entry| !entry.display_id.eq_ignore_ascii_case(display_id));498
self.displays.len() != before499
}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.version506
)));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_id520
)));521
}522
}523
Ok(())524
}525
}526
527
fn 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
549
impl 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 = path557
.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.path583
}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
version610
)),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
version699
)),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
730
impl 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.version736
)));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.id755
)));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.id764
)));765
}766
profile.config.validate()?;767
}768
if !self769
.profiles770
.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_profile776
)));777
}778
Ok(())779
}780
781
fn profile(&self, id: &str) -> Option<&ConfigProfile> {782
self.profiles783
.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.profiles789
.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
799
impl 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.path820
}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
profiles845
.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 = profiles858
.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_profile889
)));890
}891
let profile = profiles892
.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.revision898
)));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 = profiles919
.profiles920
.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].id927
)));928
}929
profiles.profiles[index].config = config.clone();930
profiles.profiles[index].revision = next_revision(profiles.profiles[index].revision)?;931
index932
} 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() - 1944
};945
if activate {946
profiles.active_profile = profiles.profiles[index].id.clone();947
}948
let result = snapshot(&profiles.profiles[index]);949
let is_active = profiles950
.active_profile951
.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 = profiles1014
.active_profile1015
.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 = profiles1030
.profiles1031
.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 profiles1035
.profiles1036
.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 = profiles1049
.active_profile1050
.eq_ignore_ascii_case(&result.profile.id);1051
return Ok((result, is_active));1052
}1053
let was_active = profiles1054
.active_profile1055
.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 = profiles1071
.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_profile1087
)));1088
}1089
let before = profiles.profiles.len();1090
profiles1091
.profiles1092
.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 = profiles1104
.profiles1105
.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 = profiles1112
.active_profile1113
.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 = profiles1145
.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_path1172
.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.config1185
}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
1214
impl 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.01236
)))1237
}1238
}1239
}1240
1241
impl Drop for CrossProcessConfigGuard {1242
fn drop(&mut self) {1243
unsafe {1244
let _ = ReleaseMutex(self.0);1245
let _ = CloseHandle(self.0);1246
}1247
}1248
}1249
1250
fn 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
1260
fn validate_profile_id(id: &str) -> Result<(), ConfigError> {1261
if id.is_empty()1262
|| id.len() > 321263
|| !id1264
.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
1275
fn next_revision(revision: u64) -> Result<u64, ConfigError> {1276
revision1277
.checked_add(1)1278
.ok_or_else(|| ConfigError("config profile revision overflow".into()))1279
}1280
1281
impl 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
1348
fn 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
1357
fn 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
1366
fn 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
1375
fn 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 = destination1383
.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
temporary1393
.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
result1413
}1414
1415
fn 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
1433
fn create_temporary(parent: &Path, destination: &Path) -> Result<(PathBuf, File), ConfigError> {1434
let file_name = destination1435
.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
sequence1444
));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
1461
fn 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
1494
fn wide(value: &OsStr) -> Vec<u16> {1495
value.encode_wide().chain(Some(0)).collect()1496
}1497
1498
#[cfg(test)]1499
mod 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
name1509
));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
config1573
}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
name1581
));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
outcome1669
.warning1670
.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": 28801729
},1730
"physical": {1731
"dimensions_mm": {1732
"width": 597.0,1733
"height": 336.01734
}1735
},1736
"rotation": "deg0"1737
},1738
"target": {1739
"native_pixels": {1740
"width": 2560,1741
"height": 14401742
},1743
"physical": {1744
"dimensions_mm": {1745
"width": 541.0,1746
"height": 303.01747
}1748
},1749
"rotation": "deg180"1750
},1751
"strategy": "rounded_scale",1752
"alignment": 2,1753
"preferred_refresh_millihz": 2400001754
}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: 28801785
}1786
);1787
assert_eq!(1788
sizing.reference.physical,1789
PhysicalMeasurement::DimensionsMm {1790
width: 597.0,1791
height: 336.01792
}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: 14401801
}1802
);1803
assert_eq!(1804
sizing.target.physical,1805
PhysicalMeasurement::DimensionsMm {1806
width: 541.0,1807
height: 303.01808
}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
outcome1859
.warning1860
.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
outcome1923
.warning1924
.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: order1978
.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
loaded1990
.groups1991
.iter()1992
.map(|group| group.id)1993
.collect::<Vec<_>>(),1994
order1995
);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
outcome2061
.warning2062
.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
overrides2074
.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
loaded2088
.overrides2089
.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: 92096
})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
overrides2128
.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| !entry2225
.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
config2251
}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
profiles2294
.save_profile("second", &config_with_target("second"), false, false)2295
.unwrap();2296
profiles2297
.save_profile("third", &config_with_target("third"), false, false)2298
.unwrap();2299
2300
let error = profiles2301
.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
profiles2378
.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 = profiles2396
.update_active(|config| {2397
*config = config_with_target("new-target");2398
Ok(())2399
})2400
.unwrap();2401
2402
let error = profiles2403
.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
profiles2417
.save_profile("bad name", &AppConfig::default(), false, false)2418
.is_err()2419
);2420
assert!(2421
profiles2422
.save_profile("DEFAULT", &AppConfig::default(), false, false)2423
.is_err()2424
);2425
fs::remove_dir_all(root).unwrap();2426
}2427
}2428