Compare commits

..

35 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
135 changed files with 7337 additions and 1752 deletions

View File

@@ -416,6 +416,7 @@ DISPLAYFLAGS
DISPLAYFREQUENCY
displayname
DISPLAYORIENTATION
DISPLAYPORT
divyan
DLGFRAME
dlgmodalframe
@@ -862,6 +863,7 @@ jjw
jobject
JOBOBJECT
jpe
JPN
jpnime
jrsoftware
Jsons
@@ -994,6 +996,7 @@ LTM
LTRREADING
luid
lusrmgr
LVDS
LWA
LWIN
LZero
@@ -1058,6 +1061,8 @@ MINIMIZESTART
MINMAXINFO
minwindef
Mip
Miracast
miracast
mkdn
mlcfg
mmc
@@ -1386,7 +1391,6 @@ popups
POPUPWINDOW
portfile
POSITIONITEM
Postbot
POWERBROADCAST
powerdisplay
POWERDISPLAYMODULEINTERFACE
@@ -1817,6 +1821,7 @@ svchost
SVGIn
SVGIO
svgz
SVIDEO
SVSI
SWFO
swp

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

@@ -1004,10 +1004,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ShortcutGuide.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj" Id="e487304a-b1fb-4e6b-8e70-014051af5b99" />
</Folder>
<Folder Name="/modules/Workspaces/">
@@ -1032,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">
@@ -1039,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

@@ -195,18 +195,10 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
A string array of all the keys that need to be pressed. If a number is supplied, it should be read as a [KeyCode](https://learn.microsoft.com/windows/win32/inputdev/virtual-key-codes) and displayed accordingly (based on the Keyboard Layout of the user).
**Literal digit keys**:
Because a bare number is interpreted as a virtual-key code, a literal digit key must be authored using the `<N>` notation (the digit enclosed between `<` and `>`), where `N` is `0``9`. For example, `<9>` represents the literal `9` key (as in the "switch to the last tab" shortcut), not the virtual-key code `9` (which is `Tab`). The interpreter strips the brackets and displays just the digit.
This applies only to a single literal digit. A range such as `1 - 8` is a free-form label, not a key, and is supplied verbatim (the brackets would only be trimmed from the ends, so `<1> - <8>` would not render as intended).
**Special keys**:
Special keys are enclosed between `<` and `>` and correspond to a key that should be displayed in a certain way. If the interpreter of the manifest file can't understand the content, the brackets should be left out.
By convention these tokens are written as double-quoted strings in the YAML (for example `"<Enter>"` and `"<9>"`), matching the quoting used for punctuation key values. YAML treats the quoted and unquoted forms identically, so quoting is for consistency rather than a strict requirement for bracketed tokens.
|Name|Description|
|----|-----------|
|`<Office>`| Corresponds to the Office key on some Windows keyboards |

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

@@ -210,7 +210,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<0>"
- 0
- SectionName: Formatting
Properties:
- Name: Bold

View File

@@ -1542,7 +1542,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<3>"
- 3
- Name: Move earlier or later by number of frames specified for stroke Duration
Shortcut:
- Win: false

View File

@@ -642,7 +642,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<3>"
- 3
- Name: Show document template
Shortcut:
- Win: false
@@ -810,7 +810,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<5>"
- 5
- Name: Release guides
Shortcut:
- Win: false
@@ -818,7 +818,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<5>"
- 5
- Name: Show/ hide smart guides
Shortcut:
- Win: false
@@ -925,7 +925,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<6>"
- 6
- Name: Select the object above the current selection
Shortcut:
- Win: false
@@ -965,7 +965,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<2>"
- 2
- Name: Unlock a selection
Shortcut:
- Win: false
@@ -973,7 +973,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Hide a selection
Shortcut:
- Win: false
@@ -981,7 +981,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<3>"
- 3
- Name: Show all selections
Shortcut:
- Win: false
@@ -989,7 +989,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<3>"
- 3
- Name: Move selection in user-defined increments
Shortcut:
- Win: false
@@ -1013,7 +1013,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<2>"
- 2
- Name: Bring a selection forward
Shortcut:
- Win: false
@@ -1071,7 +1071,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<8>"
- 8
- Name: Release a compound path
Shortcut:
- Win: false
@@ -1079,7 +1079,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<8>"
- 8
- Name: Edit a pattern
Shortcut:
- Win: false
@@ -1261,7 +1261,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<4>"
- 4
- Name: Move an object
Shortcut:
- Win: false
@@ -1285,7 +1285,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<7>"
- 7
- Name: Release a clipping mask
Shortcut:
- Win: false
@@ -1293,7 +1293,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<7>"
- 7
- Name: Toggle between fill and stroke
Shortcut:
- Win: false
@@ -1641,7 +1641,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<8>"
- 8
- Name: Insert copyright symbol
Shortcut:
- Win: false
@@ -1665,7 +1665,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<7>"
- 7
- Name: Insert section symbol
Shortcut:
- Win: false
@@ -1673,7 +1673,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<6>"
- 6
- Name: Insert trademark symbol
Shortcut:
- Win: false
@@ -1681,7 +1681,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Insert registered trademark symbol
Shortcut:
- Win: false

View File

@@ -1036,7 +1036,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<5>"
- 5
- Name: Redraw screen
Shortcut:
- Win: false
@@ -1060,7 +1060,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Switch to next/previous document window
Shortcut:
- Win: false
@@ -1155,7 +1155,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<6>"
- 6
- Name: Toggle Character/Paragraph text attributes mode
Shortcut:
- Win: false
@@ -1163,7 +1163,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<7>"
- 7
- Name: Display the pop-up menu that has focus
Shortcut:
- Win: false
@@ -1301,7 +1301,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<1>"
- 1
- Name: Show Magenta plate
Shortcut:
- Win: false
@@ -1309,7 +1309,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<2>"
- 2
- Name: Show Yellow plate
Shortcut:
- Win: false
@@ -1317,7 +1317,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<3>"
- 3
- Name: Show Black plate
Shortcut:
- Win: false
@@ -1325,7 +1325,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<4>"
- 4
- Name: Show 1st Spot plate
Shortcut:
- Win: false
@@ -1333,7 +1333,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<5>"
- 5
- Name: Show 2nd Spot plate
Shortcut:
- Win: false
@@ -1341,7 +1341,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<6>"
- 6
- Name: Show 3rd Spot plate
Shortcut:
- Win: false
@@ -1349,7 +1349,7 @@ Shortcuts:
Shift: true
Alt: true
Keys:
- "<7>"
- 7
- SectionName: Transform panel
Properties:
- Name: Apply value and copy object

View File

@@ -803,7 +803,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Switch to Hand tool (when not in text-edit mode)
Shortcut:
- Win: false
@@ -1309,7 +1309,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<1>"
- 1
- Name: Tone Curve panel
Shortcut:
- Win: false
@@ -1317,7 +1317,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Detail panel
Shortcut:
- Win: false
@@ -1325,7 +1325,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<3>"
- 3
- Name: HSL/Grayscale panel
Shortcut:
- Win: false
@@ -1333,7 +1333,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<4>"
- 4
- Name: Split Toning panel
Shortcut:
- Win: false
@@ -1341,7 +1341,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<5>"
- 5
- Name: Lens Corrections panel
Shortcut:
- Win: false
@@ -1349,7 +1349,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<6>"
- 6
- Name: Camera Calibration panel
Shortcut:
- Win: false
@@ -1357,7 +1357,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<7>"
- 7
- Name: Presets panel
Shortcut:
- Win: false
@@ -1365,7 +1365,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<9>"
- 9
- Name: Open Snapshots panel
Shortcut:
- Win: false
@@ -1373,7 +1373,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<9>"
- 9
- Name: Parametric Curve Targeted Adjustment tool
Shortcut:
- Win: false
@@ -1665,7 +1665,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<6>"
- 6
- Name: (Filmstrip mode) Add yellow label
Shortcut:
- Win: false
@@ -1673,7 +1673,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<7>"
- 7
- Name: (Filmstrip mode) Add green label
Shortcut:
- Win: false
@@ -1681,7 +1681,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<8>"
- 8
- Name: (Filmstrip mode) Add blue label
Shortcut:
- Win: false
@@ -1689,7 +1689,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<9>"
- 9
- Name: (Filmstrip mode) Add purple label
Shortcut:
- Win: false
@@ -1697,7 +1697,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<0>"
- 0
- Name: Camera Raw preferences
Shortcut:
- Win: false
@@ -1936,7 +1936,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<0>"
- 0
- Name: Cycle through blending modes
Shortcut:
- Win: false
@@ -2433,7 +2433,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Delete adjustment layer
Shortcut:
- Win: false

View File

@@ -407,7 +407,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Edge select mode
Shortcut:
- Win: false
@@ -415,7 +415,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<2>"
- 2
- Name: Face select mode
Shortcut:
- Win: false
@@ -423,7 +423,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<3>"
- 3
- Name: Extrude region
Shortcut:
- Win: false

View File

@@ -806,7 +806,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Set opacity to 50
Shortcut:
- Win: false
@@ -814,7 +814,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<5>"
- 5
- Name: Set opacity to 100
Shortcut:
- Win: false
@@ -822,7 +822,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<0>"
- 0
- SectionName: Arrange
Properties:
- Name: Bring forward

View File

@@ -489,7 +489,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- SectionName: Edit
Properties:
- Name: Undo

View File

@@ -63,7 +63,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Jump to rightmost tab
Shortcut:
- Win: false
@@ -71,7 +71,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<9>"
- 9
- Name: Open home page in current tab
Shortcut:
- Win: false
@@ -424,7 +424,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<0>"
- 0
- Name: Scroll down a screen
Shortcut:
- Win: false

View File

@@ -21,7 +21,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<1>"
- 1
- Name: Show Intention Actions
Recommended: true
Shortcut:
@@ -778,7 +778,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<1>"
- 1
- Name: Show Bookmarks window
Shortcut:
- Win: false
@@ -786,7 +786,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Show Find window
Shortcut:
- Win: false
@@ -794,7 +794,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<3>"
- 3
- Name: Show Run window
Shortcut:
- Win: false
@@ -802,7 +802,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<4>"
- 4
- Name: Show Debug window
Shortcut:
- Win: false
@@ -810,7 +810,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<5>"
- 5
- Name: Show Problems window
Shortcut:
- Win: false
@@ -818,7 +818,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<6>"
- 6
- Name: Show Structure window
Shortcut:
- Win: false
@@ -826,7 +826,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<7>"
- 7
- Name: Show Services window
Shortcut:
- Win: false
@@ -834,7 +834,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<8>"
- 8
- Name: Show Version Control window
Shortcut:
- Win: false
@@ -842,7 +842,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<9>"
- 9
- Name: Show Commit window
Shortcut:
- Win: false
@@ -850,7 +850,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<0>"
- 0
- Name: Show Terminal window
Shortcut:
- Win: false

View File

@@ -45,7 +45,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Switch to the last tab
Shortcut:
- Win: false
@@ -53,7 +53,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<9>"
- 9
- Name: Close the current tab
Shortcut:
- Win: false
@@ -479,7 +479,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<0>"
- 0
- Name: Stop loading page; close dialog or pop-up
Shortcut:
- Win: false

View File

@@ -492,7 +492,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Focus into Second Editor Group
Shortcut:
- Win: false
@@ -500,7 +500,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<2>"
- 2
- Name: Focus into Third Editor Group
Shortcut:
- Win: false
@@ -508,7 +508,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<3>"
- 3
- Name: Move Editor Left
Shortcut:
- Win: false

View File

@@ -202,7 +202,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<0>"
- 0
- SectionName: Editing
Properties:
- Name: Copy
@@ -485,7 +485,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<1>"
- 1
- Name: Go to last tab
Shortcut:
- Win: false
@@ -493,7 +493,7 @@ Shortcuts:
Shift: false
Alt: false
Keys:
- "<9>"
- 9
- Name: Move tab left
Shortcut:
- Win: false

View File

@@ -1,463 +0,0 @@
PackageName: Postman.Postman
Name: Postman
WindowFilter: "Postman.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Tabs
Properties:
- Name: Close tab
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- W
- Name: Force close tab
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- W
- Name: Switch to next tab
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Tab
- Name: Switch to previous tab
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Tab
- Name: Switch to tab at position (18)
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- '1 - 8'
- Name: Switch to last tab
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<9>"
- Name: Reopen last closed tab
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- T
- Name: New runner tab
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- R
- Name: Search tabs
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- A
- SectionName: Sidebar
Properties:
- Name: Search sidebar
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- F
- Name: Next item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Down>"
- Name: Previous item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Up>"
- Name: Expand item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Right>"
- Name: Expand all
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- "<Right>"
- Name: Collapse item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Left>"
- Name: Collapse all
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- "<Left>"
- Name: Select item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Enter>"
- Name: Rename item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Cut item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- X
- Name: Copy item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Paste item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- V
- Name: Duplicate item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Delete item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Delete>"
- SectionName: Request
Properties:
- Name: Request URL
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- L
- Name: Save request
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Save request as
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- S
- Name: Send request
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Enter>"
- Name: Send and download request
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<Enter>"
- SectionName: Interface
Properties:
- Name: Zoom in
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Plus
- Name: Zoom out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Minus
- Name: Reset zoom
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<0>"
- Name: Toggle two-pane view
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- V
- Name: Toggle left sidebar
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "\\"
- Name: Toggle right sidebar
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "\\"
- Name: Toggle workbench
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- M
- Name: Swap sidebars
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- S
- Name: Reset layout
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- R
- Name: Environment selector
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- E
- SectionName: Window and modals
Properties:
- Name: New…
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: New Postman window
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- N
- Name: New console window
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- C
- Name: Find
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- F
- Name: Import
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- O
- Name: Settings
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- ","
- Name: Open shortcut help
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "/"
- Name: Search
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- K
- Name: Search in current workspace
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- K
- Name: Open Postbot
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- P
- Name: Open Vault
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- V
- Name: Open browser tab
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- B
- Name: Cancel conversation
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Accept all
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Y
- Name: Reject all
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Escape>"
- SectionName: Console
Properties:
- Name: Clear console
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- K
- Name: Show/hide console
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "`"

View File

@@ -241,7 +241,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<1>"
- 1
- Name: Browse DMs
Shortcut:
- Win: false
@@ -249,7 +249,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<2>"
- 2
- Name: Open the Activity view
Shortcut:
- Win: false
@@ -265,7 +265,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<0>"
- 0
- Name: Open the Threads view
Shortcut:
- Win: false
@@ -525,7 +525,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<9>"
- 9
- Name: Inline code selected text
Shortcut:
- Win: false
@@ -549,7 +549,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<8>"
- 8
- Name: Numbered list
Shortcut:
- Win: false
@@ -557,7 +557,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<7>"
- 7
- Name: Apply markdown formatting
Shortcut:
- Win: false
@@ -583,7 +583,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<0>"
- 0
- Name: Big heading
Shortcut:
- Win: false
@@ -591,7 +591,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<1>"
- 1
- Name: Medium heading
Shortcut:
- Win: false
@@ -599,7 +599,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<2>"
- 2
- Name: Small heading
Shortcut:
- Win: false
@@ -607,7 +607,7 @@ Shortcuts:
Shift: false
Alt: true
Keys:
- "<3>"
- 3
- Name: Checklist
Shortcut:
- Win: false
@@ -615,7 +615,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<0>"
- 0
- Name: Bulleted list
Shortcut:
- Win: false
@@ -623,7 +623,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<8>"
- 8
- Name: Numbered list
Shortcut:
- Win: false
@@ -631,7 +631,7 @@ Shortcuts:
Shift: true
Alt: false
Keys:
- "<7>"
- 7
- Name: Toggle heading and list styles
Shortcut:
- Win: false

View File

@@ -6,7 +6,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using ShortcutGuide.Models;
@@ -33,27 +32,16 @@ namespace ShortcutGuide.Helpers
list.Add(shortcutEntry);
}
// Persist on a best-effort basis. The in-memory pinned list is the source of truth
// for the rest of the session; failing to write should not crash the overlay
// (Pin/Unpin runs from a synchronous UI handler).
Save();
PinnedShortcutsChanged?.Invoke(null, appName);
}
public static void Save()
{
try
{
string serialized = JsonSerializer.Serialize(App.PinnedShortcuts);
string pinnedPath = SettingsUtils.Default.GetSettingsFilePath(ShortcutGuideSettings.ModuleName, "Pinned.json");
File.WriteAllText(pinnedPath, serialized);
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException)
{
Logger.LogError("Failed to persist Shortcut Guide pinned shortcuts; keeping in-memory state.", ex);
}
string serialized = JsonSerializer.Serialize(App.PinnedShortcuts);
string pinnedPath = SettingsUtils.Default.GetSettingsFilePath(ShortcutGuideSettings.ModuleName, "Pinned.json");
File.WriteAllText(pinnedPath, serialized);
}
}
}

View File

@@ -2,12 +2,9 @@
// 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.IO;
using System.Text.Json;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Xaml;
@@ -34,39 +31,21 @@ namespace ShortcutGuide
public App()
{
this.InitializeComponent();
// Register process-wide exception handlers so a stray exception (e.g. an IO failure
// during a fire-and-forget UI handler, or a background Task fault) gets logged
// instead of taking the overlay down with an unhandled access violation in coreclr.
// Without these the runtime tears the process down before our local catches can run.
this.UnhandledException += App_UnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
try
this.LoadData();
MainWindow = new MainWindow();
TaskBarWindow = new TaskbarWindow();
MainWindow.Activate();
MainWindow.Closed += (_, _) =>
{
this.LoadData();
MainWindow = new MainWindow();
TaskBarWindow = new TaskbarWindow();
MainWindow.Activate();
MainWindow.Closed += (_, _) =>
{
PowerToysTelemetry.Log.WriteEvent(new ShortcutGuideSessionEvent(
MainWindow.SessionDurationMs,
MainWindow.CloseType));
TaskBarWindow.Close();
};
}
catch (Exception ex)
{
// Any failure in launch is fatal for this short-lived overlay; log and exit
// cleanly rather than letting WinUI surface a generic crash dialog.
Logger.LogError("Failed to launch Shortcut Guide.", ex);
Environment.Exit(1);
}
PowerToysTelemetry.Log.WriteEvent(new ShortcutGuideSessionEvent(
MainWindow.SessionDurationMs,
MainWindow.CloseType));
TaskBarWindow.Close();
};
}
private void LoadData()
@@ -84,53 +63,18 @@ namespace ShortcutGuide
PinnedShortcuts = loaded;
}
}
catch (Exception ex) when (ex is JsonException
or IOException
or UnauthorizedAccessException)
catch (JsonException)
{
// Fall back to the empty default if the file is corrupt or unreadable.
Logger.LogWarning($"Failed to load pinned shortcuts from '{pinnedPath}'. Falling back to empty list. Reason: {ex.Message}");
// Fall back to the empty default if the file is corrupt.
}
}
ShortcutGuideSettings = SettingsRepository<ShortcutGuideSettings>.GetInstance(settingsUtils).SettingsConfig;
ShortcutGuideProperties = ShortcutGuideSettings.Properties;
try
{
#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances
settingsUtils.SaveSettings(JsonSerializer.Serialize(App.ShortcutGuideSettings, new JsonSerializerOptions { WriteIndented = true }), "Shortcut Guide");
settingsUtils.SaveSettings(JsonSerializer.Serialize(App.ShortcutGuideSettings, new JsonSerializerOptions { WriteIndented = true }), "Shortcut Guide");
#pragma warning restore CA1869 // Cache and reuse 'JsonSerializerOptions' instances
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Persisting the round-tripped settings is best-effort; the in-memory copy is still valid.
Logger.LogWarning($"Failed to persist Shortcut Guide settings on launch. Reason: {ex.Message}");
}
}
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
// Exceptions raised on the UI thread land here. Mark handled so the runtime
// does not terminate the process; the overlay can usually continue.
Logger.LogError("Unhandled UI exception in Shortcut Guide.", e.Exception);
e.Handled = true;
}
private static void CurrentDomain_UnhandledException(object sender, System.UnhandledExceptionEventArgs e)
{
// Background-thread exceptions reach here as a last resort; we cannot prevent
// termination when IsTerminating is true, but at least we leave a log trail.
if (e.ExceptionObject is Exception ex)
{
Logger.LogError($"Unhandled background exception in Shortcut Guide (IsTerminating={e.IsTerminating}).", ex);
}
}
private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
Logger.LogError("Unobserved Task exception in Shortcut Guide.", e.Exception);
e.SetObserved();
}
}
}

View File

@@ -237,54 +237,37 @@ namespace ShortcutGuide
private void SetWindowPosition()
{
try
if (!this._hasMovedToRightMonitor)
{
if (!this._hasMovedToRightMonitor)
{
NativeMethods.GetCursorPos(out NativeMethods.POINT lpPoint);
AppWindow.Move(new NativeMethods.POINT { Y = lpPoint.Y - ((int)Height / 2), X = lpPoint.X - ((int)Width / 2) });
this._hasMovedToRightMonitor = true;
}
var hwnd = WindowNative.GetWindowHandle(this);
float dpi = DpiHelper.GetDPIScaleForWindow(hwnd);
Rect monitorRect = DisplayHelper.GetWorkAreaForDisplayWithWindow(hwnd);
var windowPosition = (ShortcutGuideWindowPosition)App.ShortcutGuideProperties.WindowPosition.Value;
// App.TaskBarWindow / its AppWindow can briefly be null during the reentrant
// Hide → Activate → BringToFront chain triggered from SelectionChanged. When the
// taskbar window is not currently observable, skip the overlap adjustment instead
// of crashing the overlay (issue #48448).
var taskbarWindow = App.TaskBarWindow?.AppWindow;
bool taskbarOnLeft = false;
bool taskbarOnRight = false;
if (taskbarWindow is not null)
{
taskbarOnLeft = taskbarWindow.IsVisible && taskbarWindow.Position.X < AppWindow.Position.X + Width && windowPosition == ShortcutGuideWindowPosition.Left;
taskbarOnRight = taskbarWindow.IsVisible && taskbarWindow.Position.X + taskbarWindow.Size.Width > AppWindow.Position.X && windowPosition == ShortcutGuideWindowPosition.Right;
}
double newHeight = monitorRect.Height / dpi;
if (taskbarWindow is not null && (taskbarOnLeft || taskbarOnRight))
{
newHeight -= taskbarWindow.Size.Height;
}
MaxHeight = newHeight;
MinHeight = newHeight;
Height = newHeight;
int xPosition = windowPosition == ShortcutGuideWindowPosition.Right
? (int)(monitorRect.X + monitorRect.Width) - (int)(Width * dpi)
: (int)monitorRect.X;
this.MoveAndResize(xPosition, (int)monitorRect.Y, Width, Height);
NativeMethods.GetCursorPos(out NativeMethods.POINT lpPoint);
AppWindow.Move(new NativeMethods.POINT { Y = lpPoint.Y - ((int)Height / 2), X = lpPoint.X - ((int)Width / 2) });
this._hasMovedToRightMonitor = true;
}
catch (Exception ex)
var hwnd = WindowNative.GetWindowHandle(this);
float dpi = DpiHelper.GetDPIScaleForWindow(hwnd);
Rect monitorRect = DisplayHelper.GetWorkAreaForDisplayWithWindow(hwnd);
var windowPosition = (ShortcutGuideWindowPosition)App.ShortcutGuideProperties.WindowPosition.Value;
var taskbarWindow = App.TaskBarWindow.AppWindow;
bool taskbarOnLeft = taskbarWindow.IsVisible && taskbarWindow.Position.X < AppWindow.Position.X + Width && windowPosition == ShortcutGuideWindowPosition.Left;
bool taskbarOnRight = taskbarWindow.IsVisible && taskbarWindow.Position.X + taskbarWindow.Size.Width > AppWindow.Position.X && windowPosition == ShortcutGuideWindowPosition.Right;
double newHeight = monitorRect.Height / dpi;
if (taskbarOnLeft || taskbarOnRight)
{
Logger.LogError("Failed to set Shortcut Guide window position; keeping previous layout.", ex);
newHeight -= taskbarWindow.Size.Height;
}
MaxHeight = newHeight;
MinHeight = newHeight;
Height = newHeight;
int xPosition = windowPosition == ShortcutGuideWindowPosition.Right
? (int)(monitorRect.X + monitorRect.Width) - (int)(Width * dpi)
: (int)monitorRect.X;
this.MoveAndResize(xPosition, (int)monitorRect.Y, Width, Height);
}
/// <summary>
@@ -299,35 +282,25 @@ namespace ShortcutGuide
return;
}
try
{
this._selectedAppName = selectedItem.Name;
App.CurrentAppName = this._selectedAppName;
this._shortcutFile = ManifestInterpreter.GetShortcutsOfApplication(this._selectedAppName);
this._selectedAppName = selectedItem.Name;
App.CurrentAppName = this._selectedAppName;
this._shortcutFile = ManifestInterpreter.GetShortcutsOfApplication(this._selectedAppName);
App.TaskBarWindow?.Hide();
if (this._shortcutFile is ShortcutFile file)
App.TaskBarWindow.Hide();
if (this._shortcutFile is ShortcutFile file)
{
// Show the taskbar button window only when the selected app exposes the <TASKBAR1-9> section.
if (file.Shortcuts is not null && file.Shortcuts.Any(c => c.SectionName?.StartsWith("<TASKBAR1-9>", StringComparison.Ordinal) == true))
{
// Show the taskbar button window only when the selected app exposes the <TASKBAR1-9> section.
if (file.Shortcuts is not null && file.Shortcuts.Any(c => c.SectionName?.StartsWith("<TASKBAR1-9>", StringComparison.Ordinal) == true))
{
this._taskBarWindowActivated = true;
App.TaskBarWindow?.Activate();
}
// Reposition before navigating so the taskbar window does not clip into the main window.
this.SetWindowPosition();
this.ContentFrame.Navigate(
typeof(ShortcutsPage),
new ShortcutPageNavParam { ShortcutFile = file, AppName = this._selectedAppName });
this._taskBarWindowActivated = true;
App.TaskBarWindow.Activate();
}
}
catch (Exception ex)
{
// Guard against exceptions during section navigation so the overlay does not close on the user.
// InitializeNavItemsAsync's catch interprets any exception bubbling out of the initial
// SelectedItem assignment as a fatal init failure and closes the window (issue #48448).
Logger.LogError($"Failed to handle Shortcut Guide section selection '{selectedItem.Name}'.", ex);
// Reposition before navigating so the taskbar window does not clip into the main window.
this.SetWindowPosition();
this.ContentFrame.Navigate(
typeof(ShortcutsPage),
new ShortcutPageNavParam { ShortcutFile = file, AppName = this._selectedAppName });
}
}

View File

@@ -30,73 +30,54 @@ namespace ShortcutGuide.ShortcutGuideXAML
public void UpdateTasklistButtons()
{
// Wrap the entire body: this method runs from the ctor and from `Activated`,
// both of which can fire while MainWindow is closing or AppWindow is in a
// transient null state. An exception here used to crash the overlay because
// there was no caller-side try/catch (issue #48441).
// This move ensures the window spawns on the same monitor as the main window
AppWindow.MoveInZOrderAtBottom();
AppWindow.Move(App.MainWindow.AppWindow.Position);
TasklistButton[] buttons = [];
try
{
// This move ensures the window spawns on the same monitor as the main window.
// App.MainWindow / its AppWindow can briefly be null during the reentrant
// Hide → Activate → BringToFront chain triggered from SelectionChanged.
var mainAppWindow = App.MainWindow?.AppWindow;
if (mainAppWindow is null)
{
return;
}
AppWindow.MoveInZOrderAtBottom();
AppWindow.Move(mainAppWindow.Position);
TasklistButton[] buttons = [];
try
{
buttons = TasklistPositions.GetButtons();
}
catch (Exception ex)
{
Logger.LogError("Failed to enumerate taskbar buttons via TasklistPositions.GetButtons.", ex);
}
if (buttons.Length == 0)
{
AppWindow.Hide();
return;
}
float dpi = this.DPI;
double windowsLogoColumnWidth = this.WindowsLogoColumnWidth.Width.Value;
double windowHeight = 58;
double windowMargin = 8 * dpi;
double windowWidth = windowsLogoColumnWidth;
double xPosition = buttons[0].X - (windowsLogoColumnWidth * dpi);
double yPosition = this.WorkArea.Bottom - (windowHeight * dpi);
this.KeyHolder.Children.Clear();
foreach (TasklistButton b in buttons)
{
TaskbarIndicator indicator = new()
{
Label = b.Keynum >= 10 ? "0" : b.Keynum.ToString(CultureInfo.InvariantCulture),
Height = b.Height / dpi,
Width = b.Width / dpi,
};
windowWidth += indicator.Width;
this.KeyHolder.Children.Add(indicator);
double indicatorPos = (b.X - xPosition) / dpi;
Canvas.SetLeft(indicator, indicatorPos - windowsLogoColumnWidth);
}
this.MoveAndResize(xPosition - windowMargin, yPosition, windowWidth + (2 * windowMargin), windowHeight);
AppWindow.MoveInZOrderAtTop();
buttons = TasklistPositions.GetButtons();
}
catch (Exception ex)
{
Logger.LogError("Failed to update Shortcut Guide taskbar indicator window.", ex);
Logger.LogError("Failed to enumerate taskbar buttons via TasklistPositions.GetButtons.", ex);
}
if (buttons.Length == 0)
{
AppWindow.Hide();
return;
}
float dpi = this.DPI;
double windowsLogoColumnWidth = this.WindowsLogoColumnWidth.Width.Value;
double windowHeight = 58;
double windowMargin = 8 * dpi;
double windowWidth = windowsLogoColumnWidth;
double xPosition = buttons[0].X - (windowsLogoColumnWidth * dpi);
double yPosition = this.WorkArea.Bottom - (windowHeight * dpi);
this.KeyHolder.Children.Clear();
foreach (TasklistButton b in buttons)
{
TaskbarIndicator indicator = new()
{
Label = b.Keynum >= 10 ? "0" : b.Keynum.ToString(CultureInfo.InvariantCulture),
Height = b.Height / dpi,
Width = b.Width / dpi,
};
windowWidth += indicator.Width;
this.KeyHolder.Children.Add(indicator);
double indicatorPos = (b.X - xPosition) / dpi;
Canvas.SetLeft(indicator, indicatorPos - windowsLogoColumnWidth);
}
this.MoveAndResize(xPosition - windowMargin, yPosition, windowWidth + (2 * windowMargin), windowHeight);
AppWindow.MoveInZOrderAtTop();
}
}
}

View File

@@ -1,60 +0,0 @@
// 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 ShortcutGuide.Converters;
using ShortcutGuide.Models;
namespace ShortcutGuide.UnitTests.ConvertersTests;
[TestClass]
public sealed class ShortcutDescriptionToKeysConverterTests
{
private static List<object> Convert(ShortcutDescription description)
=> new ShortcutDescriptionToKeysConverter().GetKeysList(description);
[TestMethod]
[DataRow("<0>")]
[DataRow("<1>")]
[DataRow("<8>")]
[DataRow("<9>")]
public void GetKeysList_LiteralDigitKey_IsPassedThroughVerbatim(string key)
{
// A literal digit key (e.g. Ctrl+9 "switch to last tab") is authored with the
// <N> convention so it is not parsed as a virtual-key code (VK 9 is Tab, VK 1 is
// the left mouse button, VK 0 is undefined). The converter forwards the token
// unchanged; KeyVisual strips the angle brackets when rendering.
var result = Convert(new ShortcutDescription(ctrl: true, shift: false, alt: false, win: false, keys: [key]));
CollectionAssert.AreEqual(new object[] { "Ctrl", key }, result);
}
[TestMethod]
public void GetKeysList_Modifiers_AreEmittedBeforeKeysInWinCtrlAltShiftOrder()
{
// Win -> 92, Ctrl -> "Ctrl", Alt -> "Alt", Shift -> 16, then the keys.
var result = Convert(new ShortcutDescription(ctrl: true, shift: true, alt: true, win: true, keys: ["A"]));
CollectionAssert.AreEqual(new object[] { 92, "Ctrl", "Alt", 16, "A" }, result);
}
[TestMethod]
public void GetKeysList_NonNumericKey_IsPassedThroughVerbatim()
{
// Non-numeric key strings (e.g. the "1 - 8" tab-range) render as-is.
var result = Convert(new ShortcutDescription(ctrl: true, shift: false, alt: false, win: false, keys: ["1 - 8"]));
CollectionAssert.AreEqual(new object[] { "Ctrl", "1 - 8" }, result);
}
[TestMethod]
public void GetKeysList_ArrowNameKey_MapsToVirtualKeyCode()
{
// Named arrow keys map to their VK codes (Up -> 38), independent of the digit handling.
var result = Convert(new ShortcutDescription(ctrl: false, shift: false, alt: false, win: false, keys: ["Up"]));
CollectionAssert.AreEqual(new object[] { 38 }, result);
}
}

View File

@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<SelfContained>true</SelfContained>
<RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\ShortcutGuide.UnitTests\</OutputPath>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ShortcutGuide.Ui\ShortcutGuide.Ui.csproj" />
</ItemGroup>
</Project>

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

@@ -1089,10 +1089,13 @@ VideoRecordingSession::VideoRecordingSession(
// Store frame interval for timeout-based frame production when webcam is active.
m_frameIntervalTicks = ( frameRate > 0 ) ? ( 10'000'000LL / frameRate ) : 333'333LL;
// NOTE: Audio encoding profile (m_encodingProfile.Audio) is set in
// StartAsync() after the audio graph is fully initialized, not here.
// Calling GetEncodingProperties() before InitializeAsync completes
// would crash because m_audioOutputNode is still null.
if (captureAudio || captureSystemAudio)
{
// Always set up audio profile for loopback capture (stereo AAC)
auto audio = m_encodingProfile.Audio();
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
m_encodingProfile.Audio(audio);
}
// Describe our input: uncompressed BGRA8 buffers
auto properties = winrt::VideoEncodingProperties::CreateUncompressed(
@@ -1173,16 +1176,7 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync()
RecDiag( L"StartAsync: co_await InitializeAsync...\n" );
co_await m_audioGenerator->InitializeAsync();
RecDiag( L"StartAsync: audio initialized\n" );
// Set up the audio encoding profile now that the audio graph is
// fully initialized. GetEncodingProperties() requires
// m_audioOutputNode to be valid, which is only guaranteed after
// InitializeAsync completes.
auto audioProps = m_audioGenerator->GetEncodingProperties();
m_encodingProfile.Audio(winrt::AudioEncodingProperties::CreateAac(
audioProps.SampleRate(), audioProps.ChannelCount(), 192000));
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(audioProps));
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(m_audioGenerator->GetEncodingProperties()));
}
else {

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -19,9 +20,6 @@ public record AppStateModel
init => _recentCommands = value;
}
// HERE BE DRAGONS: Using an ImmutableList<T> for a setting may explode in
// AOT builds. Make sure to test IN AOT setting this setting to null, [],
// and and array with values.
private ImmutableList<string>? _runHistory = ImmutableList<string>.Empty;
public ImmutableList<string> RunHistory

View File

@@ -427,39 +427,12 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var title = _commandItemViewModel?.Title ?? string.Empty;
var subtitle = _commandItemViewModel?.Subtitle ?? string.Empty;
var icon = _commandItemViewModel?.Icon;
var dockSettings = _settingsService.Settings.DockSettings;
var dockSide = dockSettings.Side;
IReadOnlyList<MonitorInfo>? monitors = GetDockEnabledMonitors(_monitorService, dockSettings);
var dockSide = _settingsService.Settings.DockSettings.Side;
IReadOnlyList<MonitorInfo>? monitors = _monitorService?.GetMonitors();
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide, monitors);
WeakReferenceMessenger.Default.Send(message);
}
// Only list monitors where the dock is currently enabled, so users can't
// pin a command to a display that has no dock visible.
private static IReadOnlyList<MonitorInfo>? GetDockEnabledMonitors(IMonitorService? monitorService, DockSettings dockSettings)
{
var monitors = monitorService?.GetMonitors();
if (monitors is null)
{
return null;
}
var configs = dockSettings.MonitorConfigs;
// When there are no per-monitor configs (legacy / first-run), the dock
// is only shown on the primary monitor.
if (configs.Count == 0)
{
return monitors.Where(m => m.IsPrimary).ToList();
}
return monitors
.Where(m => configs.Any(c =>
string.Equals(c.MonitorDeviceId, m.StableId, System.StringComparison.OrdinalIgnoreCase) &&
c.Enabled))
.ToList();
}
private void UnpinFromDock()
{
PinToDockMessage message = new(_providerId, _commandId, false);

View File

@@ -24,21 +24,9 @@ internal sealed class RunHistoryService : IRunHistoryService
if (_appStateService.State.RunHistory.IsEmpty)
{
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
// Copy the WinRT-projected IVector<string> into a plain List<string>
// before building the ImmutableList. ImmutableList.CreateRange tries to
// cast the source to IReadOnlyCollection<string>, which requires a WinRT
// helper type that isn't available in AOT builds and throws
// NotSupportedException.
var historyList = new List<string>(history.Count);
for (var i = 0; i < history.Count; i++)
{
historyList.Add(history[i]);
}
_appStateService.UpdateState(state => state with
{
RunHistory = historyList.ToImmutableList(),
RunHistory = history.ToImmutableList(),
});
}

View File

@@ -134,25 +134,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
MoreCommands = _networkPage.Commands,
};
if (isBandPage)
{
_networkUpItem = new ListItem(_networkPage)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage.Commands,
};
_networkDownItem = new ListItem(_networkPage)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage.Commands,
};
}
_networkPage.Updated += (s, e) =>
{
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
@@ -272,6 +253,22 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
}
else
{
_networkUpItem = new ListItem(_networkPage!)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage!.Commands,
};
_networkDownItem = new ListItem(_networkPage!)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage!.Commands,
};
return _batteryItem is not null
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };

View File

@@ -94,7 +94,7 @@ internal static class Commands
})
{
Title = Resources.Microsoft_plugin_sys_hibernate,
Icon = Icons.HibernateIcon,
Icon = Icons.SleepIcon, // Icon change needed
},
});

View File

@@ -25,6 +25,4 @@ internal sealed class Icons
internal static IconInfo ShutdownIcon { get; } = new IconInfo("\uE7E8");
internal static IconInfo SleepIcon { get; } = new IconInfo("\uE708");
internal static IconInfo HibernateIcon { get; } = new IconInfo("\uE823");
}

View File

@@ -243,7 +243,5 @@ namespace ColorPicker.Helpers
lpPoint.Y += yOffset;
SetCursorPos(lpPoint.X, lpPoint.Y);
}
internal IntPtr GetMainWindowHandle() => _hwndSource?.Handle ?? IntPtr.Zero;
}
}

View File

@@ -1,51 +0,0 @@
// 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 ManagedCommon;
namespace ColorPicker.Helpers;
internal static class WindowCaptureExclusionHelper
{
// Windows 10 version 2004 (build 19041) is the minimum supported version. PowerToys
// itself requires the same version, so this check is not strictly required, but is
// useful as a safeguard.
private static readonly bool IsSupported =
Environment.OSVersion.Version >= new Version(10, 0, 19041);
// Only logging once per session to avoid repeated identical warnings, as the zoom
// window may be used very often.
private static bool hasLoggedFailure;
internal static bool Exclude(IntPtr hwnd) =>
SetWindowAffinity(hwnd, NativeMethods.WDA_EXCLUDEFROMCAPTURE);
internal static bool Include(IntPtr hwnd) =>
SetWindowAffinity(hwnd, NativeMethods.WDA_NONE);
private static bool SetWindowAffinity(nint hwnd, uint affinity)
{
if (!IsSupported)
{
return false;
}
bool success = NativeMethods.SetWindowDisplayAffinity(hwnd, affinity);
if (!success)
{
int errorCode = Marshal.GetLastWin32Error();
if (!hasLoggedFailure)
{
Logger.LogWarning(
$"Failed to set window display affinity. Error code: {errorCode}");
hasLoggedFailure = true;
}
}
return success;
}
}

View File

@@ -79,30 +79,12 @@ namespace ColorPicker.Helpers
// we just started zooming, copy screen area
if (_previousZoomLevel == 0)
{
// First, exclude the color picker window from the capture; otherwise its
// corner will be included in the zoomed-in image.
var mainWindowHandle = _appStateHandler.GetMainWindowHandle();
bool exclusionSuccess =
WindowCaptureExclusionHelper.Exclude(mainWindowHandle);
var x = (int)point.X - (BaseZoomImageSize / 2);
var y = (int)point.Y - (BaseZoomImageSize / 2);
try
{
var x = (int)point.X - (BaseZoomImageSize / 2);
var y = (int)point.Y - (BaseZoomImageSize / 2);
_graphics.CopyFromScreen(x, y, 0, 0, _bmp.Size, CopyPixelOperation.SourceCopy);
_graphics.CopyFromScreen(x, y, 0, 0, _bmp.Size, CopyPixelOperation.SourceCopy);
_zoomViewModel.ZoomArea = BitmapToImageSource(_bmp);
}
finally
{
// Restore the color picker window to normal display affinity so that
// it can be captured again.
if (exclusionSuccess)
{
WindowCaptureExclusionHelper.Include(mainWindowHandle);
}
}
_zoomViewModel.ZoomArea = BitmapToImageSource(_bmp);
}
_zoomViewModel.ZoomFactor = Math.Pow(ZoomFactor, _currentZoomLevel - 1);

View File

@@ -231,17 +231,5 @@ namespace ColorPicker
var hwnd = new WindowInteropHelper(win).Handle;
_ = SetWindowLong(hwnd, GWL_EX_STYLE, GetWindowLong(hwnd, GWL_EX_STYLE) | WS_EX_TOOLWINDOW);
}
/// <summary>
/// Sets the display affinity of a window, which controls how the window is
/// displayed on a monitor. Used to exclude the picker window from ZoomWindow's
/// source bitmap.
/// </summary>
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint dwAffinity);
internal const uint WDA_NONE = 0x00000000;
internal const uint WDA_EXCLUDEFROMCAPTURE = 0x00000011;
}
}

View File

@@ -139,14 +139,6 @@ namespace KeyboardEventHandlers
if (data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN)
{
ResetIfModifierKeyForLowerLevelKeyHandlers(ii, it->first, target);
// If a Ctrl/Alt/Shift key is remapped to a non-modifier key, reset the modifier state to prevent the injected key from being delivered as WM_SYSKEYDOWN instead of WM_KEYDOWN
if (Helpers::IsModifierKey(it->first) && !Helpers::IsModifierKey(target) && target != VK_CAPITAL && !(it->first == VK_LWIN || it->first == VK_RWIN || it->first == CommonSharedConstants::VK_WIN_BOTH))
{
std::vector<INPUT> suppressList;
Helpers::SetKeyEvent(suppressList, INPUT_KEYBOARD, static_cast<WORD>(it->first), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG);
ii.SendVirtualInput(suppressList);
}
}
if (remapToKey)

View File

@@ -226,27 +226,6 @@ namespace RemappingLogicTests
Assert::AreEqual(1, mockedInputHandler.GetSendVirtualInputCallCount());
}
// Test if SendVirtualInput is sent exactly once with the suppress flag when a Ctrl/Alt/Shift key is remapped to a non-modifier key
TEST_METHOD (HandleSingleKeyRemapEvent_ShouldSendVirtualInputWithSuppressFlagExactlyOnce_WhenCtrlAltShiftIsMappedToNonModifierKey)
{
mockedInputHandler.SetSendVirtualInputTestHandler([](LowlevelKeyboardEvent* data) {
if (data->lParam->dwExtraInfo == KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
return true;
else
return false;
});
testState.AddSingleKeyRemap(VK_LMENU, (DWORD)VK_BACK);
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = VK_LMENU } },
};
mockedInputHandler.SendVirtualInput(inputs);
Assert::AreEqual(1, mockedInputHandler.GetSendVirtualInputCallCount());
}
// Test if correct keyboard states are set for a single key to two key shortcut remap
TEST_METHOD (RemappedKeyToTwoKeyShortcut_ShouldSetTargetKeyState_OnKeyEvent)
{

View File

@@ -22,8 +22,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
public string AppData { get; set; } = string.Empty;
public string SharedStorageDbPath { get; set; } = string.Empty;
public ImageSource WorkspaceIcon() => WorkspaceIconBitMap;
public ImageSource RemoteIcon() => RemoteIconBitMap;

View File

@@ -16,7 +16,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
public static class VSCodeInstances
{
private static readonly string _userAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
private static readonly string _userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
public static List<VSCodeInstance> Instances { get; set; } = new List<VSCodeInstance>();
@@ -130,7 +129,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
var portableData = Path.Join(iconPath, "data");
instance.AppData = Directory.Exists(portableData) ? Path.Join(portableData, "user-data") : Path.Combine(_userAppDataPath, version);
instance.SharedStorageDbPath = GetSharedStorageDbPath(version, iconPath, Directory.Exists(portableData));
var vsCodeIconPath = Path.Join(iconPath, $"{version}.exe");
if (!File.Exists(vsCodeIconPath))
{
@@ -159,30 +157,5 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
Instances.Add(instance);
}
}
private static string GetSharedStorageDbPath(string version, string iconPath, bool isPortable)
{
if (isPortable)
{
return Path.Join(iconPath, "data-shared", "sharedStorage", "state.vscdb");
}
var sharedStorageDirectory = version switch
{
"Code" => ".vscode-shared",
"Code - Insiders" => ".vscode-insiders-shared",
"Code - Exploration" => ".vscode-exploration-shared",
"VSCodium" => ".vscodium-shared",
"VSCodium - Insiders" => ".vscodium-insiders-shared",
_ => string.Empty,
};
if (string.IsNullOrEmpty(sharedStorageDirectory))
{
return string.Empty;
}
return Path.Combine(_userProfilePath, sharedStorageDirectory, "sharedStorage", "state.vscdb");
}
}
}

View File

@@ -97,7 +97,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
// User/globalStorage/state.vscdb - history.recentlyOpenedPathsList - vscode v1.64 or later
var vscode_storage_db = Path.Combine(vscodeInstance.AppData, "User/globalStorage/state.vscdb");
var vscode_shared_storage_db = vscodeInstance.SharedStorageDbPath;
if (File.Exists(vscode_storage))
{
@@ -105,37 +104,17 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
results.AddRange(storageResults);
}
var storageDbPaths = new[] { vscode_storage_db, vscode_shared_storage_db }
.Where(filePath => !string.IsNullOrEmpty(filePath))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var storageDbPath in storageDbPaths)
if (File.Exists(vscode_storage_db))
{
if (File.Exists(storageDbPath))
{
var storageDbResults = GetWorkspacesInVscdb(vscodeInstance, storageDbPath);
results.AddRange(storageDbResults);
}
var storageDbResults = GetWorkspacesInVscdb(vscodeInstance, vscode_storage_db);
results.AddRange(storageDbResults);
}
}
return results
.Where(workspace => workspace != null)
.GroupBy(GetWorkspaceKey, StringComparer.OrdinalIgnoreCase)
.Select(workspaceGroup => workspaceGroup.First())
.ToList();
return results;
}
}
private static string GetWorkspaceKey(VSCodeWorkspace workspace)
{
return string.Join(
"|",
workspace.VSCodeInstance?.ExecutablePath ?? string.Empty,
workspace.WorkspaceType,
workspace.Path ?? string.Empty);
}
private List<VSCodeWorkspace> GetWorkspacesInJson(VSCodeInstance vscodeInstance, string filePath)
{
var storageFileResults = new List<VSCodeWorkspace>();

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.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Common.Drivers;
namespace PowerDisplay.UnitTests;
[TestClass]
public class DisplayClassifierTests
{
[DataTestMethod]
// Internal: INTERNAL high-bit flag
[DataRow(0x80000000u, true, DisplayName = "INTERNAL bit only")]
[DataRow(0x8000000Bu, true, DisplayName = "INTERNAL | DISPLAYPORT_EMBEDDED")]
// Internal: documented embedded subtypes
[DataRow(11u, true, DisplayName = "DISPLAYPORT_EMBEDDED")]
[DataRow(13u, true, DisplayName = "UDI_EMBEDDED")]
// External: LVDS is not classified internal per docs
[DataRow(6u, false, DisplayName = "LVDS (not classified internal per docs)")]
// External: documented external connectors
[DataRow(5u, false, DisplayName = "HDMI")]
[DataRow(10u, false, DisplayName = "DISPLAYPORT_EXTERNAL")]
[DataRow(12u, false, DisplayName = "UDI_EXTERNAL")]
// External: virtual / wireless
[DataRow(15u, false, DisplayName = "MIRACAST")]
[DataRow(17u, false, DisplayName = "INDIRECT_VIRTUAL")]
// External: OTHER (-1) cast to uint
[DataRow(0xFFFFFFFFu, false, DisplayName = "OTHER (-1 cast to uint)")]
// External: unrecognized values default to external
[DataRow(0xDEADBEEFu, false, DisplayName = "Unknown value defaults to external")]
// External: INTERNAL flag combined with an undocumented subtype is treated as external
// (locks in the docstring's "INTERNAL | unknown subtype = external" rule).
[DataRow(0x80000007u, false, DisplayName = "INTERNAL | unknown subtype 7 (treated as external)")]
public void IsInternal_ReturnsExpectedClassification(uint outputTechnology, bool expected)
{
Assert.AreEqual(expected, DisplayClassifier.IsInternal(outputTechnology));
}
}

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.");
}
}

Some files were not shown because too many files have changed in this diff Show More