[PowerDisplay] Add stable profile IDs (#49175)

## Summary of the Pull Request

Gives every saved PowerDisplay profile a stable, auto-incrementing
integer ID and makes the app address profiles by that ID instead of by
name. Duplicate profile names are allowed, renames preserve identity,
and LightSwitch stores stable profile references.

> Split out of the PowerDisplay CLI branch (#48632). CLI-specific
contracts and commands remain in that stacked PR.

## PR Checklist

- [x] **Closes:** N/A - split from #48632.
- [x] **Communication:** Discussed with core contributors.
- [x] **Tests:** Added and passing in `PowerDisplay.Lib.UnitTests`.
- [x] **Localization:** The composed profile label uses a shared
localized format resource.
- [x] **New binaries:** None.
- [x] **Documentation updated:**
`doc/devdocs/modules/powerdisplay/design.md`.

## Implementation

### Profile model and persistence

- `PowerDisplayProfile.Id` is the stable JSON `id`; `0` means
unassigned.
- `PowerDisplayProfiles.NextId` is monotonic and IDs are never reused.
- `SetProfile` assigns IDs to new profiles and replaces existing
profiles by ID.
- Duplicate names are supported; name lookup remains only for migration
of legacy references.
- `ProfileStore` serializes cross-process load/modify/save operations
with a named mutex and atomically replaces `profiles.json`.
- Production callers use asynchronous `ProfileHelper` APIs.

### Migration and application

- Initial PowerDisplay discovery assigns missing profile IDs and
migrates legacy monitor IDs.
- LightSwitch legacy name references are reconciled to IDs and written
back to the current typed settings schema.
- Native LightSwitch publishes pure light/dark theme events;
PowerDisplay exclusively validates profile enablement and stable IDs.
- Settings UI and Named Pipe ApplyProfile actions send invariant
positive profile IDs.
- PowerDisplay validates the ID, loads the current profile, and applies
its monitor settings.

### Settings UI

- Create, edit, apply, and delete operations use stable IDs.
- LightSwitch selectors store profile IDs and keep legacy name fields
only for migration.
- Profile lists use a localized name-and-ID label so duplicate names
remain distinguishable.

## Accepted Trade-offs

- Profile ID migration remains dependent on the initial monitor
discovery; a failed or delayed discovery can temporarily hide legacy
ID-less profiles.
- The one-time PowerDisplay LightSwitch migration rewrites the complete
current typed settings object and does not add a new cross-process
settings transaction.

## Validation

- Built the affected x64 Debug projects with the repository build
scripts.
- `PowerDisplay.Lib.UnitTests`: 186 passed, 0 failed.

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
moooyo
2026-07-16 14:33:21 +08:00
committed by GitHub
parent ec0830396b
commit 2b8e6247fc
43 changed files with 2458 additions and 772 deletions

View File

@@ -193,26 +193,18 @@ src/modules/powerdisplay/
│ │ └── PInvoke.cs # P/Invoke declarations
│ ├── Interfaces/
│ │ ├── IMonitorController.cs # Controller abstraction
│ │ ── IMonitorData.cs # Monitor data interface
│ │ └── IProfileService.cs # Profile service interface
│ │ ── IMonitorData.cs # Monitor data interface
│ ├── Models/
│ │ ├── Monitor.cs # Runtime monitor data
│ │ ├── MonitorCapabilities.cs # Monitor capability flags
│ │ ├── MonitorOperationResult.cs # Operation result
│ │ ├── MonitorStateEntry.cs # Persisted monitor state
│ │ ├── MonitorStateFile.cs # State file schema
│ │ ├── PowerDisplayProfile.cs # Profile definition
│ │ ├── PowerDisplayProfiles.cs # Profile collection
│ │ ├── ProfileMonitorSetting.cs # Per-monitor profile settings
│ │ ├── ColorPresetItem.cs # Color preset UI item
│ │ ├── VcpCapabilities.cs # Parsed VCP capabilities
│ │ └── VcpFeatureValue.cs # VCP feature value (current/min/max)
│ ├── Serialization/
│ │ └── ProfileSerializationContext.cs # JSON source generation
│ ├── Services/
│ │ ├── DisplayRotationService.cs # Display rotation via ChangeDisplaySettingsEx
│ │ ── MonitorStateManager.cs # State persistence (debounced save) and restore on startup
│ │ └── ProfileService.cs # Profile persistence
│ │ ── MonitorStateManager.cs # State persistence (debounced save) and restore on startup
│ ├── Utils/
│ │ ├── ColorTemperatureHelper.cs # Color temp utilities
│ │ ├── EventHelper.cs # Windows Event utilities
@@ -221,11 +213,19 @@ src/modules/powerdisplay/
│ │ ├── MonitorMatchingHelper.cs # Profile-to-monitor matching
│ │ ├── MonitorValueConverter.cs # Value conversion utilities
│ │ ├── PnpIdHelper.cs # PnP manufacturer ID lookup
│ │ ├── ProfileHelper.cs # Profile helper utilities
│ │ ├── SimpleDebouncer.cs # Generic debouncer
│ │ └── VcpNames.cs # VCP code and value name lookup
│ └── PathConstants.cs # File path constants
├── PowerDisplay.Models/ # Shared profile models and persistence
│ ├── ColorPresetItem.cs # Color preset UI item
│ ├── PowerDisplayProfile.cs # Profile definition
│ ├── PowerDisplayProfiles.cs # Profile collection
│ ├── ProfileMonitorSetting.cs # Per-monitor profile settings
│ ├── ProfileHelper.cs # Shared asynchronous profile entry points
│ ├── ProfileStore.cs # Atomic cross-process profile persistence
│ └── ProfileSerializationContext.cs # JSON source generation
├── PowerDisplay/ # WinUI 3 application
│ ├── Assets/ # App icons and images
│ ├── Configuration/
@@ -304,7 +304,6 @@ flowchart TB
subgraph PowerDisplayLib["PowerDisplay.Lib"]
subgraph Services
ProfileService
MonitorStateManager
DisplayRotationService
end
@@ -316,6 +315,11 @@ flowchart TB
PnpIdHelper["PnpIdHelper<br/>(Manufacturer Names)"]
end
end
subgraph PowerDisplayModels["PowerDisplay.Models"]
ProfileHelper
ProfileStore
end
end
subgraph Storage["Persistent Storage"]
@@ -338,13 +342,14 @@ flowchart TB
ThemeChangedEvent --> LightSwitchService
%% App internal
LightSwitchService -.->|"Get profile name"| MainViewModel
LightSwitchService -.->|"Get profile id"| MainViewModel
MainViewModel --> MonitorViewModel
MonitorViewModel --> MonitorManager
DisplayChangeWatcher -.->|"DisplayChanged event"| MainViewModel
%% App to Lib services
MainViewModel --> ProfileService
%% App to services and profile persistence
MainViewModel --> ProfileHelper
ProfileHelper --> ProfileStore
MonitorViewModel --> MonitorStateManager
MonitorManager --> Drivers
MonitorManager --> DisplayRotationService
@@ -352,8 +357,8 @@ flowchart TB
%% Utils used during discovery
WmiController --> PnpIdHelper
%% Services to Storage
ProfileService --> ProfilesJson
%% Persistence to Storage
ProfileStore --> ProfilesJson
MonitorStateManager --> MonitorStateJson
%% Drivers to Hardware
@@ -1080,7 +1085,7 @@ flowchart TB
StateManager["LightSwitchStateManager"]
ThemeEval["Theme Evaluation<br/>(Time/System)"]
LightSwitchSettings["LightSwitchSettings"]
NotifyPD["NotifyPowerDisplay(isLight)"]
NotifyPD["NotifyPowerDisplayThemeChanged(isLight)"]
end
subgraph PowerDisplayModule["PowerDisplay Module (C#)"]
@@ -1090,7 +1095,8 @@ flowchart TB
MainViewModel["MainViewModel"]
end
ProfileService["ProfileService"]
ProfileHelper["ProfileHelper<br/>(PowerDisplay.Models)"]
ProfileStore["ProfileStore"]
MonitorVMs["MonitorViewModels"]
Controllers["IMonitorController"]
end
@@ -1113,17 +1119,18 @@ flowchart TB
ThemeEval -->|"Time boundary<br/>or manual"| StateManager
StateManager --> LightSwitchSettings
StateManager --> NotifyPD
NotifyPD -->|"isLight=true"| LightEvent
NotifyPD -->|"isLight=false"| DarkEvent
NotifyPD -->|"pure light theme event"| LightEvent
NotifyPD -->|"pure dark theme event"| DarkEvent
%% PowerDisplay flow - theme determined from event
LightEvent -->|"Event signaled"| EventWaiter
DarkEvent -->|"Event signaled"| EventWaiter
EventWaiter -->|"isLightMode"| LightSwitchSvc
LightSwitchSvc -->|"GetProfileForTheme()"| LSSettingsJson
LightSwitchSvc -->|"Profile name"| MainViewModel
MainViewModel -->|"LoadProfiles()"| ProfileService
ProfileService <--> PDProfilesJson
LightSwitchSvc -->|"GetProfileIdForTheme()"| LSSettingsJson
LightSwitchSvc -->|"Profile id"| MainViewModel
MainViewModel -->|"LoadProfilesAsync()"| ProfileHelper
ProfileHelper --> ProfileStore
ProfileStore <--> PDProfilesJson
MainViewModel -->|"ApplyProfileAsync()"| MonitorVMs
MonitorVMs --> Controllers
Controllers --> Monitors
@@ -1135,20 +1142,25 @@ flowchart TB
style FileSystem fill:#fffde7
```
Native LightSwitch treats these named events as pure theme-change notifications and does not parse PowerDisplay profile enablement, names, or IDs. PowerDisplay reads the typed LightSwitch settings after receiving the event and is the sole authority that validates and applies the configured profile.
### LightSwitch Settings JSON Structure
```json
{
"properties": {
"apply_monitor_settings": { "value": true },
"enable_light_mode_profile": { "value": true },
"light_mode_profile": { "value": "Productivity" },
"enable_dark_mode_profile": { "value": true },
"dark_mode_profile": { "value": "Night Mode" }
"enableLightModeProfile": { "value": true },
"lightModeProfile": { "value": "" },
"lightModeProfileId": { "value": 3 },
"enableDarkModeProfile": { "value": true },
"darkModeProfile": { "value": "" },
"darkModeProfileId": { "value": 7 }
}
}
```
The name fields are retained only for migration from pre-ID settings; current code persists and resolves the positive ID fields.
---
## Data Flow and Communication
@@ -1354,7 +1366,8 @@ sequenceDiagram
participant SettingsPage as PowerDisplayPage
participant ViewModel as PowerDisplayViewModel
participant ProfileDialog as ProfileEditorDialog
participant ProfileService
participant ProfileHelper
participant ProfileStore
participant FileSystem as profiles.json
User->>SettingsPage: Clicks "Add Profile" button
@@ -1369,20 +1382,21 @@ sequenceDiagram
User->>ProfileDialog: Clicks "Save"
ProfileDialog->>ProfileDialog: Validate inputs
Note over ProfileDialog: Check name unique,<br/>at least one monitor selected
Note over ProfileDialog: Check non-empty name,<br/>at least one monitor selected
ProfileDialog-->>ViewModel: ResultProfile (PowerDisplayProfile)
ViewModel->>ProfileService: AddOrUpdateProfile(profile)
ViewModel->>ProfileHelper: ProfileHelper.AddOrUpdateProfileAsync(profile)
ProfileHelper->>ProfileStore: AddOrUpdateProfileAsync(profile)
ProfileService->>ProfileService: lock(_lock)
ProfileService->>FileSystem: Read profiles.json
FileSystem-->>ProfileService: Existing profiles
ProfileService->>ProfileService: Add/update profile in collection
ProfileService->>ProfileService: Set LastUpdated = DateTime.Now
ProfileService->>FileSystem: Write profiles.json
FileSystem-->>ProfileService: Success
ProfileService-->>ViewModel: true
ProfileStore->>ProfileStore: Acquire process lock and named mutex
ProfileStore->>FileSystem: Read profiles.json
FileSystem-->>ProfileStore: Existing profiles
ProfileStore->>ProfileStore: Assign id and update profile
ProfileStore->>FileSystem: Write temp file and atomically replace profiles.json
FileSystem-->>ProfileStore: Success
ProfileStore-->>ProfileHelper: Completed
ProfileHelper-->>ViewModel: Completed
ViewModel->>ViewModel: RefreshProfilesList()
ViewModel-->>SettingsPage: PropertyChanged(Profiles)
@@ -1401,7 +1415,7 @@ sequenceDiagram
participant EventWaiter as NativeEventWaiter
participant LSSvc as LightSwitchService
participant MainVM as MainViewModel
participant ProfileService
participant ProfileHelper
participant MonitorVM as MonitorViewModel
participant Controller as IMonitorController
participant Monitor as Physical Monitor
@@ -1412,8 +1426,8 @@ sequenceDiagram
LightSwitch->>LightSwitch: EvaluateAndApplyIfNeeded()
LightSwitch->>LightSwitch: ApplyTheme(isLight)
LightSwitch->>LightSwitch: NotifyPowerDisplay(isLight)
Note over LightSwitch: Check if profile enabled
LightSwitch->>LightSwitch: NotifyPowerDisplayThemeChanged(isLight)
Note over LightSwitch: Publish the resulting theme only;<br/>PowerDisplay owns profile validation
alt isLight == true
LightSwitch->>WinEvent: SetEvent("Local\\PowerToys_LightSwitch_LightTheme")
@@ -1425,16 +1439,16 @@ sequenceDiagram
EventWaiter->>WinEvent: WaitAny([lightEvent, darkEvent]) returns index
Note over EventWaiter: Theme determined from event:<br/>index 0 = Light, index 1 = Dark
EventWaiter->>LSSvc: GetProfileForTheme(isLightMode)
EventWaiter->>LSSvc: GetProfileIdForTheme(isLightMode)
LSSvc->>LSSvc: Read LightSwitch/settings.json
LSSvc-->>EventWaiter: profileName (or null)
LSSvc-->>EventWaiter: profileId (or null)
EventWaiter->>MainVM: Dispatch to UI thread with profileName
EventWaiter->>MainVM: Dispatch to UI thread with profileId
MainVM->>ProfileService: LoadProfiles()
ProfileService-->>MainVM: PowerDisplayProfiles
MainVM->>ProfileHelper: LoadProfilesAsync()
ProfileHelper-->>MainVM: PowerDisplayProfiles
MainVM->>MainVM: Find profile by name
MainVM->>MainVM: Find profile by id
MainVM->>MainVM: ApplyProfileAsync(profile.MonitorSettings)
loop For each ProfileMonitorSetting