Compare commits

...

37 Commits

Author SHA1 Message Date
Boliang Zhang (from Dev Box)
2d7d210cf7 Workspaces v6: per-user uninstall prompts UAC to remove service package
Calling the non-elevated per-user teardown 'best-effort' understated it: in the
normal flow (uninstall from Settings/Apps) it is non-elevated and therefore
FAILS to delete the service, orphaning the package. UnRegisterPTSettingsSvcCA's
per-user branch now: (1) tries in-proc removal (works if already elevated), then
(2) prompts UAC once (ShellExecute runas, mirroring common/utils run_elevated)
to remove the package — which removes the service. If declined or no interactive
session (silent uninstall), it logs and leaves the signed/immutable orphan
WITHOUT blocking the uninstall. Symmetric with the per-user install's one-time
elevation. Self-contained to avoid taking a WIL dependency in the installer CA
project. See Design §12.5.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 23:41:48 +08:00
Boliang Zhang (from Dev Box)
812a075314 Workspaces v6: version-acceptance policy (min floor + max delta) replaces exact match
Exact callerVersion==serviceVersion broke multi-user/multi-version: the
machine-wide singleton service can be only one version, so the latest install
rejected every other-version caller (Design §12.7). Replace with
IsCallerVersionAcceptable: Microsoft-signed AND callerVersion >=
kMinSupportedCallerVersion (absolute anti-downgrade floor) AND the caller's
minor-release within kMaxMinorVersionDelta (default 3) of the service. This lets
v_N and v_N+1 coexist (both >= floor, within delta) while still blocking
downgrade to known-vulnerable old versions, and softens the §12.6 upgrade
transient. Floor/delta defaults (0.100.0 / 3) are placeholders to set at ship.
Policy bit-math unit-verified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 17:23:29 +08:00
Boliang Zhang (from Dev Box)
ec13493584 Workspaces v6: handle MSIX upgrade of a running packaged service (0x80073D02)
Verified: an in-place upgrade re-points the service binPath to the new version's
exe and auto-restarts it — BUT the update fails with 0x80073D02 ('resources in
use') if the old service is running, because a packaged windows.service holds
its binaries.

- Per-user (ServiceProvisioner): add -ForceApplicationShutdown to Add-AppxPackage
  so the running service is stopped for the update, then auto-restarts on the new
  version. Verified locally (binPath re-points 1.0.0.0 -> 1.0.1.0).
- Per-machine (InstallPTSettingsSvcCA): best-effort StopServiceIfRunning (SCM
  stop, not delete) before Stage/Provision to release the exe lock; service
  auto-restarts after re-registration.

See Design §12.6. Per-machine upgrade sequence still needs MSI validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:24:19 +08:00
Boliang Zhang (from Dev Box)
6921ebf7d8 Workspaces v6: scope-aware PTSettingsSvc MSIX teardown (per-machine clean, per-user best-effort)
Verified: removing the package removes the service (windows.service extension
ties service lifetime to the package). Removing a service package needs admin.

- UnRegisterPTSettingsSvcCA now reads InstallScope: per-machine deprovisions +
  RemoveForAllUsers (elevated uninstall -> full cleanup); per-user does a
  best-effort current-user RemovePackage (non-elevated uninstall cannot delete
  the service, so it no-ops gracefully - mirrors UninstallServicesTask/MWB).
- Product.wxs: wire UnRegisterPTSettingsSvc for BOTH scopes (was per-machine
  only); per-user is best-effort.

Per-user orphan (when not elevated) is a signed, immutable WindowsApps package
running opaque-IO LocalSystem service - harmless, cleaned by a later per-machine
install or a manual elevated Remove-AppxPackage. See Design §12.5.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:06:48 +08:00
Boliang Zhang (from Dev Box)
71df0723b3 Simplify dev sig-check bypass: Debug build skips signature directly
Replace the two-gate (#ifdef _DEBUG + PT_DEV_SKIP_SIGCHECK env var) with a
single conditional-compilation gate: a Debug build skips ONLY the signature
predicate (version match still enforced); Release compiles the block out
entirely so there is no bypass in shipped binaries. Matches the intended mental
model 'Debug build => signature check bypassed for local/unsigned binaries'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 14:32:53 +08:00
Boliang Zhang (from Dev Box)
6a18cf63b1 Workspaces v6: unify caller auth on signature+version for both scopes
Per user review: the per-machine path-only anchor accepted any admin-installed
binary regardless of version, violating the version-match requirement and
leaving scopes inconsistent. Drop the path-trust branch; require
Microsoft-signature AND self-version match for EVERY caller. This is secure
because the LocalSystem service verifies the signature against the MACHINE
trust store (not the forgeable per-user TrustedPeople store), and version
pinning defeats the downgrade attack. Adds a #ifdef _DEBUG-only
PT_DEV_SKIP_SIGCHECK hook (compiled out of Release; version check still applies)
so unsigned local/smoke-test builds keep working. Removed the now-unused
IsUnderDir helper. See Design §7 decision update (2026-06-30).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 14:15:55 +08:00
Boliang Zhang (from Dev Box)
4c82477042 Workspaces v6: unify service MSIX registration via CA-DLL (per-machine), remove MSI ServiceInstall
Per Design v6 §12.4: register the PTSettingsSvc MSIX through the established
signed CA-DLL PackageManager pattern (as CmdPal/Sparse), so per-machine and
per-user no longer compete for the machine-wide service name.

- New InstallPTSettingsSvcCA (Stage + ProvisionPackageForAllUsers) and
  UnRegisterPTSettingsSvcCA (Deprovision + RemoveForAllUsers) in
  PowerToysSetupCustomActionsVNext; exported in the .def.
- Product.wxs: SetInstallPTSettingsSvcParam + Install/UnRegister sequenced
  per-machine only (guarded by PerUser), mirroring InstallPackageIdentityMSIX.
- WorkspacesSettingsService.wxs: per-machine now ships PTSettingsSvc.msix and
  drops the loose-exe <ServiceInstall>; removed the obsolete NT SERVICE\
  PTSettingsSvc ACEs (service is LocalSystem under MSIX, covered by SYSTEM).
- Per-user path unchanged (deferred managed provisioner): a non-elevated
  per-user MSI cannot register a service.

Result: one MSIX, one machine-wide PTSettingsSvc, one staged package per
version; the per-machine/per-user ERROR_SERVICE_EXISTS collision is gone.
Not queued for a build (installer must be MSI-validated across both scopes,
orderings, and two user accounts first).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 13:39:30 +08:00
Boliang Zhang (from Dev Box)
ea55140b46 Sign the PTSettingsSvc MSIX in the installer pipeline (after core signing)
The MSIX shipped unsigned because the vcxproj post-build packed it at compile
time (capturing the unsigned exe), and the package was never signed -> a clean
machine couldn't install it. Move packing into steps-build-installer-vnext.yml
AFTER core ESRP signing (svc exe is already signed there) and BEFORE the MSI
build, then ESRP-sign the .msix. Removed the premature vcxproj pack. Fixed
build-msix version stamping to use case-sensitive -creplace (was corrupting the
lowercase XML declaration) and stamp the build version + arch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 23:09:48 +08:00
Boliang Zhang (from Dev Box)
c93b223fb0 Fix ARM64 MSI: pack PTSettingsSvc.msix for both x64 and arm64
The post-build msix pack was x64-only, so the ARM64 installer couldn't find
PTSettingsSvc.msix. Pack on both platforms and stamp ProcessorArchitecture per
platform.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 18:35:42 +08:00
Boliang Zhang (from Dev Box)
9f3f59b388 Workspaces v6: ship service MSIX in per-user installer payload; retire Harden ps1
Wires the MSIX service for e2e: per-user installer now stages the signed
PTSettingsSvc.msix (auto-packed by the svc vcxproj post-build) instead of the
exe + Harden ps1; ServiceProvisioner deploys it via one elevated Add-AppxPackage.
Per-machine keeps the MSI ServiceInstall (ProgramFiles is already admin-only).
Manifest Publisher set to Microsoft (matches signed build); version stamping
added to build-msix. Removed the user-writable Harden-PtSettingsPerUser.ps1 and
all dead references.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 17:30:32 +08:00
Boliang Zhang (from Dev Box)
b731122d58 Workspaces v6: distribute settings service as MSIX; deferred install via package
Per Design v6 §12.1: the PTSettingsSvc binary ships as a signed MSIX packaged
windows.service (immutable WindowsApps, LocalSystem) instead of a user-writable
staged exe. The protective DACL writer ACE moves from the NT SERVICE virtual
account to SYSTEM (the only MSIX start account that can own the store); the
LocalSystem service now also creates the store root lazily (no per-user
installer). Deferred provisioning replaces the user-writable Harden ps1 with an
inline elevated Add-AppxPackage of the signed .msix (no tamperable script).
Adds AppxManifest + build-msix.ps1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 16:58:19 +08:00
Boliang Zhang (from Dev Box)
7ae15430da Revert "Fix per-user PTSettingsSvc start: stage service binary to machine path"
This reverts commit eff7e05aed.
2026-06-29 15:42:17 +08:00
Boliang Zhang (from Dev Box)
8b3aeb10c4 Fix per-user auth: verify caller binary while impersonating client
The service authenticates per-user callers by their image's Microsoft
signature + version. Those checks read the caller .exe, which lives under
%LocalAppData% (user-only ACL). The code called RevertToSelf() BEFORE the
checks, so VerifyMicrosoftSignature and GetBinaryVersion ran as the service
account and could not read the file - both failed, so every editor/launcher
was AuthRejected and silently fell back to the legacy file. Capture the
canonical path, signature and version while still impersonating the client
(which can read its own image), then revert.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 12:03:13 +08:00
Boliang Zhang (from Dev Box)
eff7e05aed Fix per-user PTSettingsSvc start: stage service binary to machine path
Per-user install stages PowerToys.PTSettingsSvc.exe under %LocalAppData%,
ACL'd to the installing user; the machine service account cannot read it so
SCM start fails 0x5 and no pipe is created - editors fall back to legacy.
Harden-PtSettingsPerUser now copies the payload to ProgramData\...\bin
(svc + AuthUsers RX) and registers from there.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 11:16:15 +08:00
Boliang Zhang (from Dev Box)
a03aa7f8ad Fix WiX ICE38: per-user payload components use HKCU registry KeyPath
The per-user MSI installs the PTSettingsSvc payload (exe + hardening
script) under the user-profile install folder. WiX ICE38 forbids a File
KeyPath for components that install to a per-user location; they must use
an HKCU registry value as KeyPath instead.

PTSettingsServicePayloadExe and PTSettingsServicePayloadScript now carry
an HKCU RegistryValue KeyPath (mirroring RemovePTSettingsPayloadFolder),
with their File elements no longer marked KeyPath.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 02:19:39 +08:00
Boliang Zhang (from Dev Box)
fa3342d24c Workspaces v6: route native Launcher/WindowArranger through the settings service
Completes the caller integration so the protected store is the single source of
truth for the whole workspace lifecycle, not just the Editor.

- JsonUtils: add ReadWorkspacesFromService() (GetBlob -> parse the same UTF-8
  JSON -> WorkspacesListJSON::FromJson; NotFound -> empty; ServiceUnavailable ->
  legacy file fallback) and WriteWorkspacesToService() (ToJson -> Stringify ->
  PutBlob; ServiceUnavailable -> legacy file fallback).
- WorkspacesLauncher: read the workspace list and write back the
  last-launched-time update through the service (main.cpp + Launcher.cpp).
- WorkspacesWindowArranger: read the list through the service.
- WorkspacesLib links WorkspacesSettingsClient (native PTSettingsClient) so the
  EXEs resolve GetBlob/PutBlob.
- WorkspacesData: WorkspacesFile()/TempWorkspacesFile() now resolve to the
  user-writable %LocalAppData%\Microsoft\PowerToys\Workspaces folder — the
  no-service fallback location and the transient snapshot->editor temp handoff,
  matching the managed editor (FolderUtils.DataFolder). The protected store is
  reached only through the service.

This fixes the previously-broken save->launch loop (the native tools wrote/read a
different %ProgramData% path via direct IO, which would be denied under the
protected DACL and would not see Editor-saved data) and the snapshot->editor
handoff (temp file was in the protected folder).

Verified locally: WorkspacesLib + Launcher + WindowArranger + SnapshotTool build
clean in Debug AND Release (native NuGet restore via downloaded nuget.exe). The
service round-trip these wrap is already proven (verify-prototype 9/9; editor
harness 8/8) and they reuse the same PTSettingsClient + WorkspacesListJSON.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 01:06:12 +08:00
Boliang Zhang (from Dev Box)
9ffc7ede00 Workspaces v6: SID-first store layout + real file name (workspaces.json)
Two storage-layout improvements (per design review):

1. Keep the original file name instead of blob.bin. The service is still
   payload-opaque (never parses it), but the on-disk file keeps its real,
   human-readable name, sourced from the binding table (Workspaces ->
   workspaces.json). This aids diagnostics and lets native direct-readers
   (the Launcher hot path, Design §9) open the same file by the name they
   already use.

2. Partition by user SID first, then module:
   OLD: %ProgramData%\Microsoft\PowerToys\SettingsSvc\<ns>\<sid>\blob.bin
   NEW: %ProgramData%\Microsoft\PowerToys\Settings\<sid>\<ns>\workspaces.json
   The <sid> node is the user's data root and the single place the protected,
   user-isolating DACL is applied (svc:F, admin:F, this-user:RX); the namespace
   folder and file inherit it. Equally secure (the lock + isolation invariants
   hold either way) but cleaner: one isolation node per user, clean semantics,
   easier per-user cleanup.

Changes:
- Bindings: add per-namespace fileName to CallerBinding.
- Paths: GetSettingsRoot / GetUserFolder(sid) / GetUserNamespaceFolder(sid,ns) /
  GetUserFilePath(sid,ns,file); root renamed Settings.
- PipeServer: Get/PutBlob use the new paths; tighten the DACL once at <sid>
  (EnsureUserFolder) and create the namespace folder under it (inherits).
- Managed SettingsPaths: ServiceStoreRoot / CurrentUserFolder /
  CurrentUserNamespaceFolder / CurrentUserFile mirror the native layout.
- Installer Seed/Harden + dev setup/verify scripts + WiX data root updated to
  the SID-first 'Settings' layout (root grants AuthUsers RX to traverse).

Verified locally (8/8): native service Debug+Release build clean; editor harness
(allow-listed PowerToys.WorkspacesEditor.exe) round-trips through the service and
the file lands at Settings\<sid>\Workspaces\workspaces.json, owner
NT SERVICE\PTSettingsSvc, DACL applied once at <sid> and inherited, non-elevated
write+delete rejected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 00:34:59 +08:00
Boliang Zhang (from Dev Box)
5a931f9d24 Workspaces v6: route the WPF Editor read/write through the settings service
This is the core integration that makes the EoP protection actually take effect:
the Editor now persists settings through PTSettingsSvc instead of writing
workspaces.json directly into the protected folder (which would be denied for a
non-elevated user).

WorkspacesEditorIO:
- ParseWorkspaces (read): GetBlob -> WorkspacesData.Deserialize, using the SAME
  serializer/format as before (native Launcher stays compatible). NotFound ->
  empty; Unavailable -> legacy %LocalAppData% file fallback (no-admin only, §10).
- SerializeWorkspaces (write): WorkspacesData.Serialize -> PutBlob; Unavailable ->
  legacy file fallback. The transient snapshot->editor temp file stays direct IO.
- Fires SettingsBootstrapper.EnsureInitialized at editor open (the per-user
  deferred-init / migration trigger the design called for).

FolderUtils.DataFolder() reverted to the user-writable %LocalAppData% working
folder. v6 had repointed it at the protected %ProgramData% store, which also
broke the Editor's icons and temp-project handoff (those need to stay
user-writable). The protected store is reached only through the service now.

Verified locally (harness compiling the real source files, run as an
allow-listed PowerToys.WorkspacesEditor.exe against the dev service) — 8/8:
Serialize->PutBlob Ok, GetBlob->Deserialize round-trips, dash-case JSON lands in
the protected %ProgramData%\\...\\blob.bin (owner NT SERVICE\\PTSettingsSvc,
user RX), non-elevated write+delete rejected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 00:05:06 +08:00
Boliang Zhang (from Dev Box)
c06b66f696 Workspaces v6: fast pipe-existence pre-check in service availability probe
ServiceProvisioner.IsServiceAvailable() called PTSettingsClient.Ping() directly,
whose NamedPipeClientStream.Connect waits out the full ConnectTimeoutMs (3000ms)
when the pipe does not exist. On the per-user pre-provision path (no service yet)
that blocked the caller ~3s on first init.

Add a fast \\.\pipe\ enumeration pre-check: if the pipe is absent, return false
immediately (measured 0-2ms vs 2997ms). Falls back to the authoritative connect
probe if enumeration fails. Exposes PTSettingsClient.PipeName for the check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 23:07:54 +08:00
Boliang Zhang (from Dev Box)
8a22e44b9b Workspaces v6: implement per-user deferred service initialization
Implements the lazy per-user provisioning path (Design-v6-Final.md section 11)
as two reusable blocks behind one orchestrator, so it can be triggered from
multiple call sites:

- ServiceProvisioner (service-init block): probes the service and, when absent,
  performs the one-time elevation that registers the machine-wide PTSettingsSvc
  and hardens the current user's protected store. Sentinel-guarded back-off; the
  elevation step is injectable (ElevationRunner) for tests/headless hosts.
- WorkspacesMigration (existing migration block): unchanged, composed by the
  orchestrator.
- SettingsBootstrapper (orchestrator): single EnsureInitialized entry point that
  composes provisioning + migration with a once-per-process guard; TriggerReason
  lets new call sites (editor open, save, launch, explicit toggle) reuse it.
- ProvisionOptions / BootstrapRequest: dependency-free inputs (paths resolved
  from the install folder by the caller), keeping the library host-agnostic.
- SettingsPaths: add provision-attempt sentinel + service-binary/harden-script
  path helpers.
- WorkspaceService: wire the orchestrator at editor-open (GetWorkspacesAsync) and
  workspace-launch trigger points, replacing the inline migration backstop.

Installer:
- Per-user MSI now stages the service payload (exe + hardening script) unregistered
  via PTSettingsServicePayloadComponentGroup, so deferred init has something to
  register; per-machine path unchanged (still registers eagerly).

Fix: the CustomAction PowerShell scripts contained non-ASCII characters (em-dash,
section sign) with no BOM, so Windows PowerShell 5.1 misread them as Windows-1252
and failed to parse. Made all three ASCII-only (they are elevated payloads that
must parse under any engine/codepage).

Verified locally: WorkspacesCsharpLibrary builds clean (Release + Debug, analyzers
+ StyleCop as errors). A harness compiling the real source files drove the full
flow end-to-end (9/9): no service -> elevate -> register service -> migrate legacy
data -> protected store with user-read-only DACL -> non-elevated write/delete
rejected -> idempotent on second call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 22:52:53 +08:00
Boliang Zhang (from Dev Box)
b4c60b9f93 Sign the Workspaces settings service exe in the release build
The service exe ships in its own subfolder (WorkspacesSettingsService\\), so it
was not covered by the flat-named entries in ESRPSigning_core.json and would
ship unsigned. Add a path-qualified entry mirroring the existing
Tools\\PowerToys.BugReportTool.exe pattern so ESRP signs it.

Validated: build 150557866 (Signed YAML Release Build) is green; this only adds
one file to the sign list and cannot regress that build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 14:30:13 +08:00
Boliang Zhang (from Dev Box)
d36cf6227e CI hygiene: opt smoke test out of VSTest; add MIT header to SaferModify.cs
- Smoke test is a manual CLI driver, not a VSTest container; set RunVSTest=false
  so the CI /t:Build;Test pass builds but does not try to execute it as a test.
- Add the standard MIT license header to devtools/SaferModify.cs.

Verified: all three native projects clean-rebuild with zero warnings under the
inherited CI props (Level4 base, TreatWarningAsError=true, Spectre, SDLCheck);
smoke test /t:Test is now a no-op.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 17:36:41 +08:00
Boliang Zhang (from Dev Box)
98f3643513 Add service version resource; relocate dev scaffolding out of repo root
#5 Version resource:
- Add WorkspacesSettingsService.rc embedding a Win32 VERSIONINFO sourced from the
  central PowerToys version (common/version/version.h). A native exe has no managed
  assembly version; the Win32 FileVersion is the canonical value the per-user
  signature+version trust anchor (CallerVerify.cpp / VS_FIXEDFILEINFO) compares.
- Wire the .rc into WorkspacesSettingsService.vcxproj (ResourceCompile).

#4 Dev scaffolding:
- Move setup-ptsettingssvc.ps1, verify-prototype.ps1, SaferModify.cs into
  src/modules/Workspaces/WorkspacesSettingsService/devtools/ (out of the repo root,
  clearly marked dev-only, never shipped).
- Derive RepoRoot from script location instead of a hardcoded D:\ path.
- Remove superseded setup-ptworkspacessvc.ps1.
- Add devtools/.gitignore (ignore compiled *.exe helpers) and devtools/README.md.
- Drop throwaway demo binaries from the repo root.

Validated: service builds with embedded FileVersion/ProductVersion; relocated
9-step suite still passes 9/9.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 17:32:05 +08:00
Boliang Zhang (from Dev Box)
80d709d054 Add Workspaces settings service/client to solution; smoke test under Tests
- Register WorkspacesSettingsService.vcxproj and WorkspacesSettingsClient.vcxproj
  in PowerToys.slnx under /modules/Workspaces/ (shipping product folder).
- Register WorkspacesSvcSmokeTest.vcxproj under /modules/Workspaces/Tests/ so the
  CLI driver builds but is excluded from the shipping product.
- Add Debug/Release|ARM64 configurations to the client and smoke-test vcxproj so
  they match the solution platforms (service already had all four).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 15:55:46 +08:00
Copilot
61dd5952db Workspaces v6: gate PT_DEV_INSTALL_FOLDER dev override behind _DEBUG
The env-var install-folder override is a dev convenience for the smoke
test; it must not ship.  Wrap it in #ifdef _DEBUG so Release builds rely
solely on the MSI-written HKLM InstallFolder value.  Debug builds (used by
verify-prototype.ps1) keep it; suite still 9/9.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 14:39:07 +08:00
Copilot
7ff835a4ca Workspaces v6: installer migration/cleanup CustomActions + runner backstop
Implements the remaining installer + migration work (Design-v6-Final.md
§10/§11).  The seeding/cleanup/harden LOGIC is PowerShell (the CA payload)
and is validated live on the dev box; the WiX wiring is authored (MSI
validation deferred).

CustomActions/ (new, run as SYSTEM):
  * Seed-PtSettingsStore.ps1   — per-machine install-time seeding: enumerate
    HKLM ProfileList, and for each user with a legacy %LocalAppData%
    workspaces.json create a protected blob (owner=SYSTEM, PROTECTED DACL
    svc:F/admin:F/system:F/user:RX). Idempotent. Direct SYSTEM write — no
    service round-trip, no migration opcode.
  * Remove-PtSettingsStore.ps1 — uninstall cleanup: stop+delete the service
    and RECURSIVELY delete the SettingsSvc tree (the runtime-created
    <SID>\blob.bin nodes aren't MSI-tracked; only SYSTEM/elevated can).
  * Harden-PtSettingsPerUser.ps1 — per-user lazy elevation: register the
    service if absent, create the protected store, migrate THIS user.

  Validated live: seed (user can't write/delete, service can read,
  idempotent), cleanup (SYSTEM removes the protected tree the user could
  not), harden (migrates + locks this user).  verify-prototype.ps1 still 9/9.

WorkspacesSettingsService.wxs:
  * Install the three scripts next to the service binary.
  * PtSeedStore CA (deferred, no-impersonate) after InstallFiles / NOT Installed.
  * PtCleanupStore CA (deferred, no-impersonate) before RemoveFiles / REMOVE=ALL.

WorkspaceService.cs:
  * EnsureMigrationBackstop() — once-per-process, idempotent call to
    WorkspacesMigration.Run() before the first Load (the straggler backstop;
    primary seeding is install-time). Source-compatible; full-app build
    deferred (native NuGet restore needed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 13:18:21 +08:00
Copilot
1a5fc9d657 Workspaces v6: verify owner + non-elevated write/delete rejection
Adds two checks to verify-prototype.ps1 so the owner / lock-down claims
are part of the automated suite:

  8. Owner of the store nodes is a non-user trusted principal
     (SYSTEM / Administrators / NT SERVICE\PTSettingsSvc) — never the
     logged-in user.  Confirms the load-bearing 'owner != user' invariant
     (a user-owner could rewrite its own DACL via WRITE_DAC).
  9. A Medium-IL (non-elevated) SAFER user token gets BOTH write and
     delete rejected on blob.bin — proving the lock holds against
     same-user tampering and deletion.

SaferModify.cs is the helper (impersonates a SAFER NormalUser token and
attempts write+delete); the script auto-compiles it from source if the
exe is absent, so the suite stays self-contained.  All 9 steps pass on
the dev box.

Finding folded into the design: the prototype owns service-created nodes
as the service account (not SYSTEM); verified to still hold the lock, so
Design-v6-Final.md \u00a79 now accepts SYSTEM / Administrators / the service
account as valid non-user owners.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-18 17:32:29 +08:00
Copilot
d355a7a8a6 Workspaces v6: installer wiring for PTSettingsSvc (per-machine)
Updates the orphaned service fragment to the current design and wires it
into the build + per-machine feature (Design-v6-Final.md §6/§9/§11).
Installer validation is deferred (per direction) — authored, not yet
MSI-validated.

WorkspacesSettingsService.wxs:
  * PTWorkspacesSvc -> PTSettingsSvc, exe PowerToys.PTSettingsSvc.exe,
    DisplayName 'PowerToys Settings Service', Start=auto (§6).
  * Data root %ProgramData%\Microsoft\PowerToys\SettingsSvc with a
    PROTECTED DACL: svc:F, Administrators:F, SYSTEM:F, AuthUsers:RX (§9).
  * New HardenInstallFolderDacl component: applies the admin-only-writable
    INSTALLFOLDER DACL (SYSTEM/Admins/TrustedInstaller:F, Users:RX) and
    grants NT SERVICE\PTSettingsSvc:RX so the service can read the DACL
    during caller auth (§8/§11).

Product.wxs: reference PTSettingsServiceComponentGroup in CoreFeature,
guarded by NOT PerUser (per-user hardening via one-time elevation is a
documented follow-up, §15 #5 d).

wixproj: compile WorkspacesSettingsService.wxs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-18 01:06:49 +08:00
Copilot
524941f4af Workspaces v6: wire managed caller to PTSettingsSvc + fix migration
Replaces the stale managed client and wires the C# library to the
current service protocol (Design-v6-Final.md §10).

* PTSettingsClient.cs (replaces WorkspacesSvcClient.cs): correct pipe
  name PTSettingsSvc, 1 MiB cap, opcodes Ping/GetBlob/PutBlob, status
  bands mapped to a coarse Result { Ok, NotFound, AuthRejected,
  Unavailable, Protocol, IoError }.  Opaque bytes, no JSON awareness.

* WorkspacesStorage: Load() now reads via GetBlob with defensive parse
  (malformed/empty -> empty list, never throws), distinguishes NotFound
  (service up, no blob) from Unavailable (no service -> last-resort
  legacy-file read).  Adds Save() via PutBlob with the same no-service
  fallback.  JSON shape/serialisation stays caller-side.

* WorkspacesMigration: rewritten for the slim protocol — no more
  MigrateFromLegacy opcode; runner reads the legacy file once and
  PutBlobs it, idempotent via a %LocalAppData% sentinel.

* SettingsPaths: aligned to the SettingsSvc\Workspaces\<sid>\blob.bin
  layout; keeps the legacy %LocalAppData% path for migration/fallback.

Also fixes the StyleCop header/format violations that were failing the
WorkspacesCsharpLibrary build.  Library builds clean (0 errors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-18 01:03:02 +08:00
Copilot
848557e871 Workspaces v6: add signature+self-version auth fallback branch
Implements the per-user caller-authentication anchor from the latest
design (Design-v6-Final.md §7 / §15 #5 option d).  AuthenticateCaller is
now a single pipeline branched on the install-folder DACL:

  * path trusted (image under an admin-only-writable install folder)
    -> accept on the PATH anchor (per-machine, unchanged), else
  * fall back to the BINARY-IDENTITY anchor: the image must be
    Microsoft-signed AND its file version must equal the service's own
    version (per-user installs in user-writable %LocalAppData%).

New CallerVerify.{h,cpp}:
  * VerifyMicrosoftSignature  - WinVerifyTrust (chains to a machine root,
    runs in the service context) + signer leaf subject == Microsoft.
  * GetBinaryVersion / GetServiceOwnVersion - VS_FIXEDFILEINFO compare.

The signature makes the version field tamper-proof (re-stamp breaks the
signature; an old signed binary has an older version -> downgrade
defeated).  Version comparison alone would be forgeable, so it is only
ever used paired with the signature.

Links wintrust/crypt32/version.

Validation: verify-prototype.ps1 still 7/7.  Step 4 (user-writable
install folder) now exercises the signature-branch REJECT path (the
unsigned smoke test fails the signature check); step 1 confirms the path
branch still accepts.  Positive signature-accept needs a real MS-signed
binary with a matching version resource (not available in the dev
prototype).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-18 00:56:09 +08:00
Copilot
ba268b7f7f Workspaces v6: refactor service to generic PTSettingsSvc + namespace binding
Implements the v6 final design (see Workspaces-EoP-Fix/Design-v6-Final.md):

Service boundary is now tightened to the minimum.  The service knows
authentication, a namespace allow-list, and how to read/write opaque byte
blobs - nothing else.  All business logic (JSON shape, schema version,
legacy migration, sensitive-field stripping) moves into the caller.

Protocol slimmed:
  * Drop GetSchemaVersion / MigrateFromLegacy opcodes
  * Drop JsonInvalid / SchemaUnsupported / Internal / kCurrentSchemaVersion
  * Drop kIdleShutdownSeconds (unused)
  * Add NamespaceUnknown / NotFound status codes
  * Rename GetSettings/PutSettings -> GetBlob/PutBlob (payload-agnostic)
  * Max payload tightened to 1 MiB (was 8 MiB)

Service identity & naming:
  * NT SERVICE\PTWorkspacesSvc -> NT SERVICE\PTSettingsSvc
  * \\.\pipe\PTWorkspacesSvc   -> \\.\pipe\PTSettingsSvc
  * Exe TargetName             -> PowerToys.PTSettingsSvc.exe
  * Namespace WorkspacesSvc::  -> PTSettingsSvc::

Caller authentication:
  * kAllowedCallerExeNames[] flat list replaced with typed CallerBinding[]
    table mapping each allow-listed exe basename to a namespace id.
    Adding a new module is a one-row change with no service code changes.
  * AuthenticateCaller now returns the matched binding in CallerIdentity
    so the dispatch layer knows which namespace to operate on.
  * Bindings table defensively validated against IsValidNamespaceId before
    being used as a directory name.

Storage layout (was Workspaces-specific, now generic):
  * %ProgramData%\Microsoft\PowerToys\Workspaces\<sid>\workspaces.json
    -> %ProgramData%\Microsoft\PowerToys\SettingsSvc\<ns>\<sid>\blob.bin
  * Service lazily creates the <ns>\ intermediate folder.
  * Per-user <sid>\ folder gets PROTECTED DACL (svc:F, admin:F,
    specific-user:RX) - user A cannot read user B's blob.

Client lib:
  * WorkspacesSvcClient -> PTSettingsClient
  * Ping/GetBlob/PutBlob, payload as std::vector<uint8_t> (opaque bytes)

Smoke test:
  * Updated for new API (ping/get [<output-file>]/put <input-file>)

Local validation tooling:
  * setup-ptsettingssvc.ps1: registers service, sets up PROTECTED data
    root, sets up admin-only fake install folder with allow-listed exe.
  * verify-prototype.ps1: 7-step end-to-end check (liveness, basename
    rejection, path-prefix rejection, install-folder DACL hardness,
    round-trip, NotFound semantics, per-user DACL).  All 7 pass.

Smoke test verified on dev box: 7/7 PASS.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-12 17:10:12 +08:00
Boliang Zhang (from Dev Box)
c1e4f0967a Workspaces v6: reject installs in user-writable folders
Closes the custom-install-path bypass of the path-based caller auth.
PowerToys MSI lets users browse to an arbitrary install directory
(WIXUI_INSTALLDIR -> INSTALLFOLDER, PTInstallDirDlg.wxs).  If they pick
a folder under a user-writable parent (e.g. D:\Tools\PowerToys
inheriting Authenticated Users:Modify) or run a per-user MSI under
%LocalAppData%, same-user malware can drop a file named
PowerToys.WorkspacesEditor.exe there and pass both the path-prefix and
basename-allow-list checks.

Defense added to the service-side auth pipeline:

  Paths::IsFolderAdminOnlyWritable(folder)
    GetNamedSecurityInfoW on the install folder, walk DACL ACEs,
    reject if any non-admin/system/TrustedInstaller trustee has
    FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_WRITE_DATA |
    FILE_APPEND_DATA | FILE_DELETE_CHILD | DELETE | WRITE_DAC |
    WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL.

Wired into AuthenticateCaller as step 3a, after path-prefix and before
basename allow-list.  CREATOR OWNER is whitelisted (only matters for
newly-created children).

Companion MSI requirements (documented in design notes, not yet
implemented in this prototype): apply PROTECTED admin-only DACL to
INSTALLFOLDER at install time, and explicitly grant the virtual
service account (RX) on INSTALLFOLDER so it can read the DACL to
validate it.  Verified on the dev box that without that grant
GetNamedSecurityInfoW returns ERROR_ACCESS_DENIED.

Per-user MSI installs are unsupported by v6 by design (the install
folder is fundamentally user-writable).  Recommended MSI gate:
<Condition>NOT ALLUSERS=""</Condition>.

Smoke-tested on dev box:

  # folder = SYSTEM:F, Administrators:F, NT SERVICE\ALL SERVICES:RX
  Ping -> Ok                                    OK accepted
  # folder + FAREAST\bozhang:F (attacker-writable)
  Ping -> AuthRejected                          OK rejected by 3a

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 16:13:33 +08:00
Boliang Zhang (from Dev Box)
294d06ea0a Workspaces v6: fix OpenProcess across virtual-account boundary
The service runs as NT SERVICE\PTWorkspacesSvc (a virtual account, not a
member of Authenticated Users). The default DACL on a user-mode process
only grants PROCESS_QUERY_LIMITED_INFORMATION to the process owner,
SYSTEM, Administrators and Authenticated Users — so the previous code
that called OpenProcess after RevertToSelf failed with ERROR_ACCESS_DENIED
for every caller and the auth check rejected even legitimate clients.

Fix: keep impersonating the caller while OpenProcess() + the image-name
read happen, so the access check is performed against the user's own
token, which naturally has access to its own processes.  Revert to the
service identity immediately after — all subsequent file IO still runs
as the service account, preserving the DACL-based EoP protection.

Also adds:
- TESTING.md: step-by-step manual test recipe
- setup-ptworkspacessvc.ps1: idempotent admin setup script that registers
  the virtual-account service, creates the protected ProgramData folder,
  applies the PROTECTED DACL and starts the service.

End-to-end verified on a dev box: smoke test from a non-allow-listed
location returns AuthRejected, smoke test renamed to Editor under an
allowed install folder returns Ok for Ping/Get/Put, and a non-elevated
user attempting Set-Content directly against the data file is denied
by the OS DACL (the core EoP fix verification).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 15:32:41 +08:00
bozhang
d3e2555dd9 Workspaces EoP v6 prototype: settings file behind local Windows service
Drops every v5 launcher-gating element (Authenticode / MS publisher /
ProgramFiles path / empty-args triple-AND) and tackles the EoP from the
other end: same-user malware can no longer modify workspaces.json
because the file lives under a DACL that only the new PTWorkspacesSvc
service can write.

Components:
- WorkspacesSettingsService/      native C++ service exe (SCM + --console)
                                  using NT SERVICE\PTWorkspacesSvc virtual
                                  account; protocol/Protocol.h holds the
                                  tiny wire format (5 opcodes).
- WorkspacesSettingsClient/       native static lib used by Editor /
                                  SnapshotTool / smoke test.
- WorkspacesCsharpLibrary/
    SettingsService/              managed mirror: client, paths, one-shot
                                  legacy migration helper.
- WorkspacesSettingsService.wxs   ServiceInstall (virtual account, demand
                                  start, restart-on-failure) +
                                  ServiceControl + CreateFolder with
                                  protected DACL on the data root.

Caller authentication (no signatures, by design):
  1. ImpersonateNamedPipeClient -> token must be a real interactive user
     (rejects SYSTEM / SERVICE / Anonymous).
  2. GetNamedPipeClientProcessId -> caller image path must be under the
     MSI-recorded PT install folder.
  3. Image filename must match a tiny allow-list (Editor / SnapshotTool /
     runner).  The launcher is *not* on the list; it only reads, and
     reads via the user's R+X grant on the file's DACL.

End-to-end verified on dev box:
- Smoke test from arbitrary folder -> AuthRejected (allow-list works).
- Smoke test renamed + located under a PT_DEV_INSTALL_FOLDER override ->
  Ping=Ok, GetSettings=Ok (empty for first-time user).
- PutSettings completes against the service exe but errors on the DACL
  apply because the NT SERVICE\PTWorkspacesSvc account only exists after
  CreateService runs -- confirms the DACL is in fact being applied
  against the right principal.

See Workspaces-EoP-Fix/Design-v6-Prototype-Notes.md for the threat model,
wire protocol, reproduction steps, and the list of known gaps before
this can graduate from prototype.
2026-06-10 15:03:20 +08:00
gilnatab
92014c81b9 [PowerDisplay] Add linked brightness control (#48207)
## Summary of the Pull Request

Adds linked brightness control to PowerDisplay so multiple
brightness-capable monitors can be controlled from a single "All
Displays" slider.

This PR:
- Adds a linked brightness mode with one master brightness slider.
- Seeds the master slider from the linked display with the lowest
Windows DISPLAY number, falling back to monitor ID for determinism.
- Persists linked mode enabled/disabled state.
- Persists per-monitor exclusions by monitor ID.
- Keeps individual display cards available under an expandable section
while linked mode is enabled.
- Shows linked-state guidance in the link icon tooltip instead of a
separate info banner.
- Allows excluded displays to keep their own independent brightness
slider.
- Keeps profiles as per-monitor snapshots; applying a profile turns
linked brightness off before applying the profile values.
- Adds unit tests for linked-brightness selection/seed behavior and
settings compatibility.

## PR Checklist

- [x] Closes: #47319
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

**Screenshots**

| State | Light | Dark |
| --- | --- | --- |
| Linked mode off | <img width="519" height="817" alt="image"
src="https://github.com/user-attachments/assets/bdfae94b-b2e2-4ad3-a45c-7925bb9e5dcd"
/> | <img width="520" height="817" alt="image"
src="https://github.com/user-attachments/assets/69290a70-0375-480d-957c-c9e0af43d18e"
/> |
| Linked mode on | <img width="520" height="307" alt="image"
src="https://github.com/user-attachments/assets/a2b3572b-e51f-4bdc-9209-23ad2f96d27a"
/> | <img width="520" height="307" alt="image"
src="https://github.com/user-attachments/assets/8b14b665-b641-4256-a15b-eced82e62728"
/> |
| Linked mode on — individual displays expanded | <img width="520"
height="895" alt="image"
src="https://github.com/user-attachments/assets/0b40e60d-e78a-4814-baf6-00be7e283edd"
/> | <img width="520" height="895" alt="image"
src="https://github.com/user-attachments/assets/4f59bbfa-d6e5-4cb7-af84-cb484f922a7c"
/> |

The first version is intentionally scoped to brightness-only linked
control. Contrast, volume, color temperature, input source, and
LightSwitch-specific behavior remain independent.

Linked brightness is stored as global PowerDisplay settings:
- `linked_levels_active`
- `excluded_from_sync_monitor_ids`

Newly connected brightness-capable monitors are included by default,
because the exclusion list is the explicit exception. Hotplugging a
monitor does not immediately write brightness; linked hardware writes
happen only after the user changes the master slider.

Profiles remain per-monitor snapshots. This PR does not add
profile-level linked brightness configuration. If linked brightness is
active when a profile is applied, linked mode is turned off first, then
the saved per-monitor profile values are applied. That avoids leaving
the master linked slider active while hardware brightness has been
changed independently per monitor.

When linked mode is turned on, the master slider is seeded from the
linked brightness-capable display with the lowest Windows DISPLAY
number, falling back to monitor ID for determinism. Excluded displays
and displays without brightness support are ignored; if no linked target
remains, the master slider stays disabled. The seed only positions the
slider; it is never written to hardware, so the first user gesture is
the first broadcast.

## Validation Steps Performed

- Built `PowerDisplay.Lib.UnitTests` Debug x64:

```powershell
.\tools\build\build.ps1 -Platform x64 -Configuration Debug -Path src\modules\powerdisplay\PowerDisplay.Lib.UnitTests
```

- Ran `PowerDisplay.Lib.UnitTests` with `vstest.console.exe`
- Ran the XAML styling script:

```powershell
.\.pipelines\applyXamlStyling.ps1 -Main
```

- Result: the XAML styling script completed successfully and processed
`src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml`.
2026-06-10 13:54:40 +08:00
Alex Mihaiuc
d57096af20 Fix broken hotkeys condition (#48401)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This fixes a bug introduced by
`ba68b88ca1617e52647c6dde467c56f53ca2422a` in the hotkey processing
logic.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

A complex condition ended up being incorrectly broken into 2 conditions,
leading to a missed `else` execution. This led to the mishandling of
keyboard shortcuts especially during reassignment in the standalone mode
for ZoomIt.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-09 15:44:08 +02:00
Noraa Junker
f136a4fe04 [Shortcut Guide] Fix foreground window detection (#48386)
## Summary of the Pull Request

This PR fixes Shortcut Guide foreground app detection by resolving app
IDs from the window that was in the foreground before the Shortcut Guide
UI takes focus.

Based on review feedback, it also adds the missing XML `<param>`
documentation for `foregroundWindowHandle` in
`ManifestInterpreter.GetAllCurrentApplicationIds(...)` to satisfy
documentation/style requirements.

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

### Functional change
- Capture and reuse the foreground window handle taken before Shortcut
Guide UI activation.
- Use that captured handle for current-application ID resolution instead
of querying foreground window later, improving app-specific shortcut
matching reliability.

### Follow-up feedback fix
- Added missing XML parameter documentation:
-
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Helpers/ManifestInterpreter.cs`
  - Added `<param name="foregroundWindowHandle">...</param>`

## Validation Steps Performed

- Ran `parallel_validation` (Code Review: no issues; CodeQL: skipped as
trivial doc-only follow-up).
- Attempted local `dotnet build` for `ShortcutGuide.Ui.csproj`; blocked
by transient external package feed/network failure while restoring
`Microsoft.Build.CopyOnWrite/1.0.282`.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-09 20:00:46 +08:00
78 changed files with 6639 additions and 302 deletions

View File

@@ -238,6 +238,7 @@
"PowerToys.WorkspacesLauncherUI.dll",
"PowerToys.WorkspacesModuleInterface.dll",
"PowerToys.WorkspacesCsharpLibrary.dll",
"WorkspacesSettingsService\\PowerToys.PTSettingsSvc.exe",
"WinUI3Apps\\PowerToys.RegistryPreviewExt.dll",
"WinUI3Apps\\PowerToys.RegistryPreviewUILib.dll",

View File

@@ -59,6 +59,28 @@ steps:
**/PowerToysSetupCustomActionsVNext.dll
**/SilentFilesInUseBAFunction.dll
# Pack the PTSettingsSvc MSIX from the ALREADY-SIGNED service binary (core ESRP
# signing ran before this template) and then sign the package itself, so the
# per-user installer can stage a signed, immutable service package (Design
# §12.1). Must run after core signing and before the MSI build, which embeds
# the .msix as the per-user payload.
- pwsh: |-
$svcDir = "$(Build.SourcesDirectory)\$(BuildPlatform)\$(BuildConfiguration)\WorkspacesSettingsService"
& "$(Build.SourcesDirectory)\src\modules\Workspaces\WorkspacesSettingsService\devtools\build-msix.ps1" `
-ExePath "$svcDir\PowerToys.PTSettingsSvc.exe" `
-OutMsix "$svcDir\PTSettingsSvc.msix" `
-Version "${{ parameters.versionNumber }}" `
-Arch "$(BuildPlatform)"
displayName: Pack PTSettingsSvc MSIX
- ${{ if eq(parameters.codeSign, true) }}:
- template: steps-esrp-sign-files-authenticode.yml
parameters:
displayName: Sign PTSettingsSvc MSIX
signingIdentity: ${{ parameters.signingIdentity }}
folder: '$(BuildPlatform)\$(BuildConfiguration)\WorkspacesSettingsService'
pattern: '**/PTSettingsSvc.msix'
## INSTALLER START
#### MSI BUILDING AND SIGNING
#

View File

@@ -1028,6 +1028,8 @@
<Project Path="src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj" Id="45285df2-9742-4eca-9ac9-58951fc26489" />
<Project Path="src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj" Id="3d63307b-9d27-44fd-b033-b26f39245b85" />
<Project Path="src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj" Id="37d07516-4185-43a4-924f-3c7a5d95ecf6" />
<Project Path="src/modules/Workspaces/WorkspacesSettingsService/WorkspacesSettingsService.vcxproj" Id="8b6a7c32-5c8d-4ad1-9f60-7e1b3d17a220" />
<Project Path="src/modules/Workspaces/WorkspacesSettingsClient/WorkspacesSettingsClient.vcxproj" Id="d24e2c12-9911-4e51-b102-39e7b62b22f1" />
</Folder>
<Folder Name="/modules/Workspaces/Tests/">
<Project Path="src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj">
@@ -1035,6 +1037,7 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj" Id="a85d4d9f-9a39-4b5d-8b5a-9f2d5c9a8b4c" />
<Project Path="src/modules/Workspaces/WorkspacesSettingsService/smoketest/WorkspacesSvcSmokeTest.vcxproj" Id="8b6a7c32-5c8d-4ad1-9f60-7e1b3d17a221" />
</Folder>
<Folder Name="/modules/Workspaces/WindowProperties/">
<File Path="src/modules/Workspaces/WindowProperties/WorkspacesWindowPropertyUtils.h" />

93
TESTING.md Normal file
View File

@@ -0,0 +1,93 @@
# v6 prototype — one-click test setup
The CLI agent that wrote the prototype runs as a non-admin user, so it cannot
install the Windows service or apply the ACL on `%ProgramData%` itself.
Everything that needs admin has been bundled into one script.
## Step 1 — run the elevated setup (one time)
Open **PowerShell as Administrator** and run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File D:\PowerToys-Workspaces-EoP-v6\setup-ptworkspacessvc.ps1
```
It will:
1. Remove any prior `PTWorkspacesSvc` install (idempotent).
2. Register `PTWorkspacesSvc` against `NT SERVICE\PTWorkspacesSvc`
(virtual account, demand start, restart-on-failure).
3. Create `C:\ProgramData\Microsoft\PowerToys\Workspaces` with a PROTECTED DACL:
- `NT SERVICE\PTWorkspacesSvc` → FullControl
- `BUILTIN\Administrators` → FullControl
- `Authenticated Users` → ReadAndExecute
- inheritance from ProgramData stripped
4. Start the service and confirm it runs under the virtual account.
Log: `%TEMP%\ptworkspacessvc-setup.log`. Window stays open until you hit Enter.
## Step 2 — smoke test (as your normal user)
Open a **regular** PowerShell (not admin) and run:
```powershell
# 1) Build a fake "install folder" so the auth check accepts us.
$fake = "$env:TEMP\PTFakeInstall"
New-Item -ItemType Directory -Force $fake | Out-Null
Copy-Item D:\PowerToys-Workspaces-EoP-v6\x64\Debug\WorkspacesSvcSmokeTest\PowerToys.WorkspacesSvcSmokeTest.exe `
"$fake\PowerToys.WorkspacesEditor.exe" -Force
$env:PT_DEV_INSTALL_FOLDER = $fake # prototype-only override
# 2) Negative: the smoke test from its real location must be rejected.
& D:\PowerToys-Workspaces-EoP-v6\x64\Debug\WorkspacesSvcSmokeTest\PowerToys.WorkspacesSvcSmokeTest.exe ping
# Expected: AuthRejected
# 3) Positive: same exe, allowed name, allowed location.
& "$fake\PowerToys.WorkspacesEditor.exe" ping # expect Ok
& "$fake\PowerToys.WorkspacesEditor.exe" get # expect Ok (empty for new user)
# 4) Write a settings file through the service.
'{"workspaces":[]}' | Set-Content -Encoding UTF8 "$env:TEMP\sample.json"
& "$fake\PowerToys.WorkspacesEditor.exe" put "$env:TEMP\sample.json" # expect Ok
# 5) Verify the service actually wrote the file.
$me = (whoami /user /fo csv /nh).Split(',')[1].Trim('"')
Get-Content "C:\ProgramData\Microsoft\PowerToys\Workspaces\$me\workspaces.json"
# 6) CORE EoP TEST — try to write directly as the same user.
# Must be DENIED (this is the whole point of v6).
try {
Set-Content "C:\ProgramData\Microsoft\PowerToys\Workspaces\$me\workspaces.json" '{"evil":true}'
Write-Host "FAIL — direct write succeeded; DACL is not protecting the file" -ForegroundColor Red
} catch {
Write-Host "PASS — direct write rejected: $($_.Exception.Message)" -ForegroundColor Green
}
```
## Cleanup (when done testing)
Elevated PowerShell:
```powershell
sc.exe stop PTWorkspacesSvc
sc.exe delete PTWorkspacesSvc
Remove-Item -Recurse -Force C:\ProgramData\Microsoft\PowerToys\Workspaces
```
Normal PowerShell:
```powershell
Remove-Item Env:\PT_DEV_INSTALL_FOLDER
Remove-Item -Recurse -Force $env:TEMP\PTFakeInstall, $env:TEMP\sample.json
```
## Pass criteria
| Step | Expected |
|---|---|
| Setup script | "Setup complete" + service Running + owner = `NT SERVICE\PTWorkspacesSvc` |
| Smoke test step 2 | `AuthRejected` |
| Smoke test step 3 | `Ping=Ok`, `Get=Ok` (empty) |
| Smoke test step 4 | `Put=Ok` |
| Smoke test step 5 | JSON content prints |
| **Smoke test step 6** | **`PASS — direct write rejected: ...`** ← core EoP fix |

View File

@@ -26,6 +26,7 @@
#include <processthreadsapi.h>
#include <UserEnv.h>
#include <winnt.h>
#include <shellapi.h>
using namespace std;
@@ -806,10 +807,286 @@ LExit:
return WcaFinalize(er);
}
// --- PTSettingsSvc MSIX (Design-v6-Final.md §12.4 unification) ---------------
// Per-MACHINE registration of the PowerToys Settings Service MSIX. Replaces the
// former MSI <ServiceInstall> of the loose exe: provisioning the MSIX for all
// users makes the MSIX windows.service extension the single owner of the
// machine-wide PTSettingsSvc, so per-machine and per-user no longer compete for
// the service name. Per-USER registration stays in the deferred managed
// ServiceProvisioner (a non-elevated per-user MSI cannot register a service).
namespace
{
const wchar_t* const kPTSettingsSvcFamilyName = L"Microsoft.PowerToys.SettingsService_8wekyb3d8bbwe";
const wchar_t* const kPTSettingsSvcPackageName = L"Microsoft.PowerToys.SettingsService";
const wchar_t* const kPTSettingsSvcMsixRelative = L"WorkspacesSettingsService\\PTSettingsSvc.msix";
// Best-effort STOP (not delete) of a service so its (packaged) exe is not
// held open while a new MSIX version is staged/registered — a running
// packaged windows.service otherwise blocks an in-place update with
// 0x80073D02 ("resources ... currently in use"). No-op on a fresh install.
void StopServiceIfRunning(const wchar_t* serviceName)
{
SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT);
if (!scm)
{
return;
}
SC_HANDLE svc = OpenServiceW(scm, serviceName, SERVICE_STOP | SERVICE_QUERY_STATUS);
if (svc)
{
SERVICE_STATUS ss{};
if (ControlService(svc, SERVICE_CONTROL_STOP, &ss))
{
for (int i = 0; i < 10; ++i)
{
if (!QueryServiceStatus(svc, &ss) || ss.dwCurrentState == SERVICE_STOPPED)
{
break;
}
Sleep(500);
}
}
CloseServiceHandle(svc);
}
CloseServiceHandle(scm);
}
// Prompts UAC once to remove the PTSettingsSvc package elevated (deleting a
// service-bearing package needs admin, which a per-user uninstall lacks).
// Mirrors the shared run_elevated() helper (common/utils/elevation.h) — the
// same UAC-via-ShellExecute("runas") mechanism used elsewhere in the product
// — kept self-contained here so the installer CA project need not take a new
// WIL/header dependency (elevation.h transitively pulls in wil/resource.h).
// Returns true only if the elevated removal completed (exit 0). False if the
// user declines UAC, there is no interactive session (silent uninstall), or
// removal fails — the caller then leaves the signed/immutable orphan WITHOUT
// blocking the uninstall (Design §12.5).
bool TryRemovePackageElevated(const wchar_t* packageName)
{
std::wstring params =
L"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "
L"\"Get-AppxPackage -Name '";
params += packageName;
params += L"' | Remove-AppxPackage\"";
SHELLEXECUTEINFOW sei{};
sei.cbSize = sizeof(sei);
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC;
sei.lpVerb = L"runas"; // triggers the UAC consent prompt
sei.lpFile = L"powershell.exe";
sei.lpParameters = params.c_str();
sei.nShow = SW_HIDE;
if (!ShellExecuteExW(&sei) || !sei.hProcess)
{
// ERROR_CANCELLED (1223) == user declined UAC; or no shell/session.
return false;
}
WaitForSingleObject(sei.hProcess, 120000);
DWORD exitCode = 1;
GetExitCodeProcess(sei.hProcess, &exitCode);
CloseHandle(sei.hProcess);
return exitCode == 0;
}
}
UINT __stdcall InstallPTSettingsSvcCA(MSIHANDLE hInstall)
{
using namespace winrt::Windows::Foundation;
using namespace winrt::Windows::Management::Deployment;
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
std::wstring installationFolder;
hr = WcaInitialize(hInstall, "InstallPTSettingsSvcCA");
ExitOnFailure(hr, "Failed to initialize");
hr = getInstallFolder(hInstall, installationFolder);
ExitOnFailure(hr, "Failed to get install folder");
try
{
std::filesystem::path msixPath = std::filesystem::path(installationFolder) / kPTSettingsSvcMsixRelative;
if (!std::filesystem::exists(msixPath))
{
Logger::error(L"PTSettingsSvc MSIX not found: " + msixPath.wstring());
er = ERROR_INSTALL_FAILURE;
ExitFunction();
}
Uri packageUri{ msixPath.wstring() };
PackageManager pm;
// Upgrade case: if a previous version's service is still running, stop it
// first so its packaged exe isn't held open (else the update fails with
// 0x80073D02). No-op on a fresh install. The service auto-restarts
// (AUTO_START) once the new version is registered (Design §12.6).
StopServiceIfRunning(L"PTSettingsSvc");
// Per-machine: stage once, then provision for all users. The MSIX
// windows.service extension registers the machine-wide PTSettingsSvc.
StagePackageOptions stageOptions;
auto stageResult = pm.StagePackageByUriAsync(packageUri, stageOptions).get();
uint32_t stageErrorCode = static_cast<uint32_t>(stageResult.ExtendedErrorCode());
if (stageErrorCode != 0)
{
Logger::error(L"PTSettingsSvc staging failed: 0x{:08X} - {}", stageErrorCode, stageResult.ErrorText());
er = ERROR_INSTALL_FAILURE;
ExitFunction();
}
auto provisionResult = pm.ProvisionPackageForAllUsersAsync(kPTSettingsSvcFamilyName).get();
uint32_t provisionErrorCode = static_cast<uint32_t>(provisionResult.ExtendedErrorCode());
if (provisionErrorCode != 0)
{
Logger::error(L"PTSettingsSvc provisioning failed: 0x{:08X}", provisionErrorCode);
er = ERROR_INSTALL_FAILURE;
ExitFunction();
}
Logger::info(L"PTSettingsSvc MSIX staged + provisioned for all users.");
}
catch (const winrt::hresult_error& ex)
{
Logger::error(L"PTSettingsSvc MSIX install exception: HRESULT 0x{:08X}", static_cast<uint32_t>(ex.code()));
er = ERROR_INSTALL_FAILURE;
}
catch (const std::exception& ex)
{
std::string errorMessage{ "Exception while installing PTSettingsSvc MSIX: " };
errorMessage += ex.what();
Logger::error(errorMessage);
er = ERROR_INSTALL_FAILURE;
}
LExit:
er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er;
return WcaFinalize(er);
}
UINT __stdcall UnRegisterPTSettingsSvcCA(MSIHANDLE hInstall)
{
using namespace winrt::Windows::Foundation;
using namespace winrt::Windows::Management::Deployment;
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
LPWSTR installScope = nullptr;
bool isMachineLevel = false;
hr = WcaInitialize(hInstall, "UnRegisterPTSettingsSvcCA");
ExitOnFailure(hr, "Failed to initialize");
// Removing a windows.service-bearing package deletes the SCM service, which
// requires admin. Per-machine uninstall runs elevated → full cleanup.
// Per-user uninstall is non-elevated → this is best-effort (Return="ignore",
// Impersonate="yes", mirroring UninstallServicesTask); when it cannot
// elevate, the signed+immutable WindowsApps package is left as a harmless
// orphan, removed later by a per-machine install or a manual elevated
// Remove-AppxPackage (Design §12.5).
hr = WcaGetProperty(L"InstallScope", &installScope);
if (SUCCEEDED(hr) && installScope && wcscmp(installScope, L"perMachine") == 0)
{
isMachineLevel = true;
}
Logger::info(L"Unregistering PTSettingsSvc MSIX - perUser: {}", !isMachineLevel);
try
{
PackageManager pm;
if (isMachineLevel)
{
// Per-machine: deprovision, then remove for all users.
try
{
pm.DeprovisionPackageForAllUsersAsync(kPTSettingsSvcFamilyName).get();
}
catch (const winrt::hresult_error& ex)
{
Logger::warn(L"PTSettingsSvc deprovision failed: HRESULT 0x{:08X}", static_cast<uint32_t>(ex.code()));
}
auto packages = pm.FindPackagesForUserWithPackageTypes({}, kPTSettingsSvcFamilyName, PackageTypes::Main);
for (const auto& package : packages)
{
try
{
auto removeResult = pm.RemovePackageAsync(package.Id().FullName(), RemovalOptions::RemoveForAllUsers).get();
uint32_t errorCode = static_cast<uint32_t>(removeResult.ExtendedErrorCode());
if (errorCode != 0)
{
Logger::error(L"PTSettingsSvc removal failed: 0x{:08X} - {}", errorCode, removeResult.ErrorText());
}
}
catch (const winrt::hresult_error& ex)
{
Logger::error(L"PTSettingsSvc removal exception: HRESULT 0x{:08X}", static_cast<uint32_t>(ex.code()));
}
}
}
else
{
// Per-user uninstall is non-elevated, but deleting a service-bearing
// package needs admin. 1) Try in-proc removal (succeeds only if this
// uninstall already happens to be elevated). 2) If anything remains,
// prompt UAC ONCE to remove it elevated. 3) If the user declines or
// there's no interactive session (silent uninstall), leave the
// signed/immutable orphan WITHOUT blocking the uninstall (Design §12.5).
bool foundAny = false;
bool removed = false;
auto packages = pm.FindPackagesForUserWithPackageTypes({}, kPTSettingsSvcFamilyName, PackageTypes::Main);
for (const auto& package : packages)
{
foundAny = true;
try
{
auto removeResult = pm.RemovePackageAsync(package.Id().FullName()).get();
if (static_cast<uint32_t>(removeResult.ExtendedErrorCode()) == 0)
{
removed = true;
}
}
catch (const winrt::hresult_error&)
{
// Expected when non-elevated; fall through to the UAC prompt.
}
}
if (foundAny && !removed)
{
if (TryRemovePackageElevated(kPTSettingsSvcPackageName))
{
Logger::info(L"PTSettingsSvc removed via one-time elevation at uninstall.");
}
else
{
Logger::warn(L"PTSettingsSvc left registered (UAC declined or no interactive session); "
L"removable later by an elevated Remove-AppxPackage or a per-machine install.");
}
}
}
}
catch (const std::exception& ex)
{
std::string errorMessage{ "Exception while unregistering PTSettingsSvc MSIX: " };
errorMessage += ex.what();
Logger::error(errorMessage);
// Don't fail the whole uninstall over service-package cleanup.
Logger::warn(L"Continuing uninstall despite PTSettingsSvc MSIX error");
}
LExit:
ReleaseStr(installScope);
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}
UINT __stdcall RemoveWindowsServiceByName(std::wstring serviceName)
{
SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT);
if (!hSCManager)
{
return ERROR_INSTALL_FAILURE;

View File

@@ -36,5 +36,7 @@ EXPORTS
SetBundleInstallLocationCA
InstallPackageIdentityMSIXCA
UninstallPackageIdentityMSIXCA
InstallPTSettingsSvcCA
UnRegisterPTSettingsSvcCA
CreateWinAppSDKHardlinksCA
DeleteWinAppSDKHardlinksCA

View File

@@ -0,0 +1,52 @@
<#
.SYNOPSIS
PTSettingsSvc - uninstall cleanup (Design-v6-Final.md section 11 uninstall/cleanup).
Runs as SYSTEM from the per-machine MSI (deferred CustomAction, on uninstall).
Removes the service and recursively deletes the protected data tree.
This recursive delete is REQUIRED: the per-user <SID>\blob.bin nodes are created
by the service at runtime and are NOT in the MSI component table, so the MSI's
default RemoveFolder won't touch them. A non-elevated per-user uninstall cannot
do this (the tree is SYSTEM-owned, user has only RX) - only the elevated/SYSTEM
per-machine uninstall can.
.PARAMETER RemoveService Stop + delete the PTSettingsSvc service (default: on).
.PARAMETER RemoveData Recursively delete the SettingsSvc data tree (default: on).
#>
[CmdletBinding()]
param(
[string]$ServiceName = 'PTSettingsSvc',
[switch]$RemoveService = $true,
[switch]$RemoveData = $true
)
$ErrorActionPreference = 'Continue'
if ($RemoveService)
{
$svc = Get-Service $ServiceName -ErrorAction SilentlyContinue
if ($svc)
{
if ($svc.Status -ne 'Stopped') { sc.exe stop $ServiceName | Out-Null; Start-Sleep -Milliseconds 800 }
sc.exe delete $ServiceName | Out-Null
Write-Output "service '$ServiceName' removed."
}
else { Write-Output "service '$ServiceName' not present." }
}
if ($RemoveData)
{
$root = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'Microsoft\PowerToys\Settings'
if (Test-Path $root)
{
# Recursive delete works because this runs as SYSTEM/admin (the tree is
# SYSTEM-owned with the user only RX; a non-elevated user could not).
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $root) { Write-Output "WARNING: '$root' not fully removed." }
else { Write-Output "data tree '$root' removed." }
}
else { Write-Output "data tree not present." }
}
exit 0

View File

@@ -0,0 +1,114 @@
<#
.SYNOPSIS
PTSettingsSvc - install-time per-machine seeding (Design-v6-Final.md section 11 MIGRATION).
Runs as SYSTEM from the per-machine MSI (deferred CustomAction). Seeds every
existing user's protected blob from their legacy %LocalAppData% Workspaces file:
%LocalAppData%\Microsoft\PowerToys\Workspaces\workspaces.json (user U)
-> %ProgramData%\Microsoft\PowerToys\SettingsSvc\<ns>\<SID(U)>\blob.bin
Direct SYSTEM file write - no service round-trip, no migration opcode. The blob
is created with owner=SYSTEM and a PROTECTED DACL (svc:F, admin:F, system:F,
<user>:RX) so the user can read but never tamper. Idempotent (skips a SID that
already has a blob).
.NOTES
Standalone (no modules); safe to invoke via `powershell -ExecutionPolicy Bypass -File`.
#>
[CmdletBinding()]
param(
[string]$NamespaceId = 'Workspaces',
[string]$FileName = 'workspaces.json',
[string]$LegacyRelative = 'AppData\Local\Microsoft\PowerToys\Workspaces\workspaces.json',
[string]$ServiceAccount = 'NT SERVICE\PTSettingsSvc'
)
$ErrorActionPreference = 'Stop'
$programData = [Environment]::GetFolderPath('CommonApplicationData')
# SID-first layout: <storeRoot>\<sid>\<namespace>\<file>
$storeRoot = Join-Path $programData 'Microsoft\PowerToys\Settings'
# Store root: SYSTEM/Admins/service Full, Authenticated Users RX (so each user
# can traverse to their own <sid> node), owner SYSTEM, PROTECTED.
function New-RootDir([string]$path)
{
if (-not (Test-Path $path)) { New-Item -ItemType Directory -Force $path | Out-Null }
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$acl.SetOwner([System.Security.Principal.SecurityIdentifier]'S-1-5-18') # SYSTEM
$inherit = 'ContainerInherit,ObjectInherit'
foreach ($p in @('NT AUTHORITY\SYSTEM','BUILTIN\Administrators',$ServiceAccount))
{
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($p,'FullControl',$inherit,'None','Allow')))
}
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule(
'NT AUTHORITY\Authenticated Users','ReadAndExecute',$inherit,'None','Allow')))
Set-Acl -Path $path -AclObject $acl
}
function New-ProtectedDir([string]$path, [string]$userSid)
{
if (-not (Test-Path $path)) { New-Item -ItemType Directory -Force $path | Out-Null }
# Build a PROTECTED DACL: SYSTEM:F, Administrators:F, service:F, <user>:RX.
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false) # protected, drop inheritance
$acl.SetOwner([System.Security.Principal.SecurityIdentifier]'S-1-5-18') # SYSTEM
$inherit = 'ContainerInherit,ObjectInherit'
$rules = @(
(New-Object Security.AccessControl.FileSystemAccessRule('NT AUTHORITY\SYSTEM','FullControl',$inherit,'None','Allow')),
(New-Object Security.AccessControl.FileSystemAccessRule('BUILTIN\Administrators','FullControl',$inherit,'None','Allow')),
(New-Object Security.AccessControl.FileSystemAccessRule($ServiceAccount,'FullControl',$inherit,'None','Allow')),
(New-Object Security.AccessControl.FileSystemAccessRule(
(New-Object Security.Principal.SecurityIdentifier($userSid)),'ReadAndExecute,Synchronize',$inherit,'None','Allow'))
)
foreach ($r in $rules) { $acl.AddAccessRule($r) }
Set-Acl -Path $path -AclObject $acl
}
# Enumerate real user profiles from ProfileList (SID -> profile path).
$profileListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
$seeded = 0; $skipped = 0
# Ensure the store root exists with the traversable root DACL (once).
New-RootDir -path $storeRoot
Get-ChildItem $profileListKey -ErrorAction SilentlyContinue | ForEach-Object {
$sid = $_.PSChildName
# Only real interactive users: local (S-1-5-21-*) or AAD/MSA (S-1-12-1-*).
if ($sid -notmatch '^(S-1-5-21-|S-1-12-1-)') { return }
$profilePath = (Get-ItemProperty $_.PSPath -Name ProfileImagePath -ErrorAction SilentlyContinue).ProfileImagePath
if ([string]::IsNullOrEmpty($profilePath)) { return }
$legacy = Join-Path $profilePath $LegacyRelative
if (-not (Test-Path $legacy)) { return }
$userRoot = Join-Path $storeRoot $sid # per-user node (protected, inherits down)
$nsFolder = Join-Path $userRoot $NamespaceId
$file = Join-Path $nsFolder $FileName
if (Test-Path $file) { $skipped++; return } # idempotent
try
{
# Protect the <sid> node once; the namespace folder + file inherit it.
New-ProtectedDir -path $userRoot -userSid $sid
if (-not (Test-Path $nsFolder)) { New-Item -ItemType Directory -Force $nsFolder | Out-Null }
[System.IO.File]::WriteAllBytes($file, [System.IO.File]::ReadAllBytes($legacy))
$bytes = ([System.IO.FileInfo]::new($file)).Length
Write-Output "seeded: $sid ($bytes bytes)"
$seeded++
}
catch
{
Write-Output "FAILED for $sid : $($_.Exception.Message)"
}
}
Write-Output "PTSettingsSvc seeding done: $seeded seeded, $skipped already present."
exit 0

View File

@@ -138,6 +138,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
<Compile Include="Resources.wxs" />
<Compile Include="WinAppSDK.wxs" />
<Compile Include="Workspaces.wxs" />
<Compile Include="WorkspacesSettingsService.wxs" />
</ItemGroup>
<ItemGroup>
<Folder Include="CustomDialogs" />

View File

@@ -69,6 +69,21 @@
<ComponentGroupRef Id="ToolComponentGroup" />
<ComponentGroupRef Id="MonacoSRCHeatGenerated" />
<ComponentGroupRef Id="WorkspacesComponentGroup" />
<!--
PowerToys Settings Service (PTSettingsSvc).
Per-machine: the MSI is elevated, so it registers the service and lays
down the protected store eagerly (PTSettingsServiceComponentGroup).
Per-user: the MSI is non-elevated and cannot register a service, so it
only stages the service payload (exe + hardening script); the service is
registered + the store hardened lazily via a one-time elevation the first
time protection is needed (Design §11 / §15 #5 d), driven by
SettingsBootstrapper / ServiceProvisioner in the managed code.
-->
<?if $(var.PerUser) != "true" ?>
<ComponentGroupRef Id="PTSettingsServiceComponentGroup" />
<?else?>
<ComponentGroupRef Id="PTSettingsServicePayloadComponentGroup" />
<?endif?>
<ComponentGroupRef Id="CmdPalComponentGroup" />
</Feature>
@@ -117,6 +132,11 @@
<Custom Action="SetApplyModulesRegistryChangeSetsParam" Before="ApplyModulesRegistryChangeSets" />
<Custom Action="SetInstallPackageIdentityMSIXParam" Before="InstallPackageIdentityMSIX" />
<?if $(var.PerUser) != "true" ?>
<!-- Per-machine: provision the PTSettingsSvc MSIX for all users (Design §12.4). -->
<Custom Action="SetInstallPTSettingsSvcParam" Before="InstallPTSettingsSvc" />
<?endif?>
<?if $(var.PerUser) = "true" ?>
<Custom Action="SetInstallDSCModuleParam" Before="InstallDSCModule" />
<?endif?>
@@ -129,6 +149,9 @@
<Custom Action="CreateWinAppSDKHardlinks" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED OR REINSTALL" />
<Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER &gt;= 22000" />
<?if $(var.PerUser) != "true" ?>
<Custom Action="InstallPTSettingsSvc" After="InstallFiles" Condition="NOT Installed" />
<?endif?>
<Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" />
<Custom Action="RemovePowerToysSchTasks" After="RemoveFiles" />
<!-- TODO: Use to activate embedded MSIX -->
@@ -152,6 +175,12 @@
<Custom Action="UninstallCommandNotFound" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" />
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<!-- PTSettingsSvc MSIX teardown (Design §12.5). Per-machine runs elevated
and removes the service package for all users; per-user is best-effort
(non-elevated uninstall cannot delete the service, mirroring
UninstallServicesTask) — the signed/immutable orphan is cleaned by a
later per-machine install or a manual elevated Remove-AppxPackage. -->
<Custom Action="UnRegisterPTSettingsSvc" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE=&quot;ALL&quot;)" />
<!-- TODO: Use to activate embedded MSIX -->
<!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize">
@@ -218,6 +247,15 @@
<CustomAction Id="UninstallPackageIdentityMSIX" Return="ignore" Impersonate="yes" DllEntry="UninstallPackageIdentityMSIXCA" BinaryRef="PTCustomActions" />
<!-- PTSettingsSvc MSIX provisioning (per-machine, Design §12.4). Mirrors the
PackageIdentity CAs: deferred+impersonated install provisions for all
users; immediate uninstall deprovisions + removes for all users. -->
<CustomAction Id="SetInstallPTSettingsSvcParam" Property="InstallPTSettingsSvc" Value="[INSTALLFOLDER]" />
<CustomAction Id="InstallPTSettingsSvc" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="InstallPTSettingsSvcCA" BinaryRef="PTCustomActions" />
<CustomAction Id="UnRegisterPTSettingsSvc" Return="ignore" Impersonate="yes" DllEntry="UnRegisterPTSettingsSvcCA" BinaryRef="PTCustomActions" />
<CustomAction Id="InstallDSCModule" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="InstallDSCModuleCA" BinaryRef="PTCustomActions" />
<CustomAction Id="UninstallDSCModule" Return="ignore" Impersonate="yes" DllEntry="UninstallDSCModuleCA" BinaryRef="PTCustomActions" />

View File

@@ -0,0 +1,243 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
PowerToys Settings Service (PTSettingsSvc) installer fragment.
Implements Design-v6-Final.md §9 (storage DACL) and §12.1/§12.4 (MSIX service
distribution + scope unification).
The service binary is distributed as a signed MSIX (windows.service extension,
LocalSystem). Both scopes use the SAME MSIX, so there is a single machine-wide
PTSettingsSvc and a single staged package per version (no MSI <ServiceInstall>).
Per-machine: this fragment stages PTSettingsSvc.msix and the per-machine MSI
(elevated) provisions it for all users via the InstallPTSettingsSvc custom
action (Product.wxs) — the MSIX windows.service extension registers the
service.
Per-user: the per-user payload fragment (below) stages the same MSIX; the
managed ServiceProvisioner deploys it under one UAC on first editor open (a
non-elevated per-user MSI cannot register a service at install time).
Responsibilities:
1. Stage PTSettingsSvc.msix under <InstallFolder>\WorkspacesSettingsService\
2. Provision it for all users (per-machine CA) / deferred deploy (per-user)
3. Create %ProgramData%\Microsoft\PowerToys\Settings with a root DACL
(Administrators:FullControl, SYSTEM:FullControl, Authenticated Users:RX).
Per-user (<sid>) and per-namespace subfolders are created and tightened
lazily by the LocalSystem service on first write (SID-first layout).
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<?include $(sys.CURRENTDIR)\Common.wxi?>
<?define PTSettingsSvcFilesPath=$(var.BinDir)\WorkspacesSettingsService\?>
<Fragment>
<!-- Service binary directory -->
<DirectoryRef Id="INSTALLFOLDER">
<Directory Id="WorkspacesSettingsServiceFolder" Name="WorkspacesSettingsService" />
</DirectoryRef>
<!-- Service MSIX payload (Design §12.4 unification). Per-machine ships the
signed PTSettingsSvc.msix and provisions it for all users via the
InstallPTSettingsSvc custom action (Product.wxs). The MSIX
windows.service extension owns the single machine-wide PTSettingsSvc,
so there is no MSI <ServiceInstall> competing with the per-user MSIX. -->
<DirectoryRef Id="WorkspacesSettingsServiceFolder"
FileSource="$(var.PTSettingsSvcFilesPath)">
<Component Id="RegisterPTSettingsService"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0001}">
<File Id="PTSettingsSvcMsixPerMachine"
Name="PTSettingsSvc.msix"
KeyPath="yes" />
</Component>
<!-- Remove the per-install service folder on uninstall -->
<Component Id="RemovePTSettingsServiceFolder"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0002}"
Directory="INSTALLFOLDER">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string"
Name="RemovePTSettingsServiceFolder"
Value=""
KeyPath="yes" />
</RegistryKey>
<RemoveFolder Id="RemoveFolderPTSettingsServiceFolder"
Directory="WorkspacesSettingsServiceFolder"
On="uninstall" />
</Component>
</DirectoryRef>
<!--
%ProgramData%\Microsoft\PowerToys\Settings — created at install with a
PROTECTED DACL (Design §9). util:PermissionEx replaces the inherited
DACL with the explicit ACEs below, so the default %ProgramData%
"Users can create" ACE does not carry through. Authenticated Users get
RX so each user can traverse to their own \<sid> node. Per-user (\<sid>)
and per-namespace (\<sid>\Workspaces) subfolders are created and tightened
by the service on first write (SID-first layout).
-->
<StandardDirectory Id="CommonAppDataFolder">
<Directory Id="PTSettingsDataMicrosoft" Name="Microsoft">
<Directory Id="PTSettingsDataPT" Name="PowerToys">
<Directory Id="PTSettingsDataRoot" Name="Settings">
<Component Id="CreatePTSettingsDataRoot"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0003}">
<CreateFolder>
<util:PermissionEx User="Administrators" Domain="BUILTIN"
GenericAll="yes" />
<util:PermissionEx User="SYSTEM" Domain="NT AUTHORITY"
GenericAll="yes" />
<util:PermissionEx User="Authenticated Users" Domain="NT AUTHORITY"
GenericRead="yes" GenericExecute="yes" />
</CreateFolder>
<RegistryKey Root="HKLM"
Key="Software\Microsoft\PowerToys\SettingsSvc">
<RegistryValue Type="integer"
Name="DataRootCreated"
Value="1"
KeyPath="yes" />
</RegistryKey>
</Component>
</Directory>
</Directory>
</Directory>
</StandardDirectory>
<!--
INSTALLFOLDER hardening (Design §8/§11). Replaces the install folder's
DACL with the admin-only-writable set the runtime check expects. The
service runs as LocalSystem (MSIX), which is covered by the SYSTEM ACE
below, so it can read this DACL during caller authentication — no separate
virtual-account ACE is needed.
NOTE: util:PermissionEx replaces the whole DACL, so the full intended
ACL is specified here. This touches the shared INSTALLFOLDER and MUST
be validated end-to-end (installer validation is deferred).
-->
<DirectoryRef Id="INSTALLFOLDER">
<Component Id="HardenInstallFolderDacl"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0004}">
<CreateFolder>
<util:PermissionEx User="SYSTEM" Domain="NT AUTHORITY"
GenericAll="yes" />
<util:PermissionEx User="Administrators" Domain="BUILTIN"
GenericAll="yes" />
<util:PermissionEx User="TrustedInstaller" Domain="NT SERVICE"
GenericAll="yes" />
<util:PermissionEx User="Users" Domain="BUILTIN"
GenericRead="yes" GenericExecute="yes" />
</CreateFolder>
<RegistryKey Root="HKLM"
Key="Software\Microsoft\PowerToys\SettingsSvc">
<RegistryValue Type="integer"
Name="InstallFolderHardened"
Value="1"
KeyPath="yes" />
</RegistryKey>
</Component>
</DirectoryRef>
<!--
Migration / cleanup CustomActions (Design-v6-Final.md §11). The seeding
and cleanup logic lives in PowerShell scripts (installed next to the
service binary); these CAs invoke them as SYSTEM. Installer validation
is deferred — authored, not yet MSI-validated.
* Seed at install: enumerate user profiles → create each user's protected
blob from their legacy %LocalAppData% file (direct SYSTEM write).
* Cleanup at uninstall: recursively delete the SettingsSvc data tree
(the runtime-created <SID>\blob.bin nodes aren't MSI-tracked, so the
default RemoveFolder can't remove them; the service itself is removed
by ServiceControl above).
-->
<DirectoryRef Id="WorkspacesSettingsServiceFolder"
FileSource="$(var.ProjectDir)\CustomActions">
<Component Id="PTSettingsCustomActionScripts"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0005}">
<File Id="PtSeedScript" Name="Seed-PtSettingsStore.ps1" KeyPath="yes" />
<File Id="PtCleanupScript" Name="Remove-PtSettingsStore.ps1" />
</Component>
</DirectoryRef>
<!-- Run the seeding script as SYSTEM after files are laid down (fresh install only). -->
<CustomAction Id="PtSeedStore"
Directory="WorkspacesSettingsServiceFolder"
ExeCommand="powershell.exe -NonInteractive -NoProfile -ExecutionPolicy Bypass -File &quot;[#PtSeedScript]&quot;"
Execute="deferred" Impersonate="no" Return="ignore" />
<!-- Recursively remove the protected data tree as SYSTEM on uninstall. -->
<CustomAction Id="PtCleanupStore"
Directory="WorkspacesSettingsServiceFolder"
ExeCommand="powershell.exe -NonInteractive -NoProfile -ExecutionPolicy Bypass -File &quot;[#PtCleanupScript]&quot; -RemoveService:$false -RemoveData"
Execute="deferred" Impersonate="no" Return="ignore" />
<InstallExecuteSequence>
<!-- After the data root + files exist; only on a fresh install. -->
<Custom Action="PtSeedStore" After="InstallFiles" Condition="NOT Installed" />
<!-- While the script still exists; only on full uninstall. -->
<Custom Action="PtCleanupStore" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
</InstallExecuteSequence>
<ComponentGroup Id="PTSettingsServiceComponentGroup">
<ComponentRef Id="RegisterPTSettingsService" />
<ComponentRef Id="RemovePTSettingsServiceFolder" />
<ComponentRef Id="CreatePTSettingsDataRoot" />
<ComponentRef Id="HardenInstallFolderDacl" />
<ComponentRef Id="PTSettingsCustomActionScripts" />
</ComponentGroup>
</Fragment>
<!--
Per-user payload (Design §12.1). A per-user MSI is non-elevated and cannot
register a service, so it only STAGES the SIGNED service MSIX under the
install folder. The managed ServiceProvisioner deploys it via one elevated
Add-AppxPackage (the windows.service extension auto-registers PTSettingsSvc
as LocalSystem; the service hardens the store on first PutBlob). No
user-writable hardening script (the prior Harden ps1 was an EoP hole).
-->
<Fragment>
<DirectoryRef Id="INSTALLFOLDER">
<Directory Id="PTSettingsPayloadFolder" Name="WorkspacesSettingsService" />
</DirectoryRef>
<DirectoryRef Id="PTSettingsPayloadFolder"
FileSource="$(var.PTSettingsSvcFilesPath)">
<Component Id="PTSettingsServicePayloadMsix"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0006}">
<File Id="PTSettingsSvcMsixPerUser"
Name="PTSettingsSvc.msix" />
<!-- Per-user (user-profile) component: KeyPath must be an HKCU
registry value, not a file (WiX ICE38). -->
<RegistryKey Root="HKCU" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string"
Name="PTSettingsServicePayloadMsix"
Value=""
KeyPath="yes" />
</RegistryKey>
</Component>
</DirectoryRef>
<!-- Remove the staged payload folder on uninstall. -->
<DirectoryRef Id="PTSettingsPayloadFolder">
<Component Id="RemovePTSettingsPayloadFolder"
Guid="{F1F4C1B3-2E11-4F11-9E0E-2DBB4D9E0008}">
<RegistryKey Root="HKCU" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string"
Name="RemovePTSettingsPayloadFolder"
Value=""
KeyPath="yes" />
</RegistryKey>
<RemoveFolder Id="RemoveFolderPTSettingsPayload"
Directory="PTSettingsPayloadFolder"
On="uninstall" />
</Component>
</DirectoryRef>
<ComponentGroup Id="PTSettingsServicePayloadComponentGroup">
<ComponentRef Id="PTSettingsServicePayloadMsix" />
<ComponentRef Id="RemovePTSettingsPayloadFolder" />
</ComponentGroup>
</Fragment>
</Wix>

View File

@@ -102,19 +102,18 @@ namespace ShortcutGuide.Helpers
/// <summary>
/// Retrieves all application IDs that should be displayed, based on the foreground window and background processes.
/// </summary>
/// <param name="foregroundWindowHandle">The window handle captured before Shortcut Guide UI takes focus.</param>
/// <returns>
/// A dictionary mapping each application ID to the full path of the executable
/// that caused the match (used for icon extraction), or <c>null</c> when no
/// specific executable is associated (for example, wildcard filters like the
/// default shell).
/// </returns>
public static Dictionary<string, string?> GetAllCurrentApplicationIds()
public static Dictionary<string, string?> GetAllCurrentApplicationIds(nint foregroundWindowHandle)
{
nint handle = NativeMethods.GetForegroundWindow();
Dictionary<string, string?> applicationIds = new(StringComparer.Ordinal);
if (NativeMethods.GetWindowThreadProcessId(handle, out uint processId) > 0)
if (NativeMethods.GetWindowThreadProcessId(foregroundWindowHandle, out uint processId) > 0)
{
string? name = null;
string? executablePath = null;

View File

@@ -21,9 +21,12 @@ namespace ShortcutGuide
{
public static Thread CopyAndIndexGenerationThread { get; private set; } = null!;
public static nint ForegroundWindowHandle { get; private set; } = nint.Zero;
[STAThread]
public static void Main(string[] args)
{
ForegroundWindowHandle = NativeMethods.GetForegroundWindow();
Logger.InitializeLogger("\\ShortcutGuide\\Logs");
// The module interface passes: <powertoys_pid> [telemetry]

View File

@@ -53,7 +53,7 @@ namespace ShortcutGuide
_getAppIdsTask = Task.Run(() =>
{
Program.CopyAndIndexGenerationThread.Join();
_currentApplicationIds = ManifestInterpreter.GetAllCurrentApplicationIds();
_currentApplicationIds = ManifestInterpreter.GetAllCurrentApplicationIds(Program.ForegroundWindowHandle);
return _currentApplicationIds;
});

View File

@@ -9,6 +9,7 @@ using ManagedCommon;
using PowerToys.Interop;
using PowerToys.ModuleContracts;
using WorkspacesCsharpLibrary.Data;
using WorkspacesCsharpLibrary.SettingsService;
namespace Workspaces.ModuleServices;
@@ -52,6 +53,8 @@ public sealed class WorkspaceService : ModuleServiceBase, IWorkspaceService
try
{
EnsureSettingsInitialized(SettingsBootstrapper.TriggerReason.WorkspaceLaunching);
var powertoysBaseDir = PowerToysPathResolver.GetPowerToysInstallPath();
if (string.IsNullOrEmpty(powertoysBaseDir))
{
@@ -84,6 +87,8 @@ public sealed class WorkspaceService : ModuleServiceBase, IWorkspaceService
{
try
{
EnsureSettingsInitialized();
var items = WorkspacesStorage.Load();
return Task.FromResult(OperationResults.Ok<IReadOnlyList<ProjectWrapper>>(items));
@@ -93,4 +98,27 @@ public sealed class WorkspaceService : ModuleServiceBase, IWorkspaceService
return Task.FromResult(OperationResults.Fail<IReadOnlyList<ProjectWrapper>>($"Failed to read workspaces: {ex.Message}"));
}
}
// Deferred settings initialization (Design-v6-Final.md §11). Composes the
// service-initialization and legacy-migration blocks behind one call so new
// trigger points only have to invoke SettingsBootstrapper.EnsureInitialized.
// On a per-machine install the service is already up, so provisioning is a
// no-op and only the migration backstop runs. On a per-user install with no
// service yet, this performs the one-time elevation to register + harden it.
private static void EnsureSettingsInitialized(
SettingsBootstrapper.TriggerReason reason = SettingsBootstrapper.TriggerReason.EditorOpened)
{
try
{
SettingsBootstrapper.EnsureInitialized(new BootstrapRequest
{
Reason = reason,
InstallFolder = PowerToysPathResolver.GetPowerToysInstallPath(),
});
}
catch (Exception)
{
// Best-effort; on failure reads fall back per WorkspacesStorage.
}
}
}

View File

@@ -9,29 +9,77 @@ using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using WorkspacesCsharpLibrary.SettingsService;
namespace WorkspacesCsharpLibrary.Data;
/// <summary>
/// Lightweight reader for persisted workspaces.
/// Reader/writer for persisted workspaces. All access goes through the
/// PTSettingsSvc service (Design-v6-Final.md §10): the service stores opaque
/// bytes, this class owns the JSON shape, defensive parsing and the
/// no-service last-resort fallback to the legacy %LocalAppData% file.
/// </summary>
public static class WorkspacesStorage
{
public static IReadOnlyList<ProjectWrapper> Load()
{
var filePath = GetDefaultFilePath();
if (!File.Exists(filePath))
var rc = PTSettingsClient.GetBlob(out var blob);
switch (rc)
{
return [];
case PTSettingsClient.Result.Ok:
return ParseDefensive(blob);
case PTSettingsClient.Result.NotFound:
// Service is up but this user has no blob yet (first run /
// pre-migration). Not an error.
return Array.Empty<ProjectWrapper>();
case PTSettingsClient.Result.Unavailable:
// No service installed (no-admin install / declined elevation).
// Last resort: read the legacy file directly (Design §10/§11).
return ParseDefensive(ReadLegacyBytes());
default:
// AuthRejected / Protocol / IoError → fail safe to empty.
return Array.Empty<ProjectWrapper>();
}
}
/// <summary>
/// Persists the workspaces through the service. Returns true on success.
/// Falls back to a direct legacy-file write only when no service exists.
/// </summary>
public static bool Save(IReadOnlyList<ProjectWrapper> workspaces)
{
byte[] bytes = Serialise(workspaces);
var rc = PTSettingsClient.PutBlob(bytes);
switch (rc)
{
case PTSettingsClient.Result.Ok:
return true;
case PTSettingsClient.Result.Unavailable:
return WriteLegacyBytes(bytes);
default:
return false;
}
}
private static IReadOnlyList<ProjectWrapper> ParseDefensive(byte[] bytes)
{
if (bytes == null || bytes.Length == 0)
{
return Array.Empty<ProjectWrapper>();
}
try
{
var json = File.ReadAllText(filePath);
var data = JsonSerializer.Deserialize(json, WorkspacesStorageJsonContext.Default.WorkspacesFile);
var data = JsonSerializer.Deserialize(bytes, WorkspacesStorageJsonContext.Default.WorkspacesFile);
if (data?.Workspaces == null)
{
return [];
return Array.Empty<ProjectWrapper>();
}
return data.Workspaces
@@ -50,16 +98,77 @@ public static class WorkspacesStorage
.ToList()
.AsReadOnly();
}
catch
catch (JsonException)
{
return Array.Empty<ProjectWrapper>();
}
catch (NotSupportedException)
{
return Array.Empty<ProjectWrapper>();
}
}
public static string GetDefaultFilePath()
private static byte[] Serialise(IReadOnlyList<ProjectWrapper> workspaces)
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "Microsoft", "PowerToys", "Workspaces", "workspaces.json");
var file = new WorkspacesFile
{
Workspaces = (workspaces ?? new List<ProjectWrapper>())
.Select(ws => new WorkspaceProject
{
Id = ws.Id,
Name = ws.Name,
Applications = ws.Applications ?? new List<ApplicationWrapper>(),
MonitorConfiguration = ws.MonitorConfiguration ?? new List<MonitorConfigurationWrapper>(),
CreationTime = ws.CreationTime,
LastLaunchedTime = ws.LastLaunchedTime,
IsShortcutNeeded = ws.IsShortcutNeeded,
MoveExistingWindows = ws.MoveExistingWindows,
})
.ToList(),
};
return JsonSerializer.SerializeToUtf8Bytes(file, WorkspacesStorageJsonContext.Default.WorkspacesFile);
}
private static byte[] ReadLegacyBytes()
{
try
{
var legacy = SettingsPaths.LegacyWorkspacesFile();
return File.Exists(legacy) ? File.ReadAllBytes(legacy) : Array.Empty<byte>();
}
catch (IOException)
{
return Array.Empty<byte>();
}
catch (UnauthorizedAccessException)
{
return Array.Empty<byte>();
}
}
private static bool WriteLegacyBytes(byte[] bytes)
{
try
{
var legacy = SettingsPaths.LegacyWorkspacesFile();
var dir = Path.GetDirectoryName(legacy);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllBytes(legacy, bytes);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
internal sealed class WorkspacesFile

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// Inputs for <see cref="SettingsBootstrapper.EnsureInitialized"/>. Hosts build
/// this from their own context (install-path resolver, optional test seam).
/// </summary>
public sealed class BootstrapRequest
{
/// <summary>What triggered the bootstrap (an explicit request bypasses back-off).</summary>
public SettingsBootstrapper.TriggerReason Reason { get; init; }
/// <summary>
/// Resolved PowerToys install folder. When null/empty, service provisioning
/// is skipped and only migration (with its no-service fallback) runs.
/// </summary>
public string? InstallFolder { get; init; }
/// <summary>Optional elevation override forwarded to the provisioner (tests / headless).</summary>
public ServiceProvisioner.ElevationRunner? ElevationRunner { get; init; }
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Security.Principal;
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// Managed client for PTSettingsSvc. Mirrors the native client and the
/// service's wire protocol (see Protocol.h) so the Editor, runner and unit
/// tests can talk to the service without P/Invoke. The service treats the
/// payload as opaque bytes; all JSON / schema concerns live in the caller.
/// </summary>
public static class PTSettingsClient
{
/// <summary>Coarse result surfaced to callers (mirrors the service status bands).</summary>
public enum Result : byte
{
/// <summary>Request succeeded.</summary>
Ok = 0,
/// <summary>GetBlob: the blob does not exist yet (service is up).</summary>
NotFound,
/// <summary>Caller authentication / namespace check failed.</summary>
AuthRejected,
/// <summary>No service to talk to (not installed / not running).</summary>
Unavailable,
/// <summary>Framing / unexpected protocol error.</summary>
Protocol,
/// <summary>Underlying file IO failed in the service.</summary>
IoError,
}
// Mirror of PTSettingsSvc::kPipeName (server side strips the \\.\pipe\ prefix).
public const string PipeName = "PTSettingsSvc";
// Mirror of PTSettingsSvc::kMaxPayloadBytes (1 MiB).
private const int MaxPayloadBytes = 1 * 1024 * 1024;
private const int ConnectTimeoutMs = 3000;
// Opcodes (mirror of PTSettingsSvc::Opcode).
private const byte OpPing = 0x00;
private const byte OpGetBlob = 0x01;
private const byte OpPutBlob = 0x02;
/// <summary>Liveness probe. Authentication still runs server-side.</summary>
public static Result Ping()
{
return RoundTrip(OpPing, ReadOnlySpan<byte>.Empty, out _);
}
/// <summary>Reads this caller's namespace blob. Returns NotFound if none exists yet.</summary>
public static Result GetBlob(out byte[] blob)
{
var rc = RoundTrip(OpGetBlob, ReadOnlySpan<byte>.Empty, out var resp);
blob = rc == Result.Ok ? resp : Array.Empty<byte>();
return rc;
}
/// <summary>Atomically replaces this caller's namespace blob with the given bytes.</summary>
public static Result PutBlob(ReadOnlySpan<byte> blob)
{
return RoundTrip(OpPutBlob, blob, out _);
}
private static Result RoundTrip(byte opcode, ReadOnlySpan<byte> payload, out byte[] response)
{
response = Array.Empty<byte>();
if (payload.Length > MaxPayloadBytes)
{
return Result.Protocol;
}
NamedPipeClientStream pipe;
try
{
// TokenImpersonation lets the service impersonate us to read our
// SID (per-user data partitioning) and open our process for the
// image-path / signature checks.
pipe = new NamedPipeClientStream(
".",
PipeName,
PipeDirection.InOut,
PipeOptions.None,
TokenImpersonationLevel.Impersonation);
pipe.Connect(ConnectTimeoutMs);
}
catch (TimeoutException)
{
return Result.Unavailable;
}
catch (IOException)
{
return Result.Unavailable;
}
catch (UnauthorizedAccessException)
{
return Result.Unavailable;
}
using (pipe)
{
try
{
Span<byte> header = stackalloc byte[5];
header[0] = opcode;
BitConverter.TryWriteBytes(header[1..], (uint)payload.Length);
pipe.Write(header);
if (payload.Length > 0)
{
pipe.Write(payload);
}
pipe.Flush();
Span<byte> respHeader = stackalloc byte[5];
if (!ReadExact(pipe, respHeader))
{
return Result.Protocol;
}
byte status = respHeader[0];
uint respLen = BitConverter.ToUInt32(respHeader[1..]);
if (respLen > MaxPayloadBytes)
{
return Result.Protocol;
}
if (respLen > 0)
{
response = new byte[respLen];
if (!ReadExact(pipe, response))
{
response = Array.Empty<byte>();
return Result.Protocol;
}
}
return MapStatus(status);
}
catch (IOException)
{
return Result.Protocol;
}
}
}
private static bool ReadExact(Stream stream, Span<byte> dest)
{
int offset = 0;
while (offset < dest.Length)
{
int got = stream.Read(dest[offset..]);
if (got <= 0)
{
return false;
}
offset += got;
}
return true;
}
private static Result MapStatus(byte status)
{
// Mirror of PTSettingsSvc::Status, collapsed to the coarse Result.
return status switch
{
0x00 => Result.Ok,
0x20 => Result.NotFound,
0x10 or 0x11 or 0x12 => Result.AuthRejected,
0x21 => Result.IoError,
_ => Result.Protocol,
};
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// Inputs for <see cref="ServiceProvisioner.EnsureProvisioned"/>. Paths are
/// supplied by the caller (resolved from the install folder) so the provisioner
/// stays free of host/registry dependencies and is fully testable.
/// </summary>
public sealed class ProvisionOptions
{
/// <summary>Full path to the settings-service executable to register.</summary>
public string? ServiceBinaryPath { get; init; }
/// <summary>Full path to the signed service MSIX to deploy (deferred install).</summary>
public string? ServiceMsixPath { get; init; }
/// <summary>SID of the user to harden; defaults to the current user when null/empty.</summary>
public string? UserSid { get; init; }
/// <summary>
/// When true, bypass the "already attempted" back-off and prompt again.
/// Use for explicit user actions (e.g. an "enable protection" toggle).
/// </summary>
public bool Force { get; init; }
/// <summary>
/// Optional override for how the elevated step is launched. Defaults to
/// <see cref="ServiceProvisioner.RunElevatedPowerShell"/> (a real UAC prompt).
/// Tests and headless hosts can inject a direct runner.
/// </summary>
public ServiceProvisioner.ElevationRunner? ElevationRunner { get; init; }
/// <summary>Builds options from a resolved PowerToys install folder.</summary>
public static ProvisionOptions FromInstallFolder(string installFolder, bool force = false)
{
return new ProvisionOptions
{
ServiceBinaryPath = SettingsPaths.ServiceBinaryPath(installFolder),
ServiceMsixPath = SettingsPaths.ServiceMsixPath(installFolder),
Force = force,
};
}
}

View File

@@ -0,0 +1,256 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Security.Principal;
#nullable enable
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// Service-initialization block (Design-v6-Final.md §11 "Lazy per-user install").
///
/// The per-machine MSI registers PTSettingsSvc eagerly at install time. A
/// per-user install ships the service payload unregistered; this block performs
/// the one-time elevation that registers the machine-wide service and hardens
/// the current user's protected store the first time protection is actually
/// needed. It is deliberately self-contained so the same logic can be invoked
/// from any trigger point (editor open, first save, workspace launch, an
/// explicit Settings toggle) — see <see cref="SettingsBootstrapper"/>.
///
/// The elevation step is injectable (<see cref="ElevationRunner"/>) so callers
/// and tests can substitute the UAC prompt with a direct run.
/// </summary>
public static class ServiceProvisioner
{
/// <summary>Result of an attempt to provision the service for the current user.</summary>
public enum Outcome
{
/// <summary>The service was already reachable; nothing to do.</summary>
ServiceAvailable,
/// <summary>Elevation ran and the service is now reachable.</summary>
Provisioned,
/// <summary>Elevation ran but the service still isn't reachable.</summary>
AttemptedNotConfirmed,
/// <summary>A prior attempt was already made; not re-prompting (unless forced).</summary>
AlreadyAttempted,
/// <summary>The user declined the elevation (UAC cancelled).</summary>
UserDeclined,
/// <summary>The service payload (exe / script) was not found in the install.</summary>
PayloadMissing,
/// <summary>The elevation could not be launched at all.</summary>
ElevationFailed,
}
/// <summary>Outcome of launching the elevated provisioning helper.</summary>
public enum ElevationResult
{
/// <summary>The elevated helper ran to completion.</summary>
Completed,
/// <summary>The user cancelled the UAC prompt.</summary>
Declined,
/// <summary>The helper could not be launched.</summary>
Failed,
}
/// <summary>
/// Launches the elevated provisioning helper. Implementations must block
/// until the helper exits and report whether it completed, was declined, or
/// failed to launch. The default is <see cref="RunElevatedPowerShell"/>.
/// </summary>
public delegate ElevationResult ElevationRunner(string fileName, string arguments);
/// <summary>True when the service answers (installed and running).</summary>
public static bool IsServiceAvailable()
{
// Fast pre-check: if the named pipe doesn't exist, the service isn't
// running, so skip PTSettingsClient.Ping() whose connect waits out a
// multi-second timeout for a missing pipe. This keeps the common
// "no service yet" path (per-user, pre-provision) cheap (~ms) instead
// of blocking the caller for the full connect timeout.
if (!PipeExists())
{
return false;
}
return PTSettingsClient.Ping() != PTSettingsClient.Result.Unavailable;
}
private static bool PipeExists()
{
try
{
foreach (var pipe in Directory.EnumerateFiles(@"\\.\pipe\"))
{
if (string.Equals(Path.GetFileName(pipe), PTSettingsClient.PipeName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch (Exception)
{
// If enumeration fails for any reason, fall back to the (slower but
// authoritative) connect probe rather than wrongly reporting absent.
return true;
}
return false;
}
/// <summary>
/// Ensures the service is provisioned for the current user, performing the
/// one-time elevation if needed. Idempotent and sentinel-guarded so it is
/// safe to call from multiple trigger points.
/// </summary>
public static Outcome EnsureProvisioned(ProvisionOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (IsServiceAvailable())
{
return Outcome.ServiceAvailable;
}
// Back off if we've already prompted this user, unless the caller forces
// it (e.g. an explicit "enable protection" action in Settings).
if (!options.Force && File.Exists(SettingsPaths.ProvisionAttemptSentinel()))
{
return Outcome.AlreadyAttempted;
}
var serviceMsix = options.ServiceMsixPath;
if (string.IsNullOrEmpty(serviceMsix) || !File.Exists(serviceMsix))
{
// No package to install from (e.g. a no-admin xcopy deployment).
// Don't write the sentinel: a later install that adds the payload
// should still be allowed to try.
return Outcome.PayloadMissing;
}
var userSid = string.IsNullOrEmpty(options.UserSid)
? WindowsIdentity.GetCurrent().User?.Value
: options.UserSid;
if (string.IsNullOrEmpty(userSid))
{
return Outcome.ElevationFailed;
}
// Record the attempt up front so a crash mid-elevation doesn't make us
// re-prompt on the next trigger.
TryWriteAttemptSentinel();
var runner = options.ElevationRunner ?? RunElevatedPowerShell;
var arguments = BuildInstallArguments(serviceMsix);
var elevation = runner("powershell.exe", arguments);
switch (elevation)
{
case ElevationResult.Declined:
return Outcome.UserDeclined;
case ElevationResult.Failed:
return Outcome.ElevationFailed;
case ElevationResult.Completed:
default:
return IsServiceAvailable() ? Outcome.Provisioned : Outcome.AttemptedNotConfirmed;
}
}
/// <summary>
/// Builds the elevated install command. Deploys the SIGNED service MSIX via
/// <c>Add-AppxPackage</c> — an inline command (in our signed binary, NOT a
/// user-writable script) whose only payload is the signed .msix; the OS
/// verifies its signature on deploy, so this cannot run attacker code. The
/// packaged windows.service extension auto-registers PTSettingsSvc; DACL and
/// migration are then done by the LocalSystem service (Design §12.1) — no
/// extra elevation. Replaces the retired user-writable Harden-PtSettings ps1.
/// </summary>
public static string BuildInstallArguments(string serviceMsix)
{
// -ForceApplicationShutdown is REQUIRED for the upgrade case: a packaged
// windows.service holds its binaries while running, so replacing them on
// an in-place update fails with 0x80073D02 ("resources ... currently in
// use") unless the running service is force-stopped first. The flag stops
// the old service so the new version's files can be laid down; the service
// then auto-restarts pointing at the new exe (verified 2026-06-30,
// Design §12.6). -ForceUpdateFromAnyVersion allows same/again deploys.
return "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "
+ "\"Add-AppxPackage -Path '" + serviceMsix + "' -ForceUpdateFromAnyVersion -ForceApplicationShutdown\"";
}
/// <summary>
/// Default elevation runner: launches PowerShell elevated (UAC) and waits.
/// Maps a cancelled UAC prompt to <see cref="ElevationResult.Declined"/>.
/// </summary>
public static ElevationResult RunElevatedPowerShell(string fileName, string arguments)
{
try
{
var psi = new ProcessStartInfo(fileName, arguments)
{
UseShellExecute = true,
Verb = "runas",
WindowStyle = ProcessWindowStyle.Hidden,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return ElevationResult.Failed;
}
proc.WaitForExit();
return ElevationResult.Completed;
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
// ERROR_CANCELLED — the user dismissed the UAC prompt.
return ElevationResult.Declined;
}
catch (Win32Exception)
{
return ElevationResult.Failed;
}
catch (InvalidOperationException)
{
return ElevationResult.Failed;
}
}
private static void TryWriteAttemptSentinel()
{
try
{
var sentinel = SettingsPaths.ProvisionAttemptSentinel();
var dir = Path.GetDirectoryName(sentinel);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(sentinel, DateTime.UtcNow.ToString("o"));
}
catch (IOException)
{
// Best-effort: a missing sentinel only means we may re-prompt once more.
}
catch (UnauthorizedAccessException)
{
}
}
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
#nullable enable
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// Orchestrates the two settings blocks — service initialization
/// (<see cref="ServiceProvisioner"/>) and settings-file migration
/// (<see cref="WorkspacesMigration"/>) — behind a single entry point that can be
/// invoked from any number of trigger points (editor open, first save, workspace
/// launch, an explicit Settings toggle). Keeping the orchestration here means
/// new trigger points only have to call <see cref="EnsureInitialized"/>; they
/// don't need to know the ordering or guards.
/// </summary>
public static class SettingsBootstrapper
{
/// <summary>Where the bootstrap was invoked from (diagnostics / policy).</summary>
public enum TriggerReason
{
/// <summary>The Workspaces editor was opened / its list loaded.</summary>
EditorOpened,
/// <summary>A workspace is about to be saved.</summary>
WorkspaceSaving,
/// <summary>A workspace is about to be launched.</summary>
WorkspaceLaunching,
/// <summary>The user explicitly asked to enable protection.</summary>
ExplicitUserRequest,
}
/// <summary>Combined result of a bootstrap pass.</summary>
public readonly record struct Result(
ServiceProvisioner.Outcome Provision,
WorkspacesMigration.Outcome Migration);
// Auto (non-forced) bootstrap runs at most once per process to keep the hot
// path (every editor open) cheap; an explicit user request always runs.
private static int _autoBootstrapped;
/// <summary>
/// Ensures the service is provisioned (if a payload is available) and that
/// this user's legacy data has been migrated. Safe to call repeatedly and
/// from multiple trigger points.
/// </summary>
/// <param name="request">Trigger, install folder and provisioning knobs.</param>
public static Result EnsureInitialized(BootstrapRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var force = request.Reason == TriggerReason.ExplicitUserRequest;
if (!force && Interlocked.Exchange(ref _autoBootstrapped, 1) != 0)
{
// Already ran the automatic pass this process; nothing cheap left to do.
return new Result(ServiceProvisioner.Outcome.AlreadyAttempted, WorkspacesMigration.Outcome.AlreadyMigrated);
}
// Block 1: service initialization. Only attempt when we have an install
// folder to locate the payload; otherwise skip straight to migration,
// which has its own no-service fallback.
var provision = ServiceProvisioner.Outcome.PayloadMissing;
if (!string.IsNullOrEmpty(request.InstallFolder))
{
try
{
var options = ProvisionOptions.FromInstallFolder(request.InstallFolder!, force);
if (request.ElevationRunner != null)
{
options = new ProvisionOptions
{
ServiceBinaryPath = options.ServiceBinaryPath,
ServiceMsixPath = options.ServiceMsixPath,
UserSid = options.UserSid,
Force = force,
ElevationRunner = request.ElevationRunner,
};
}
provision = ServiceProvisioner.EnsureProvisioned(options);
}
catch (Exception)
{
// Provisioning is best-effort; fall through to migration so the
// editor still works via the no-service fallback.
provision = ServiceProvisioner.Outcome.ElevationFailed;
}
}
// Block 2: settings-file migration. Idempotent; when the service is up
// this seeds the protected blob, otherwise it no-ops cleanly.
var migration = WorkspacesMigration.Outcome.SkippedServiceUnavailable;
try
{
migration = WorkspacesMigration.Run();
}
catch (Exception)
{
// Best-effort backstop; reads fall back per WorkspacesStorage.
}
return new Result(provision, migration);
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Security.Principal;
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// Resolves the new (v6) and legacy paths used for the Workspaces data.
/// The new location lives under %ProgramData% in the service-managed
/// SettingsSvc tree, partitioned by namespace and per-user SID; only the
/// PTSettingsSvc service may write into it, but the owning user (and
/// Administrators) can read it directly. The legacy location is the
/// pre-v6 %LocalAppData% file, used only by one-shot migration and the
/// no-service last-resort fallback.
/// </summary>
public static class SettingsPaths
{
// Namespace id the Workspaces module is bound to in the service's
// CallerBinding table (mirror of the native "Workspaces" namespace).
private const string NamespaceId = "Workspaces";
// Canonical file name kept inside the namespace folder (mirror of the
// native CallerBinding fileName). Keeps the original, human-readable name.
private const string WorkspacesFileName = "workspaces.json";
// %ProgramData%\Microsoft\PowerToys\Settings (the service-managed store root)
private const string SettingsStoreSubpath = @"Microsoft\PowerToys\Settings";
// Pre-v6 per-user data folder under %LocalAppData%.
private const string LegacySubpath = @"Microsoft\PowerToys\Workspaces";
// Subfolder of the install root that carries the settings-service payload
// (the service exe and the per-user hardening script). The per-machine MSI
// registers the service from here; the per-user install ships the same
// payload unregistered so deferred initialization can register it lazily.
private const string ServicePayloadSubdir = "WorkspacesSettingsService";
/// <summary>File name of the settings-service executable.</summary>
public const string ServiceBinaryName = "PowerToys.PTSettingsSvc.exe";
/// <summary>File name of the signed service MSIX package (deferred install).</summary>
public const string ServiceMsixName = "PTSettingsSvc.msix";
/// <summary>%ProgramData%\Microsoft\PowerToys\Settings (the store root).</summary>
public static string ServiceStoreRoot()
{
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
return Path.Combine(programData, SettingsStoreSubpath);
}
/// <summary>%ProgramData%\Microsoft\PowerToys\Settings\&lt;current-user-sid&gt; (per-user node).</summary>
public static string CurrentUserFolder()
{
var sid = WindowsIdentity.GetCurrent().User?.Value
?? throw new InvalidOperationException("No current user SID");
return Path.Combine(ServiceStoreRoot(), sid);
}
/// <summary>%ProgramData%\Microsoft\PowerToys\Settings\&lt;sid&gt;\Workspaces (namespace folder).</summary>
public static string CurrentUserNamespaceFolder()
{
return Path.Combine(CurrentUserFolder(), NamespaceId);
}
/// <summary>The per-user settings file the service reads/writes (direct-read allowed).</summary>
public static string CurrentUserFile()
{
return Path.Combine(CurrentUserNamespaceFolder(), WorkspacesFileName);
}
/// <summary>The pre-v6 location. Used by one-shot migration and the no-service fallback.</summary>
public static string LegacyWorkspacesFile()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, LegacySubpath, "workspaces.json");
}
/// <summary>Sentinel dropped by the runner the first time a user is migrated.</summary>
public static string MigrationSentinel()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, LegacySubpath, ".migrated-to-svc");
}
/// <summary>
/// Sentinel recording that deferred service provisioning has already been
/// attempted for this user, so repeated trigger points don't re-prompt for
/// elevation. Lives under %LocalAppData% (user-writable): it only governs
/// UX back-off, never security.
/// </summary>
public static string ProvisionAttemptSentinel()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, LegacySubpath, ".svc-provision-attempted");
}
/// <summary>Folder under the install root that carries the settings-service payload.</summary>
public static string ServicePayloadDir(string installFolder)
{
ArgumentException.ThrowIfNullOrEmpty(installFolder);
return Path.Combine(installFolder, ServicePayloadSubdir);
}
/// <summary>Full path to the settings-service executable inside an install folder.</summary>
public static string ServiceBinaryPath(string installFolder)
{
return Path.Combine(ServicePayloadDir(installFolder), ServiceBinaryName);
}
/// <summary>Full path to the signed service MSIX package inside an install folder.</summary>
public static string ServiceMsixPath(string installFolder)
{
return Path.Combine(ServicePayloadDir(installFolder), ServiceMsixName);
}
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.IO;
namespace WorkspacesCsharpLibrary.SettingsService;
/// <summary>
/// One-shot legacy migration, called by the runner on startup (idempotent).
/// The service has no "migrate" concept (Design-v6-Final.md §10): migration is
/// simply "read the legacy %LocalAppData% file once and PutBlob it through the
/// service". A sentinel under %LocalAppData% short-circuits subsequent calls.
/// </summary>
public static class WorkspacesMigration
{
public enum Outcome
{
AlreadyMigrated,
NothingToMigrate,
Migrated,
SkippedServiceUnavailable,
SkippedLegacyUnreadable,
SkippedServerRejected,
}
public static Outcome Run()
{
var sentinel = SettingsPaths.MigrationSentinel();
if (File.Exists(sentinel))
{
return Outcome.AlreadyMigrated;
}
// If the service already holds a blob for this user, another runner
// invocation migrated it; drop the sentinel and stop.
var probe = PTSettingsClient.GetBlob(out var existing);
if (probe == PTSettingsClient.Result.Ok && existing.Length > 0)
{
TryWriteSentinel(sentinel);
return Outcome.AlreadyMigrated;
}
if (probe == PTSettingsClient.Result.Unavailable)
{
return Outcome.SkippedServiceUnavailable;
}
// probe is NotFound (no blob yet) or a transient error — proceed only
// when we positively know there is nothing yet.
if (probe != PTSettingsClient.Result.NotFound)
{
return Outcome.SkippedServerRejected;
}
var legacy = SettingsPaths.LegacyWorkspacesFile();
if (!File.Exists(legacy))
{
TryWriteSentinel(sentinel);
return Outcome.NothingToMigrate;
}
byte[] bytes;
try
{
bytes = File.ReadAllBytes(legacy);
}
catch (IOException)
{
return Outcome.SkippedLegacyUnreadable;
}
catch (System.UnauthorizedAccessException)
{
return Outcome.SkippedLegacyUnreadable;
}
var put = PTSettingsClient.PutBlob(bytes);
switch (put)
{
case PTSettingsClient.Result.Ok:
// Keep the legacy file as a backup for one release; the service
// blob is the authority going forward.
TryWriteSentinel(sentinel);
return Outcome.Migrated;
case PTSettingsClient.Result.Unavailable:
return Outcome.SkippedServiceUnavailable;
default:
return Outcome.SkippedServerRejected;
}
}
private static void TryWriteSentinel(string sentinel)
{
try
{
var dir = Path.GetDirectoryName(sentinel);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(sentinel, System.DateTime.UtcNow.ToString("o"));
}
catch (IOException)
{
// Best-effort: if we can't write the sentinel we simply re-probe
// next time, which is cheap and idempotent.
}
catch (System.UnauthorizedAccessException)
{
}
}
}

View File

@@ -19,9 +19,23 @@ public class FolderUtils
return Path.GetTempPath();
}
// Note: the same path should be used in SnapshotTool and Launcher
// User-writable working folder for the Editor's transient files (icons,
// temp-project handoff) AND the legacy / no-service fallback store.
//
// v6 note: the *protected* settings store does NOT live here — it is the
// service-managed blob under %ProgramData% (see SettingsPaths / §9). The
// Editor reads/writes the real settings through PTSettingsClient
// (GetBlob / PutBlob); this %LocalAppData% path is only the working dir and
// the no-service fallback, both of which must stay user-writable.
public static string DataFolder()
{
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\Microsoft\\PowerToys\\Workspaces";
}
// The pre-v6 location. Same as DataFolder() now; kept as a distinct name
// for the one-shot migration source and the no-service fallback.
public static string LegacyDataFolder()
{
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\Microsoft\\PowerToys\\Workspaces";
}
}

View File

@@ -6,8 +6,10 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using ManagedCommon;
using WorkspacesCsharpLibrary.Data;
using WorkspacesCsharpLibrary.SettingsService;
using WorkspacesCsharpLibrary.Utils;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
@@ -24,14 +26,45 @@ namespace WorkspacesEditor.Utils
{
try
{
// Deferred per-user service init + legacy migration (Design §11 / §14.1).
// On a per-machine install the service is already up (no-op); on a
// per-user install with no service yet, this performs the one-time
// elevation to register + harden it, then migrates the legacy file.
TryBootstrapSettings();
WorkspacesData parser = new();
if (!File.Exists(parser.File))
WorkspacesData.WorkspacesListWrapper workspaces;
// v6: read the settings through the service (GetBlob). Fall back to
// the legacy %LocalAppData% file only when no service is installed
// (no-admin / declined-UAC), per §10.
var rc = PTSettingsClient.GetBlob(out var blob);
switch (rc)
{
Logger.LogWarning($"Workspaces storage file not found: {parser.File}");
return new ParsingResult(true);
case PTSettingsClient.Result.Ok:
workspaces = parser.Deserialize(Encoding.UTF8.GetString(blob));
break;
case PTSettingsClient.Result.NotFound:
// Service is up but this user has no blob yet (first run).
return new ParsingResult(true);
case PTSettingsClient.Result.Unavailable:
if (!File.Exists(parser.File))
{
Logger.LogWarning($"Workspaces storage file not found: {parser.File}");
return new ParsingResult(true);
}
workspaces = parser.Read(parser.File);
break;
default:
// AuthRejected / Protocol / IoError → fail safe to empty.
Logger.LogWarning($"GetBlob returned {rc}; treating workspaces as empty.");
return new ParsingResult(true);
}
WorkspacesData.WorkspacesListWrapper workspaces = parser.Read(parser.File);
if (workspaces.Workspaces == null)
{
return new ParsingResult(true);
@@ -52,6 +85,23 @@ namespace WorkspacesEditor.Utils
}
}
private static void TryBootstrapSettings()
{
try
{
SettingsBootstrapper.EnsureInitialized(new BootstrapRequest
{
Reason = SettingsBootstrapper.TriggerReason.EditorOpened,
InstallFolder = PowerToysPathResolver.GetPowerToysInstallPath(),
});
}
catch (Exception e)
{
// Best-effort: on failure reads/writes fall back to the legacy file.
Logger.LogWarning($"Settings bootstrap failed (continuing with fallback): {e.Message}");
}
}
public Project ParseTempProject()
{
try
@@ -151,8 +201,35 @@ namespace WorkspacesEditor.Utils
try
{
IOUtils ioUtils = new();
ioUtils.WriteFile(useTempFile ? TempProjectData.File : serializer.File, serializer.Serialize(workspacesWrapper));
string json = serializer.Serialize(workspacesWrapper);
if (useTempFile)
{
// Transient snapshot→editor handoff stays a direct user-writable
// file (not the protected store).
IOUtils ioUtils = new();
ioUtils.WriteFile(TempProjectData.File, json);
return;
}
// v6: persist the settings through the service (PutBlob). Fall back
// to the legacy %LocalAppData% file only when no service is installed
// (no-admin / declined-UAC), per §10.
var rc = PTSettingsClient.PutBlob(Encoding.UTF8.GetBytes(json));
switch (rc)
{
case PTSettingsClient.Result.Ok:
break;
case PTSettingsClient.Result.Unavailable:
IOUtils fallback = new();
fallback.WriteFile(serializer.File, json);
break;
default:
Logger.LogError($"Failed to save workspaces through the settings service: {rc}");
break;
}
}
catch (Exception e)
{

View File

@@ -9,6 +9,7 @@
#include <AppLauncher.h>
#include <WorkspacesLib/AppUtils.h>
#include <WorkspacesLib/JsonUtils.h>
Launcher::Launcher(const WorkspacesData::WorkspacesProject& project,
std::vector<WorkspacesData::WorkspacesProject>& workspaces,
@@ -68,7 +69,7 @@ Launcher::~Launcher()
break;
}
}
json::to_file(WorkspacesData::WorkspacesFile(), WorkspacesData::WorkspacesListJSON::ToJson(m_workspaces));
JsonUtils::WriteWorkspacesToService(m_workspaces);
}
// telemetry

View File

@@ -126,7 +126,7 @@ int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE hInstPrev, LPSTR cmdline, int cm
if (projectToLaunch.id.empty())
{
auto file = WorkspacesData::WorkspacesFile();
auto res = JsonUtils::ReadWorkspaces(file);
auto res = JsonUtils::ReadWorkspacesFromService();
if (res.isOk())
{
workspaces = res.getValue();
@@ -201,7 +201,7 @@ int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE hInstPrev, LPSTR cmdline, int cm
}
}
json::to_file(WorkspacesData::WorkspacesFile(), WorkspacesData::WorkspacesListJSON::ToJson(workspaces));
JsonUtils::WriteWorkspacesToService(workspaces);
}
// launch

View File

@@ -5,6 +5,8 @@
#include <common/logger/logger.h>
#include "../WorkspacesSettingsClient/PTSettingsClient.h"
namespace JsonUtils
{
Result<WorkspacesData::WorkspacesProject, WorkspacesFileError> ReadSingleWorkspace(const std::wstring& fileName)
@@ -89,6 +91,76 @@ namespace JsonUtils
return true;
}
Result<std::vector<WorkspacesData::WorkspacesProject>, WorkspacesFileError> ReadWorkspacesFromService()
{
std::vector<uint8_t> bytes;
auto rc = PTSettingsClient::GetBlob(bytes);
switch (rc)
{
case PTSettingsClient::Result::Ok:
{
try
{
// The blob is the same UTF-8 JSON the Editor writes.
std::string utf8(bytes.begin(), bytes.end());
auto obj = json::JsonValue::Parse(winrt::to_hstring(utf8)).GetObjectW();
auto parsed = WorkspacesData::WorkspacesListJSON::FromJson(obj);
if (parsed.has_value())
{
return Ok(parsed.value());
}
Logger::critical("Incorrect Workspaces blob from service");
return Error(WorkspacesFileError::IncorrectFileError);
}
catch (std::exception ex)
{
Logger::critical("Exception parsing Workspaces blob: {}", ex.what());
return Error(WorkspacesFileError::FileReadingError);
}
}
case PTSettingsClient::Result::NotFound:
// Service is up but this user has no blob yet (first run).
return Ok(std::vector<WorkspacesData::WorkspacesProject>{});
case PTSettingsClient::Result::ServiceUnavailable:
// No service (no-admin / declined-UAC): legacy file fallback.
return ReadWorkspaces(WorkspacesData::WorkspacesFile());
default:
Logger::error("GetBlob failed ({}); treating workspaces as empty.", static_cast<int>(rc));
return Ok(std::vector<WorkspacesData::WorkspacesProject>{});
}
}
bool WriteWorkspacesToService(const std::vector<WorkspacesData::WorkspacesProject>& projects)
{
try
{
std::wstring str{ WorkspacesData::WorkspacesListJSON::ToJson(projects).Stringify().c_str() };
std::string utf8 = winrt::to_string(winrt::hstring(str));
std::vector<uint8_t> bytes(utf8.begin(), utf8.end());
auto rc = PTSettingsClient::PutBlob(bytes);
if (rc == PTSettingsClient::Result::Ok)
{
return true;
}
if (rc == PTSettingsClient::Result::ServiceUnavailable)
{
// No service: legacy file fallback (no-admin / declined-UAC).
return Write(WorkspacesData::WorkspacesFile(), projects);
}
Logger::error("PutBlob failed ({}) writing workspaces.", static_cast<int>(rc));
return false;
}
catch (std::exception ex)
{
Logger::error("Exception writing workspaces via service: {}", ex.what());
return false;
}
}
bool Write(const std::wstring& fileName, const WorkspacesData::WorkspacesProject& project)
{
try

View File

@@ -14,6 +14,14 @@ namespace JsonUtils
Result<WorkspacesData::WorkspacesProject, WorkspacesFileError> ReadSingleWorkspace(const std::wstring& fileName);
Result<std::vector<WorkspacesData::WorkspacesProject>, WorkspacesFileError> ReadWorkspaces(const std::wstring& fileName);
// v6: read/write the workspaces list through the PTSettingsSvc service
// (PTSettingsClient GetBlob/PutBlob) so the protected %ProgramData% store is
// the single source of truth. Both fall back to direct file IO on
// WorkspacesData::WorkspacesFile() only when the service is unavailable
// (no-admin / declined-UAC), per Design-v6-Final.md §10.
Result<std::vector<WorkspacesData::WorkspacesProject>, WorkspacesFileError> ReadWorkspacesFromService();
bool WriteWorkspacesToService(const std::vector<WorkspacesData::WorkspacesProject>& projects);
bool Write(const std::wstring& fileName, const std::vector<WorkspacesData::WorkspacesProject>& projects);
bool Write(const std::wstring& fileName, const WorkspacesData::WorkspacesProject& project);
}

View File

@@ -4,23 +4,59 @@
#include <workspaces-common/GuidUtils.h>
#include <windows.h>
#include <sddl.h>
#include <shlobj.h>
#include <vector>
#pragma comment(lib, "Advapi32.lib")
#pragma comment(lib, "Shell32.lib")
namespace NonLocalizable
{
const inline wchar_t ModuleKey[] = L"Workspaces";
}
namespace
{
// v6: the protected settings store lives under %ProgramData% and is reached
// only through the PTSettingsSvc named pipe (PTSettingsClient GetBlob/PutBlob)
// — see JsonUtils::ReadWorkspacesFromService / WriteWorkspacesToService.
//
// This %LocalAppData% folder is the *user-writable* working location: the
// pre-v6 / no-service fallback file and the transient snapshot->editor temp
// handoff. It matches the managed editor (FolderUtils.DataFolder), so the
// snapshot tool (writer) and editor (reader) agree on the temp path.
std::wstring GetUserWritableWorkspacesFolder()
{
PWSTR localAppData = nullptr;
std::wstring root;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &localAppData)))
{
root = localAppData;
CoTaskMemFree(localAppData);
}
else
{
return L"";
}
root += L"\\Microsoft\\PowerToys\\Workspaces";
return root;
}
}
namespace WorkspacesData
{
std::wstring WorkspacesFile()
{
std::wstring settingsFolderPath = PTSettingsHelper::get_module_save_folder_location(NonLocalizable::ModuleKey);
return settingsFolderPath + L"\\workspaces.json";
// No-service fallback location (also the legacy / migration source).
return GetUserWritableWorkspacesFolder() + L"\\workspaces.json";
}
std::wstring TempWorkspacesFile()
{
std::wstring settingsFolderPath = PTSettingsHelper::get_module_save_folder_location(NonLocalizable::ModuleKey);
return settingsFolderPath + L"\\temp-workspaces.json";
// Transient snapshot->editor handoff; user-writable, matches the editor.
return GetUserWritableWorkspacesFolder() + L"\\temp-workspaces.json";
}
RECT WorkspacesProject::Application::Position::toRect() const noexcept

View File

@@ -74,6 +74,9 @@
<ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj">
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project>
</ProjectReference>
<ProjectReference Include="..\WorkspacesSettingsClient\WorkspacesSettingsClient.vcxproj">
<Project>{d24e2c12-9911-4e51-b102-39e7b62b22f1}</Project>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />

View File

@@ -0,0 +1,168 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "PTSettingsClient.h"
#include "../WorkspacesSettingsService/protocol/Protocol.h"
#include <windows.h>
#include <vector>
#include <cstring>
namespace PTSettingsClient
{
namespace
{
using PTSettingsSvc::kPipeName;
using PTSettingsSvc::kMaxPayloadBytes;
using PTSettingsSvc::Opcode;
using PTSettingsSvc::Status;
struct PipeHandle
{
HANDLE h = INVALID_HANDLE_VALUE;
~PipeHandle()
{
if (h != INVALID_HANDLE_VALUE) CloseHandle(h);
}
};
bool Connect(PipeHandle& out)
{
for (int attempt = 0; attempt < 3; ++attempt)
{
HANDLE h = CreateFileW(kPipeName,
GENERIC_READ | GENERIC_WRITE,
0,
nullptr,
OPEN_EXISTING,
// Allow the server to impersonate us
// so it can read our SID; anything
// weaker yields an Anonymous token
// and the server's auth check fails.
SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION,
nullptr);
if (h != INVALID_HANDLE_VALUE)
{
out.h = h;
return true;
}
DWORD err = GetLastError();
if (err != ERROR_PIPE_BUSY && err != ERROR_FILE_NOT_FOUND)
{
return false;
}
WaitNamedPipeW(kPipeName, 2000);
}
return false;
}
bool WriteAll(HANDLE h, const void* buf, DWORD len)
{
const BYTE* p = static_cast<const BYTE*>(buf);
while (len > 0)
{
DWORD wrote = 0;
if (!WriteFile(h, p, len, &wrote, nullptr) || wrote == 0) return false;
p += wrote;
len -= wrote;
}
return true;
}
bool ReadAll(HANDLE h, void* buf, DWORD len)
{
BYTE* p = static_cast<BYTE*>(buf);
while (len > 0)
{
DWORD got = 0;
if (!ReadFile(h, p, len, &got, nullptr) || got == 0) return false;
p += got;
len -= got;
}
return true;
}
Result MapStatus(Status s)
{
switch (s)
{
case Status::Ok: return Result::Ok;
case Status::AuthFailToken:
case Status::AuthFailCaller: return Result::AuthRejected;
case Status::NamespaceUnknown: return Result::NamespaceUnknown;
case Status::BadRequest:
case Status::UnknownOpcode: return Result::ProtocolError;
case Status::PayloadTooLarge: return Result::PayloadTooLarge;
case Status::NotFound: return Result::NotFound;
case Status::IoError: return Result::IoError;
}
return Result::UnknownStatus;
}
Result RoundTrip(Opcode op, const void* payload, uint32_t payloadLen,
std::vector<uint8_t>& outResp)
{
outResp.clear();
if (payloadLen > kMaxPayloadBytes)
{
return Result::PayloadTooLarge;
}
PipeHandle pipe;
if (!Connect(pipe))
{
return Result::ServiceUnavailable;
}
uint8_t opByte = static_cast<uint8_t>(op);
if (!WriteAll(pipe.h, &opByte, sizeof(opByte)) ||
!WriteAll(pipe.h, &payloadLen, sizeof(payloadLen)) ||
(payloadLen > 0 && !WriteAll(pipe.h, payload, payloadLen)))
{
return Result::ProtocolError;
}
uint8_t statusByte = 0;
uint32_t respLen = 0;
if (!ReadAll(pipe.h, &statusByte, sizeof(statusByte)) ||
!ReadAll(pipe.h, &respLen, sizeof(respLen)))
{
return Result::ProtocolError;
}
if (respLen > kMaxPayloadBytes)
{
return Result::ProtocolError;
}
if (respLen > 0)
{
outResp.resize(respLen);
if (!ReadAll(pipe.h, outResp.data(), respLen))
{
outResp.clear();
return Result::ProtocolError;
}
}
return MapStatus(static_cast<Status>(statusByte));
}
}
Result Ping()
{
std::vector<uint8_t> resp;
return RoundTrip(Opcode::Ping, nullptr, 0, resp);
}
Result GetBlob(std::vector<uint8_t>& outBytes)
{
return RoundTrip(Opcode::GetBlob, nullptr, 0, outBytes);
}
Result PutBlob(const std::vector<uint8_t>& bytes)
{
std::vector<uint8_t> resp;
return RoundTrip(Opcode::PutBlob,
bytes.data(),
static_cast<uint32_t>(bytes.size()),
resp);
}
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
//
// Thin C++ client for PTSettingsSvc. Linked into PowerToys.WorkspacesEditor /
// WorkspacesSnapshotTool / runner / etc. The client is payload-agnostic —
// it shuttles opaque bytes to and from the service. Whatever the bytes mean
// is the caller's responsibility (JSON shape, schema version, sensitive-
// field stripping, migration logic — see Design-v6-Final.md §10).
//
// Modules using settings (e.g. Workspaces) wrap this in their own
// type-safe layer (Workspaces serialises its `Workspaces` object → UTF-8
// JSON bytes → PutBlob; reverse on read).
#pragma once
#include <string>
#include <vector>
#include <cstdint>
namespace PTSettingsClient
{
enum class Result : uint8_t
{
Ok = 0,
ServiceUnavailable, // Pipe couldn't be opened (service stopped
// or wrong machine).
AuthRejected, // Service refused the caller — usually
// means binary isn't where the MSI put it,
// basename not allow-listed, or the
// install folder DACL isn't hardened.
NamespaceUnknown, // Caller authenticated but isn't in the
// binding table. Build-time misconfig.
NotFound, // GetBlob: blob does not exist yet.
ProtocolError, // Truncated / malformed wire frames.
PayloadTooLarge, // Local or remote rejected oversize payload.
IoError, // Service-side disk failure.
UnknownStatus, // Server returned a status code we don't recognise.
};
Result Ping();
// Reads the caller's namespace blob. Returns NotFound (with `outBytes`
// empty) when no blob has ever been written for this user+namespace.
Result GetBlob(std::vector<uint8_t>& outBytes);
// Replaces the caller's namespace blob with `bytes`. Service does
// the atomic write + DACL re-assertion.
Result PutBlob(const std::vector<uint8_t>& bytes);
}

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{D24E2C12-9911-4E51-B102-39E7B62B22F1}</ProjectGuid>
<RootNamespace>WorkspacesSettingsClient</RootNamespace>
<WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion>
<ProjectName>WorkspacesSettingsClient</ProjectName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>StaticLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>StaticLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration">
<ConfigurationType>StaticLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
<ConfigurationType>StaticLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<PropertyGroup>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>./;../WorkspacesSettingsService;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="PTSettingsClient.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="PTSettingsClient.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

View File

@@ -0,0 +1,82 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "Bindings.h"
#include <cwctype>
namespace PTSettingsSvc
{
namespace
{
// The one place in the service where module-specific knowledge lives.
// Each row: { exe basename, namespace id, file name }.
//
// The on-disk file keeps its original, human-readable name (e.g.
// workspaces.json) rather than an opaque "blob.bin": the service still
// treats the bytes as opaque (it never parses them), but a real name
// aids diagnostics and lets native direct-readers (the Launcher hot
// path, §9) open the same file by the name they already use.
//
// Workspaces ships five executables; all operate on the same namespace
// ("Workspaces") / file and so share one store. The runner
// (PowerToys.exe) is bound to the same namespace so it can perform the
// one-shot legacy migration during startup.
//
// To add a new module:
// 1. Add a row for each of its executables here (with its file name).
// 2. Point that module's read/write code at PTSettingsClient.
// No service code changes required.
constexpr CallerBinding kBindings[] = {
{ L"PowerToys.WorkspacesEditor.exe", L"Workspaces", L"workspaces.json" },
{ L"PowerToys.WorkspacesLauncher.exe", L"Workspaces", L"workspaces.json" },
{ L"PowerToys.WorkspacesSnapshotTool.exe", L"Workspaces", L"workspaces.json" },
{ L"PowerToys.WorkspacesWindowArranger.exe", L"Workspaces", L"workspaces.json" },
{ L"PowerToys.WorkspacesLauncherUI.exe", L"Workspaces", L"workspaces.json" },
// Runner can act on behalf of any module that needs runner-owned
// one-shot tasks (e.g. legacy migration). v6.0 ships with one
// such module so the runner gets exactly one row.
{ L"PowerToys.exe", L"Workspaces", L"workspaces.json" },
};
bool ICaseEquals(const wchar_t* a, const wchar_t* b)
{
while (*a && *b)
{
if (std::towlower(*a) != std::towlower(*b)) return false;
++a; ++b;
}
return *a == 0 && *b == 0;
}
}
const CallerBinding* FindBindingByExeBasename(const std::wstring& basename)
{
for (const auto& row : kBindings)
{
if (ICaseEquals(basename.c_str(), row.exeBasename))
{
return &row;
}
}
return nullptr;
}
bool IsValidNamespaceId(const wchar_t* id)
{
if (!id || !*id) return false;
size_t len = 0;
for (const wchar_t* p = id; *p; ++p, ++len)
{
if (len >= 64) return false;
wchar_t c = *p;
bool ok = (c >= L'A' && c <= L'Z') ||
(c >= L'a' && c <= L'z') ||
(c >= L'0' && c <= L'9') ||
c == L'_' || c == L'-' || c == L'.';
if (!ok) return false;
}
return len > 0;
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
//
// Caller-to-namespace binding table for PTSettingsSvc.
//
// The service is intentionally namespace-agnostic at the storage layer —
// every PutBlob / GetBlob touches
// `<storeRoot>\<userSid>\<namespaceId>\<fileName>`.
// The only place the service knows anything module-specific is this
// table: which executable basenames are allowed to talk to it, and which
// namespace each one operates on.
//
// Adding a new PowerToys module to the protection scheme is a one-line
// change here (plus pointing that module's read/write code at PTSettingsClient).
#pragma once
#include <string>
namespace PTSettingsSvc
{
struct CallerBinding
{
const wchar_t* exeBasename; // case-insensitive compare
const wchar_t* namespaceId; // subfolder under <storeRoot>\<sid>
const wchar_t* fileName; // canonical file name kept inside that namespace folder
};
// Pointer into a static, immutable table. Lifetime is the lifetime of
// the service process. Do not free. Returns nullptr if the basename
// isn't allow-listed.
const CallerBinding* FindBindingByExeBasename(const std::wstring& basename);
// Returns true if `id` looks like a syntactically valid namespace id —
// ASCII alphanumeric / underscore / hyphen / dot, no path separators,
// length 1..64. Defensive check used before turning the id into a
// directory name.
bool IsValidNamespaceId(const wchar_t* id);
}

View File

@@ -0,0 +1,256 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "CallerAuth.h"
#include "Bindings.h"
#include "Paths.h"
#include "CallerVerify.h"
#include <windows.h>
#include <sddl.h>
#include <pathcch.h>
#include <vector>
#include <algorithm>
#pragma comment(lib, "Advapi32.lib")
#pragma comment(lib, "Pathcch.lib")
namespace PTSettingsSvc
{
namespace
{
HRESULT RejectionForToken(HANDLE token, std::wstring& outSidString)
{
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
if (size == 0)
{
return E_FAIL;
}
std::vector<BYTE> buf(size);
if (!GetTokenInformation(token, TokenUser, buf.data(), size, &size))
{
return HRESULT_FROM_WIN32(GetLastError());
}
PSID sid = reinterpret_cast<TOKEN_USER*>(buf.data())->User.Sid;
// Reject well-known synthetic principals — we want a real
// interactive user so the data folder is scoped to a human.
const WELL_KNOWN_SID_TYPE rejected[] = {
WinLocalSystemSid,
WinLocalServiceSid,
WinNetworkServiceSid,
WinAnonymousSid,
WinNullSid,
};
for (auto wk : rejected)
{
if (IsWellKnownSid(sid, wk))
{
return E_ACCESSDENIED;
}
}
outSidString = SidToString(sid);
if (outSidString.empty())
{
return E_FAIL;
}
return S_OK;
}
std::wstring CanonicalizePath(const std::wstring& path)
{
// Open with backup-semantics so we can canonicalize even
// executables that the loader has already mapped.
HANDLE h = CreateFileW(path.c_str(),
READ_CONTROL,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
nullptr);
if (h == INVALID_HANDLE_VALUE)
{
return path;
}
wchar_t buf[1024] = {};
DWORD len = GetFinalPathNameByHandleW(h, buf, ARRAYSIZE(buf), FILE_NAME_NORMALIZED);
CloseHandle(h);
if (len == 0 || len >= ARRAYSIZE(buf))
{
return path;
}
std::wstring result(buf);
if (result.compare(0, 4, L"\\\\?\\") == 0)
{
result.erase(0, 4);
}
return result;
}
std::wstring BaseName(const std::wstring& path)
{
auto pos = path.find_last_of(L"\\/");
return pos == std::wstring::npos ? path : path.substr(pos + 1);
}
}
HRESULT AuthenticateCaller(HANDLE pipeHandle, CallerIdentity& outIdentity)
{
outIdentity = {};
// 1) Capture client pid up front (cheap, doesn't need impersonation).
ULONG pid = 0;
if (!GetNamedPipeClientProcessId(pipeHandle, &pid))
{
return HRESULT_FROM_WIN32(GetLastError());
}
outIdentity.processId = pid;
// 2) Impersonate the client. We need the caller's token to (a) read
// its SID and (b) open a handle to its own process. The service
// runs as NT SERVICE\<vacct>, which is NOT a member of
// Authenticated Users and so cannot satisfy the default process
// DACL when calling OpenProcess across user boundaries. Doing
// the OpenProcess while impersonating means the DACL check is
// against the user's own token, which naturally grants access
// to its own processes.
if (!ImpersonateNamedPipeClient(pipeHandle))
{
return HRESULT_FROM_WIN32(GetLastError());
}
HANDLE clientToken = nullptr;
BOOL gotToken = OpenThreadToken(GetCurrentThread(),
TOKEN_QUERY,
TRUE,
&clientToken);
DWORD tokenErr = gotToken ? ERROR_SUCCESS : GetLastError();
if (!gotToken)
{
RevertToSelf();
return HRESULT_FROM_WIN32(tokenErr);
}
HRESULT hr = RejectionForToken(clientToken, outIdentity.userSidString);
CloseHandle(clientToken);
if (FAILED(hr))
{
RevertToSelf();
return hr;
}
// 3) While still impersonating: open the client process and read its
// image path. Hold the handle for the rest of validation so the
// PID can't be reused under us.
HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
DWORD openErr = hProc ? ERROR_SUCCESS : GetLastError();
wchar_t exePath[MAX_PATH * 2] = {};
DWORD cch = ARRAYSIZE(exePath);
BOOL gotImage = FALSE;
DWORD imageErr = ERROR_SUCCESS;
if (hProc)
{
gotImage = QueryFullProcessImageNameW(hProc, 0, exePath, &cch);
imageErr = gotImage ? ERROR_SUCCESS : GetLastError();
}
// The caller binary often lives under %LocalAppData% (per-user install),
// which is ACL'd to the user only. The service account cannot read it,
// so canonicalization and the signature/version checks MUST run while we
// are still impersonating the client (which can read its own image).
std::wstring canonical;
bool sigMicrosoft = false;
unsigned long long callerVersion = 0;
if (gotImage)
{
canonical = CanonicalizePath(exePath);
sigMicrosoft = VerifyMicrosoftSignature(canonical);
callerVersion = GetBinaryVersion(canonical);
}
// Revert before we touch any service-side resources (file IO etc).
RevertToSelf();
if (!hProc)
{
return HRESULT_FROM_WIN32(openErr);
}
CloseHandle(hProc);
if (!gotImage)
{
return HRESULT_FROM_WIN32(imageErr);
}
outIdentity.imagePath = canonical;
// 4) Caller-image trust anchor (UNIFIED — Design §7/§12.7, updated 2026-06-30).
// EVERY caller, per-machine and per-user alike, must be Microsoft-
// signed AND its version must satisfy the floor + max-delta policy
// against the service's own version (IsCallerVersionAcceptable).
//
// Why a version POLICY, not exact equality:
// * The machine-wide service is a singleton (one version). Exact
// `caller == service` broke multi-user / multi-version: the
// latest install would reject every other-version caller (§12.7).
// * The real goal is anti-DOWNGRADE — block old vulnerable signed
// binaries — which a minimum-version FLOOR achieves, while a
// bounded max-delta keeps callers reasonably current.
// * The signature is verified by this LocalSystem service against
// the MACHINE trust store (CallerVerify.cpp), so it is NOT
// forgeable by a non-admin user-store root (defeats the §13
// per-user TrustedPeople objection that argued path > signature).
// * Binary immutability is already guaranteed by deployment
// (WindowsApps for the service, %ProgramFiles% for per-machine
// callers), so it need not be re-proven during authentication.
//
// sigMicrosoft and callerVersion were captured above under
// impersonation so a user-profile image is readable.
const unsigned long long serviceVersion = GetServiceOwnVersion();
bool sigOk = sigMicrosoft;
#ifdef _DEBUG
// DEV-ONLY, conditional compilation: this block exists ONLY in Debug
// builds and is physically absent from Release, so there is no bypass to
// abuse in shipped binaries. Local/smoke-test builds are not
// Microsoft-signed, so a Debug build accepts an unsigned caller — but
// the version policy below STILL applies, so the anchor's logic is
// exercised. Production is always Release + ESRP-signed, where a real
// Microsoft signature is mandatory.
sigOk = true;
#endif
const bool accepted =
sigOk &&
IsCallerVersionAcceptable(callerVersion, serviceVersion);
if (!accepted)
{
return E_ACCESSDENIED;
}
// 5) Caller binding lookup (basename allow-list + namespace selection).
std::wstring basename = BaseName(canonical);
const CallerBinding* binding = FindBindingByExeBasename(basename);
if (!binding)
{
return E_ACCESSDENIED;
}
// Defensive: the table should always carry a well-formed namespace id;
// verify before we hand it to the storage layer to use as a directory
// name. Failure here is a build-time misconfiguration of Bindings.cpp.
if (!IsValidNamespaceId(binding->namespaceId))
{
return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
}
outIdentity.binding = binding;
return S_OK;
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#pragma once
#include <windows.h>
#include <string>
namespace PTSettingsSvc
{
struct CallerBinding; // Bindings.h
struct CallerIdentity
{
std::wstring userSidString; // S-1-5-21-... (per-user data partition key)
std::wstring imagePath; // Canonicalised, reparse-points resolved
DWORD processId{};
const CallerBinding* binding = nullptr; // never freed (static table)
};
// Authenticates the client connected to the named-pipe handle.
//
// Successful authentication means ALL of the following hold:
// * Caller token is a real interactive user (not SYSTEM / SERVICE /
// ANONYMOUS), so we have a SID to scope the per-user data folder.
// * Caller image is trusted by EITHER anchor (Design-v6-Final.md §7):
// - PATH anchor: image resolves under %ProgramFiles%\PowerToys and
// that folder's DACL is admin-only writable (per-machine), OR
// - BINARY-IDENTITY anchor: image is Microsoft-signed AND its version
// equals the service's own version (per-user, user-writable folder).
// * Caller image basename is in the CallerBinding allow-list — and the
// matched binding is returned in outIdentity.binding so the dispatch
// layer knows which namespace this caller may operate on.
//
// The path anchor is preferred where available (smaller privileged surface,
// immutability); the signature+version anchor is the fallback used only
// when the path cannot be trusted. See §7 and §15 #5.
//
// The function ImpersonateNamedPipeClient()s internally and reverts
// before returning, regardless of success.
//
// Returns:
// S_OK — all checks passed
// E_ACCESSDENIED — auth-rejected (path, DACL, or basename)
// HRESULT_FROM_WIN32(ERROR_NOT_FOUND) — basename allow-listed but
// binding lookup returned nullptr
// any other HRESULT — Win32 failure (token read,
// OpenProcess, etc.)
HRESULT AuthenticateCaller(HANDLE pipeHandle, CallerIdentity& outIdentity);
}

View File

@@ -0,0 +1,212 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "CallerVerify.h"
#include <windows.h>
#include <wintrust.h>
#include <softpub.h>
#include <wincrypt.h>
#include <vector>
#pragma comment(lib, "wintrust.lib")
#pragma comment(lib, "crypt32.lib")
#pragma comment(lib, "version.lib")
namespace PTSettingsSvc
{
namespace
{
// WinVerifyTrust with no UI; confirms the embedded signature is valid
// and chains to a trusted root. Runs in the service's own security
// context, so it consults the machine trust stores, not the caller's.
bool EmbeddedSignatureChainsToTrustedRoot(const std::wstring& path)
{
WINTRUST_FILE_INFO fileInfo = {};
fileInfo.cbStruct = sizeof(fileInfo);
fileInfo.pcwszFilePath = path.c_str();
GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;
WINTRUST_DATA wd = {};
wd.cbStruct = sizeof(wd);
wd.dwUIChoice = WTD_UI_NONE;
// Prototype: skip network revocation on the hot path. Production
// should use WTD_REVOKE_WHOLECHAIN with a cached/offline policy.
wd.fdwRevocationChecks = WTD_REVOKE_NONE;
wd.dwUnionChoice = WTD_CHOICE_FILE;
wd.pFile = &fileInfo;
wd.dwStateAction = WTD_STATEACTION_VERIFY;
wd.dwProvFlags = WTD_SAFER_FLAG;
HWND noWindow = static_cast<HWND>(INVALID_HANDLE_VALUE);
LONG status = WinVerifyTrust(noWindow, &action, &wd);
wd.dwStateAction = WTD_STATEACTION_CLOSE;
WinVerifyTrust(noWindow, &action, &wd);
return status == ERROR_SUCCESS;
}
// Extracts the signer leaf certificate's simple display name and checks
// it is "Microsoft Corporation". Production should pin the exact cert
// (public key / thumbprint) rather than the subject string.
bool SignerSubjectIsMicrosoft(const std::wstring& path)
{
HCERTSTORE store = nullptr;
HCRYPTMSG msg = nullptr;
DWORD encoding = 0;
DWORD contentType = 0;
DWORD formatType = 0;
if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE,
path.c_str(),
CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
CERT_QUERY_FORMAT_FLAG_BINARY,
0,
&encoding,
&contentType,
&formatType,
&store,
&msg,
nullptr))
{
return false;
}
bool isMicrosoft = false;
DWORD signerInfoSize = 0;
if (CryptMsgGetParam(msg, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &signerInfoSize) &&
signerInfoSize > 0)
{
std::vector<BYTE> signerInfoBuf(signerInfoSize);
if (CryptMsgGetParam(msg, CMSG_SIGNER_INFO_PARAM, 0, signerInfoBuf.data(), &signerInfoSize))
{
auto signerInfo = reinterpret_cast<CMSG_SIGNER_INFO*>(signerInfoBuf.data());
CERT_INFO certInfo = {};
certInfo.Issuer = signerInfo->Issuer;
certInfo.SerialNumber = signerInfo->SerialNumber;
PCCERT_CONTEXT cert = CertFindCertificateInStore(
store,
X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
0,
CERT_FIND_SUBJECT_CERT,
&certInfo,
nullptr);
if (cert)
{
wchar_t name[256] = {};
DWORD n = CertGetNameStringW(cert,
CERT_NAME_SIMPLE_DISPLAY_TYPE,
0,
nullptr,
name,
ARRAYSIZE(name));
if (n > 1)
{
isMicrosoft = (wcsstr(name, L"Microsoft Corporation") != nullptr);
}
CertFreeCertificateContext(cert);
}
}
}
if (msg)
{
CryptMsgClose(msg);
}
if (store)
{
CertCloseStore(store, 0);
}
return isMicrosoft;
}
}
bool VerifyMicrosoftSignature(const std::wstring& path)
{
if (path.empty())
{
return false;
}
return EmbeddedSignatureChainsToTrustedRoot(path) && SignerSubjectIsMicrosoft(path);
}
unsigned long long GetBinaryVersion(const std::wstring& path)
{
if (path.empty())
{
return 0;
}
DWORD ignored = 0;
DWORD size = GetFileVersionInfoSizeW(path.c_str(), &ignored);
if (size == 0)
{
return 0;
}
std::vector<BYTE> buf(size);
if (!GetFileVersionInfoW(path.c_str(), 0, size, buf.data()))
{
return 0;
}
VS_FIXEDFILEINFO* ffi = nullptr;
UINT ffiLen = 0;
if (!VerQueryValueW(buf.data(), L"\\", reinterpret_cast<LPVOID*>(&ffi), &ffiLen) ||
ffi == nullptr || ffiLen == 0)
{
return 0;
}
return (static_cast<unsigned long long>(ffi->dwFileVersionMS) << 32) |
static_cast<unsigned long long>(ffi->dwFileVersionLS);
}
unsigned long long GetServiceOwnVersion()
{
wchar_t self[MAX_PATH * 2] = {};
DWORD n = GetModuleFileNameW(nullptr, self, ARRAYSIZE(self));
if (n == 0 || n >= ARRAYSIZE(self))
{
return 0;
}
return GetBinaryVersion(self);
}
bool IsCallerVersionAcceptable(unsigned long long callerVersion,
unsigned long long serviceVersion)
{
if (callerVersion == 0 || serviceVersion == 0)
{
return false;
}
// 1) Absolute floor — the anti-downgrade boundary.
if (callerVersion < kMinSupportedCallerVersion)
{
return false;
}
// 2) Bounded staleness on the MINOR-release field (bits 32..47). Compare
// the absolute distance so a caller may trail OR (transiently, mid-
// upgrade) lead the service by at most kMaxMinorVersionDelta releases.
const unsigned long long callerMinor = (callerVersion >> 32) & 0xFFFFull;
const unsigned long long serviceMinor = (serviceVersion >> 32) & 0xFFFFull;
const unsigned long long minorDelta =
(serviceMinor > callerMinor) ? (serviceMinor - callerMinor)
: (callerMinor - serviceMinor);
if (minorDelta > kMaxMinorVersionDelta)
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#pragma once
#include <string>
namespace PTSettingsSvc
{
// Binary-identity anchor used when the install-path anchor cannot be
// trusted (per-user installs in a user-writable folder — Design-v6-Final.md
// §7 fallback branch / §15 #5 option d).
//
// Accepting a caller on this branch requires BOTH:
// * VerifyMicrosoftSignature(exe) — the on-disk image carries a valid
// Authenticode signature that chains to a trusted machine root AND is
// signed by "Microsoft Corporation". The check runs in the service's
// own context, so a user poisoning their HKCU cert stores cannot affect
// it (contrast the §13 package-identity attack).
// * GetBinaryVersion(exe) == GetServiceOwnVersion() — the caller is the
// same release as the service. Because the signature protects the
// version resource, a re-stamped version breaks the signature, and an
// old (downgrade) signed binary has an older version. Version
// comparison ALONE is insecure — VERSIONINFO is attacker-writable
// metadata — which is why it must be paired with the signature.
// True iff the file at `path` has a valid embedded Authenticode signature
// (chains to a trusted root) AND the signer leaf subject is Microsoft.
bool VerifyMicrosoftSignature(const std::wstring& path);
// 64-bit file version (dwFileVersionMS<<32 | dwFileVersionLS) from the
// VS_FIXEDFILEINFO of `path`. 0 if the file has no version resource.
unsigned long long GetBinaryVersion(const std::wstring& path);
// Version of the running service executable (this module). 0 if the
// service binary carries no version resource (production builds must).
unsigned long long GetServiceOwnVersion();
// Packs a (major, minor, build, revision) tuple into the same 64-bit layout
// GetBinaryVersion returns: major<<48 | minor<<32 | build<<16 | revision.
constexpr unsigned long long MakeVersion(unsigned short major,
unsigned short minor,
unsigned short build,
unsigned short revision)
{
return (static_cast<unsigned long long>(major) << 48) |
(static_cast<unsigned long long>(minor) << 32) |
(static_cast<unsigned long long>(build) << 16) |
static_cast<unsigned long long>(revision);
}
// --- Version-acceptance policy (Design §12.7, decided 2026-06-30) ----------
// Replaces the exact `caller == service` rule, which broke multi-user /
// multi-version (a machine-wide singleton service can be only one version,
// so the latest install would reject every other-version caller). A caller
// is version-acceptable iff BOTH bounds hold:
// 1. ABSOLUTE FLOOR: callerVersion >= kMinSupportedCallerVersion. This is
// the real anti-downgrade control — set it to exclude any version known
// to be vulnerable. Bump it when a bad old version must be cut off.
// 2. BOUNDED STALENESS (max delta): the caller's MINOR-release number is
// within kMaxMinorVersionDelta of the service's, so a caller can be at
// most N monthly releases away from the running service.
// The signature check (VerifyMicrosoftSignature) is still required and is
// what makes the version fields trustworthy.
// Oldest caller MINOR release still accepted. PowerToys versions are
// 0.<minor>.<build>; the minor is the monthly release train. Set to the
// first v6 shipping minor at release; placeholder baseline below.
constexpr unsigned long long kMinSupportedCallerVersion = MakeVersion(0, 100, 0, 0);
// Max number of MINOR releases a caller may trail (or lead) the service.
constexpr unsigned int kMaxMinorVersionDelta = 3;
// True iff `callerVersion` satisfies the floor + max-delta policy against the
// running `serviceVersion`. Both are packed (GetBinaryVersion layout).
bool IsCallerVersionAcceptable(unsigned long long callerVersion,
unsigned long long serviceVersion);
}

View File

@@ -0,0 +1,323 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "FileGuard.h"
#include <windows.h>
#include <sddl.h>
#include <aclapi.h>
#include <pathcch.h>
#include <memory>
#include <vector>
#pragma comment(lib, "Advapi32.lib")
#pragma comment(lib, "Pathcch.lib")
namespace PTSettingsSvc
{
namespace
{
struct LocalFreeDeleter
{
void operator()(void* p) const noexcept { if (p) LocalFree(p); }
};
HRESULT GetServiceSid(PSID& outSid)
{
// The service runs as LocalSystem (S-1-5-18) under MSIX, whose
// windows.service extension only allows LocalSystem/LocalService/
// NetworkService start accounts (no virtual NT SERVICE\<name>
// account — Design §12.1). Grant the writer ACE to SYSTEM.
BYTE buf[SECURITY_MAX_SID_SIZE];
DWORD cb = sizeof(buf);
if (!CreateWellKnownSid(WinLocalSystemSid, nullptr, buf, &cb))
{
return HRESULT_FROM_WIN32(GetLastError());
}
outSid = static_cast<PSID>(LocalAlloc(LMEM_FIXED, cb));
if (!outSid)
{
return E_OUTOFMEMORY;
}
CopySid(cb, outSid, buf);
return S_OK;
}
HRESULT ApplyProtectiveDacl(const std::wstring& target,
const std::wstring& userSidString)
{
PSID serviceSid = nullptr;
HRESULT hr = GetServiceSid(serviceSid);
if (FAILED(hr))
{
return hr;
}
std::unique_ptr<void, LocalFreeDeleter> serviceSidGuard(serviceSid);
PSID userSid = nullptr;
if (!ConvertStringSidToSidW(userSidString.c_str(), &userSid))
{
return HRESULT_FROM_WIN32(GetLastError());
}
std::unique_ptr<void, LocalFreeDeleter> userSidGuard(userSid);
PSID adminSid = nullptr;
if (!ConvertStringSidToSidW(L"S-1-5-32-544", &adminSid)) // BUILTIN\Administrators
{
return HRESULT_FROM_WIN32(GetLastError());
}
std::unique_ptr<void, LocalFreeDeleter> adminSidGuard(adminSid);
// Per Design-v6-Final.md §9 the per-user folder DACL is:
// svc:F, admin:F, <specific user>:RX
// Everyone else implicitly denied because we PROTECT the DACL
// below (no inheritance from <storeRoot>\, so the blanket
// AuthUsers:RX granted at the store root does NOT carry through
// here — that's how user A can't read user B's data). Applied at
// the per-user <sid> node, it inherits down to the namespace folder
// and the file.
EXPLICIT_ACCESS_W ea[3] = {};
ea[0].grfAccessPermissions = GENERIC_ALL;
ea[0].grfAccessMode = SET_ACCESS;
ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[0].Trustee.TrusteeType = TRUSTEE_IS_USER;
ea[0].Trustee.ptstrName = static_cast<LPWSTR>(serviceSid);
ea[1].grfAccessPermissions = GENERIC_ALL;
ea[1].grfAccessMode = SET_ACCESS;
ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[1].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
ea[1].Trustee.ptstrName = static_cast<LPWSTR>(adminSid);
ea[2].grfAccessPermissions = GENERIC_READ | GENERIC_EXECUTE;
ea[2].grfAccessMode = SET_ACCESS;
ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea[2].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[2].Trustee.TrusteeType = TRUSTEE_IS_USER;
ea[2].Trustee.ptstrName = static_cast<LPWSTR>(userSid);
PACL acl = nullptr;
DWORD rc = SetEntriesInAclW(ARRAYSIZE(ea), ea, nullptr, &acl);
if (rc != ERROR_SUCCESS)
{
return HRESULT_FROM_WIN32(rc);
}
std::unique_ptr<void, LocalFreeDeleter> aclGuard(acl);
// PROTECTED_DACL_SECURITY_INFORMATION blocks inheritance from
// <root>\<namespace>\. SetNamedSecurityInfoW takes a non-const
// LPWSTR by historical signature; copy into a local mutable buffer.
std::vector<wchar_t> mutableName(target.begin(), target.end());
mutableName.push_back(L'\0');
rc = SetNamedSecurityInfoW(mutableName.data(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
nullptr, nullptr, acl, nullptr);
return rc == ERROR_SUCCESS ? S_OK : HRESULT_FROM_WIN32(rc);
}
}
HRESULT EnsureStoreRoot(const std::wstring& root)
{
if (!CreateDirectoryW(root.c_str(), nullptr))
{
DWORD err = GetLastError();
if (err != ERROR_ALREADY_EXISTS)
{
return HRESULT_FROM_WIN32(err);
}
}
PSID adminSid = nullptr;
if (!ConvertStringSidToSidW(L"S-1-5-32-544", &adminSid))
{
return HRESULT_FROM_WIN32(GetLastError());
}
std::unique_ptr<void, LocalFreeDeleter> adminGuard(adminSid);
PSID systemSid = nullptr;
if (!ConvertStringSidToSidW(L"S-1-5-18", &systemSid))
{
return HRESULT_FROM_WIN32(GetLastError());
}
std::unique_ptr<void, LocalFreeDeleter> systemGuard(systemSid);
PSID authUsersSid = nullptr;
if (!ConvertStringSidToSidW(L"S-1-5-11", &authUsersSid)) // Authenticated Users
{
return HRESULT_FROM_WIN32(GetLastError());
}
std::unique_ptr<void, LocalFreeDeleter> authUsersGuard(authUsersSid);
// Root: SYSTEM/Admins Full, Authenticated Users RX (traverse only). Not
// protected — each <sid> node below protects itself; the blanket RX here
// lets every user reach their own node but the protected child DACL
// stops A reading B.
EXPLICIT_ACCESS_W ea[3] = {};
ea[0].grfAccessPermissions = GENERIC_ALL;
ea[0].grfAccessMode = SET_ACCESS;
ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[0].Trustee.TrusteeType = TRUSTEE_IS_USER;
ea[0].Trustee.ptstrName = static_cast<LPWSTR>(systemSid);
ea[1].grfAccessPermissions = GENERIC_ALL;
ea[1].grfAccessMode = SET_ACCESS;
ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[1].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
ea[1].Trustee.ptstrName = static_cast<LPWSTR>(adminSid);
ea[2].grfAccessPermissions = GENERIC_READ | GENERIC_EXECUTE;
ea[2].grfAccessMode = SET_ACCESS;
ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea[2].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[2].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
ea[2].Trustee.ptstrName = static_cast<LPWSTR>(authUsersSid);
PACL acl = nullptr;
DWORD rc = SetEntriesInAclW(ARRAYSIZE(ea), ea, nullptr, &acl);
if (rc != ERROR_SUCCESS)
{
return HRESULT_FROM_WIN32(rc);
}
std::unique_ptr<void, LocalFreeDeleter> aclGuard(acl);
std::vector<wchar_t> mutableName(root.begin(), root.end());
mutableName.push_back(L'\0');
rc = SetNamedSecurityInfoW(mutableName.data(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
nullptr, nullptr, acl, nullptr);
return rc == ERROR_SUCCESS ? S_OK : HRESULT_FROM_WIN32(rc);
}
HRESULT EnsureUserFolder(const std::wstring& folder,
const std::wstring& userSidString)
{
if (!CreateDirectoryW(folder.c_str(), nullptr))
{
DWORD err = GetLastError();
if (err != ERROR_ALREADY_EXISTS)
{
return HRESULT_FROM_WIN32(err);
}
}
return ApplyProtectiveDacl(folder, userSidString);
}
HRESULT WriteFileAtomically(const std::wstring& targetFile,
const std::vector<BYTE>& bytes)
{
std::wstring tmp = targetFile + L".tmp";
HANDLE h = CreateFileW(tmp.c_str(),
GENERIC_WRITE,
0,
nullptr,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
nullptr);
if (h == INVALID_HANDLE_VALUE)
{
return HRESULT_FROM_WIN32(GetLastError());
}
DWORD written = 0;
BOOL ok = WriteFile(h,
bytes.data(),
static_cast<DWORD>(bytes.size()),
&written,
nullptr);
DWORD writeErr = ok ? ERROR_SUCCESS : GetLastError();
FlushFileBuffers(h);
CloseHandle(h);
if (!ok || written != bytes.size())
{
DeleteFileW(tmp.c_str());
return HRESULT_FROM_WIN32(writeErr ? writeErr : ERROR_WRITE_FAULT);
}
if (!ReplaceFileW(targetFile.c_str(),
tmp.c_str(),
nullptr,
REPLACEFILE_WRITE_THROUGH | REPLACEFILE_IGNORE_MERGE_ERRORS,
nullptr,
nullptr))
{
DWORD err = GetLastError();
if (err == ERROR_FILE_NOT_FOUND)
{
// No existing file — MoveFile is sufficient.
if (!MoveFileExW(tmp.c_str(),
targetFile.c_str(),
MOVEFILE_WRITE_THROUGH))
{
DWORD mvErr = GetLastError();
DeleteFileW(tmp.c_str());
return HRESULT_FROM_WIN32(mvErr);
}
}
else
{
DeleteFileW(tmp.c_str());
return HRESULT_FROM_WIN32(err);
}
}
return S_OK;
}
HRESULT ReadFileFully(const std::wstring& path,
uint32_t maxBytes,
std::vector<BYTE>& outBytes)
{
outBytes.clear();
HANDLE h = CreateFileW(path.c_str(),
GENERIC_READ,
FILE_SHARE_READ,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr);
if (h == INVALID_HANDLE_VALUE)
{
return HRESULT_FROM_WIN32(GetLastError());
}
LARGE_INTEGER size{};
if (!GetFileSizeEx(h, &size))
{
DWORD err = GetLastError();
CloseHandle(h);
return HRESULT_FROM_WIN32(err);
}
if (size.QuadPart > static_cast<LONGLONG>(maxBytes))
{
CloseHandle(h);
return HRESULT_FROM_WIN32(ERROR_FILE_TOO_LARGE);
}
outBytes.resize(static_cast<size_t>(size.QuadPart));
DWORD read = 0;
BOOL ok = ReadFile(h,
outBytes.data(),
static_cast<DWORD>(outBytes.size()),
&read,
nullptr);
DWORD err = ok ? ERROR_SUCCESS : GetLastError();
CloseHandle(h);
if (!ok || read != outBytes.size())
{
outBytes.clear();
return HRESULT_FROM_WIN32(err ? err : ERROR_READ_FAULT);
}
return S_OK;
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#pragma once
#include <windows.h>
#include <string>
#include <vector>
namespace PTSettingsSvc
{
// Creates the store root (<ProgramData>\Microsoft\PowerToys\Settings) if it
// doesn't exist and applies the root DACL: SYSTEM/Admins Full, Authenticated
// Users RX (traverse so each user reaches their own <sid> node). Idempotent;
// the per-user MSIX install has no installer step so the LocalSystem service
// creates the root lazily on first PutBlob (Design §12.1).
HRESULT EnsureStoreRoot(const std::wstring& root);
// Creates `folder` if it doesn't exist and applies the DACL that locks
// the directory to:
// * the service account — Full Control
// * BUILTIN\Administrators — Read & Execute (audit/backup)
// * the user whose SID is passed in — Read & Execute (Launcher needs to read)
// * Everyone else — denied (DACL is protected, no inherit)
HRESULT EnsureUserFolder(const std::wstring& folder,
const std::wstring& userSidString);
// Atomically replaces `targetFile` with `bytes`. Internally writes to
// a sibling .tmp and uses ReplaceFileW so a crash during write never
// leaves the file in a half-written state. Re-asserts the directory's
// protective DACL after the write in case something has tampered with it.
HRESULT WriteFileAtomically(const std::wstring& targetFile,
const std::vector<BYTE>& bytes);
// Reads an entire file into memory. Caps at maxBytes; returns
// HRESULT_FROM_WIN32(ERROR_FILE_TOO_LARGE) if exceeded.
HRESULT ReadFileFully(const std::wstring& path,
uint32_t maxBytes,
std::vector<BYTE>& outBytes);
}

View File

@@ -0,0 +1,230 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "Paths.h"
#include <windows.h>
#include <sddl.h>
#include <shlobj.h>
#include <pathcch.h>
#include <aclapi.h>
#include <memory>
#pragma comment(lib, "Shell32.lib")
#pragma comment(lib, "Pathcch.lib")
#pragma comment(lib, "Advapi32.lib")
namespace PTSettingsSvc
{
namespace
{
std::wstring GetProgramDataFolder()
{
PWSTR path = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_ProgramData, 0, nullptr, &path)))
{
std::wstring result(path);
CoTaskMemFree(path);
return result;
}
return L"C:\\ProgramData";
}
}
std::wstring GetSettingsRoot()
{
return GetProgramDataFolder() + L"\\Microsoft\\PowerToys\\Settings";
}
std::wstring GetUserFolder(const std::wstring& userSidString)
{
return GetSettingsRoot() + L"\\" + userSidString;
}
std::wstring GetUserNamespaceFolder(const std::wstring& userSidString,
const std::wstring& namespaceId)
{
return GetUserFolder(userSidString) + L"\\" + namespaceId;
}
std::wstring GetUserFilePath(const std::wstring& userSidString,
const std::wstring& namespaceId,
const std::wstring& fileName)
{
return GetUserNamespaceFolder(userSidString, namespaceId) + L"\\" + fileName;
}
std::wstring GetPowerToysInstallFolder()
{
// The MSI writes InstallFolder under HKLM\SOFTWARE\Classes\PowerToys
// for per-machine installs. This is the authoritative location the
// service uses to validate the caller image path.
HKEY hKey = nullptr;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
L"SOFTWARE\\Classes\\PowerToys",
0,
KEY_READ | KEY_WOW64_64KEY,
&hKey) != ERROR_SUCCESS)
{
return {};
}
wchar_t buf[MAX_PATH] = {};
DWORD cb = sizeof(buf);
DWORD type = 0;
LSTATUS rc = RegQueryValueExW(hKey,
L"InstallFolder",
nullptr,
&type,
reinterpret_cast<LPBYTE>(buf),
&cb);
RegCloseKey(hKey);
if (rc != ERROR_SUCCESS || type != REG_SZ)
{
return {};
}
std::wstring result(buf);
// Strip trailing backslash.
while (!result.empty() && result.back() == L'\\')
{
result.pop_back();
}
return result;
}
std::wstring SidToString(void* psid)
{
LPWSTR str = nullptr;
if (!ConvertSidToStringSidW(static_cast<PSID>(psid), &str))
{
return {};
}
std::wstring result(str);
LocalFree(str);
return result;
}
namespace
{
bool IsAdminClassPrincipal(PSID sid)
{
// Build a small set of well-known principals that are allowed to
// write to an install folder we still consider hardened.
const WELL_KNOWN_SID_TYPE wellKnown[] = {
WinLocalSystemSid,
WinBuiltinAdministratorsSid,
};
for (auto wk : wellKnown)
{
BYTE buf[SECURITY_MAX_SID_SIZE];
DWORD cb = sizeof(buf);
if (CreateWellKnownSid(wk, nullptr, buf, &cb) &&
EqualSid(sid, reinterpret_cast<PSID>(buf)))
{
return true;
}
}
// NT SERVICE\TrustedInstaller — no WELL_KNOWN_SID_TYPE constant,
// but the SID is stable.
PSID tiSid = nullptr;
if (ConvertStringSidToSidW(
L"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
&tiSid))
{
bool match = EqualSid(sid, tiSid) != 0;
LocalFree(tiSid);
if (match) return true;
}
return false;
}
}
bool IsFolderAdminOnlyWritable(const std::wstring& folder)
{
if (folder.empty())
{
return false;
}
// Rights that let an attacker influence what's inside the folder
// (drop a fake exe, swap an existing one, change the DACL itself).
constexpr DWORD kDangerousRights =
FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY |
FILE_WRITE_DATA | FILE_APPEND_DATA |
FILE_DELETE_CHILD | DELETE |
WRITE_DAC | WRITE_OWNER |
GENERIC_WRITE | GENERIC_ALL;
PACL dacl = nullptr;
PSECURITY_DESCRIPTOR sd = nullptr;
DWORD rc = GetNamedSecurityInfoW(
folder.c_str(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
nullptr,
nullptr,
&dacl,
nullptr,
&sd);
if (rc != ERROR_SUCCESS)
{
return false;
}
// NULL DACL means "allow everyone everything" — definitely not safe.
if (!dacl)
{
if (sd) LocalFree(sd);
return false;
}
bool safe = true;
for (WORD i = 0; safe && i < dacl->AceCount; ++i)
{
PACE_HEADER hdr = nullptr;
if (!GetAce(dacl, i, reinterpret_cast<LPVOID*>(&hdr)))
{
continue;
}
// Only positive ACEs matter. ACCESS_DENIED only narrows
// permissions further.
if (hdr->AceType != ACCESS_ALLOWED_ACE_TYPE &&
hdr->AceType != ACCESS_ALLOWED_OBJECT_ACE_TYPE)
{
continue;
}
ACCESS_ALLOWED_ACE* ace = reinterpret_cast<ACCESS_ALLOWED_ACE*>(hdr);
if ((ace->Mask & kDangerousRights) == 0)
{
continue;
}
PSID sid = reinterpret_cast<PSID>(&ace->SidStart);
// CREATOR OWNER / CREATOR GROUP only apply when something is
// created; they don't grant the current trustee anything by
// themselves, so they're benign here.
BYTE creatorOwner[SECURITY_MAX_SID_SIZE];
DWORD cb = sizeof(creatorOwner);
if (CreateWellKnownSid(WinCreatorOwnerSid, nullptr, creatorOwner, &cb) &&
EqualSid(sid, reinterpret_cast<PSID>(creatorOwner)))
{
continue;
}
if (!IsAdminClassPrincipal(sid))
{
safe = false;
}
}
LocalFree(sd);
return safe;
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#pragma once
#include <string>
namespace PTSettingsSvc
{
// %ProgramData%\Microsoft\PowerToys\Settings
std::wstring GetSettingsRoot();
// %ProgramData%\Microsoft\PowerToys\Settings\<sid>
// Per-user node: this is where the protected, user-isolating DACL is
// applied; everything below inherits it.
std::wstring GetUserFolder(const std::wstring& userSidString);
// %ProgramData%\Microsoft\PowerToys\Settings\<sid>\<namespaceId>
std::wstring GetUserNamespaceFolder(const std::wstring& userSidString,
const std::wstring& namespaceId);
// %ProgramData%\Microsoft\PowerToys\Settings\<sid>\<namespaceId>\<fileName>
std::wstring GetUserFilePath(const std::wstring& userSidString,
const std::wstring& namespaceId,
const std::wstring& fileName);
// Path to the PowerToys install folder (from HKLM\SOFTWARE\Classes\PowerToys
// or the registry key the bootstrapper writes). Empty string on failure.
std::wstring GetPowerToysInstallFolder();
// Returns true iff `folder` exists AND its DACL grants write/create/delete
// only to admin-class principals (BUILTIN\Administrators,
// NT AUTHORITY\SYSTEM, NT SERVICE\TrustedInstaller). Used by the auth
// pipeline to reject install paths that landed in a user-writable
// location (custom MSI directory under a Users-writable parent, per-user
// MSI under %LocalAppData%, etc.) — in those cases same-user malware
// could plant a fake allow-listed exe there and pass the path+name check.
bool IsFolderAdminOnlyWritable(const std::wstring& folder);
// Convert a binary SID to its string form (S-1-5-21-...). Empty on failure.
std::wstring SidToString(void* psid);
}

View File

@@ -0,0 +1,294 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#include "PipeServer.h"
#include "Bindings.h"
#include "CallerAuth.h"
#include "FileGuard.h"
#include "Paths.h"
#include "protocol/Protocol.h"
#include <windows.h>
#include <sddl.h>
#include <aclapi.h>
#include <vector>
#include <string>
#include <cstring>
#pragma comment(lib, "Advapi32.lib")
namespace PTSettingsSvc
{
namespace
{
// Pipe SD: Authenticated Users may connect; SYSTEM and BUILTIN\Administrators
// get full control for diagnostics; everyone else is implicitly denied
// because the DACL doesn't grant them anything. The protocol layer
// does the real access control (caller image + allow-list).
constexpr const wchar_t* kPipeSddl =
L"D:"
L"(A;;GRGW;;;AU)" // Authenticated Users : connect/read/write
L"(A;;GA;;;SY)" // SYSTEM : full
L"(A;;GA;;;BA)"; // BUILTIN\Administrators : full
bool ReadExact(HANDLE pipe, void* buf, DWORD len)
{
BYTE* p = static_cast<BYTE*>(buf);
DWORD remaining = len;
while (remaining > 0)
{
DWORD got = 0;
if (!ReadFile(pipe, p, remaining, &got, nullptr) || got == 0)
{
return false;
}
p += got;
remaining -= got;
}
return true;
}
bool WriteExact(HANDLE pipe, const void* buf, DWORD len)
{
const BYTE* p = static_cast<const BYTE*>(buf);
DWORD remaining = len;
while (remaining > 0)
{
DWORD wrote = 0;
if (!WriteFile(pipe, p, remaining, &wrote, nullptr) || wrote == 0)
{
return false;
}
p += wrote;
remaining -= wrote;
}
return true;
}
void SendResponse(HANDLE pipe, Status status,
const std::vector<BYTE>& payload = {})
{
uint8_t st = static_cast<uint8_t>(status);
uint32_t len = static_cast<uint32_t>(payload.size());
WriteExact(pipe, &st, sizeof(st));
WriteExact(pipe, &len, sizeof(len));
if (len > 0)
{
WriteExact(pipe, payload.data(), len);
}
}
void SendStatus(HANDLE pipe, Status status)
{
SendResponse(pipe, status);
}
void HandleGetBlob(HANDLE pipe, const CallerIdentity& id)
{
std::wstring target = GetUserFilePath(id.userSidString,
id.binding->namespaceId,
id.binding->fileName);
std::vector<BYTE> bytes;
HRESULT hr = ReadFileFully(target, kMaxPayloadBytes, bytes);
if (hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) ||
hr == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND))
{
// Brand new user / namespace — explicit NotFound so the
// caller can distinguish "blob is empty" from "blob doesn't
// exist yet" (matters for migration).
SendStatus(pipe, Status::NotFound);
return;
}
if (FAILED(hr))
{
SendStatus(pipe, hr == HRESULT_FROM_WIN32(ERROR_FILE_TOO_LARGE)
? Status::PayloadTooLarge
: Status::IoError);
return;
}
SendResponse(pipe, Status::Ok, bytes);
}
void HandlePutBlob(HANDLE pipe, const CallerIdentity& id,
const std::vector<BYTE>& payload)
{
// No structural / schema check on the payload. The service is
// payload-agnostic; the caller is responsible for whatever
// shape it wants on disk. See Design-v6-Final.md §4.
// Ensure the store root exists with the traverse DACL (no installer
// creates it in the per-user MSIX case; LocalSystem does it lazily).
HRESULT hr = EnsureStoreRoot(GetSettingsRoot());
if (FAILED(hr))
{
SendStatus(pipe, Status::IoError);
return;
}
// Ensure the per-user node <storeRoot>\<sid> exists and carries the
// PROTECTED, user-isolating DACL (svc:F, admin:F, this-user:RX).
// It is applied once here and inherited by the namespace folder and
// the file below — that single tightening is what stops user A from
// reading user B's data (Design §9).
hr = EnsureUserFolder(GetUserFolder(id.userSidString),
id.userSidString);
if (FAILED(hr))
{
SendStatus(pipe, Status::IoError);
return;
}
// Ensure the <sid>\<namespace> folder. It inherits the protected
// DACL from the per-user node, so no tightening is needed here.
std::wstring nsFolder = GetUserNamespaceFolder(id.userSidString,
id.binding->namespaceId);
if (!CreateDirectoryW(nsFolder.c_str(), nullptr))
{
DWORD err = GetLastError();
if (err != ERROR_ALREADY_EXISTS)
{
SendStatus(pipe, Status::IoError);
return;
}
}
hr = WriteFileAtomically(
GetUserFilePath(id.userSidString,
id.binding->namespaceId,
id.binding->fileName),
payload);
SendStatus(pipe, FAILED(hr) ? Status::IoError : Status::Ok);
}
void HandleConnection(HANDLE pipe)
{
CallerIdentity id;
HRESULT hr = AuthenticateCaller(pipe, id);
if (FAILED(hr))
{
Status s = (hr == E_ACCESSDENIED)
? Status::AuthFailCaller
: (hr == HRESULT_FROM_WIN32(ERROR_NOT_FOUND))
? Status::NamespaceUnknown
: Status::AuthFailToken;
SendStatus(pipe, s);
return;
}
// ── Read request frame ─────────────────────────────────
uint8_t op = 0;
uint32_t plen = 0;
if (!ReadExact(pipe, &op, sizeof(op)) ||
!ReadExact(pipe, &plen, sizeof(plen)))
{
SendStatus(pipe, Status::BadRequest);
return;
}
if (plen > kMaxPayloadBytes)
{
SendStatus(pipe, Status::PayloadTooLarge);
return;
}
std::vector<BYTE> payload(plen);
if (plen > 0 && !ReadExact(pipe, payload.data(), plen))
{
SendStatus(pipe, Status::BadRequest);
return;
}
// ── Dispatch ───────────────────────────────────────────
switch (static_cast<Opcode>(op))
{
case Opcode::Ping:
SendStatus(pipe, Status::Ok);
break;
case Opcode::GetBlob:
HandleGetBlob(pipe, id);
break;
case Opcode::PutBlob:
HandlePutBlob(pipe, id, payload);
break;
default:
SendStatus(pipe, Status::UnknownOpcode);
break;
}
}
HANDLE CreateProtectedPipe()
{
PSECURITY_DESCRIPTOR sd = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
kPipeSddl, SDDL_REVISION_1, &sd, nullptr))
{
return INVALID_HANDLE_VALUE;
}
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = sd;
sa.bInheritHandle = FALSE;
HANDLE pipe = CreateNamedPipeW(
kPipeName,
PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT |
PIPE_REJECT_REMOTE_CLIENTS,
/*nMaxInstances*/ PIPE_UNLIMITED_INSTANCES,
/*nOutBufferSize*/ 64 * 1024,
/*nInBufferSize*/ 64 * 1024,
/*nDefaultTimeOut*/ 5000,
&sa);
LocalFree(sd);
return pipe;
}
}
DWORD RunPipeServer(HANDLE stopEvent)
{
for (;;)
{
if (WaitForSingleObject(stopEvent, 0) == WAIT_OBJECT_0)
{
return ERROR_SUCCESS;
}
HANDLE pipe = CreateProtectedPipe();
if (pipe == INVALID_HANDLE_VALUE)
{
return GetLastError();
}
// ConnectNamedPipe blocks until a client opens the pipe. The
// service control handler signals stopEvent AND closes the pipe
// handle (via DisconnectNamedPipe from the stop handler) to
// unblock us during shutdown — we observe that path via
// ERROR_BROKEN_PIPE / ERROR_INVALID_HANDLE.
BOOL connected = ConnectNamedPipe(pipe, nullptr);
DWORD err = connected ? ERROR_SUCCESS : GetLastError();
if (!connected && err == ERROR_PIPE_CONNECTED)
{
connected = TRUE;
}
if (WaitForSingleObject(stopEvent, 0) == WAIT_OBJECT_0)
{
CloseHandle(pipe);
return ERROR_SUCCESS;
}
if (connected)
{
HandleConnection(pipe);
FlushFileBuffers(pipe);
DisconnectNamedPipe(pipe);
}
CloseHandle(pipe);
}
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#pragma once
#include <windows.h>
#include <atomic>
namespace PTSettingsSvc
{
// Runs the named-pipe loop until `stopEvent` is signalled.
// Returns 0 on a clean stop, non-zero on a fatal error.
DWORD RunPipeServer(HANDLE stopEvent);
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
//
// PTSettingsSvc — PowerToys Settings Service.
//
// Design context — see Design-v6-Final.md in the Workspaces-EoP-Fix folder.
//
// The service runs as a virtual service account (NT SERVICE\PTSettingsSvc),
// owns the DACL on %ProgramData%\Microsoft\PowerToys\SettingsSvc\<ns>\<sid>\
// and is the only writer to the blob.bin file inside it. Callers (Editor,
// SnapshotTool, runner, etc. — see Bindings.cpp) connect over a named pipe
// to GetBlob / PutBlob.
#include <windows.h>
#include <tchar.h>
#include <atomic>
#include "PipeServer.h"
#include "protocol/Protocol.h"
namespace
{
SERVICE_STATUS g_status{};
SERVICE_STATUS_HANDLE g_statusHandle = nullptr;
HANDLE g_stopEvent = nullptr;
HANDLE g_workerThread = nullptr;
void ReportStatus(DWORD state, DWORD waitHintMs = 0, DWORD exitCode = 0)
{
static DWORD checkPoint = 1;
g_status.dwCurrentState = state;
g_status.dwWin32ExitCode = exitCode;
g_status.dwWaitHint = waitHintMs;
g_status.dwControlsAccepted =
(state == SERVICE_START_PENDING) ? 0 : (SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN);
g_status.dwCheckPoint = (state == SERVICE_RUNNING || state == SERVICE_STOPPED)
? 0
: checkPoint++;
if (g_statusHandle)
{
SetServiceStatus(g_statusHandle, &g_status);
}
}
DWORD WINAPI WorkerThread(LPVOID)
{
return PTSettingsSvc::RunPipeServer(g_stopEvent);
}
VOID WINAPI ServiceCtrlHandler(DWORD ctrl)
{
switch (ctrl)
{
case SERVICE_CONTROL_STOP:
case SERVICE_CONTROL_SHUTDOWN:
ReportStatus(SERVICE_STOP_PENDING, 5000);
if (g_stopEvent)
{
SetEvent(g_stopEvent);
}
break;
default:
break;
}
}
VOID WINAPI ServiceMain(DWORD, LPTSTR*)
{
g_statusHandle = RegisterServiceCtrlHandlerW(
PTSettingsSvc::kServiceName, ServiceCtrlHandler);
if (!g_statusHandle)
{
return;
}
g_status.dwServiceType = SERVICE_WIN32_OWN_PROCESS;
ReportStatus(SERVICE_START_PENDING, 3000);
g_stopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!g_stopEvent)
{
ReportStatus(SERVICE_STOPPED, 0, GetLastError());
return;
}
g_workerThread = CreateThread(nullptr, 0, WorkerThread, nullptr, 0, nullptr);
if (!g_workerThread)
{
DWORD err = GetLastError();
CloseHandle(g_stopEvent);
g_stopEvent = nullptr;
ReportStatus(SERVICE_STOPPED, 0, err);
return;
}
ReportStatus(SERVICE_RUNNING);
WaitForSingleObject(g_workerThread, INFINITE);
DWORD workerRc = 0;
GetExitCodeThread(g_workerThread, &workerRc);
CloseHandle(g_workerThread);
g_workerThread = nullptr;
CloseHandle(g_stopEvent);
g_stopEvent = nullptr;
ReportStatus(SERVICE_STOPPED, 0, workerRc);
}
}
int wmain(int argc, wchar_t* argv[])
{
// `--console` runs the pipe server in the foreground for local debugging
// and prototype testing without going through SCM. Production launch
// always goes through StartServiceCtrlDispatcher.
bool console = false;
for (int i = 1; i < argc; ++i)
{
if (wcscmp(argv[i], L"--console") == 0)
{
console = true;
}
}
if (console)
{
g_stopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
SetConsoleCtrlHandler([](DWORD) -> BOOL {
if (g_stopEvent) { SetEvent(g_stopEvent); }
return TRUE;
}, TRUE);
DWORD rc = PTSettingsSvc::RunPipeServer(g_stopEvent);
CloseHandle(g_stopEvent);
return static_cast<int>(rc);
}
wchar_t name[] = L"PTSettingsSvc";
SERVICE_TABLE_ENTRYW table[] = {
{ name, ServiceMain },
{ nullptr, nullptr },
};
if (!StartServiceCtrlDispatcherW(table))
{
return static_cast<int>(GetLastError());
}
return 0;
}

View File

@@ -0,0 +1,46 @@
// Win32 version resource for the PowerToys Workspaces Settings Service.
//
// The FileVersion / ProductVersion are sourced from the central PowerToys
// version (common/version/version.h -> Generated Files/version_gen.h), so the
// service exe carries the same product version as the rest of PowerToys.
//
// This version is load-bearing for the per-user hardening path: the service
// reads its own VS_FIXEDFILEINFO (CallerVerify.cpp) and compares it against the
// caller's file version as the signature+version trust anchor. A native exe has
// no managed "assembly version"; the Win32 FileVersion is the canonical value.
#include <windows.h>
#include "..\..\..\common\version\version.h"
1 VERSIONINFO
FILEVERSION FILE_VERSION
PRODUCTVERSION PRODUCT_VERSION
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", COMPANY_NAME
VALUE "FileDescription", "PowerToys Workspaces Settings Service"
VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", "WorkspacesSettingsService"
VALUE "LegalCopyright", COPYRIGHT_NOTE
VALUE "OriginalFilename", "PowerToys.PTSettingsSvc.exe"
VALUE "ProductName", PRODUCT_NAME
VALUE "ProductVersion", PRODUCT_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{8B6A7C32-5C8D-4AD1-9F60-7E1B3D17A220}</ProjectGuid>
<RootNamespace>WorkspacesSettingsService</RootNamespace>
<WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion>
<ProjectName>WorkspacesSettingsService</ProjectName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<PropertyGroup>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
<TargetName>PowerToys.PTSettingsSvc</TargetName>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>./;./protocol;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>Advapi32.lib;Shell32.lib;Pathcch.lib;Ole32.lib;Wintrust.lib;Crypt32.lib;Version.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="WorkspacesSettingsService.cpp" />
<ClCompile Include="PipeServer.cpp" />
<ClCompile Include="CallerAuth.cpp" />
<ClCompile Include="CallerVerify.cpp" />
<ClCompile Include="Bindings.cpp" />
<ClCompile Include="FileGuard.cpp" />
<ClCompile Include="Paths.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="PipeServer.h" />
<ClInclude Include="CallerAuth.h" />
<ClInclude Include="CallerVerify.h" />
<ClInclude Include="Bindings.h" />
<ClInclude Include="FileGuard.h" />
<ClInclude Include="Paths.h" />
<ClInclude Include="protocol\Protocol.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="WorkspacesSettingsService.rc" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<!--
NOTE: the signed MSIX (PTSettingsSvc.msix) is NOT packed here. It must be
built from the SIGNED service binary and then signed itself, which happens
in the installer pipeline (steps-build-installer-vnext.yml) AFTER core ESRP
signing and BEFORE the MSI build. Packing it at compile time would capture
the unsigned exe and ship an unsigned package (Design §12.1). For local dev
builds, run devtools\build-msix.ps1 manually.
-->
</Project>

View File

@@ -0,0 +1,3 @@
# Compiled helpers produced by the validation scripts are build artifacts and
# must never be committed.
*.exe

View File

@@ -0,0 +1,24 @@
# WorkspacesSettingsService — dev validation tooling
These scripts stand up and exercise `PTSettingsSvc` locally to validate the v6
tamper-resistant settings design. They are **developer tooling, not product
code** and are not part of the shipping build or the installer.
| File | Purpose |
| --- | --- |
| `setup-ptsettingssvc.ps1` | Registers the service, creates the PROTECTED `%ProgramData%` store, and a fake admin-locked install folder. Run elevated. |
| `verify-prototype.ps1` | Runs the 9-step end-to-end security suite (liveness, caller allow-list, path-prefix, DACL hardness, round-trip, NotFound, per-user DACL, non-user owner, non-elevated write/delete rejection). Does not need elevation. |
| `SaferModify.cs` | Helper compiled on demand by step 9 to obtain a Medium-IL (non-elevated) SAFER token and attempt a tamper write/delete. |
## Usage
```powershell
# 1. Build the service + smoke test (Debug|x64) first.
# 2. Elevated:
pwsh -File .\setup-ptsettingssvc.ps1
# 3. Non-elevated:
pwsh -File .\verify-prototype.ps1
```
`RepoRoot` is derived automatically from the script location; pass `-RepoRoot`
to override. Requires PowerShell 7+ (the suite uses the ternary operator).

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.IO;
class P {
[DllImport("advapi32",SetLastError=true)] static extern bool SaferCreateLevel(int s,int l,int o,out IntPtr h,IntPtr r);
[DllImport("advapi32",SetLastError=true)] static extern bool SaferComputeTokenFromLevel(IntPtr h,IntPtr it,out IntPtr ot,int f,IntPtr r);
[DllImport("advapi32",SetLastError=true)] static extern bool SaferCloseLevel(IntPtr h);
[DllImport("advapi32",SetLastError=true)] static extern bool ImpersonateLoggedOnUser(IntPtr t);
[DllImport("advapi32",SetLastError=true)] static extern bool RevertToSelf();
static int Main(string[] a){
string f=a[0]; IntPtr lvl,tok;
SaferCreateLevel(2,0x20000,1,out lvl,IntPtr.Zero);
SaferComputeTokenFromLevel(lvl,IntPtr.Zero,out tok,0,IntPtr.Zero);
SaferCloseLevel(lvl);
ImpersonateLoggedOnUser(tok);
Console.WriteLine("[as] "+System.Security.Principal.WindowsIdentity.GetCurrent().Name+" (non-elevated SAFER token)");
try { File.WriteAllText(f,"PWNED"); Console.WriteLine("WRITE : SUCCEEDED <-- lock broken"); }
catch(Exception e){ Console.WriteLine("WRITE : rejected -> "+e.GetType().Name); }
try { File.Delete(f); Console.WriteLine("DELETE: SUCCEEDED <-- lock broken"); }
catch(Exception e){ Console.WriteLine("DELETE: rejected -> "+e.GetType().Name); }
RevertToSelf(); return 0;
}
}

View File

@@ -0,0 +1,64 @@
<#
.SYNOPSIS
Build the PowerToys Settings Service MSIX from a built service exe.
Local-dev helper; production packaging happens in the signed build pipeline.
.DESCRIPTION
Stages AppxManifest + logo + the built PowerToys.PTSettingsSvc.exe into a
layout, packs it with makeappx, and (optionally) signs it with a dev cert.
Mirrors the validated prototype (Design-v6-Final.md §12.1).
#>
[CmdletBinding()]
param(
[string]$Config = 'Release',
[string]$ExePath = "$PSScriptRoot\..\x64\$Config\WorkspacesSettingsService\PowerToys.PTSettingsSvc.exe",
[string]$OutMsix = "$PSScriptRoot\..\package\PTSettingsSvc.msix",
[string]$Version = '',
[string]$Arch = 'x64',
[string]$PfxPath = '',
[string]$PfxPass = ''
)
$ErrorActionPreference = 'Stop'
$pkgSrc = Join-Path $PSScriptRoot '..\package'
$staging = Join-Path $env:TEMP 'ptsettingssvc-msix'
$sdkBin = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter makeappx.exe |
Where-Object { $_.FullName -match 'x64' } | Select-Object -Last 1).DirectoryName
if (-not (Test-Path $ExePath)) { throw "Service exe not found: $ExePath (build the vcxproj first)." }
# 1x1 transparent logo if none present.
$logo = Join-Path $pkgSrc 'logo.png'
if (-not (Test-Path $logo)) {
[IO.File]::WriteAllBytes($logo,[Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='))
}
Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $staging | Out-Null
Copy-Item (Join-Path $pkgSrc 'AppxManifest.xml') $staging
Copy-Item $logo $staging
Copy-Item $ExePath $staging
# Stamp the package version (must be 4-part) to keep it in lockstep with the build.
# Use case-sensitive -creplace so the lowercase `version` in the XML declaration
# is left untouched (only the Identity's `Version` attribute is replaced).
if ($Version) {
$v = if (($Version -split '\.').Count -eq 3) { "$Version.0" } else { $Version }
$mf = Join-Path $staging 'AppxManifest.xml'
(Get-Content $mf -Raw) -creplace 'Version="[0-9.]+"', "Version=`"$v`"" | Set-Content $mf -Encoding utf8
}
# Stamp the package architecture (MSIX uses lowercase x64/arm64).
$arch = $Arch.ToLowerInvariant()
$mf = Join-Path $staging 'AppxManifest.xml'
(Get-Content $mf -Raw) -creplace 'ProcessorArchitecture="[a-zA-Z0-9]+"', "ProcessorArchitecture=`"$arch`"" | Set-Content $mf -Encoding utf8
& "$sdkBin\makeappx.exe" pack /d $staging /p $OutMsix /o | Out-Null
if ($LASTEXITCODE -ne 0) { throw "makeappx failed ($LASTEXITCODE)." }
Write-Output "packed: $OutMsix"
if ($PfxPath) {
& "$sdkBin\signtool.exe" sign /fd SHA256 /f $PfxPath /p $PfxPass $OutMsix | Out-Null
if ($LASTEXITCODE -ne 0) { throw "signtool failed ($LASTEXITCODE)." }
Write-Output "signed: $OutMsix"
}

View File

@@ -0,0 +1,184 @@
# setup-ptsettingssvc.ps1
#
# Stands up PTSettingsSvc for local v6 prototype validation:
# * Registers the service under NT SERVICE\PTSettingsSvc
# * Creates the PROTECTED data root at %ProgramData%\Microsoft\PowerToys\SettingsSvc
# * Creates a fake "install folder" under %TEMP%, locks its DACL to admin-only,
# copies the smoke-test exe in renamed to an allow-listed basename
# (PowerToys.WorkspacesEditor.exe)
# * Sets HKLM\SOFTWARE\Classes\PowerToys\InstallFolder so the service finds
# the fake install folder via the same code path the production MSI uses
#
# Must be run elevated.
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..\..')).Path,
[string]$SvcName = 'PTSettingsSvc',
[string]$DisplayName= 'PowerToys Settings Service',
[string]$Description= 'Provides tamper-resistant storage for PowerToys module settings. Stopping this service prevents affected modules (e.g. Workspaces) from saving configuration changes.',
[string]$FakeInstall= (Join-Path $env:TEMP 'PTFakeInstall')
)
$ErrorActionPreference = 'Stop'
$svcExe = Join-Path $RepoRoot 'x64\Debug\WorkspacesSettingsService\PowerToys.PTSettingsSvc.exe'
$smokeExe = Join-Path $RepoRoot 'x64\Debug\WorkspacesSvcSmokeTest\PowerToys.PTSettingsSvcSmokeTest.exe'
$dataRoot = 'C:\ProgramData\Microsoft\PowerToys\Settings'
$renamedCaller = Join-Path $FakeInstall 'PowerToys.WorkspacesEditor.exe'
$badCaller = Join-Path $FakeInstall 'PowerToys.PTSettingsSvcSmokeTest.exe'
if (-not (Test-Path $svcExe)) { throw "Service exe not found: $svcExe`nBuild WorkspacesSettingsService.vcxproj first." }
if (-not (Test-Path $smokeExe)) { throw "Smoke-test exe not found: $smokeExe`nBuild WorkspacesSvcSmokeTest.vcxproj first." }
Write-Host "=== Setting up PTSettingsSvc for local validation ===" -ForegroundColor Cyan
Write-Host "Running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# -------------------------------------------------------------------
# 1) Stop & remove any prior install so we can iterate cleanly.
# -------------------------------------------------------------------
$existing = Get-Service -Name $SvcName -ErrorAction SilentlyContinue
if ($existing)
{
Write-Host "`n[1/5] Found existing service $SvcName - removing ..."
if ($existing.Status -ne 'Stopped')
{
sc.exe stop $SvcName | Out-Null
Start-Sleep -Seconds 2
}
sc.exe delete $SvcName | Out-Null
Start-Sleep -Seconds 1
}
else
{
Write-Host "`n[1/5] No prior install of $SvcName - clean slate."
}
# Also clean any legacy PTWorkspacesSvc from earlier prototype builds.
$legacy = Get-Service -Name 'PTWorkspacesSvc' -ErrorAction SilentlyContinue
if ($legacy)
{
Write-Host " Removing legacy PTWorkspacesSvc from earlier prototype ..."
if ($legacy.Status -ne 'Stopped') { sc.exe stop 'PTWorkspacesSvc' | Out-Null; Start-Sleep 2 }
sc.exe delete 'PTWorkspacesSvc' | Out-Null
}
# -------------------------------------------------------------------
# 2) Create the service under the virtual account.
# -------------------------------------------------------------------
Write-Host "`n[2/5] Creating service $SvcName under NT SERVICE\$SvcName ..."
$out = sc.exe create $SvcName binPath= "`"$svcExe`"" start= demand `
obj= "NT SERVICE\$SvcName" DisplayName= "$DisplayName" 2>&1
Write-Host $out
if ($LASTEXITCODE -ne 0) { throw "sc.exe create failed (exit $LASTEXITCODE)" }
sc.exe description $SvcName "$Description" | Out-Null
sc.exe failure $SvcName reset= 86400 actions= restart/60000/restart/60000/``/``/0 | Out-Null
# -------------------------------------------------------------------
# 3) Create the data root with PROTECTED admin-only DACL.
# -------------------------------------------------------------------
Write-Host "`n[3/5] Setting up data root $dataRoot ..."
if (Test-Path $dataRoot)
{
Write-Host " Folder exists - resetting ACL."
}
else
{
New-Item -ItemType Directory -Force $dataRoot | Out-Null
}
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false) # PROTECTED, drop inherited ACEs
$svcPrincipal = New-Object System.Security.Principal.NTAccount("NT SERVICE\$SvcName")
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$svcPrincipal, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
'NT AUTHORITY\Authenticated Users', 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
$acl.SetOwner((New-Object System.Security.Principal.NTAccount('BUILTIN\Administrators')))
Set-Acl -Path $dataRoot -AclObject $acl
Write-Host " DACL:"
(Get-Acl $dataRoot).Access | ForEach-Object {
Write-Host (" {0,-45} {1,-20} {2}" -f $_.IdentityReference, $_.FileSystemRights, $_.AccessControlType)
}
# -------------------------------------------------------------------
# 4) Set up fake install folder so the smoke test can pass auth.
# -------------------------------------------------------------------
Write-Host "`n[4/5] Setting up fake install folder $FakeInstall ..."
if (Test-Path $FakeInstall) { Remove-Item $FakeInstall -Recurse -Force }
New-Item -ItemType Directory -Force $FakeInstall | Out-Null
# Admin-only DACL. Without this the service's IsFolderAdminOnlyWritable
# check (see Paths.cpp) rejects the install folder and every caller fails
# AuthFailCaller, regardless of binary name.
$ial = New-Object System.Security.AccessControl.DirectorySecurity
$ial.SetAccessRuleProtection($true, $false)
$ial.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
'NT AUTHORITY\SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
$ial.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
# Virtual service account needs RX so it can read the folder's own DACL
# from inside IsFolderAdminOnlyWritable. Production WiX will grant this
# explicitly to NT SERVICE\PTSettingsSvc; for the smoke test we grant it
# to the whole NT SERVICE bucket which is equivalent for the lookup.
$ial.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
'NT SERVICE\ALL SERVICES', 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
# Non-admin user needs RX too so the smoke test exe can actually launch
# from this folder under our current login.
$ial.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
'BUILTIN\Users', 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
$ial.SetOwner((New-Object System.Security.Principal.NTAccount('BUILTIN\Administrators')))
Set-Acl -Path $FakeInstall -AclObject $ial
# Copy smoke test twice: one renamed to an allow-listed basename (positive
# case), one keeping its real name (negative case: AuthRejected).
Copy-Item $smokeExe $renamedCaller -Force
Copy-Item $smokeExe $badCaller -Force
Write-Host " Copied:"
Write-Host " $renamedCaller (allow-listed basename, should pass auth)"
Write-Host " $badCaller (real name, should be rejected)"
# Point the service at the fake install folder via the same HKLM key the
# production MSI writes. Without this the service reads InstallFolder=""
# and rejects every caller.
$hklmKey = 'HKLM:\SOFTWARE\Classes\PowerToys'
if (-not (Test-Path $hklmKey)) { New-Item -Path $hklmKey -Force | Out-Null }
Set-ItemProperty -Path $hklmKey -Name 'InstallFolder' -Value $FakeInstall -Type String
Write-Host " HKLM\SOFTWARE\Classes\PowerToys\InstallFolder = $FakeInstall"
# -------------------------------------------------------------------
# 5) Start the service.
# -------------------------------------------------------------------
Write-Host "`n[5/5] Starting service ..."
sc.exe start $SvcName | Out-Null
Start-Sleep -Seconds 2
$svc = Get-Service -Name $SvcName
Write-Host " Status: $($svc.Status)"
if ($svc.Status -eq 'Running')
{
$proc = Get-CimInstance Win32_Process -Filter "Name = 'PowerToys.PTSettingsSvc.exe'" -ErrorAction SilentlyContinue
if ($proc)
{
$owner = Invoke-CimMethod -InputObject $proc -MethodName GetOwner
Write-Host " Running as: $($owner.Domain)\$($owner.User) (PID $($proc.ProcessId))"
}
}
else
{
Write-Warning "Service is not Running. sc.exe query output:"
sc.exe query $SvcName
}
Write-Host "`n=== Setup complete ===" -ForegroundColor Green
Write-Host "Pipe: \\.\pipe\$SvcName"
Write-Host "DataRoot: $dataRoot"
Write-Host "InstallFld: $FakeInstall"
Write-Host ""
Write-Host "Next: run verify-prototype.ps1 (does not need elevation)."

View File

@@ -0,0 +1,284 @@
# verify-prototype.ps1
#
# Exercises the PTSettingsSvc prototype end-to-end.
# Run AFTER setup-ptsettingssvc.ps1. Does NOT need elevation.
#
# Coverage:
# 1. Liveness (Ping)
# 2. Caller-allow-list — bad-basename caller is rejected
# 3. Path-prefix — caller outside install folder rejected
# 4. Install-folder DACL hardness — temporarily relax DACL, expect rejection
# 5. Round-trip — PutBlob a payload, GetBlob it back
# 6. GetBlob NotFound — fresh user/namespace returns NotFound
# 7. Per-user folder DACL — only this user can read; admin can; others cannot
# 8. Owner is a non-user principal — store nodes owned by SYSTEM/Admin/service, never the user
# 9. Non-elevated write+delete rejected — Medium-IL user token cannot tamper or delete the blob
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..\..')).Path,
[string]$FakeInstall = (Join-Path $env:TEMP 'PTFakeInstall'),
[string]$DataRoot = 'C:\ProgramData\Microsoft\PowerToys\Settings'
)
$ErrorActionPreference = 'Continue'
$smokeExe = Join-Path $RepoRoot 'x64\Debug\WorkspacesSvcSmokeTest\PowerToys.PTSettingsSvcSmokeTest.exe'
$renamedCaller = Join-Path $FakeInstall 'PowerToys.WorkspacesEditor.exe'
$badCaller = Join-Path $FakeInstall 'PowerToys.PTSettingsSvcSmokeTest.exe'
$tmpPayload = Join-Path $env:TEMP 'pt-prototype-payload.bin'
$tmpReadBack = Join-Path $env:TEMP 'pt-prototype-readback.bin'
$pass = 0; $fail = 0
function Step([string]$name, [scriptblock]$body)
{
Write-Host ""
Write-Host "── $name ──" -ForegroundColor Cyan
try
{
$ok = & $body
if ($ok) { Write-Host " PASS" -ForegroundColor Green; $script:pass++ }
else { Write-Host " FAIL" -ForegroundColor Red; $script:fail++ }
}
catch
{
Write-Host " FAIL (exception): $_" -ForegroundColor Red
$script:fail++
}
}
function Run-Caller([string]$caller, [string[]]$callerArgs)
{
$out = & $caller @callerArgs 2>&1
[pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($out -join "`n") }
}
# Sanity: artefacts exist.
if (-not (Test-Path $smokeExe)) { throw "Smoke test not built: $smokeExe" }
if (-not (Test-Path $renamedCaller)) { throw "$renamedCaller missing - run setup-ptsettingssvc.ps1 first" }
if (-not (Test-Path $badCaller)) { throw "$badCaller missing - run setup-ptsettingssvc.ps1 first" }
Write-Host "==============================================" -ForegroundColor Yellow
Write-Host " PTSettingsSvc prototype verification" -ForegroundColor Yellow
Write-Host "==============================================" -ForegroundColor Yellow
Write-Host " User : $env:USERDOMAIN\$env:USERNAME"
Write-Host " Pipe : \\.\pipe\PTSettingsSvc"
Write-Host " DataRoot : $DataRoot"
Write-Host " InstallFld: $FakeInstall"
# 1) Liveness ----------------------------------------------------------
Step "1. Ping (allow-listed caller, happy path)" {
$r = Run-Caller $renamedCaller @('ping')
Write-Host " output: $($r.Output)"
return ($r.ExitCode -eq 0 -and $r.Output -match 'Ping -> Ok')
}
# 2) Caller allow-list -------------------------------------------------
Step "2. Bad basename -> AuthRejected" {
$r = Run-Caller $badCaller @('ping')
Write-Host " output: $($r.Output)"
return ($r.ExitCode -ne 0 -and $r.Output -match 'AuthRejected')
}
# 3) Path-prefix -------------------------------------------------------
Step "3. Caller outside install folder -> AuthRejected" {
# Run the smoke test directly from its build folder — that path is
# NOT under InstallFolder so the path-prefix check should reject it
# (even though its basename also isn't allow-listed).
$r = Run-Caller $smokeExe @('ping')
Write-Host " output: $($r.Output)"
return ($r.ExitCode -ne 0 -and $r.Output -match 'AuthRejected')
}
# 4) Install-folder DACL hardness check -------------------------------
Step "4. User-write ACE on install folder -> AuthRejected" {
# This step needs elevation because we have to add an ACL ourselves.
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$isAdmin = (New-Object Security.Principal.WindowsPrincipal $identity).IsInRole(
[Security.Principal.WindowsBuiltinRole]::Administrator)
if (-not $isAdmin)
{
Write-Host " SKIPPED (needs elevation; re-run this script from an admin shell to exercise)"
return $true
}
# Snapshot original DACL.
$original = Get-Acl $FakeInstall
try
{
$acl = Get-Acl $FakeInstall
$ace = New-Object System.Security.AccessControl.FileSystemAccessRule(
"$env:USERDOMAIN\$env:USERNAME", 'Modify',
'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($ace)
Set-Acl $FakeInstall $acl
$r = Run-Caller $renamedCaller @('ping')
Write-Host " output (with user-write ACE present): $($r.Output)"
$rejected = ($r.ExitCode -ne 0 -and $r.Output -match 'AuthRejected')
# Restore.
Set-Acl $FakeInstall $original
$r2 = Run-Caller $renamedCaller @('ping')
Write-Host " output (DACL restored): $($r2.Output)"
$restoredOk = ($r2.ExitCode -eq 0 -and $r2.Output -match 'Ping -> Ok')
return ($rejected -and $restoredOk)
}
catch
{
Set-Acl $FakeInstall $original
throw
}
}
# 5) Round-trip --------------------------------------------------------
Step "5. PutBlob then GetBlob round-trip" {
$payload = '{"$schemaVersion":1,"workspaces":[{"id":"abc","name":"test-' + (Get-Date -Format o) + '"}]}'
[System.IO.File]::WriteAllText($tmpPayload, $payload, [System.Text.UTF8Encoding]::new($false))
$put = Run-Caller $renamedCaller @('put', $tmpPayload)
Write-Host " put: $($put.Output)"
if ($put.ExitCode -ne 0 -or $put.Output -notmatch 'Ok') { return $false }
if (Test-Path $tmpReadBack) { Remove-Item $tmpReadBack -Force }
$get = Run-Caller $renamedCaller @('get', $tmpReadBack)
Write-Host " get: $($get.Output)"
if ($get.ExitCode -ne 0) { return $false }
$readBack = [System.IO.File]::ReadAllText($tmpReadBack)
return ($readBack -eq $payload)
}
# 6) GetBlob NotFound on fresh namespace -------------------------------
Step "6. GetBlob NotFound semantics (delete blob, expect NotFound)" {
$blobPath = Join-Path (Join-Path (Join-Path $DataRoot ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) 'Workspaces') 'workspaces.json'
if (Test-Path $blobPath)
{
# Need elevation to delete - service owns the dir.
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$isAdmin = (New-Object Security.Principal.WindowsPrincipal $identity).IsInRole(
[Security.Principal.WindowsBuiltinRole]::Administrator)
if (-not $isAdmin)
{
Write-Host " SKIPPED (needs elevation to clear the blob; the blob exists from step 5)"
return $true
}
Remove-Item $blobPath -Force
}
$get = Run-Caller $renamedCaller @('get')
Write-Host " get: $($get.Output)"
return ($get.Output -match 'NotFound')
}
# 7) Per-user folder DACL ---------------------------------------------
Step "7. Per-user folder DACL (svc:F, admin:F, current-user:RX, others denied)" {
# First PutBlob so the user folder exists.
$payload = 'hello'
[System.IO.File]::WriteAllText($tmpPayload, $payload, [System.Text.UTF8Encoding]::new($false))
Run-Caller $renamedCaller @('put', $tmpPayload) | Out-Null
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$userDir = Join-Path $DataRoot $userSid
if (-not (Test-Path $userDir))
{
Write-Host " user folder not created: $userDir"
return $false
}
$acl = Get-Acl $userDir
Write-Host " DACL of $userDir :"
$acl.Access | ForEach-Object {
Write-Host (" {0,-45} {1,-20} {2}" -f $_.IdentityReference, $_.FileSystemRights, $_.AccessControlType)
}
$svcOk = $acl.Access | Where-Object {
$_.IdentityReference.Value -like '*PTSettingsSvc*' -and
$_.AccessControlType -eq 'Allow' -and
$_.FileSystemRights -match 'FullControl'
} | Select-Object -First 1
$admOk = $acl.Access | Where-Object {
$_.IdentityReference.Value -like '*Administrators*' -and
$_.AccessControlType -eq 'Allow' -and
$_.FileSystemRights -match 'FullControl'
} | Select-Object -First 1
$userOk = $acl.Access | Where-Object {
($_.IdentityReference.Value -eq "$env:USERDOMAIN\$env:USERNAME" -or
$_.IdentityReference.Value -like "*$userSid*") -and
$_.AccessControlType -eq 'Allow' -and
($_.FileSystemRights -match 'Read' -or $_.FileSystemRights -match 'Execute')
} | Select-Object -First 1
$noWild = -not ($acl.Access | Where-Object {
$_.IdentityReference.Value -like '*Authenticated Users*' -or
$_.IdentityReference.Value -like '*Everyone*'
})
$protectedOk = -not $acl.AreAccessRulesProtected -eq $false
Write-Host " svc:F=$([bool]$svcOk) admin:F=$([bool]$admOk) user:R*=$([bool]$userOk) no-blanket-AuthUsers=$noWild PROTECTED=$($acl.AreAccessRulesProtected)"
return ([bool]$svcOk -and [bool]$admOk -and [bool]$userOk -and $noWild -and $acl.AreAccessRulesProtected)
}
# 8) Owner is a non-user trusted principal ----------------------------
Step "8. Owner of store nodes is a non-user principal (SYSTEM/Admin/service)" {
# Ensure the user folder + blob exist.
[System.IO.File]::WriteAllText($tmpPayload, 'hello', [System.Text.UTF8Encoding]::new($false))
Run-Caller $renamedCaller @('put', $tmpPayload) | Out-Null
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$userDir = Join-Path $DataRoot $userSid
$blob = Join-Path (Join-Path $userDir 'Workspaces') 'workspaces.json'
$me = "$env:USERDOMAIN\$env:USERNAME"
$trusted = @('NT AUTHORITY\SYSTEM', 'BUILTIN\Administrators', 'NT SERVICE\PTSettingsSvc')
$allOk = $true
foreach ($p in @($DataRoot, $userDir, $blob))
{
if (-not (Test-Path $p)) { continue }
$owner = (Get-Acl $p).Owner
$ok = ($owner -ne $me) -and ($trusted -contains $owner)
Write-Host (" {0,-70} owner={1} {2}" -f (Split-Path $p -Leaf), $owner, ($ok ? 'OK' : 'BAD'))
if (-not $ok) { $allOk = $false }
}
return $allOk
}
# 9) Non-elevated write + delete are both rejected --------------------
Step "9. Medium-IL user token cannot write or delete the blob" {
$safer = Join-Path $PSScriptRoot 'SaferModify.exe'
$saferSrc = Join-Path $PSScriptRoot 'SaferModify.cs'
if (-not (Test-Path $safer) -and (Test-Path $saferSrc))
{
# Build the helper from source so the suite is self-contained.
$csc = Get-ChildItem 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
if ($csc) { & $csc.FullName /nologo /out:$safer $saferSrc 2>&1 | Out-Null }
}
if (-not (Test-Path $safer))
{
Write-Host " SKIPPED (SaferModify.exe/.cs not present in $PSScriptRoot)"
return $true
}
# Ensure the blob exists to target.
[System.IO.File]::WriteAllText($tmpPayload, 'hello', [System.Text.UTF8Encoding]::new($false))
Run-Caller $renamedCaller @('put', $tmpPayload) | Out-Null
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$blob = Join-Path (Join-Path (Join-Path $DataRoot $userSid) 'Workspaces') 'workspaces.json'
$out = (& $safer $blob 2>&1) -join "`n"
Write-Host ($out -split "`n" | ForEach-Object { " $_" }) -Separator "`n"
$writeRej = $out -match 'WRITE\s*:\s*rejected'
$deleteRej = $out -match 'DELETE\s*:\s*rejected'
$intact = Test-Path $blob
return ($writeRej -and $deleteRej -and $intact)
}
Write-Host ""
Write-Host "==============================================" -ForegroundColor Yellow
Write-Host " Result: $pass passed, $fail failed" -ForegroundColor (@('Green','Red')[[int]($fail -gt 0)])
Write-Host "==============================================" -ForegroundColor Yellow
if ($fail -gt 0) { exit 1 } else { exit 0 }

View File

@@ -0,0 +1,5 @@
*.pfx
*.cer
*.msix
logo.png
*.ps1

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
MSIX package for the PowerToys Settings Service (Design-v6-Final.md §12.1).
The service binary lives in the immutable, signed WindowsApps store so a
non-admin same-user attacker cannot replace it. The windows.service
extension auto-registers PTSettingsSvc; it runs as LocalSystem (the only
start account MSIX allows that can own/protect the per-SID store DACL).
Data is written to real %ProgramData% by the service, not virtualized.
Publisher must match the signing certificate subject. For production this
is the Microsoft cert; for local validation the dev cert subject is used.
-->
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6">
<Identity Name="Microsoft.PowerToys.SettingsService"
Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
Version="0.0.1.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>PowerToys Settings Service</DisplayName>
<PublisherDisplayName>Microsoft Corporation</PublisherDisplayName>
<Logo>logo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="packagedServices" />
<rescap:Capability Name="localSystemServices" />
</Capabilities>
<Applications>
<Application Id="PTSettingsSvc"
Executable="PowerToys.PTSettingsSvc.exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="PowerToys Settings Service"
Description="Sole writer of the protected PowerToys settings store (EoP defense)."
BackgroundColor="transparent" Square150x150Logo="logo.png" Square44x44Logo="logo.png"
AppListEntry="none" />
<Extensions>
<desktop6:Extension Category="windows.service"
Executable="PowerToys.PTSettingsSvc.exe"
EntryPoint="Windows.FullTrustApplication">
<desktop6:Service Name="PTSettingsSvc"
StartupType="auto"
StartAccount="localSystem" />
</desktop6:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View File

@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
//
// Shared wire protocol between PTSettingsSvc and its clients.
//
// Wire format (little-endian, no padding):
//
// REQUEST := opcode(uint8) | length(uint32) | payload[length]
// RESPONSE := status(uint8) | length(uint32) | payload[length]
//
// One request per connection. After the response is written the server
// disconnects. Keep this surface as small as possible — every additional
// opcode is a new attack surface on a privileged endpoint.
//
// The service treats `payload` as opaque bytes. It does not parse them,
// does not validate their shape, does not interpret a "schema version"
// inside them. Module-specific concerns (JSON shape, schema versioning,
// migration from legacy on-disk layouts, sensitive-field stripping) all
// live in the caller — see Design-v6-Final.md §4 and §10.
#pragma once
#include <cstdint>
namespace PTSettingsSvc
{
// Wire constants ---------------------------------------------------------
constexpr const wchar_t* kPipeName = L"\\\\.\\pipe\\PTSettingsSvc";
constexpr const wchar_t* kServiceName = L"PTSettingsSvc";
// Payload size guard rails. A typical settings blob sits in the low
// tens of KB. 1 MiB is generous and bounds memory the service has to
// allocate per request.
constexpr uint32_t kMaxPayloadBytes = 1u * 1024u * 1024u;
enum class Opcode : uint8_t
{
Ping = 0x00, // No payload. Authn still runs. Used by liveness checks.
GetBlob = 0x01, // No payload. Returns the caller's namespace blob bytes.
PutBlob = 0x02, // payload = full blob bytes. Atomic replace.
};
enum class Status : uint8_t
{
Ok = 0x00,
// Framing / dispatch errors.
BadRequest = 0x01,
UnknownOpcode = 0x02,
PayloadTooLarge = 0x03,
// Authentication outcomes.
AuthFailToken = 0x10, // Caller token is synthetic (SYSTEM / SERVICE / etc.)
// or the SID couldn't be read.
AuthFailCaller = 0x11, // Caller exe failed path / DACL-hardness /
// basename allow-list.
NamespaceUnknown = 0x12, // Caller authenticated but is not in the
// binding table (should never happen for
// well-formed clients).
// Storage outcomes.
NotFound = 0x20, // GetBlob: blob does not exist yet.
IoError = 0x21, // Underlying file IO failed.
};
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
//
// Console smoke test for PTSettingsSvc.
//
// Usage:
// PowerToys.PTSettingsSvcSmokeTest.exe ping
// PowerToys.PTSettingsSvcSmokeTest.exe get [<output-file>]
// PowerToys.PTSettingsSvcSmokeTest.exe put <input-file>
//
// Pair with `PowerToys.PTSettingsSvc.exe --console` in another terminal
// when iterating without installing & registering the service.
//
// NB: this exe is NOT in the caller-binding allow-list, so the service
// will return AuthRejected unless one of the following holds:
// * you copy/rename this exe to one of the allow-listed basenames
// (e.g. PowerToys.WorkspacesEditor.exe) under the PT install folder
// pointed to by HKLM\SOFTWARE\Classes\PowerToys\InstallFolder
// (or by the PT_DEV_INSTALL_FOLDER env var in dev builds), AND
// * that folder's DACL is admin-only writable (per Design-v6-Final.md §8).
//
// The verify-prototype.ps1 script automates both prerequisites.
#include "../../WorkspacesSettingsClient/PTSettingsClient.h"
#include <windows.h>
#include <cstdio>
#include <string>
#include <fstream>
#include <vector>
namespace
{
std::vector<uint8_t> ReadAllBytes(const char* path)
{
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return {};
std::streamsize size = f.tellg();
if (size <= 0)
{
return {};
}
std::vector<uint8_t> buf(static_cast<size_t>(size));
f.seekg(0, std::ios::beg);
f.read(reinterpret_cast<char*>(buf.data()), size);
return buf;
}
bool WriteAllBytes(const char* path, const std::vector<uint8_t>& bytes)
{
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
if (!bytes.empty())
{
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
}
return static_cast<bool>(f);
}
const char* Name(PTSettingsClient::Result r)
{
switch (r)
{
case PTSettingsClient::Result::Ok: return "Ok";
case PTSettingsClient::Result::ServiceUnavailable: return "ServiceUnavailable";
case PTSettingsClient::Result::AuthRejected: return "AuthRejected";
case PTSettingsClient::Result::NamespaceUnknown: return "NamespaceUnknown";
case PTSettingsClient::Result::NotFound: return "NotFound";
case PTSettingsClient::Result::ProtocolError: return "ProtocolError";
case PTSettingsClient::Result::PayloadTooLarge: return "PayloadTooLarge";
case PTSettingsClient::Result::IoError: return "IoError";
case PTSettingsClient::Result::UnknownStatus: return "UnknownStatus";
}
return "?";
}
}
int main(int argc, char* argv[])
{
if (argc < 2)
{
std::printf("usage: %s ping | get [<output-file>] | put <input-file>\n", argv[0]);
return 2;
}
std::string cmd = argv[1];
if (cmd == "ping")
{
auto rc = PTSettingsClient::Ping();
std::printf("Ping -> %s\n", Name(rc));
return rc == PTSettingsClient::Result::Ok ? 0 : 1;
}
if (cmd == "get")
{
std::vector<uint8_t> bytes;
auto rc = PTSettingsClient::GetBlob(bytes);
std::printf("GetBlob -> %s, %zu bytes\n", Name(rc), bytes.size());
if (rc == PTSettingsClient::Result::Ok)
{
if (argc >= 3)
{
bool ok = WriteAllBytes(argv[2], bytes);
std::printf(" wrote %zu bytes to %s%s\n",
bytes.size(), argv[2], ok ? "" : " (FAILED)");
if (!ok) return 1;
}
else if (!bytes.empty())
{
std::fwrite(bytes.data(), 1, bytes.size(), stdout);
std::printf("\n");
}
}
return rc == PTSettingsClient::Result::Ok ||
rc == PTSettingsClient::Result::NotFound ? 0 : 1;
}
if (cmd == "put" && argc >= 3)
{
auto bytes = ReadAllBytes(argv[2]);
if (bytes.empty())
{
std::fprintf(stderr, "input file empty or unreadable: %s\n", argv[2]);
return 2;
}
auto rc = PTSettingsClient::PutBlob(bytes);
std::printf("PutBlob (%zu bytes) -> %s\n", bytes.size(), Name(rc));
return rc == PTSettingsClient::Result::Ok ? 0 : 1;
}
std::fprintf(stderr, "unknown / incomplete command: %s\n", argv[1]);
return 2;
}

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{8B6A7C32-5C8D-4AD1-9F60-7E1B3D17A221}</ProjectGuid>
<RootNamespace>WorkspacesSvcSmokeTest</RootNamespace>
<WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion>
<ProjectName>WorkspacesSvcSmokeTest</ProjectName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<PropertyGroup>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
<TargetName>PowerToys.PTSettingsSvcSmokeTest</TargetName>
<!-- Manual CLI driver, not an automated VSTest container. Opt out of the
RunVSTest SDK so the CI "/t:Test" pass does not try to execute it. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="SmokeTest.cpp" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\WorkspacesSettingsClient\WorkspacesSettingsClient.vcxproj">
<Project>{D24E2C12-9911-4E51-B102-39E7B62B22F1}</Project>
</ProjectReference>
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

View File

@@ -64,7 +64,7 @@ int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE hInstPrev, LPSTR cmdline, int cm
if (projectToLaunch.id.empty())
{
auto file = WorkspacesData::WorkspacesFile();
auto res = JsonUtils::ReadWorkspaces(file);
auto res = JsonUtils::ReadWorkspacesFromService();
if (res.isOk())
{
workspaces = res.getValue();

View File

@@ -5465,18 +5465,15 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
break;
}
else if( newRecordToggleKey ) {
UINT cropMod = newRecordToggleMod ^ MOD_SHIFT;
UINT windowMod = newRecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) {
else if( UINT cropMod = newRecordToggleMod ^ MOD_SHIFT, windowMod = newRecordToggleMod ^ MOD_ALT; newRecordToggleKey &&
(!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)))) {
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
} else {
g_BreakTimeout = newTimeout;

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Common.Services;
using static PowerDisplay.Common.Services.LinkedBrightnessPlanner;
namespace PowerDisplay.UnitTests;
/// <summary>
/// Behavior tests for pure linked-brightness decision logic. These cover review-flagged seed
/// cases without needing a WinUI DispatcherQueue.
/// </summary>
[TestClass]
public class LinkedBrightnessPlannerTests
{
private static LinkTarget Monitor(
string id,
int number,
int brightness)
=> new LinkTarget(id, number, brightness);
[TestMethod]
public void Seed_EmptyList_Null()
{
Assert.IsNull(LinkedBrightnessPlanner.Seed(new List<LinkTarget>()));
}
[TestMethod]
public void Seed_PrefersLowestDisplayNumber_RegardlessOfListOrder()
{
// Enumeration order is deliberately reversed; the seed must still come from Display 1.
var monitors = new[]
{
Monitor("c", 3, 90),
Monitor("a", 1, 30),
Monitor("b", 2, 60),
};
Assert.AreEqual(30, LinkedBrightnessPlanner.Seed(monitors));
}
[TestMethod]
public void Seed_UnknownDisplayNumbers_FallBackToIdOrder()
{
// MonitorNumber 0 means "unknown"; those sort last and tie-break by Id for determinism.
var monitors = new[]
{
Monitor("z", 0, 90),
Monitor("m", 0, 45),
};
Assert.AreEqual(45, LinkedBrightnessPlanner.Seed(monitors));
}
[TestMethod]
public void Seed_SingleControllableDisplay_UsesItsBrightness()
{
var monitors = new[] { Monitor("only", 1, 64) };
Assert.AreEqual(64, LinkedBrightnessPlanner.Seed(monitors));
}
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerDisplay.UnitTests;
/// <summary>
/// Covers the persisted shape of the linked-brightness feature on
/// <see cref="PowerDisplayProperties"/>: defaults, JSON property names, and — most importantly —
/// that settings written before the feature existed deserialize to safe defaults without any
/// migration step (the forward-compatibility promise made to the module owner).
/// </summary>
[TestClass]
public class LinkedBrightnessSettingsTests
{
[TestMethod]
public void Defaults_LinkDisabled_AndExclusionListEmptyButNotNull()
{
var properties = new PowerDisplayProperties();
Assert.IsFalse(properties.LinkedLevelsActive, "Linked brightness must default to off.");
Assert.IsNotNull(properties.ExcludedFromSyncMonitorIds, "Exclusion list must never be null.");
Assert.AreEqual(0, properties.ExcludedFromSyncMonitorIds.Count, "Exclusion list must start empty.");
}
[TestMethod]
public void Deserialize_LegacyJsonMissingLinkFields_UsesDefaultsWithoutMigration()
{
// A settings.json captured before the linked-brightness feature shipped: it has none of
// the new keys. Deserializing must fall back to the constructor defaults rather than
// produce nulls or throw — this is the "no migration needed" guarantee.
const string legacyJson = """
{
"monitor_refresh_delay": 5,
"restore_settings_on_startup": false,
"show_system_tray_icon": true
}
""";
var properties = JsonSerializer.Deserialize<PowerDisplayProperties>(legacyJson);
Assert.IsNotNull(properties);
Assert.IsFalse(properties.LinkedLevelsActive);
Assert.IsNotNull(properties.ExcludedFromSyncMonitorIds);
Assert.AreEqual(0, properties.ExcludedFromSyncMonitorIds.Count);
}
[TestMethod]
public void RoundTrip_PreservesLinkStateAndExclusionList()
{
var original = new PowerDisplayProperties
{
LinkedLevelsActive = true,
};
original.ExcludedFromSyncMonitorIds.Add(@"\\?\DISPLAY#DELD1A8#5&abc&0&UID4357");
original.ExcludedFromSyncMonitorIds.Add(@"\\?\DISPLAY#DELD1A8#5&abc&0&UID4358");
var json = JsonSerializer.Serialize(original);
var restored = JsonSerializer.Deserialize<PowerDisplayProperties>(json);
Assert.IsNotNull(restored);
Assert.IsTrue(restored.LinkedLevelsActive);
CollectionAssert.AreEqual(original.ExcludedFromSyncMonitorIds, restored.ExcludedFromSyncMonitorIds);
}
[TestMethod]
public void Serialize_UsesSnakeCaseJsonKeys()
{
var properties = new PowerDisplayProperties { LinkedLevelsActive = true };
properties.ExcludedFromSyncMonitorIds.Add("monitor-id");
var json = JsonSerializer.Serialize(properties);
StringAssert.Contains(json, "\"linked_levels_active\":true");
StringAssert.Contains(json, "\"excluded_from_sync_monitor_ids\"");
}
[TestMethod]
public void ExclusionList_DistinguishesIdenticalModelMonitorsByDevicePath()
{
// Two physically identical monitors share an EdidId (DELD1A8) but differ in the PnP UID
// segment of Monitor.Id. Keying the exclusion set by Monitor.Id keeps them distinct, which
// is the whole reason the issue's "three identical monitors" scenario works.
var properties = new PowerDisplayProperties();
properties.ExcludedFromSyncMonitorIds.Add(@"\\?\DISPLAY#DELD1A8#5&abc&0&UID4357");
Assert.IsTrue(properties.ExcludedFromSyncMonitorIds.Contains(@"\\?\DISPLAY#DELD1A8#5&abc&0&UID4357"));
Assert.IsFalse(
properties.ExcludedFromSyncMonitorIds.Contains(@"\\?\DISPLAY#DELD1A8#5&abc&0&UID4358"),
"A different physical port (UID) must not be treated as excluded.");
}
}

View File

@@ -483,8 +483,15 @@ namespace PowerDisplay.Common.Drivers.DDC
{
if (TryGetVcpFeature(handle, VcpCodeBrightness, monitor.Id, out uint current, out uint max))
{
monitor.BrightnessVcpMax = (int)max;
var brightnessInfo = new VcpFeatureValue((int)current, 0, (int)max);
if (!brightnessInfo.IsValid)
{
Logger.LogWarning(
$"DDC: [{monitor.Id}] Ignoring invalid brightness range current={current}, max={max}");
return;
}
monitor.BrightnessVcpMax = (int)max;
monitor.CurrentBrightness = brightnessInfo.ToPercentage();
}
}

View File

@@ -130,7 +130,7 @@ namespace PowerDisplay.Common.Drivers.DDC
{
Id = monitorId,
Name = name.Trim(),
CurrentBrightness = 50, // Default value, will be updated by MonitorManager after discovery
CurrentBrightness = 50, // Initial placeholder; overwritten if the VCP read succeeds
Handle = physicalMonitor.HPhysicalMonitor,
Capabilities = MonitorCapabilities.DdcCi,
CommunicationMethod = "DDC/CI",

View File

@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Linq;
namespace PowerDisplay.Common.Services
{
/// <summary>
/// Pure decision logic for linked brightness, factored out of the WinUI ViewModel so initial
/// seed behavior can be unit-tested without a DispatcherQueue.
/// </summary>
public static class LinkedBrightnessPlanner
{
/// <summary>
/// A monitor's state relevant to linked-brightness planning.
/// </summary>
/// <param name="Id">Stable <c>Monitor.Id</c> (DevicePath form).</param>
/// <param name="MonitorNumber">Windows DISPLAY number (1-based); 0 when unknown.</param>
/// <param name="Brightness">Current brightness percentage (0-100).</param>
public readonly record struct LinkTarget(
string Id,
int MonitorNumber,
int Brightness);
/// <summary>
/// The value to seed the master slider with when link mode turns on, or null when there is
/// no linked target. The caller passes only included brightness-capable targets. Prefers
/// the lowest Windows DISPLAY number and Id order for determinism when numbers are missing
/// or tie.
/// </summary>
public static int? Seed(IEnumerable<LinkTarget> linkedTargets) =>
linkedTargets
.OrderBy(m => m.MonitorNumber <= 0 ? int.MaxValue : m.MonitorNumber)
.ThenBy(m => m.Id, System.StringComparer.Ordinal)
.Select(m => (int?)m.Brightness)
.FirstOrDefault();
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.UI.Dispatching;
using PowerDisplay.Configuration;
namespace PowerDisplay.Helpers;
internal static class SliderCommitScheduler
{
internal static void Schedule(ref DispatcherQueueTimer? timer, DispatcherQueue dispatcherQueue, Func<Task> commit)
{
if (timer == null)
{
timer = dispatcherQueue.CreateTimer();
timer.IsRepeating = false;
timer.Interval = TimeSpan.FromMilliseconds(AppConstants.UI.SliderCommitDebounceMs);
timer.Tick += (_, _) => _ = commit();
}
timer.Stop();
timer.Start();
}
}

View File

@@ -1,4 +1,4 @@
<winuiex:WindowEx
<winuiex:WindowEx
x:Class="PowerDisplay.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
@@ -29,14 +29,16 @@
<x:Double x:Key="FlyoutSeparatorHeight">1</x:Double>
<GridLength x:Key="FlyoutSliderIconColumnWidth">20</GridLength>
<x:Double x:Key="FlyoutActionGlyphFontSize">16</x:Double>
<x:Double x:Key="FlyoutLinkGlyphFontSize">16</x:Double>
<x:Double x:Key="FlyoutActionGlyphSmallFontSize">14</x:Double>
<x:Double x:Key="FlyoutActionGlyphExtraSmallFontSize">12</x:Double>
<x:Double x:Key="FlyoutSecondaryTextFontSize">12</x:Double>
<x:Double x:Key="FlyoutRotationGlyphFontSize">14</x:Double>
<x:Double x:Key="FlyoutButtonSize">32</x:Double>
<x:Double x:Key="FlyoutSliderRowHeight">32</x:Double>
<x:Double x:Key="FlyoutCompactSpacing">4</x:Double>
<x:Double x:Key="FlyoutStandardSpacing">8</x:Double>
<x:Double x:Key="FlyoutMonitorSectionSpacing">32</x:Double>
<x:Double x:Key="FlyoutMonitorSectionSpacing">16</x:Double>
<x:Double x:Key="FlyoutScanningIndicatorSize">24</x:Double>
<x:Double x:Key="FlyoutScanningSpacing">16</x:Double>
<Thickness x:Key="FlyoutButtonPadding">6</Thickness>
@@ -45,11 +47,11 @@
<Thickness x:Key="FlyoutListHeaderMargin">16,0,8,0</Thickness>
<Thickness x:Key="FlyoutListItemPadding">0,4</Thickness>
<Thickness x:Key="FlyoutSelectionGlyphMargin">8,0,0,0</Thickness>
<Thickness x:Key="FlyoutSectionTopMargin">0,4,0,0</Thickness>
<Thickness x:Key="FlyoutSectionTopMargin">0,8,0,0</Thickness>
<Thickness x:Key="FlyoutSectionContainerPadding">8,0,8,8</Thickness>
<Thickness x:Key="FlyoutSeparatorMargin">8,8</Thickness>
<Thickness x:Key="FlyoutStatusBarMargin">12,-8,12,0</Thickness>
<Thickness x:Key="MonitorHeaderOffsetMargin">10,0,0,0</Thickness>
<Thickness x:Key="MonitorHeaderOffsetMargin">0,8,0,0</Thickness>
<Thickness x:Key="MonitorNameBaselineMargin">2,0,0,2</Thickness>
<x:Double x:Key="FlyoutContextMenuMinWidth">240</x:Double>
<x:Double x:Key="FlyoutContextMenuMaxWidth">320</x:Double>
@@ -62,6 +64,57 @@
<Setter Property="Height" Value="{StaticResource FlyoutButtonSize}" />
<Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" />
</Style>
<Style x:Key="FlyoutIconToggleButtonStyle" TargetType="ToggleButton">
<Setter Property="Padding" Value="{StaticResource FlyoutButtonPadding}" />
<Setter Property="Width" Value="{StaticResource FlyoutButtonSize}" />
<Setter Property="Height" Value="{StaticResource FlyoutButtonSize}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FlyoutHeaderToggleButtonStyle" TargetType="ToggleButton">
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Grid.Resources>
<Border x:Name="MainContainer">
<Grid>
@@ -114,190 +167,131 @@
HorizontalScrollMode="Disabled"
IsTabStop="False"
VerticalScrollBarVisibility="Auto">
<!-- Monitors List with modern card design -->
<ItemsRepeater
x:Name="MonitorsRepeater"
ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"
TabFocusNavigation="Local"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Vertical" Spacing="{StaticResource FlyoutMonitorSectionSpacing}" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:MonitorViewModel">
<StackPanel Spacing="{StaticResource FlyoutCompactSpacing}" TabFocusNavigation="Local">
<!-- Monitor Name with Icon -->
<Grid
Margin="{StaticResource MonitorHeaderOffsetMargin}"
HorizontalAlignment="Stretch"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<local:MonitorIcon
VerticalAlignment="Center"
IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}"
MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" />
<!-- Linked-brightness + monitors list -->
<StackPanel Spacing="{StaticResource FlyoutStandardSpacing}" Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}">
<!-- All displays card (visible only when link mode is on) -->
<StackPanel
Padding="{StaticResource FlyoutSectionContainerPadding}"
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}"
Spacing="{StaticResource FlyoutCompactSpacing}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.LinkedLevelsActive), Mode=OneWay}">
<Grid Margin="{StaticResource FlyoutSectionTopMargin}" ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Viewbox
Width="16"
Height="16"
VerticalAlignment="Center">
<FontIcon
FontSize="22"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
Glyph="&#xE975;" />
</Viewbox>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock
x:Uid="AllDisplaysTitle"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<StackPanel Orientation="Horizontal" Spacing="{StaticResource FlyoutCompactSpacing}">
<TextBlock
Grid.Column="1"
Margin="{StaticResource MonitorNameBaselineMargin}"
VerticalAlignment="Center"
Text="{x:Bind DisplayName, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
ToolTipService.ToolTip="{x:Bind DisplayName, Mode=OneWay}" />
<!-- Icon buttons for InputSource and ColorTemperature -->
<StackPanel
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="{StaticResource FlyoutCompactSpacing}">
<!-- Color Temperature Button -->
<Button
x:Uid="ColorTemperatureTooltip"
Content="{ui:FontIcon Glyph=&#xE790;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowColorTemperature), Mode=OneWay}">
<Button.Flyout>
<Flyout Opened="Flyout_Opened" ShouldConstrainToRootBounds="False">
<ListView
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
SelectionChanged="{x:Bind HandleColorTemperatureSelectionChanged}"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="ColorTemperatureHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:ColorTemperatureItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind DisplayName}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
<!-- More Button (Input Source + Power State) -->
<Button
x:Uid="MoreOptionsTooltip"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
Opened="Flyout_Opened"
Placement="BottomEdgeAlignedRight"
ShouldConstrainToRootBounds="False">
<StackPanel MinWidth="{StaticResource FlyoutContextMenuMinWidth}" MaxWidth="{StaticResource FlyoutContextMenuMaxWidth}">
<!-- Input Source Section -->
<ListView
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
SelectionChanged="{x:Bind HandleInputSourceSelectionChanged}"
SelectionMode="Single"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
<ListView.Header>
<TextBlock
x:Uid="InputSourceHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:InputSourceItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- Separator between Input Source and Power State -->
<Border
Height="{StaticResource FlyoutSeparatorHeight}"
Margin="{StaticResource FlyoutSeparatorMargin}"
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
<!-- Power State Section -->
<ListView
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
SelectionChanged="{x:Bind HandlePowerStateSelectionChanged}"
SelectionMode="Single"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
<ListView.Header>
<TextBlock
x:Uid="PowerStateHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:PowerStateItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
</Grid>
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.LinkedMonitorsCountText, Mode=OneWay}" />
<TextBlock
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="•"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasExcludedMonitors), Mode=OneWay}" />
<TextBlock
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.ExcludedMonitorsCountText, Mode=OneWay}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasExcludedMonitors), Mode=OneWay}" />
</StackPanel>
</StackPanel>
</Grid>
<Grid
Height="{StaticResource FlyoutSliderRowHeight}"
Margin="{StaticResource FlyoutSectionTopMargin}"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
Glyph="&#xEC8A;" />
<Slider
x:Uid="AllDisplaysBrightnessAutomation"
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="5"
IsEnabled="{x:Bind ViewModel.IsLinkedBrightnessSliderEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
Minimum="0"
Value="{x:Bind ViewModel.LinkedBrightness, Mode=TwoWay}" />
</Grid>
</StackPanel>
<!-- Individual displays section header (visible only when link mode is on) -->
<ToggleButton
x:Name="IndividualDisplaysToggle"
x:Uid="IndividualDisplaysToggle"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
IsChecked="{x:Bind ViewModel.IndividualDisplaysExpanded, Mode=TwoWay}"
Style="{StaticResource FlyoutHeaderToggleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.LinkedLevelsActive), Mode=OneWay}">
<Grid HorizontalAlignment="Stretch" ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock x:Uid="IndividualDisplaysHeader" VerticalAlignment="Center" />
<FontIcon
Grid.Column="1"
FontSize="{StaticResource FlyoutActionGlyphExtraSmallFontSize}"
Glyph="&#xE70D;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIndividualDisplaysCollapsedIcon), Mode=OneWay}" />
<FontIcon
Grid.Column="1"
FontSize="{StaticResource FlyoutActionGlyphExtraSmallFontSize}"
Glyph="&#xE70E;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.IndividualDisplaysExpanded), Mode=OneWay}" />
<Border
Grid.Column="2"
Height="{StaticResource FlyoutSeparatorHeight}"
VerticalAlignment="Center"
Background="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</ToggleButton>
<!-- Monitors List with modern card design -->
<ItemsRepeater
x:Name="MonitorsRepeater"
ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"
TabFocusNavigation="Local"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIndividualDisplays), Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Vertical" Spacing="{StaticResource FlyoutMonitorSectionSpacing}" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:MonitorViewModel">
<StackPanel
Margin="{StaticResource FlyoutSectionTopMargin}"
Padding="{StaticResource FlyoutSectionContainerPadding}"
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
@@ -305,11 +299,216 @@
CornerRadius="{StaticResource OverlayCornerRadius}"
TabFocusNavigation="Local"
XYFocusKeyboardNavigation="Enabled">
<!-- Monitor Name with Icon -->
<Grid
Height="{StaticResource FlyoutButtonSize}"
Margin="{StaticResource MonitorHeaderOffsetMargin}"
HorizontalAlignment="Stretch"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<local:MonitorIcon
VerticalAlignment="Center"
IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}"
MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
Margin="{StaticResource MonitorNameBaselineMargin}"
VerticalAlignment="Center"
Text="{x:Bind DisplayName, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
ToolTipService.ToolTip="{x:Bind DisplayName, Mode=OneWay}" />
<!-- Icon buttons for InputSource and ColorTemperature -->
<StackPanel
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="{StaticResource FlyoutCompactSpacing}">
<!-- Exclude-from-linked-brightness toggle (only shown while link mode is on) -->
<ToggleButton
x:Uid="ExcludeFromSyncToggle"
IsChecked="{x:Bind IsExcludedFromSync, Mode=TwoWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Style="{StaticResource FlyoutIconToggleButtonStyle}"
ToolTipService.ToolTip="{x:Bind SyncToggleToolTip, Mode=OneWay}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowExcludeButton), Mode=OneWay}">
<Grid>
<FontIcon
FontSize="{StaticResource FlyoutLinkGlyphFontSize}"
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
Glyph="&#xE71B;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowIncludedInSyncIcon), Mode=OneWay}" />
<Grid Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsExcludedFromSync), Mode=OneWay}">
<FontIcon
FontSize="{StaticResource FlyoutLinkGlyphFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE71B;" />
<Border
Width="2"
Height="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{ThemeResource TextFillColorSecondaryBrush}"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<RotateTransform Angle="-45" />
</Border.RenderTransform>
</Border>
</Grid>
</Grid>
</ToggleButton>
<!-- Color Temperature Button -->
<Button
x:Uid="ColorTemperatureTooltip"
Content="{ui:FontIcon Glyph=&#xE790;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowColorTemperature), Mode=OneWay}">
<Button.Flyout>
<Flyout Opened="Flyout_Opened" ShouldConstrainToRootBounds="False">
<ListView
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
SelectionChanged="{x:Bind HandleColorTemperatureSelectionChanged}"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="ColorTemperatureHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:ColorTemperatureItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind DisplayName}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
<!-- More Button (Input Source + Power State) -->
<Button
x:Uid="MoreOptionsTooltip"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
Opened="Flyout_Opened"
Placement="BottomEdgeAlignedRight"
ShouldConstrainToRootBounds="False">
<StackPanel MinWidth="{StaticResource FlyoutContextMenuMinWidth}" MaxWidth="{StaticResource FlyoutContextMenuMaxWidth}">
<!-- Input Source Section -->
<ListView
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
SelectionChanged="{x:Bind HandleInputSourceSelectionChanged}"
SelectionMode="Single"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
<ListView.Header>
<TextBlock
x:Uid="InputSourceHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:InputSourceItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- Separator between Input Source and Power State -->
<Border
Height="{StaticResource FlyoutSeparatorHeight}"
Margin="{StaticResource FlyoutSeparatorMargin}"
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
<!-- Power State Section -->
<ListView
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
SelectionChanged="{x:Bind HandlePowerStateSelectionChanged}"
SelectionMode="Single"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
<ListView.Header>
<TextBlock
x:Uid="PowerStateHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:PowerStateItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
</Grid>
<!-- Brightness Control -->
<Grid
Height="{StaticResource FlyoutSliderRowHeight}"
Margin="{StaticResource FlyoutSectionTopMargin}"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowBrightness), Mode=OneWay}">
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowBrightnessSlider), Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
<ColumnDefinition Width="*" />
@@ -327,7 +526,7 @@
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="5"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsEnabled="{x:Bind IsBrightnessSliderEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
Minimum="0"
@@ -473,10 +672,10 @@
</ToggleButton>-->
</Grid>
</StackPanel>
</StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
@@ -490,49 +689,74 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
x:Name="ProfilesButton"
x:Uid="ProfilesTooltip"
Content="{ui:FontIcon Glyph=&#xE748;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
x:Name="ProfilesFlyout"
Opened="Flyout_Opened"
ShouldConstrainToRootBounds="False">
<ListView
x:Name="ProfilesListView"
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
SelectionChanged="ProfileListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="ProfilesHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:PowerDisplayProfile">
<StackPanel
HorizontalAlignment="Left"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="{StaticResource FlyoutStandardSpacing}">
<Button
x:Name="ProfilesButton"
x:Uid="ProfilesTooltip"
Content="{ui:FontIcon Glyph=&#xE748;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
x:Name="ProfilesFlyout"
Opened="Flyout_Opened"
ShouldConstrainToRootBounds="False">
<ListView
x:Name="ProfilesListView"
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
SelectionChanged="ProfileListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
Padding="{StaticResource FlyoutListItemPadding}"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
x:Uid="ProfilesHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:PowerDisplayProfile">
<TextBlock
Padding="{StaticResource FlyoutListItemPadding}"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
<StackPanel
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="{StaticResource FlyoutStandardSpacing}">
<ToggleButton
x:Name="LinkLevelsToggle"
x:Uid="LinkLevelsToggle"
IsChecked="{x:Bind ViewModel.LinkedLevelsActive, Mode=TwoWay}"
Style="{StaticResource FlyoutIconToggleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowLinkLevelsToggle), Mode=OneWay}">
<Grid>
<FontIcon
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE71B;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowLinkLevelsInactiveIcon), Mode=OneWay}" />
<FontIcon
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
Glyph="&#xE71B;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.LinkedLevelsActive), Mode=OneWay}" />
</Grid>
</ToggleButton>
<Button
x:Uid="RefreshTooltip"
Content="{ui:FontIcon Glyph=&#xE777;,
@@ -574,4 +798,4 @@
</Grid>
</Border>
</Grid>
</winuiex:WindowEx>
</winuiex:WindowEx>

View File

@@ -250,17 +250,14 @@ namespace PowerDisplay
private void OnUIRefreshRequested(object? sender, EventArgs e)
{
// Adjust window size when UI configuration changes (feature visibility toggles)
DispatcherQueue.TryEnqueue(() => AdjustWindowSizeToContent());
QueueWindowSizeAdjustment();
}
private void OnMonitorsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
// Adjust window size when monitors collection changes (event-driven!)
// The UI binding will update first, then we adjust size
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
AdjustWindowSizeToContent();
});
QueueWindowSizeAdjustment();
}
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -268,16 +265,28 @@ namespace PowerDisplay
// Adjust window size when relevant properties change (event-driven!)
if (e.PropertyName == nameof(_viewModel.IsScanning) ||
e.PropertyName == nameof(_viewModel.HasMonitors) ||
e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage))
e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage) ||
e.PropertyName == nameof(_viewModel.LinkedLevelsActive) ||
e.PropertyName == nameof(_viewModel.IndividualDisplaysExpanded) ||
e.PropertyName == nameof(_viewModel.ShowIndividualDisplays) ||
e.PropertyName == nameof(_viewModel.ShowLinkLevelsToggle) ||
e.PropertyName == nameof(_viewModel.LinkedMonitorsCount) ||
e.PropertyName == nameof(_viewModel.LinkedMonitorsCountText) ||
e.PropertyName == nameof(_viewModel.ExcludedMonitorsCount) ||
e.PropertyName == nameof(_viewModel.ExcludedMonitorsCountText) ||
e.PropertyName == nameof(_viewModel.HasExcludedMonitors) ||
e.PropertyName == nameof(_viewModel.IsLinkedBrightnessAvailable))
{
// Use Low priority to ensure UI bindings update first
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
AdjustWindowSizeToContent();
});
QueueWindowSizeAdjustment();
}
}
private void QueueWindowSizeAdjustment()
{
// Use Low priority to ensure x:Bind visibility/layout updates land before measuring.
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, AdjustWindowSizeToContent);
}
private void OnRefreshClick(object sender, RoutedEventArgs e)
{
try
@@ -368,17 +377,29 @@ namespace PowerDisplay
return;
}
var maxHeightDip = GetAdaptiveWindowMaxHeightDip();
if (MainScrollViewer != null)
{
MainScrollViewer.MaxHeight = double.PositiveInfinity;
}
// Force layout update and measure content height
RootGrid.UpdateLayout();
MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidthDip, double.PositiveInfinity));
var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0);
var maxHeightDip = GetAdaptiveWindowMaxHeightDip();
// Apply min/max height limits and reposition using DIP values.
// Min height ensures window is visible even if content hasn't loaded yet
var finalHeightDip = Math.Min(contentHeight, maxHeightDip);
var finalHeightDip = Math.Max(AppConstants.UI.WindowMinHeightDip, Math.Min(contentHeight, maxHeightDip));
Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, maxHeightDip={maxHeightDip}, finalHeightDip={finalHeightDip}");
if (MainScrollViewer != null)
{
var statusBarHeight = StatusBar?.ActualHeight > 0 ? StatusBar.ActualHeight : 48;
MainScrollViewer.MaxHeight = Math.Max(0, maxHeightDip - statusBarHeight);
RootGrid.UpdateLayout();
}
// FlyoutWindowHelper handles cross-monitor DPI internally via a 1×1 teleport
// before the final move, so no WM_DPICHANGED suppression is required here.
FlyoutWindowHelper.PositionWindowBottomRight(

View File

@@ -197,6 +197,43 @@
<data name="ProfilesTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Profiles</value>
</data>
<data name="LinkLevelsToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Link brightness levels</value>
</data>
<data name="LinkLevelsToggle.ToolTipService.ToolTip" xml:space="preserve">
<value>Link brightness across monitors</value>
</data>
<data name="AllDisplaysTitle.Text" xml:space="preserve">
<value>All displays</value>
<comment>Title of the card that controls brightness for all linked monitors at once</comment>
</data>
<data name="AllDisplaysBrightnessAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>All displays brightness</value>
</data>
<data name="AllDisplaysLinkedCountFormat" xml:space="preserve">
<value>{0} linked</value>
<comment>Subtitle on the All displays card. {0} is the number of monitors whose brightness is linked</comment>
</data>
<data name="AllDisplaysExcludedCountFormat" xml:space="preserve">
<value>{0} excluded</value>
<comment>Subtitle on the All displays card. {0} is the number of monitors excluded from linked brightness</comment>
</data>
<data name="IndividualDisplaysHeader.Text" xml:space="preserve">
<value>Individual displays</value>
<comment>Header for the collapsible section listing each monitor while link mode is on</comment>
</data>
<data name="IndividualDisplaysToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Individual displays</value>
</data>
<data name="ExcludeFromSyncToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Exclude from linked brightness</value>
</data>
<data name="ExcludeFromSyncToggleToolTip" xml:space="preserve">
<value>Brightness is controlled by linked mode.</value>
</data>
<data name="IncludeInSyncToggleToolTip" xml:space="preserve">
<value>Include this display in linked brightness</value>
</data>
<data name="IdentifyText.Text" xml:space="preserve">
<value>Identify monitors</value>
</data>
@@ -221,4 +258,4 @@
<data name="ColorTemperatureHeader.Text" xml:space="preserve">
<value>Color temperature</value>
</data>
</root>
</root>

View File

@@ -0,0 +1,281 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using PowerDisplay.Common.Services;
using PowerDisplay.Helpers;
namespace PowerDisplay.ViewModels;
/// <summary>
/// MainViewModel — linked-brightness behavior: toggle side effects, initial value,
/// per-monitor exclusion set, debounced broadcast across linked monitors.
/// </summary>
public partial class MainViewModel
{
// Monitor Ids (Monitor.Id form) the user has excluded from linked brightness. An excluded
// monitor keeps its own slider while link mode is on. Authoritative runtime copy of
// PowerDisplayProperties.ExcludedFromSyncMonitorIds; loaded by LoadExcludedMonitorIds and
// persisted by SaveExcludedMonitorIds. Monitors not in this set are linked by default.
private readonly HashSet<string> _excludedMonitorIds = new(StringComparer.OrdinalIgnoreCase);
// Set while the toggle handler is seeding the initial LinkedBrightness value
// so OnLinkedBrightnessChanged does not treat the seed as a user-driven change
// and schedule a broadcast — the design promise is "no auto-write on toggle ON".
private bool _suppressLinkedBrightnessBroadcast;
private DispatcherQueueTimer? _linkedBrightnessCommitTimer;
/// <summary>
/// Returns true when the monitor is currently driven by linked brightness — it supports
/// brightness and the user has not excluded it. This mirrors the planner's linked-target rule
/// for live ViewModels on the broadcast and optimistic-update hot paths.
/// </summary>
private bool IsLinkedTarget(MonitorViewModel monitor) =>
monitor.SupportsBrightness && !_excludedMonitorIds.Contains(monitor.Id);
/// <summary>
/// Snapshot the currently included brightness-capable monitors as
/// <see cref="LinkedBrightnessPlanner.LinkTarget"/> values for seed decisions.
/// </summary>
private List<LinkedBrightnessPlanner.LinkTarget> BuildLinkTargets() =>
Monitors.Where(IsLinkedTarget)
.Select(m => new LinkedBrightnessPlanner.LinkTarget(
m.Id,
m.MonitorNumber,
m.Brightness))
.ToList();
/// <summary>
/// Gets whether the given monitor Id is excluded from linked brightness. Queried by
/// <see cref="MonitorViewModel.IsExcludedFromSync"/> so each card reflects the shared set.
/// </summary>
internal bool IsMonitorExcludedFromSync(string monitorId) => _excludedMonitorIds.Contains(monitorId);
/// <summary>
/// Add or remove a monitor from the linked-brightness exclusion set, persist the change, and
/// refresh the "N linked" summary. When a monitor is brought back into an existing linked set
/// (<paramref name="excluded"/> = false) while link mode is on, re-broadcast so it snaps to
/// the current master level. If it is the first linked target, seed the master slider from it
/// instead. Excluding a monitor does not write hardware: it simply stops following the group.
/// </summary>
internal void SetMonitorExcludedFromSync(string monitorId, bool excluded)
{
var hadLinkedTarget = Monitors.Any(IsLinkedTarget);
var changed = excluded
? _excludedMonitorIds.Add(monitorId)
: _excludedMonitorIds.Remove(monitorId);
if (!changed)
{
return;
}
SaveExcludedMonitorIds();
// Excluding can empty the linked set (slider must disable); including can refill it.
IsLinkedBrightnessAvailable = Monitors.Any(IsLinkedTarget);
OnPropertyChanged(nameof(LinkedMonitorsCount));
OnPropertyChanged(nameof(LinkedMonitorsCountText));
OnPropertyChanged(nameof(ExcludedMonitorsCount));
OnPropertyChanged(nameof(ExcludedMonitorsCountText));
OnPropertyChanged(nameof(HasExcludedMonitors));
if (!excluded && LinkedLevelsActive)
{
if (hadLinkedTarget)
{
ScheduleLinkedBrightnessCommit();
}
else
{
SeedInitialLinkedBrightness();
}
}
}
/// <summary>
/// Replace the runtime exclusion set from persisted settings. Called by LoadUIDisplaySettings
/// before monitor discovery so cards read the correct excluded state on first render.
/// </summary>
internal void LoadExcludedMonitorIds(IEnumerable<string>? excludedIds)
{
_excludedMonitorIds.Clear();
if (excludedIds == null)
{
return;
}
foreach (var id in excludedIds)
{
if (!string.IsNullOrEmpty(id))
{
_excludedMonitorIds.Add(id);
}
}
}
/// <summary>
/// Partial change hook fired by the source-generator for <see cref="LinkedLevelsActive"/>.
/// Persists the new value to settings.json and, on transition to ON, seeds the
/// linked-brightness slider with an initial value drawn from the connected monitors.
/// </summary>
partial void OnLinkedLevelsActiveChanged(bool value)
{
SaveLinkedLevelsActive(value);
if (value)
{
// Entering linked mode starts with the individual cards collapsed so the master
// slider is the focus; the user can expand to reach per-display settings.
IndividualDisplaysExpanded = false;
SeedInitialLinkedBrightness();
}
else
{
// Drop any pending master-slider write. Once the user turns linked mode off, the
// delayed broadcast from the previous linked slider gesture should not still run.
_linkedBrightnessCommitTimer?.Stop();
}
}
/// <summary>
/// Partial change hook fired by the source-generator for <see cref="LinkedBrightness"/>.
/// Optimistically updates every linked monitor's brightness value to match the master value
/// that will be broadcast to hardware, so it is correct if the monitor is later excluded or
/// link mode is disabled. Then schedules a debounced hardware broadcast. Suppressed during
/// initial-value seeding so turning the toggle on does not auto-write hardware.
/// </summary>
partial void OnLinkedBrightnessChanged(int value)
{
if (_suppressLinkedBrightnessBroadcast)
{
return;
}
if (!LinkedLevelsActive)
{
return;
}
// Keep linked monitor VMs aligned with the master value without per-VM commits. The row is
// usually covered by the linked-mode hint, but the backing value should be correct if the
// monitor is later excluded or link mode is disabled.
foreach (var vm in Monitors)
{
if (IsLinkedTarget(vm))
{
vm.UpdateBrightnessDisplay(value);
}
}
ScheduleLinkedBrightnessCommit();
}
/// <summary>
/// Seeds <see cref="LinkedBrightness"/> with the initial value used to position the master
/// slider when link mode turns on, via <see cref="LinkedBrightnessPlanner.Seed"/> (lowest
/// DISPLAY number, then deterministic fallback). The seed is positional only: it is never
/// written to hardware (the suppress flag gates <see cref="OnLinkedBrightnessChanged"/>), so
/// the first user gesture is the first broadcast.
/// </summary>
private void SeedInitialLinkedBrightness()
{
var seed = LinkedBrightnessPlanner.Seed(BuildLinkTargets());
IsLinkedBrightnessAvailable = seed.HasValue;
if (seed.HasValue)
{
SetLinkedBrightnessSilently(seed.Value);
}
}
/// <summary>
/// Set <see cref="LinkedBrightness"/> without triggering a hardware broadcast — used for
/// positioning the master slider during seeding.
/// </summary>
private void SetLinkedBrightnessSilently(int value)
{
_suppressLinkedBrightnessBroadcast = true;
try
{
LinkedBrightness = value;
}
finally
{
_suppressLinkedBrightnessBroadcast = false;
}
}
/// <summary>
/// Recompute <see cref="IsLinkedBrightnessAvailable"/> and the linked-monitors count after a
/// monitor add/remove/refresh. Called from <c>UpdateMonitorList</c> so the master slider's
/// enabled state and "N linked" subtitle stay in sync with the live monitor set.
/// </summary>
internal void RecomputeLinkedBrightnessAvailability()
{
IsLinkedBrightnessAvailable = Monitors.Any(IsLinkedTarget);
OnPropertyChanged(nameof(LinkedMonitorsCount));
OnPropertyChanged(nameof(LinkedMonitorsCountText));
OnPropertyChanged(nameof(ExcludedMonitorsCount));
OnPropertyChanged(nameof(ExcludedMonitorsCountText));
OnPropertyChanged(nameof(HasExcludedMonitors));
if (LinkedLevelsActive)
{
SeedInitialLinkedBrightness();
}
}
private void ScheduleLinkedBrightnessCommit()
{
SliderCommitScheduler.Schedule(
ref _linkedBrightnessCommitTimer,
_dispatcherQueue,
() => BroadcastLinkedBrightnessAsync(LinkedBrightness));
}
/// <summary>
/// Drop a queued linked-brightness hardware write. Used when the monitor topology is changing,
/// so a delayed commit from the previous topology cannot apply to a newly discovered target set.
/// </summary>
private void CancelPendingLinkedBrightnessCommit()
{
_linkedBrightnessCommitTimer?.Stop();
}
/// <summary>
/// Broadcast the linked brightness value to every linked target via
/// <see cref="MonitorViewModel.SetBrightnessAsync"/>. Each per-VM call already wraps hardware
/// errors in its own try/catch, so a single failing monitor does not break the others.
/// </summary>
private async Task BroadcastLinkedBrightnessAsync(int value)
{
if (!LinkedLevelsActive)
{
return;
}
try
{
var writes = Monitors
.Where(IsLinkedTarget)
.Select(m => m.SetBrightnessAsync(value))
.ToList();
if (writes.Count > 0)
{
await Task.WhenAll(writes);
}
}
catch (Exception ex)
{
Logger.LogError($"[LinkedBrightness] Broadcast failed: {ex.Message}");
}
}
}

View File

@@ -108,6 +108,7 @@ public partial class MainViewModel
try
{
CancelPendingLinkedBrightnessCommit();
IsScanning = true;
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
@@ -133,6 +134,8 @@ public partial class MainViewModel
private void UpdateMonitorList(IReadOnlyList<Monitor> monitors, bool isInitialLoad)
{
CancelPendingLinkedBrightnessCommit();
// Dispose old ViewModels to unsubscribe PropertyChanged handlers
foreach (var vm in Monitors)
{
@@ -160,6 +163,8 @@ public partial class MainViewModel
OnPropertyChanged(nameof(HasMonitors));
OnPropertyChanged(nameof(ShowNoMonitorsMessage));
OnPropertyChanged(nameof(ShowLinkLevelsToggle));
RecomputeLinkedBrightnessAvailability();
// Save monitor information to settings
SaveMonitorsToSettings();

View File

@@ -25,6 +25,55 @@ namespace PowerDisplay.ViewModels;
/// </summary>
public partial class MainViewModel
{
/// <summary>
/// Persist the link-levels toggle state to settings.json. Called from
/// <c>OnLinkedLevelsActiveChanged</c> in <c>MainViewModel.LinkedBrightness.cs</c>
/// (which owns the source-generator hook plus the broadcast/seed side effects).
/// </summary>
private void SaveLinkedLevelsActive(bool value)
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
if (settings.Properties.LinkedLevelsActive == value)
{
return;
}
settings.Properties.LinkedLevelsActive = value;
_settingsUtils.SaveSettings(
System.Text.Json.JsonSerializer.Serialize(settings, AppJsonContext.Default.PowerDisplaySettings),
PowerDisplaySettings.ModuleName);
}
catch (Exception ex)
{
Logger.LogError($"[Settings] Failed to save LinkedLevelsActive: {ex.Message}");
}
}
/// <summary>
/// Persist the linked-brightness exclusion set to settings.json. Called from
/// <c>SetMonitorExcludedFromSync</c> in <c>MainViewModel.LinkedBrightness.cs</c> after the
/// runtime <c>_excludedMonitorIds</c> set has been mutated.
/// </summary>
private void SaveExcludedMonitorIds()
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
settings.Properties.ExcludedFromSyncMonitorIds = _excludedMonitorIds.ToList();
_settingsUtils.SaveSettings(
System.Text.Json.JsonSerializer.Serialize(settings, AppJsonContext.Default.PowerDisplaySettings),
PowerDisplaySettings.ModuleName);
}
catch (Exception ex)
{
Logger.LogError($"[Settings] Failed to save ExcludedFromSyncMonitorIds: {ex.Message}");
}
}
private static void TryRestore(
List<Task> tasks,
int? savedValue,
@@ -208,10 +257,17 @@ public partial class MainViewModel
}
/// <summary>
/// Apply profile settings to monitors
/// Apply profile settings to monitors. Profiles are per-monitor snapshots, so applying one
/// turns off linked brightness before writing individual monitor values.
/// </summary>
private async Task ApplyProfileAsync(List<ProfileMonitorSetting> monitorSettings)
{
if (LinkedLevelsActive)
{
Logger.LogInfo("[Profile] Disabling linked brightness before applying per-monitor profile values");
LinkedLevelsActive = false;
}
var updateTasks = new List<Task>();
foreach (var setting in monitorSettings)

View File

@@ -53,6 +53,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
[NotifyPropertyChangedFor(nameof(HasMonitors))]
[NotifyPropertyChangedFor(nameof(ShowNoMonitorsMessage))]
[NotifyPropertyChangedFor(nameof(IsInteractionEnabled))]
[NotifyPropertyChangedFor(nameof(IsLinkedBrightnessSliderEnabled))]
public partial bool IsScanning { get; set; }
private bool _isInitialized;
@@ -128,6 +129,113 @@ public partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool ShowIdentifyMonitorsButton { get; set; }
/// <summary>
/// Gets or sets a value indicating whether brightness slider changes are broadcast to all
/// non-excluded monitors as one linked level. Persisted in <c>PowerDisplaySettings</c> so
/// the choice survives restarts. The toggle is meaningful only when two or more monitors
/// are connected — see <see cref="ShowLinkLevelsToggle"/> for the visibility gate.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowIndividualDisplays))]
[NotifyPropertyChangedFor(nameof(ShowLinkLevelsToggle))]
[NotifyPropertyChangedFor(nameof(ShowLinkLevelsInactiveIcon))]
public partial bool LinkedLevelsActive { get; set; }
/// <summary>
/// Gets or sets the brightness value driving the linked "All Displays" slider. Setter is
/// debounced and broadcasts on commit to every linked target: a
/// <see cref="MonitorViewModel.SupportsBrightness"/> monitor not excluded from sync (see
/// <c>OnLinkedBrightnessChanged</c>). Synchronously updates each linked monitor's brightness
/// value so it is correct if the monitor is later excluded or link mode is disabled — the
/// linked broadcast is the single source of hardware writes while link mode is active.
/// </summary>
[ObservableProperty]
public partial int LinkedBrightness { get; set; }
/// <summary>
/// Gets a value indicating whether the linked brightness slider has at least one linked
/// brightness-capable monitor to control.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLinkedBrightnessSliderEnabled))]
public partial bool IsLinkedBrightnessAvailable { get; set; }
/// <summary>
/// Gets a value indicating whether the linked "All Displays" brightness slider accepts input.
/// Disabled both when no linked target is available and while monitor discovery/restore is busy.
/// </summary>
public bool IsLinkedBrightnessSliderEnabled => IsLinkedBrightnessAvailable && IsInteractionEnabled;
/// <summary>
/// Gets the count of monitors that participate in linked brightness — used for the
/// "All Displays" card subtitle ("N linked"). Counts brightness-capable monitors the user
/// has not excluded (see <see cref="MonitorViewModel.IsExcludedFromSync"/>).
/// </summary>
public int LinkedMonitorsCount => Monitors.Count(m => m.SupportsBrightness && !IsMonitorExcludedFromSync(m.Id));
/// <summary>
/// Gets the count of brightness-capable monitors excluded from linked brightness.
/// </summary>
public int ExcludedMonitorsCount => Monitors.Count(m => m.SupportsBrightness && IsMonitorExcludedFromSync(m.Id));
/// <summary>
/// Gets a value indicating whether the "N excluded" subtitle fragment should be visible on
/// the "All displays" card.
/// </summary>
public bool HasExcludedMonitors => ExcludedMonitorsCount > 0;
/// <summary>
/// Gets the localized "N linked" subtitle shown on the "All Displays" card. Recomputed
/// alongside <see cref="LinkedMonitorsCount"/> via <c>RecomputeLinkedBrightnessAvailability</c>.
/// </summary>
public string LinkedMonitorsCountText =>
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
Helpers.ResourceLoaderInstance.ResourceLoader.GetString("AllDisplaysLinkedCountFormat"),
LinkedMonitorsCount);
/// <summary>
/// Gets the localized "N excluded" subtitle fragment shown on the "All Displays" card.
/// </summary>
public string ExcludedMonitorsCountText =>
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
Helpers.ResourceLoaderInstance.ResourceLoader.GetString("AllDisplaysExcludedCountFormat"),
ExcludedMonitorsCount);
/// <summary>
/// Gets or sets a value indicating whether the "Individual displays" section is expanded
/// while link mode is on. Collapsed by default so linked mode reads as a single master
/// slider; expanding reveals the per-monitor cards (with linked monitors' sliders disabled).
/// Ignored when link mode is off — see <see cref="ShowIndividualDisplays"/>.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowIndividualDisplays))]
[NotifyPropertyChangedFor(nameof(ShowIndividualDisplaysCollapsedIcon))]
public partial bool IndividualDisplaysExpanded { get; set; }
/// <summary>
/// Gets a value indicating whether the per-monitor cards list is shown. Always shown when
/// link mode is off (the classic layout); when link mode is on the cards are tucked into the
/// collapsible "Individual displays" section and only shown once the user expands it.
/// </summary>
public bool ShowIndividualDisplays => !LinkedLevelsActive || IndividualDisplaysExpanded;
public bool ShowIndividualDisplaysCollapsedIcon => !IndividualDisplaysExpanded;
/// <summary>
/// Gets a value indicating whether the link-levels toggle should be visible. Shown when at
/// least two monitors report <see cref="MonitorViewModel.SupportsBrightness"/> (the entry-point
/// gate — counting raw entries would show the toggle even when only one display can actually be
/// driven), OR whenever link mode is already active. The second clause guarantees the user can
/// always turn link mode back off, even if monitors were unplugged down to one controllable
/// display while linked — otherwise the "All displays" card would strand them with no way out.
/// Recomputed by <see cref="UpdateMonitorList"/> and on <see cref="LinkedLevelsActive"/> change.
/// </summary>
public bool ShowLinkLevelsToggle => LinkedLevelsActive || Monitors.Count(m => m.SupportsBrightness) >= 2;
public bool ShowLinkLevelsInactiveIcon => !LinkedLevelsActive;
/// <summary>
/// Gets a value indicating whether to show the profile switcher button.
/// Combines settings value with HasProfiles check.
@@ -173,6 +281,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
_isLoading = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsInteractionEnabled));
OnPropertyChanged(nameof(IsLinkedBrightnessSliderEnabled));
}
}
@@ -266,6 +375,16 @@ public partial class MainViewModel : ObservableObject, IDisposable
// Cancel all async operations first
_cancellationTokenSource?.Cancel();
// Stop the linked-brightness debounce timer so its Tick handler does not fire after
// we have already cleared Monitors below (broadcast would iterate an empty list).
try
{
_linkedBrightnessCommitTimer?.Stop();
}
catch
{
}
// Dispose each resource independently to ensure all get cleaned up
try
{
@@ -361,6 +480,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
// Load the linked-brightness exclusion set before applying LinkedLevelsActive. If this
// method runs after monitors are already discovered, the toggle hook can seed the master
// slider immediately and must see the persisted exclusions.
LoadExcludedMonitorIds(settings.Properties.ExcludedFromSyncMonitorIds);
LinkedLevelsActive = settings.Properties.LinkedLevelsActive;
// Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models)
CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List<CustomVcpValueMapping>();
Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings");
@@ -381,6 +507,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
/// </summary>
private void OnDisplayChanging(object? sender, EventArgs e)
{
CancelPendingLinkedBrightnessCommit();
if (!IsScanning)
{
Logger.LogInfo("[MainViewModel] Display change detected — locking UI ahead of rediscovery");

View File

@@ -50,6 +50,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
// Visibility settings (controlled by Settings UI)
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowBrightnessSlider))]
public partial bool ShowBrightness { get; set; }
[ObservableProperty]
@@ -85,7 +86,6 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(Brightness));
}
// Apply to hardware
await ApplyPropertyToHardwareAsync(nameof(Brightness), brightness, _monitorManager.SetBrightnessAsync);
}
@@ -180,7 +180,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
/// <param name="propertyName">Name of the property being set (for logging and state persistence)</param>
/// <param name="value">Value to apply</param>
/// <param name="setAsyncFunc">Async function to call on MonitorManager</param>
private async Task ApplyPropertyToHardwareAsync(
private async Task<bool> ApplyPropertyToHardwareAsync(
string propertyName,
int value,
Func<string, int, CancellationToken, Task<MonitorOperationResult>> setAsyncFunc)
@@ -192,6 +192,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
if (result.IsSuccess)
{
_mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, propertyName, value);
return true;
}
else
{
@@ -202,6 +203,8 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
{
Logger.LogError($"[{Id}] Exception setting {propertyName.ToLowerInvariant()}: {ex.Message}");
}
return false;
}
// Property to access IsInteractionEnabled from parent ViewModel
@@ -299,6 +302,64 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
public bool SupportsBrightness => _monitor.SupportsBrightness;
/// <summary>
/// Gets a value indicating whether this monitor's brightness is currently driven by linked
/// mode rather than its own slider. True when the parent has link mode on, this monitor
/// supports brightness, and the user has not excluded it. When true the per-card brightness
/// row shows a disabled linked-mode hint — the "All Displays" master slider broadcasts the
/// value instead.
/// </summary>
public bool IsBrightnessLinked => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness && !IsExcludedFromSync;
/// <summary>
/// Gets a value indicating whether the per-card brightness slider accepts input. Disabled both
/// while the app is busy (<see cref="IsInteractionEnabled"/>) and while link mode owns this
/// monitor's brightness (<see cref="IsBrightnessLinked"/>). Excluded monitors keep an active
/// slider, so this is true for them whenever interaction is enabled.
/// </summary>
public bool IsBrightnessSliderEnabled => IsInteractionEnabled && !IsBrightnessLinked;
public bool ShowBrightnessSlider => ShowBrightness && !IsBrightnessLinked;
public bool ShowIncludedInSyncIcon => !IsExcludedFromSync;
/// <summary>
/// Gets or sets a value indicating whether this monitor is excluded from linked brightness.
/// Backed by the parent's shared exclusion set (keyed by <see cref="Id"/>), so the state
/// survives monitor-list rebuilds and is persisted to settings.json. An excluded monitor keeps
/// its own independent brightness slider while link mode is on.
/// </summary>
public bool IsExcludedFromSync
{
get => _mainViewModel?.IsMonitorExcludedFromSync(Id) ?? false;
set
{
if (IsExcludedFromSync != value)
{
_mainViewModel?.SetMonitorExcludedFromSync(Id, value);
OnPropertyChanged();
OnPropertyChanged(nameof(IsBrightnessLinked));
OnPropertyChanged(nameof(IsBrightnessSliderEnabled));
OnPropertyChanged(nameof(ShowBrightnessSlider));
OnPropertyChanged(nameof(ShowIncludedInSyncIcon));
OnPropertyChanged(nameof(SyncToggleToolTip));
}
}
}
/// <summary>
/// Gets a value indicating whether the per-card exclude toggle is shown. Only relevant while
/// link mode is on and the monitor supports brightness — otherwise exclusion has no effect.
/// </summary>
public bool ShowExcludeButton => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness;
/// <summary>
/// Gets the tooltip for the per-card linked-brightness toggle. The action reverses with the
/// current exclusion state: linked monitors can be excluded, excluded monitors can be included.
/// </summary>
public string SyncToggleToolTip => ResourceLoaderInstance.ResourceLoader.GetString(
IsExcludedFromSync ? "IncludeInSyncToggleToolTip" : "ExcludeFromSyncToggleToolTip");
/// <summary>
/// Gets a value indicating whether this monitor supports volume control via VCP 0x62
/// </summary>
@@ -426,6 +487,24 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
}
}
/// <summary>
/// Update the brightness backing field and notify the UI without scheduling a per-VM commit
/// or hardware write. Used by <c>MainViewModel.LinkedBrightness</c> to keep each linked
/// monitor's stored brightness aligned with the master value, so it is correct if the
/// monitor is later excluded or link mode is disabled. The master broadcast is the single
/// source of hardware writes while link mode is on, so this path must not schedule its own
/// debounced commit.
/// </summary>
public void UpdateBrightnessDisplay(int value)
{
value = Math.Clamp(value, 0, 100);
if (_brightness != value)
{
_brightness = value;
OnPropertyChanged(nameof(Brightness));
}
}
/// <summary>
/// Gets color temperature VCP preset value (from VCP code 0x14).
/// Read-only in flyout UI - controlled via Settings UI.
@@ -784,12 +863,21 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
if (e.PropertyName == nameof(MainViewModel.IsInteractionEnabled))
{
OnPropertyChanged(nameof(IsInteractionEnabled));
OnPropertyChanged(nameof(IsBrightnessSliderEnabled));
}
else if (e.PropertyName == nameof(MainViewModel.HasMonitors))
{
// Monitor count changed, update display name to show/hide number suffix
OnPropertyChanged(nameof(DisplayName));
}
else if (e.PropertyName == nameof(MainViewModel.LinkedLevelsActive))
{
// Link mode toggled — refresh the linked hint, exclude button, and slider state.
OnPropertyChanged(nameof(IsBrightnessLinked));
OnPropertyChanged(nameof(IsBrightnessSliderEnabled));
OnPropertyChanged(nameof(ShowBrightnessSlider));
OnPropertyChanged(nameof(ShowExcludeButton));
}
}
private void OnMonitorPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -814,16 +902,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
// reads the latest field at fire-time so intermediate values are coalesced.
private void ScheduleCommit(ref DispatcherQueueTimer? timer, Func<Task> commit)
{
if (timer == null)
{
timer = DispatcherQueue.GetForCurrentThread().CreateTimer();
timer.IsRepeating = false;
timer.Interval = TimeSpan.FromMilliseconds(AppConstants.UI.SliderCommitDebounceMs);
timer.Tick += (_, _) => _ = commit();
}
timer.Stop();
timer.Start();
SliderCommitScheduler.Schedule(ref timer, DispatcherQueue.GetForCurrentThread(), commit);
}
// Rotation button handlers — one per orientation to avoid Tag string parsing

View File

@@ -24,6 +24,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library
ShowProfileSwitcher = true;
ShowIdentifyMonitorsButton = true;
MaxCompatibilityMode = false;
LinkedLevelsActive = false;
ExcludedFromSyncMonitorIds = new List<string>();
CustomVcpMappings = new List<CustomVcpValueMapping>();
// Note: saved_monitor_settings has been moved to monitor_state.json
@@ -79,6 +81,26 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("show_identify_monitors_button")]
public bool ShowIdentifyMonitorsButton { get; set; }
/// <summary>
/// Gets or sets a value indicating whether brightness slider changes are broadcast
/// to all connected monitors as a single linked level. When false (default), each
/// monitor's slider operates independently. The toggle is only meaningful when two
/// or more monitors are connected; the UI hides the entry point otherwise.
/// </summary>
[JsonPropertyName("linked_levels_active")]
public bool LinkedLevelsActive { get; set; }
/// <summary>
/// Gets or sets the set of monitor <c>Id</c> values excluded from linked brightness.
/// Keyed by <c>Monitor.Id</c> (the DevicePath-based identifier, unique per physical
/// device × port — the same key profiles use), so three identical monitors of the same
/// model are distinguished. An excluded monitor keeps its own independent brightness
/// slider while link mode is on. Monitors not present here are linked by default,
/// including newly connected ones.
/// </summary>
[JsonPropertyName("excluded_from_sync_monitor_ids")]
public List<string> ExcludedFromSyncMonitorIds { get; set; }
/// <summary>
/// Gets or sets custom VCP value name mappings shared across all monitors.
/// Allows users to define custom names for color temperature presets and input sources.

View File

@@ -988,6 +988,26 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
SignalSettingsUpdated();
}
/// <summary>
/// Re-read the flyout-owned runtime fields (linked brightness enabled + per-monitor
/// exclusion list) from disk into <see cref="_settings"/> so a save originating from an
/// unrelated Settings toggle does not overwrite them with the page's stale snapshot.
/// These fields have no Settings UI surface — the PowerDisplay flyout is their sole editor.
/// </summary>
private void PreserveFlyoutOwnedState()
{
try
{
var current = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
_settings.Properties.LinkedLevelsActive = current.Properties.LinkedLevelsActive;
_settings.Properties.ExcludedFromSyncMonitorIds = current.Properties.ExcludedFromSyncMonitorIds;
}
catch (Exception ex)
{
Logger.LogError($"Failed to preserve flyout-owned PowerDisplay state before save: {ex.Message}");
}
}
private void NotifySettingsChanged()
{
// Skip during initialization when SendConfigMSG is not yet set
@@ -996,6 +1016,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
return;
}
// linked_levels_active and excluded_from_sync_monitor_ids are owned by the PowerDisplay
// flyout (the only UI that toggles them); the Settings page has no surface for them.
// _settings was loaded once at page construction, so serializing it would otherwise
// clobber flyout changes made meanwhile — both on disk and in the IPC config pushed
// to the module. Re-read the current on-disk values and carry them forward untouched.
PreserveFlyoutOwnedState();
// Persist locally first so settings survive even if the module DLL isn't loaded yet.
SettingsUtils.SaveSettings(_settings.ToJsonString(), PowerDisplaySettings.ModuleName);