mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-07 11:00:32 +02:00
Compare commits
37 Commits
integrate/
...
LegendaryB
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7d210cf7 | ||
|
|
812a075314 | ||
|
|
ec13493584 | ||
|
|
6921ebf7d8 | ||
|
|
71df0723b3 | ||
|
|
6a18cf63b1 | ||
|
|
4c82477042 | ||
|
|
ea55140b46 | ||
|
|
c93b223fb0 | ||
|
|
9f3f59b388 | ||
|
|
b731122d58 | ||
|
|
7ae15430da | ||
|
|
8b3aeb10c4 | ||
|
|
eff7e05aed | ||
|
|
a03aa7f8ad | ||
|
|
fa3342d24c | ||
|
|
9ffc7ede00 | ||
|
|
5a931f9d24 | ||
|
|
c06b66f696 | ||
|
|
8a22e44b9b | ||
|
|
b4c60b9f93 | ||
|
|
d36cf6227e | ||
|
|
98f3643513 | ||
|
|
80d709d054 | ||
|
|
61dd5952db | ||
|
|
7ff835a4ca | ||
|
|
1a5fc9d657 | ||
|
|
d355a7a8a6 | ||
|
|
524941f4af | ||
|
|
848557e871 | ||
|
|
ba268b7f7f | ||
|
|
c1e4f0967a | ||
|
|
294d06ea0a | ||
|
|
d3e2555dd9 | ||
|
|
92014c81b9 | ||
|
|
d57096af20 | ||
|
|
f136a4fe04 |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -1028,6 +1028,8 @@
|
||||
<Project Path="src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj" Id="45285df2-9742-4eca-9ac9-58951fc26489" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj" Id="3d63307b-9d27-44fd-b033-b26f39245b85" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj" Id="37d07516-4185-43a4-924f-3c7a5d95ecf6" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSettingsService/WorkspacesSettingsService.vcxproj" Id="8b6a7c32-5c8d-4ad1-9f60-7e1b3d17a220" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSettingsClient/WorkspacesSettingsClient.vcxproj" Id="d24e2c12-9911-4e51-b102-39e7b62b22f1" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/Tests/">
|
||||
<Project Path="src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj">
|
||||
@@ -1035,6 +1037,7 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj" Id="a85d4d9f-9a39-4b5d-8b5a-9f2d5c9a8b4c" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSettingsService/smoketest/WorkspacesSvcSmokeTest.vcxproj" Id="8b6a7c32-5c8d-4ad1-9f60-7e1b3d17a221" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/WindowProperties/">
|
||||
<File Path="src/modules/Workspaces/WindowProperties/WorkspacesWindowPropertyUtils.h" />
|
||||
|
||||
93
TESTING.md
Normal file
93
TESTING.md
Normal 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 |
|
||||
@@ -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;
|
||||
|
||||
@@ -36,5 +36,7 @@ EXPORTS
|
||||
SetBundleInstallLocationCA
|
||||
InstallPackageIdentityMSIXCA
|
||||
UninstallPackageIdentityMSIXCA
|
||||
InstallPTSettingsSvcCA
|
||||
UnRegisterPTSettingsSvcCA
|
||||
CreateWinAppSDKHardlinksCA
|
||||
DeleteWinAppSDKHardlinksCA
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 >= 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="ALL")" />
|
||||
<Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<!-- 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="ALL")" />
|
||||
<Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<!-- 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" />
|
||||
|
||||
243
installer/PowerToysSetupVNext/WorkspacesSettingsService.wxs
Normal file
243
installer/PowerToysSetupVNext/WorkspacesSettingsService.wxs
Normal 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 "[#PtSeedScript]""
|
||||
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 "[#PtCleanupScript]" -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="ALL")" />
|
||||
</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>
|
||||
@@ -102,19 +102,18 @@ namespace ShortcutGuide.Helpers
|
||||
/// <summary>
|
||||
/// Retrieves all application IDs that should be displayed, based on the foreground window and background processes.
|
||||
/// </summary>
|
||||
/// <param name="foregroundWindowHandle">The window handle captured before Shortcut Guide UI takes focus.</param>
|
||||
/// <returns>
|
||||
/// A dictionary mapping each application ID to the full path of the executable
|
||||
/// that caused the match (used for icon extraction), or <c>null</c> when no
|
||||
/// specific executable is associated (for example, wildcard filters like the
|
||||
/// default shell).
|
||||
/// </returns>
|
||||
public static Dictionary<string, string?> GetAllCurrentApplicationIds()
|
||||
public static Dictionary<string, string?> GetAllCurrentApplicationIds(nint foregroundWindowHandle)
|
||||
{
|
||||
nint handle = NativeMethods.GetForegroundWindow();
|
||||
|
||||
Dictionary<string, string?> applicationIds = new(StringComparer.Ordinal);
|
||||
|
||||
if (NativeMethods.GetWindowThreadProcessId(handle, out uint processId) > 0)
|
||||
if (NativeMethods.GetWindowThreadProcessId(foregroundWindowHandle, out uint processId) > 0)
|
||||
{
|
||||
string? name = null;
|
||||
string? executablePath = null;
|
||||
|
||||
@@ -21,9 +21,12 @@ namespace ShortcutGuide
|
||||
{
|
||||
public static Thread CopyAndIndexGenerationThread { get; private set; } = null!;
|
||||
|
||||
public static nint ForegroundWindowHandle { get; private set; } = nint.Zero;
|
||||
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
ForegroundWindowHandle = NativeMethods.GetForegroundWindow();
|
||||
Logger.InitializeLogger("\\ShortcutGuide\\Logs");
|
||||
|
||||
// The module interface passes: <powertoys_pid> [telemetry]
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace ShortcutGuide
|
||||
_getAppIdsTask = Task.Run(() =>
|
||||
{
|
||||
Program.CopyAndIndexGenerationThread.Join();
|
||||
_currentApplicationIds = ManifestInterpreter.GetAllCurrentApplicationIds();
|
||||
_currentApplicationIds = ManifestInterpreter.GetAllCurrentApplicationIds(Program.ForegroundWindowHandle);
|
||||
return _currentApplicationIds;
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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\<current-user-sid> (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\<sid>\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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
39
src/modules/Workspaces/WorkspacesSettingsService/Bindings.h
Normal file
39
src/modules/Workspaces/WorkspacesSettingsService/Bindings.h
Normal 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);
|
||||
}
|
||||
256
src/modules/Workspaces/WorkspacesSettingsService/CallerAuth.cpp
Normal file
256
src/modules/Workspaces/WorkspacesSettingsService/CallerAuth.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
323
src/modules/Workspaces/WorkspacesSettingsService/FileGuard.cpp
Normal file
323
src/modules/Workspaces/WorkspacesSettingsService/FileGuard.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/modules/Workspaces/WorkspacesSettingsService/FileGuard.h
Normal file
40
src/modules/Workspaces/WorkspacesSettingsService/FileGuard.h
Normal 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);
|
||||
}
|
||||
230
src/modules/Workspaces/WorkspacesSettingsService/Paths.cpp
Normal file
230
src/modules/Workspaces/WorkspacesSettingsService/Paths.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/modules/Workspaces/WorkspacesSettingsService/Paths.h
Normal file
42
src/modules/Workspaces/WorkspacesSettingsService/Paths.h
Normal 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);
|
||||
}
|
||||
294
src/modules/Workspaces/WorkspacesSettingsService/PipeServer.cpp
Normal file
294
src/modules/Workspaces/WorkspacesSettingsService/PipeServer.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
3
src/modules/Workspaces/WorkspacesSettingsService/devtools/.gitignore
vendored
Normal file
3
src/modules/Workspaces/WorkspacesSettingsService/devtools/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Compiled helpers produced by the validation scripts are build artifacts and
|
||||
# must never be committed.
|
||||
*.exe
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)."
|
||||
@@ -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 }
|
||||
5
src/modules/Workspaces/WorkspacesSettingsService/package/.gitignore
vendored
Normal file
5
src/modules/Workspaces/WorkspacesSettingsService/package/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
*.pfx
|
||||
*.cer
|
||||
*.msix
|
||||
logo.png
|
||||
*.ps1
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -5465,18 +5465,15 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
break;
|
||||
|
||||
}
|
||||
else if( newRecordToggleKey ) {
|
||||
UINT cropMod = newRecordToggleMod ^ MOD_SHIFT;
|
||||
UINT windowMod = newRecordToggleMod ^ MOD_ALT;
|
||||
if (!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
|
||||
(cropMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)) ||
|
||||
(windowMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) {
|
||||
else if( UINT cropMod = newRecordToggleMod ^ MOD_SHIFT, windowMod = newRecordToggleMod ^ MOD_ALT; newRecordToggleKey &&
|
||||
(!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
|
||||
(cropMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)) ||
|
||||
(windowMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)))) {
|
||||
|
||||
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
}
|
||||
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
} else {
|
||||
|
||||
g_BreakTimeout = newTimeout;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -483,8 +483,15 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodeBrightness, monitor.Id, out uint current, out uint max))
|
||||
{
|
||||
monitor.BrightnessVcpMax = (int)max;
|
||||
var brightnessInfo = new VcpFeatureValue((int)current, 0, (int)max);
|
||||
if (!brightnessInfo.IsValid)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"DDC: [{monitor.Id}] Ignoring invalid brightness range current={current}, max={max}");
|
||||
return;
|
||||
}
|
||||
|
||||
monitor.BrightnessVcpMax = (int)max;
|
||||
monitor.CurrentBrightness = brightnessInfo.ToPercentage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
Id = monitorId,
|
||||
Name = name.Trim(),
|
||||
CurrentBrightness = 50, // Default value, will be updated by MonitorManager after discovery
|
||||
CurrentBrightness = 50, // Initial placeholder; overwritten if the VCP read succeeds
|
||||
Handle = physicalMonitor.HPhysicalMonitor,
|
||||
Capabilities = MonitorCapabilities.DdcCi,
|
||||
CommunicationMethod = "DDC/CI",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace PowerDisplay.Common.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure decision logic for linked brightness, factored out of the WinUI ViewModel so initial
|
||||
/// seed behavior can be unit-tested without a DispatcherQueue.
|
||||
/// </summary>
|
||||
public static class LinkedBrightnessPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// A monitor's state relevant to linked-brightness planning.
|
||||
/// </summary>
|
||||
/// <param name="Id">Stable <c>Monitor.Id</c> (DevicePath form).</param>
|
||||
/// <param name="MonitorNumber">Windows DISPLAY number (1-based); 0 when unknown.</param>
|
||||
/// <param name="Brightness">Current brightness percentage (0-100).</param>
|
||||
public readonly record struct LinkTarget(
|
||||
string Id,
|
||||
int MonitorNumber,
|
||||
int Brightness);
|
||||
|
||||
/// <summary>
|
||||
/// The value to seed the master slider with when link mode turns on, or null when there is
|
||||
/// no linked target. The caller passes only included brightness-capable targets. Prefers
|
||||
/// the lowest Windows DISPLAY number and Id order for determinism when numbers are missing
|
||||
/// or tie.
|
||||
/// </summary>
|
||||
public static int? Seed(IEnumerable<LinkTarget> linkedTargets) =>
|
||||
linkedTargets
|
||||
.OrderBy(m => m.MonitorNumber <= 0 ? int.MaxValue : m.MonitorNumber)
|
||||
.ThenBy(m => m.Id, System.StringComparer.Ordinal)
|
||||
.Select(m => (int?)m.Brightness)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using PowerDisplay.Configuration;
|
||||
|
||||
namespace PowerDisplay.Helpers;
|
||||
|
||||
internal static class SliderCommitScheduler
|
||||
{
|
||||
internal static void Schedule(ref DispatcherQueueTimer? timer, DispatcherQueue dispatcherQueue, Func<Task> commit)
|
||||
{
|
||||
if (timer == null)
|
||||
{
|
||||
timer = dispatcherQueue.CreateTimer();
|
||||
timer.IsRepeating = false;
|
||||
timer.Interval = TimeSpan.FromMilliseconds(AppConstants.UI.SliderCommitDebounceMs);
|
||||
timer.Tick += (_, _) => _ = commit();
|
||||
}
|
||||
|
||||
timer.Stop();
|
||||
timer.Start();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<winuiex:WindowEx
|
||||
<winuiex:WindowEx
|
||||
x:Class="PowerDisplay.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
@@ -29,14 +29,16 @@
|
||||
<x:Double x:Key="FlyoutSeparatorHeight">1</x:Double>
|
||||
<GridLength x:Key="FlyoutSliderIconColumnWidth">20</GridLength>
|
||||
<x:Double x:Key="FlyoutActionGlyphFontSize">16</x:Double>
|
||||
<x:Double x:Key="FlyoutLinkGlyphFontSize">16</x:Double>
|
||||
<x:Double x:Key="FlyoutActionGlyphSmallFontSize">14</x:Double>
|
||||
<x:Double x:Key="FlyoutActionGlyphExtraSmallFontSize">12</x:Double>
|
||||
<x:Double x:Key="FlyoutSecondaryTextFontSize">12</x:Double>
|
||||
<x:Double x:Key="FlyoutRotationGlyphFontSize">14</x:Double>
|
||||
<x:Double x:Key="FlyoutButtonSize">32</x:Double>
|
||||
<x:Double x:Key="FlyoutSliderRowHeight">32</x:Double>
|
||||
<x:Double x:Key="FlyoutCompactSpacing">4</x:Double>
|
||||
<x:Double x:Key="FlyoutStandardSpacing">8</x:Double>
|
||||
<x:Double x:Key="FlyoutMonitorSectionSpacing">32</x:Double>
|
||||
<x:Double x:Key="FlyoutMonitorSectionSpacing">16</x:Double>
|
||||
<x:Double x:Key="FlyoutScanningIndicatorSize">24</x:Double>
|
||||
<x:Double x:Key="FlyoutScanningSpacing">16</x:Double>
|
||||
<Thickness x:Key="FlyoutButtonPadding">6</Thickness>
|
||||
@@ -45,11 +47,11 @@
|
||||
<Thickness x:Key="FlyoutListHeaderMargin">16,0,8,0</Thickness>
|
||||
<Thickness x:Key="FlyoutListItemPadding">0,4</Thickness>
|
||||
<Thickness x:Key="FlyoutSelectionGlyphMargin">8,0,0,0</Thickness>
|
||||
<Thickness x:Key="FlyoutSectionTopMargin">0,4,0,0</Thickness>
|
||||
<Thickness x:Key="FlyoutSectionTopMargin">0,8,0,0</Thickness>
|
||||
<Thickness x:Key="FlyoutSectionContainerPadding">8,0,8,8</Thickness>
|
||||
<Thickness x:Key="FlyoutSeparatorMargin">8,8</Thickness>
|
||||
<Thickness x:Key="FlyoutStatusBarMargin">12,-8,12,0</Thickness>
|
||||
<Thickness x:Key="MonitorHeaderOffsetMargin">10,0,0,0</Thickness>
|
||||
<Thickness x:Key="MonitorHeaderOffsetMargin">0,8,0,0</Thickness>
|
||||
<Thickness x:Key="MonitorNameBaselineMargin">2,0,0,2</Thickness>
|
||||
<x:Double x:Key="FlyoutContextMenuMinWidth">240</x:Double>
|
||||
<x:Double x:Key="FlyoutContextMenuMaxWidth">320</x:Double>
|
||||
@@ -62,6 +64,57 @@
|
||||
<Setter Property="Height" Value="{StaticResource FlyoutButtonSize}" />
|
||||
<Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" />
|
||||
</Style>
|
||||
<Style x:Key="FlyoutIconToggleButtonStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Padding" Value="{StaticResource FlyoutButtonPadding}" />
|
||||
<Setter Property="Width" Value="{StaticResource FlyoutButtonSize}" />
|
||||
<Setter Property="Height" Value="{StaticResource FlyoutButtonSize}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}">
|
||||
<ContentPresenter
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="FlyoutHeaderToggleButtonStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}">
|
||||
<ContentPresenter
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Grid.Resources>
|
||||
<Border x:Name="MainContainer">
|
||||
<Grid>
|
||||
@@ -114,190 +167,131 @@
|
||||
HorizontalScrollMode="Disabled"
|
||||
IsTabStop="False"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<!-- Monitors List with modern card design -->
|
||||
<ItemsRepeater
|
||||
x:Name="MonitorsRepeater"
|
||||
ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"
|
||||
TabFocusNavigation="Local"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}">
|
||||
<ItemsRepeater.Layout>
|
||||
<StackLayout Orientation="Vertical" Spacing="{StaticResource FlyoutMonitorSectionSpacing}" />
|
||||
</ItemsRepeater.Layout>
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MonitorViewModel">
|
||||
<StackPanel Spacing="{StaticResource FlyoutCompactSpacing}" TabFocusNavigation="Local">
|
||||
<!-- Monitor Name with Icon -->
|
||||
<Grid
|
||||
Margin="{StaticResource MonitorHeaderOffsetMargin}"
|
||||
HorizontalAlignment="Stretch"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<local:MonitorIcon
|
||||
VerticalAlignment="Center"
|
||||
IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}"
|
||||
MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
<!-- Linked-brightness + monitors list -->
|
||||
<StackPanel Spacing="{StaticResource FlyoutStandardSpacing}" Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}">
|
||||
|
||||
<!-- All displays card (visible only when link mode is on) -->
|
||||
<StackPanel
|
||||
Padding="{StaticResource FlyoutSectionContainerPadding}"
|
||||
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}"
|
||||
Spacing="{StaticResource FlyoutCompactSpacing}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.LinkedLevelsActive), Mode=OneWay}">
|
||||
<Grid Margin="{StaticResource FlyoutSectionTopMargin}" ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Viewbox
|
||||
Width="16"
|
||||
Height="16"
|
||||
VerticalAlignment="Center">
|
||||
<FontIcon
|
||||
FontSize="22"
|
||||
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
|
||||
Glyph="" />
|
||||
</Viewbox>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock
|
||||
x:Uid="AllDisplaysTitle"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource FlyoutCompactSpacing}">
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource MonitorNameBaselineMargin}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap"
|
||||
ToolTipService.ToolTip="{x:Bind DisplayName, Mode=OneWay}" />
|
||||
<!-- Icon buttons for InputSource and ColorTemperature -->
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="{StaticResource FlyoutCompactSpacing}">
|
||||
<!-- Color Temperature Button -->
|
||||
<Button
|
||||
x:Uid="ColorTemperatureTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowColorTemperature), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout Opened="Flyout_Opened" ShouldConstrainToRootBounds="False">
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandleColorTemperatureSelectionChanged}"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="ColorTemperatureHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ColorTemperatureItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<!-- More Button (Input Source + Power State) -->
|
||||
<Button
|
||||
x:Uid="MoreOptionsTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout
|
||||
Opened="Flyout_Opened"
|
||||
Placement="BottomEdgeAlignedRight"
|
||||
ShouldConstrainToRootBounds="False">
|
||||
<StackPanel MinWidth="{StaticResource FlyoutContextMenuMinWidth}" MaxWidth="{StaticResource FlyoutContextMenuMaxWidth}">
|
||||
<!-- Input Source Section -->
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandleInputSourceSelectionChanged}"
|
||||
SelectionMode="Single"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="InputSourceHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:InputSourceItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<!-- Separator between Input Source and Power State -->
|
||||
<Border
|
||||
Height="{StaticResource FlyoutSeparatorHeight}"
|
||||
Margin="{StaticResource FlyoutSeparatorMargin}"
|
||||
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
|
||||
<!-- Power State Section -->
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandlePowerStateSelectionChanged}"
|
||||
SelectionMode="Single"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="PowerStateHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PowerStateItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind ViewModel.LinkedMonitorsCountText, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="•"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasExcludedMonitors), Mode=OneWay}" />
|
||||
<TextBlock
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind ViewModel.ExcludedMonitorsCountText, Mode=OneWay}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasExcludedMonitors), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid
|
||||
Height="{StaticResource FlyoutSliderRowHeight}"
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
|
||||
Glyph="" />
|
||||
<Slider
|
||||
x:Uid="AllDisplaysBrightnessAutomation"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
IsEnabled="{x:Bind ViewModel.IsLinkedBrightnessSliderEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
Value="{x:Bind ViewModel.LinkedBrightness, Mode=TwoWay}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Individual displays section header (visible only when link mode is on) -->
|
||||
<ToggleButton
|
||||
x:Name="IndividualDisplaysToggle"
|
||||
x:Uid="IndividualDisplaysToggle"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
IsChecked="{x:Bind ViewModel.IndividualDisplaysExpanded, Mode=TwoWay}"
|
||||
Style="{StaticResource FlyoutHeaderToggleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.LinkedLevelsActive), Mode=OneWay}">
|
||||
<Grid HorizontalAlignment="Stretch" ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Uid="IndividualDisplaysHeader" VerticalAlignment="Center" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
FontSize="{StaticResource FlyoutActionGlyphExtraSmallFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIndividualDisplaysCollapsedIcon), Mode=OneWay}" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
FontSize="{StaticResource FlyoutActionGlyphExtraSmallFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.IndividualDisplaysExpanded), Mode=OneWay}" />
|
||||
<Border
|
||||
Grid.Column="2"
|
||||
Height="{StaticResource FlyoutSeparatorHeight}"
|
||||
VerticalAlignment="Center"
|
||||
Background="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
|
||||
<!-- Monitors List with modern card design -->
|
||||
<ItemsRepeater
|
||||
x:Name="MonitorsRepeater"
|
||||
ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"
|
||||
TabFocusNavigation="Local"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIndividualDisplays), Mode=OneWay}">
|
||||
<ItemsRepeater.Layout>
|
||||
<StackLayout Orientation="Vertical" Spacing="{StaticResource FlyoutMonitorSectionSpacing}" />
|
||||
</ItemsRepeater.Layout>
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MonitorViewModel">
|
||||
<StackPanel
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
Padding="{StaticResource FlyoutSectionContainerPadding}"
|
||||
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
@@ -305,11 +299,216 @@
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}"
|
||||
TabFocusNavigation="Local"
|
||||
XYFocusKeyboardNavigation="Enabled">
|
||||
<!-- Monitor Name with Icon -->
|
||||
<Grid
|
||||
Height="{StaticResource FlyoutButtonSize}"
|
||||
Margin="{StaticResource MonitorHeaderOffsetMargin}"
|
||||
HorizontalAlignment="Stretch"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<local:MonitorIcon
|
||||
VerticalAlignment="Center"
|
||||
IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}"
|
||||
MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource MonitorNameBaselineMargin}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap"
|
||||
ToolTipService.ToolTip="{x:Bind DisplayName, Mode=OneWay}" />
|
||||
<!-- Icon buttons for InputSource and ColorTemperature -->
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="{StaticResource FlyoutCompactSpacing}">
|
||||
<!-- Exclude-from-linked-brightness toggle (only shown while link mode is on) -->
|
||||
<ToggleButton
|
||||
x:Uid="ExcludeFromSyncToggle"
|
||||
IsChecked="{x:Bind IsExcludedFromSync, Mode=TwoWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Style="{StaticResource FlyoutIconToggleButtonStyle}"
|
||||
ToolTipService.ToolTip="{x:Bind SyncToggleToolTip, Mode=OneWay}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowExcludeButton), Mode=OneWay}">
|
||||
<Grid>
|
||||
<FontIcon
|
||||
FontSize="{StaticResource FlyoutLinkGlyphFontSize}"
|
||||
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowIncludedInSyncIcon), Mode=OneWay}" />
|
||||
<Grid Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsExcludedFromSync), Mode=OneWay}">
|
||||
<FontIcon
|
||||
FontSize="{StaticResource FlyoutLinkGlyphFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Glyph="" />
|
||||
<Border
|
||||
Width="2"
|
||||
Height="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Background="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform Angle="-45" />
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
<!-- Color Temperature Button -->
|
||||
<Button
|
||||
x:Uid="ColorTemperatureTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowColorTemperature), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout Opened="Flyout_Opened" ShouldConstrainToRootBounds="False">
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandleColorTemperatureSelectionChanged}"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="ColorTemperatureHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ColorTemperatureItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<!-- More Button (Input Source + Power State) -->
|
||||
<Button
|
||||
x:Uid="MoreOptionsTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout
|
||||
Opened="Flyout_Opened"
|
||||
Placement="BottomEdgeAlignedRight"
|
||||
ShouldConstrainToRootBounds="False">
|
||||
<StackPanel MinWidth="{StaticResource FlyoutContextMenuMinWidth}" MaxWidth="{StaticResource FlyoutContextMenuMaxWidth}">
|
||||
<!-- Input Source Section -->
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandleInputSourceSelectionChanged}"
|
||||
SelectionMode="Single"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="InputSourceHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:InputSourceItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<!-- Separator between Input Source and Power State -->
|
||||
<Border
|
||||
Height="{StaticResource FlyoutSeparatorHeight}"
|
||||
Margin="{StaticResource FlyoutSeparatorMargin}"
|
||||
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
|
||||
<!-- Power State Section -->
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandlePowerStateSelectionChanged}"
|
||||
SelectionMode="Single"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="PowerStateHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PowerStateItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<!-- Brightness Control -->
|
||||
<Grid
|
||||
Height="{StaticResource FlyoutSliderRowHeight}"
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowBrightness), Mode=OneWay}">
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowBrightnessSlider), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -327,7 +526,7 @@
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsBrightnessSliderEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
@@ -473,10 +672,10 @@
|
||||
</ToggleButton>-->
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -490,49 +689,74 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
x:Name="ProfilesButton"
|
||||
x:Uid="ProfilesTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout
|
||||
x:Name="ProfilesFlyout"
|
||||
Opened="Flyout_Opened"
|
||||
ShouldConstrainToRootBounds="False">
|
||||
<ListView
|
||||
x:Name="ProfilesListView"
|
||||
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
|
||||
SelectionChanged="ProfileListView_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="ProfilesHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:PowerDisplayProfile">
|
||||
<StackPanel
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Button
|
||||
x:Name="ProfilesButton"
|
||||
x:Uid="ProfilesTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout
|
||||
x:Name="ProfilesFlyout"
|
||||
Opened="Flyout_Opened"
|
||||
ShouldConstrainToRootBounds="False">
|
||||
<ListView
|
||||
x:Name="ProfilesListView"
|
||||
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
|
||||
SelectionChanged="ProfileListView_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
Padding="{StaticResource FlyoutListItemPadding}"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
x:Uid="ProfilesHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:PowerDisplayProfile">
|
||||
<TextBlock
|
||||
Padding="{StaticResource FlyoutListItemPadding}"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<ToggleButton
|
||||
x:Name="LinkLevelsToggle"
|
||||
x:Uid="LinkLevelsToggle"
|
||||
IsChecked="{x:Bind ViewModel.LinkedLevelsActive, Mode=TwoWay}"
|
||||
Style="{StaticResource FlyoutIconToggleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowLinkLevelsToggle), Mode=OneWay}">
|
||||
<Grid>
|
||||
<FontIcon
|
||||
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowLinkLevelsInactiveIcon), Mode=OneWay}" />
|
||||
<FontIcon
|
||||
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
|
||||
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.LinkedLevelsActive), Mode=OneWay}" />
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
<Button
|
||||
x:Uid="RefreshTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
@@ -574,4 +798,4 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</winuiex:WindowEx>
|
||||
</winuiex:WindowEx>
|
||||
|
||||
@@ -250,17 +250,14 @@ namespace PowerDisplay
|
||||
private void OnUIRefreshRequested(object? sender, EventArgs e)
|
||||
{
|
||||
// Adjust window size when UI configuration changes (feature visibility toggles)
|
||||
DispatcherQueue.TryEnqueue(() => AdjustWindowSizeToContent());
|
||||
QueueWindowSizeAdjustment();
|
||||
}
|
||||
|
||||
private void OnMonitorsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
// Adjust window size when monitors collection changes (event-driven!)
|
||||
// The UI binding will update first, then we adjust size
|
||||
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
AdjustWindowSizeToContent();
|
||||
});
|
||||
QueueWindowSizeAdjustment();
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -268,16 +265,28 @@ namespace PowerDisplay
|
||||
// Adjust window size when relevant properties change (event-driven!)
|
||||
if (e.PropertyName == nameof(_viewModel.IsScanning) ||
|
||||
e.PropertyName == nameof(_viewModel.HasMonitors) ||
|
||||
e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage))
|
||||
e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage) ||
|
||||
e.PropertyName == nameof(_viewModel.LinkedLevelsActive) ||
|
||||
e.PropertyName == nameof(_viewModel.IndividualDisplaysExpanded) ||
|
||||
e.PropertyName == nameof(_viewModel.ShowIndividualDisplays) ||
|
||||
e.PropertyName == nameof(_viewModel.ShowLinkLevelsToggle) ||
|
||||
e.PropertyName == nameof(_viewModel.LinkedMonitorsCount) ||
|
||||
e.PropertyName == nameof(_viewModel.LinkedMonitorsCountText) ||
|
||||
e.PropertyName == nameof(_viewModel.ExcludedMonitorsCount) ||
|
||||
e.PropertyName == nameof(_viewModel.ExcludedMonitorsCountText) ||
|
||||
e.PropertyName == nameof(_viewModel.HasExcludedMonitors) ||
|
||||
e.PropertyName == nameof(_viewModel.IsLinkedBrightnessAvailable))
|
||||
{
|
||||
// Use Low priority to ensure UI bindings update first
|
||||
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
AdjustWindowSizeToContent();
|
||||
});
|
||||
QueueWindowSizeAdjustment();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueWindowSizeAdjustment()
|
||||
{
|
||||
// Use Low priority to ensure x:Bind visibility/layout updates land before measuring.
|
||||
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, AdjustWindowSizeToContent);
|
||||
}
|
||||
|
||||
private void OnRefreshClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
@@ -368,17 +377,29 @@ namespace PowerDisplay
|
||||
return;
|
||||
}
|
||||
|
||||
var maxHeightDip = GetAdaptiveWindowMaxHeightDip();
|
||||
if (MainScrollViewer != null)
|
||||
{
|
||||
MainScrollViewer.MaxHeight = double.PositiveInfinity;
|
||||
}
|
||||
|
||||
// Force layout update and measure content height
|
||||
RootGrid.UpdateLayout();
|
||||
MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidthDip, double.PositiveInfinity));
|
||||
var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0);
|
||||
var maxHeightDip = GetAdaptiveWindowMaxHeightDip();
|
||||
|
||||
// Apply min/max height limits and reposition using DIP values.
|
||||
// Min height ensures window is visible even if content hasn't loaded yet
|
||||
var finalHeightDip = Math.Min(contentHeight, maxHeightDip);
|
||||
var finalHeightDip = Math.Max(AppConstants.UI.WindowMinHeightDip, Math.Min(contentHeight, maxHeightDip));
|
||||
Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, maxHeightDip={maxHeightDip}, finalHeightDip={finalHeightDip}");
|
||||
|
||||
if (MainScrollViewer != null)
|
||||
{
|
||||
var statusBarHeight = StatusBar?.ActualHeight > 0 ? StatusBar.ActualHeight : 48;
|
||||
MainScrollViewer.MaxHeight = Math.Max(0, maxHeightDip - statusBarHeight);
|
||||
RootGrid.UpdateLayout();
|
||||
}
|
||||
|
||||
// FlyoutWindowHelper handles cross-monitor DPI internally via a 1×1 teleport
|
||||
// before the final move, so no WM_DPICHANGED suppression is required here.
|
||||
FlyoutWindowHelper.PositionWindowBottomRight(
|
||||
|
||||
@@ -197,6 +197,43 @@
|
||||
<data name="ProfilesTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Profiles</value>
|
||||
</data>
|
||||
<data name="LinkLevelsToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Link brightness levels</value>
|
||||
</data>
|
||||
<data name="LinkLevelsToggle.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Link brightness across monitors</value>
|
||||
</data>
|
||||
<data name="AllDisplaysTitle.Text" xml:space="preserve">
|
||||
<value>All displays</value>
|
||||
<comment>Title of the card that controls brightness for all linked monitors at once</comment>
|
||||
</data>
|
||||
<data name="AllDisplaysBrightnessAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>All displays brightness</value>
|
||||
</data>
|
||||
<data name="AllDisplaysLinkedCountFormat" xml:space="preserve">
|
||||
<value>{0} linked</value>
|
||||
<comment>Subtitle on the All displays card. {0} is the number of monitors whose brightness is linked</comment>
|
||||
</data>
|
||||
<data name="AllDisplaysExcludedCountFormat" xml:space="preserve">
|
||||
<value>{0} excluded</value>
|
||||
<comment>Subtitle on the All displays card. {0} is the number of monitors excluded from linked brightness</comment>
|
||||
</data>
|
||||
<data name="IndividualDisplaysHeader.Text" xml:space="preserve">
|
||||
<value>Individual displays</value>
|
||||
<comment>Header for the collapsible section listing each monitor while link mode is on</comment>
|
||||
</data>
|
||||
<data name="IndividualDisplaysToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Individual displays</value>
|
||||
</data>
|
||||
<data name="ExcludeFromSyncToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Exclude from linked brightness</value>
|
||||
</data>
|
||||
<data name="ExcludeFromSyncToggleToolTip" xml:space="preserve">
|
||||
<value>Brightness is controlled by linked mode.</value>
|
||||
</data>
|
||||
<data name="IncludeInSyncToggleToolTip" xml:space="preserve">
|
||||
<value>Include this display in linked brightness</value>
|
||||
</data>
|
||||
<data name="IdentifyText.Text" xml:space="preserve">
|
||||
<value>Identify monitors</value>
|
||||
</data>
|
||||
@@ -221,4 +258,4 @@
|
||||
<data name="ColorTemperatureHeader.Text" xml:space="preserve">
|
||||
<value>Color temperature</value>
|
||||
</data>
|
||||
</root>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Helpers;
|
||||
|
||||
namespace PowerDisplay.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// MainViewModel — linked-brightness behavior: toggle side effects, initial value,
|
||||
/// per-monitor exclusion set, debounced broadcast across linked monitors.
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
// Monitor Ids (Monitor.Id form) the user has excluded from linked brightness. An excluded
|
||||
// monitor keeps its own slider while link mode is on. Authoritative runtime copy of
|
||||
// PowerDisplayProperties.ExcludedFromSyncMonitorIds; loaded by LoadExcludedMonitorIds and
|
||||
// persisted by SaveExcludedMonitorIds. Monitors not in this set are linked by default.
|
||||
private readonly HashSet<string> _excludedMonitorIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Set while the toggle handler is seeding the initial LinkedBrightness value
|
||||
// so OnLinkedBrightnessChanged does not treat the seed as a user-driven change
|
||||
// and schedule a broadcast — the design promise is "no auto-write on toggle ON".
|
||||
private bool _suppressLinkedBrightnessBroadcast;
|
||||
|
||||
private DispatcherQueueTimer? _linkedBrightnessCommitTimer;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the monitor is currently driven by linked brightness — it supports
|
||||
/// brightness and the user has not excluded it. This mirrors the planner's linked-target rule
|
||||
/// for live ViewModels on the broadcast and optimistic-update hot paths.
|
||||
/// </summary>
|
||||
private bool IsLinkedTarget(MonitorViewModel monitor) =>
|
||||
monitor.SupportsBrightness && !_excludedMonitorIds.Contains(monitor.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the currently included brightness-capable monitors as
|
||||
/// <see cref="LinkedBrightnessPlanner.LinkTarget"/> values for seed decisions.
|
||||
/// </summary>
|
||||
private List<LinkedBrightnessPlanner.LinkTarget> BuildLinkTargets() =>
|
||||
Monitors.Where(IsLinkedTarget)
|
||||
.Select(m => new LinkedBrightnessPlanner.LinkTarget(
|
||||
m.Id,
|
||||
m.MonitorNumber,
|
||||
m.Brightness))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the given monitor Id is excluded from linked brightness. Queried by
|
||||
/// <see cref="MonitorViewModel.IsExcludedFromSync"/> so each card reflects the shared set.
|
||||
/// </summary>
|
||||
internal bool IsMonitorExcludedFromSync(string monitorId) => _excludedMonitorIds.Contains(monitorId);
|
||||
|
||||
/// <summary>
|
||||
/// Add or remove a monitor from the linked-brightness exclusion set, persist the change, and
|
||||
/// refresh the "N linked" summary. When a monitor is brought back into an existing linked set
|
||||
/// (<paramref name="excluded"/> = false) while link mode is on, re-broadcast so it snaps to
|
||||
/// the current master level. If it is the first linked target, seed the master slider from it
|
||||
/// instead. Excluding a monitor does not write hardware: it simply stops following the group.
|
||||
/// </summary>
|
||||
internal void SetMonitorExcludedFromSync(string monitorId, bool excluded)
|
||||
{
|
||||
var hadLinkedTarget = Monitors.Any(IsLinkedTarget);
|
||||
var changed = excluded
|
||||
? _excludedMonitorIds.Add(monitorId)
|
||||
: _excludedMonitorIds.Remove(monitorId);
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveExcludedMonitorIds();
|
||||
|
||||
// Excluding can empty the linked set (slider must disable); including can refill it.
|
||||
IsLinkedBrightnessAvailable = Monitors.Any(IsLinkedTarget);
|
||||
OnPropertyChanged(nameof(LinkedMonitorsCount));
|
||||
OnPropertyChanged(nameof(LinkedMonitorsCountText));
|
||||
OnPropertyChanged(nameof(ExcludedMonitorsCount));
|
||||
OnPropertyChanged(nameof(ExcludedMonitorsCountText));
|
||||
OnPropertyChanged(nameof(HasExcludedMonitors));
|
||||
|
||||
if (!excluded && LinkedLevelsActive)
|
||||
{
|
||||
if (hadLinkedTarget)
|
||||
{
|
||||
ScheduleLinkedBrightnessCommit();
|
||||
}
|
||||
else
|
||||
{
|
||||
SeedInitialLinkedBrightness();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the runtime exclusion set from persisted settings. Called by LoadUIDisplaySettings
|
||||
/// before monitor discovery so cards read the correct excluded state on first render.
|
||||
/// </summary>
|
||||
internal void LoadExcludedMonitorIds(IEnumerable<string>? excludedIds)
|
||||
{
|
||||
_excludedMonitorIds.Clear();
|
||||
if (excludedIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in excludedIds)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
_excludedMonitorIds.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partial change hook fired by the source-generator for <see cref="LinkedLevelsActive"/>.
|
||||
/// Persists the new value to settings.json and, on transition to ON, seeds the
|
||||
/// linked-brightness slider with an initial value drawn from the connected monitors.
|
||||
/// </summary>
|
||||
partial void OnLinkedLevelsActiveChanged(bool value)
|
||||
{
|
||||
SaveLinkedLevelsActive(value);
|
||||
|
||||
if (value)
|
||||
{
|
||||
// Entering linked mode starts with the individual cards collapsed so the master
|
||||
// slider is the focus; the user can expand to reach per-display settings.
|
||||
IndividualDisplaysExpanded = false;
|
||||
SeedInitialLinkedBrightness();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Drop any pending master-slider write. Once the user turns linked mode off, the
|
||||
// delayed broadcast from the previous linked slider gesture should not still run.
|
||||
_linkedBrightnessCommitTimer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partial change hook fired by the source-generator for <see cref="LinkedBrightness"/>.
|
||||
/// Optimistically updates every linked monitor's brightness value to match the master value
|
||||
/// that will be broadcast to hardware, so it is correct if the monitor is later excluded or
|
||||
/// link mode is disabled. Then schedules a debounced hardware broadcast. Suppressed during
|
||||
/// initial-value seeding so turning the toggle on does not auto-write hardware.
|
||||
/// </summary>
|
||||
partial void OnLinkedBrightnessChanged(int value)
|
||||
{
|
||||
if (_suppressLinkedBrightnessBroadcast)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LinkedLevelsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep linked monitor VMs aligned with the master value without per-VM commits. The row is
|
||||
// usually covered by the linked-mode hint, but the backing value should be correct if the
|
||||
// monitor is later excluded or link mode is disabled.
|
||||
foreach (var vm in Monitors)
|
||||
{
|
||||
if (IsLinkedTarget(vm))
|
||||
{
|
||||
vm.UpdateBrightnessDisplay(value);
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleLinkedBrightnessCommit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <see cref="LinkedBrightness"/> with the initial value used to position the master
|
||||
/// slider when link mode turns on, via <see cref="LinkedBrightnessPlanner.Seed"/> (lowest
|
||||
/// DISPLAY number, then deterministic fallback). The seed is positional only: it is never
|
||||
/// written to hardware (the suppress flag gates <see cref="OnLinkedBrightnessChanged"/>), so
|
||||
/// the first user gesture is the first broadcast.
|
||||
/// </summary>
|
||||
private void SeedInitialLinkedBrightness()
|
||||
{
|
||||
var seed = LinkedBrightnessPlanner.Seed(BuildLinkTargets());
|
||||
IsLinkedBrightnessAvailable = seed.HasValue;
|
||||
if (seed.HasValue)
|
||||
{
|
||||
SetLinkedBrightnessSilently(seed.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set <see cref="LinkedBrightness"/> without triggering a hardware broadcast — used for
|
||||
/// positioning the master slider during seeding.
|
||||
/// </summary>
|
||||
private void SetLinkedBrightnessSilently(int value)
|
||||
{
|
||||
_suppressLinkedBrightnessBroadcast = true;
|
||||
try
|
||||
{
|
||||
LinkedBrightness = value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressLinkedBrightnessBroadcast = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recompute <see cref="IsLinkedBrightnessAvailable"/> and the linked-monitors count after a
|
||||
/// monitor add/remove/refresh. Called from <c>UpdateMonitorList</c> so the master slider's
|
||||
/// enabled state and "N linked" subtitle stay in sync with the live monitor set.
|
||||
/// </summary>
|
||||
internal void RecomputeLinkedBrightnessAvailability()
|
||||
{
|
||||
IsLinkedBrightnessAvailable = Monitors.Any(IsLinkedTarget);
|
||||
OnPropertyChanged(nameof(LinkedMonitorsCount));
|
||||
OnPropertyChanged(nameof(LinkedMonitorsCountText));
|
||||
OnPropertyChanged(nameof(ExcludedMonitorsCount));
|
||||
OnPropertyChanged(nameof(ExcludedMonitorsCountText));
|
||||
OnPropertyChanged(nameof(HasExcludedMonitors));
|
||||
|
||||
if (LinkedLevelsActive)
|
||||
{
|
||||
SeedInitialLinkedBrightness();
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleLinkedBrightnessCommit()
|
||||
{
|
||||
SliderCommitScheduler.Schedule(
|
||||
ref _linkedBrightnessCommitTimer,
|
||||
_dispatcherQueue,
|
||||
() => BroadcastLinkedBrightnessAsync(LinkedBrightness));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop a queued linked-brightness hardware write. Used when the monitor topology is changing,
|
||||
/// so a delayed commit from the previous topology cannot apply to a newly discovered target set.
|
||||
/// </summary>
|
||||
private void CancelPendingLinkedBrightnessCommit()
|
||||
{
|
||||
_linkedBrightnessCommitTimer?.Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast the linked brightness value to every linked target via
|
||||
/// <see cref="MonitorViewModel.SetBrightnessAsync"/>. Each per-VM call already wraps hardware
|
||||
/// errors in its own try/catch, so a single failing monitor does not break the others.
|
||||
/// </summary>
|
||||
private async Task BroadcastLinkedBrightnessAsync(int value)
|
||||
{
|
||||
if (!LinkedLevelsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var writes = Monitors
|
||||
.Where(IsLinkedTarget)
|
||||
.Select(m => m.SetBrightnessAsync(value))
|
||||
.ToList();
|
||||
|
||||
if (writes.Count > 0)
|
||||
{
|
||||
await Task.WhenAll(writes);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[LinkedBrightness] Broadcast failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ public partial class MainViewModel
|
||||
|
||||
try
|
||||
{
|
||||
CancelPendingLinkedBrightnessCommit();
|
||||
IsScanning = true;
|
||||
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
@@ -133,6 +134,8 @@ public partial class MainViewModel
|
||||
|
||||
private void UpdateMonitorList(IReadOnlyList<Monitor> monitors, bool isInitialLoad)
|
||||
{
|
||||
CancelPendingLinkedBrightnessCommit();
|
||||
|
||||
// Dispose old ViewModels to unsubscribe PropertyChanged handlers
|
||||
foreach (var vm in Monitors)
|
||||
{
|
||||
@@ -160,6 +163,8 @@ public partial class MainViewModel
|
||||
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
OnPropertyChanged(nameof(ShowNoMonitorsMessage));
|
||||
OnPropertyChanged(nameof(ShowLinkLevelsToggle));
|
||||
RecomputeLinkedBrightnessAvailability();
|
||||
|
||||
// Save monitor information to settings
|
||||
SaveMonitorsToSettings();
|
||||
|
||||
@@ -25,6 +25,55 @@ namespace PowerDisplay.ViewModels;
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Persist the link-levels toggle state to settings.json. Called from
|
||||
/// <c>OnLinkedLevelsActiveChanged</c> in <c>MainViewModel.LinkedBrightness.cs</c>
|
||||
/// (which owns the source-generator hook plus the broadcast/seed side effects).
|
||||
/// </summary>
|
||||
private void SaveLinkedLevelsActive(bool value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
if (settings.Properties.LinkedLevelsActive == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settings.Properties.LinkedLevelsActive = value;
|
||||
|
||||
_settingsUtils.SaveSettings(
|
||||
System.Text.Json.JsonSerializer.Serialize(settings, AppJsonContext.Default.PowerDisplaySettings),
|
||||
PowerDisplaySettings.ModuleName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[Settings] Failed to save LinkedLevelsActive: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist the linked-brightness exclusion set to settings.json. Called from
|
||||
/// <c>SetMonitorExcludedFromSync</c> in <c>MainViewModel.LinkedBrightness.cs</c> after the
|
||||
/// runtime <c>_excludedMonitorIds</c> set has been mutated.
|
||||
/// </summary>
|
||||
private void SaveExcludedMonitorIds()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
settings.Properties.ExcludedFromSyncMonitorIds = _excludedMonitorIds.ToList();
|
||||
|
||||
_settingsUtils.SaveSettings(
|
||||
System.Text.Json.JsonSerializer.Serialize(settings, AppJsonContext.Default.PowerDisplaySettings),
|
||||
PowerDisplaySettings.ModuleName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[Settings] Failed to save ExcludedFromSyncMonitorIds: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryRestore(
|
||||
List<Task> tasks,
|
||||
int? savedValue,
|
||||
@@ -208,10 +257,17 @@ public partial class MainViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply profile settings to monitors
|
||||
/// Apply profile settings to monitors. Profiles are per-monitor snapshots, so applying one
|
||||
/// turns off linked brightness before writing individual monitor values.
|
||||
/// </summary>
|
||||
private async Task ApplyProfileAsync(List<ProfileMonitorSetting> monitorSettings)
|
||||
{
|
||||
if (LinkedLevelsActive)
|
||||
{
|
||||
Logger.LogInfo("[Profile] Disabling linked brightness before applying per-monitor profile values");
|
||||
LinkedLevelsActive = false;
|
||||
}
|
||||
|
||||
var updateTasks = new List<Task>();
|
||||
|
||||
foreach (var setting in monitorSettings)
|
||||
|
||||
@@ -53,6 +53,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
[NotifyPropertyChangedFor(nameof(HasMonitors))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowNoMonitorsMessage))]
|
||||
[NotifyPropertyChangedFor(nameof(IsInteractionEnabled))]
|
||||
[NotifyPropertyChangedFor(nameof(IsLinkedBrightnessSliderEnabled))]
|
||||
public partial bool IsScanning { get; set; }
|
||||
|
||||
private bool _isInitialized;
|
||||
@@ -128,6 +129,113 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
[ObservableProperty]
|
||||
public partial bool ShowIdentifyMonitorsButton { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether brightness slider changes are broadcast to all
|
||||
/// non-excluded monitors as one linked level. Persisted in <c>PowerDisplaySettings</c> so
|
||||
/// the choice survives restarts. The toggle is meaningful only when two or more monitors
|
||||
/// are connected — see <see cref="ShowLinkLevelsToggle"/> for the visibility gate.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowIndividualDisplays))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowLinkLevelsToggle))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowLinkLevelsInactiveIcon))]
|
||||
public partial bool LinkedLevelsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the brightness value driving the linked "All Displays" slider. Setter is
|
||||
/// debounced and broadcasts on commit to every linked target: a
|
||||
/// <see cref="MonitorViewModel.SupportsBrightness"/> monitor not excluded from sync (see
|
||||
/// <c>OnLinkedBrightnessChanged</c>). Synchronously updates each linked monitor's brightness
|
||||
/// value so it is correct if the monitor is later excluded or link mode is disabled — the
|
||||
/// linked broadcast is the single source of hardware writes while link mode is active.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial int LinkedBrightness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the linked brightness slider has at least one linked
|
||||
/// brightness-capable monitor to control.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsLinkedBrightnessSliderEnabled))]
|
||||
public partial bool IsLinkedBrightnessAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the linked "All Displays" brightness slider accepts input.
|
||||
/// Disabled both when no linked target is available and while monitor discovery/restore is busy.
|
||||
/// </summary>
|
||||
public bool IsLinkedBrightnessSliderEnabled => IsLinkedBrightnessAvailable && IsInteractionEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of monitors that participate in linked brightness — used for the
|
||||
/// "All Displays" card subtitle ("N linked"). Counts brightness-capable monitors the user
|
||||
/// has not excluded (see <see cref="MonitorViewModel.IsExcludedFromSync"/>).
|
||||
/// </summary>
|
||||
public int LinkedMonitorsCount => Monitors.Count(m => m.SupportsBrightness && !IsMonitorExcludedFromSync(m.Id));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of brightness-capable monitors excluded from linked brightness.
|
||||
/// </summary>
|
||||
public int ExcludedMonitorsCount => Monitors.Count(m => m.SupportsBrightness && IsMonitorExcludedFromSync(m.Id));
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the "N excluded" subtitle fragment should be visible on
|
||||
/// the "All displays" card.
|
||||
/// </summary>
|
||||
public bool HasExcludedMonitors => ExcludedMonitorsCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized "N linked" subtitle shown on the "All Displays" card. Recomputed
|
||||
/// alongside <see cref="LinkedMonitorsCount"/> via <c>RecomputeLinkedBrightnessAvailability</c>.
|
||||
/// </summary>
|
||||
public string LinkedMonitorsCountText =>
|
||||
string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
Helpers.ResourceLoaderInstance.ResourceLoader.GetString("AllDisplaysLinkedCountFormat"),
|
||||
LinkedMonitorsCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized "N excluded" subtitle fragment shown on the "All Displays" card.
|
||||
/// </summary>
|
||||
public string ExcludedMonitorsCountText =>
|
||||
string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
Helpers.ResourceLoaderInstance.ResourceLoader.GetString("AllDisplaysExcludedCountFormat"),
|
||||
ExcludedMonitorsCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the "Individual displays" section is expanded
|
||||
/// while link mode is on. Collapsed by default so linked mode reads as a single master
|
||||
/// slider; expanding reveals the per-monitor cards (with linked monitors' sliders disabled).
|
||||
/// Ignored when link mode is off — see <see cref="ShowIndividualDisplays"/>.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowIndividualDisplays))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowIndividualDisplaysCollapsedIcon))]
|
||||
public partial bool IndividualDisplaysExpanded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the per-monitor cards list is shown. Always shown when
|
||||
/// link mode is off (the classic layout); when link mode is on the cards are tucked into the
|
||||
/// collapsible "Individual displays" section and only shown once the user expands it.
|
||||
/// </summary>
|
||||
public bool ShowIndividualDisplays => !LinkedLevelsActive || IndividualDisplaysExpanded;
|
||||
|
||||
public bool ShowIndividualDisplaysCollapsedIcon => !IndividualDisplaysExpanded;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the link-levels toggle should be visible. Shown when at
|
||||
/// least two monitors report <see cref="MonitorViewModel.SupportsBrightness"/> (the entry-point
|
||||
/// gate — counting raw entries would show the toggle even when only one display can actually be
|
||||
/// driven), OR whenever link mode is already active. The second clause guarantees the user can
|
||||
/// always turn link mode back off, even if monitors were unplugged down to one controllable
|
||||
/// display while linked — otherwise the "All displays" card would strand them with no way out.
|
||||
/// Recomputed by <see cref="UpdateMonitorList"/> and on <see cref="LinkedLevelsActive"/> change.
|
||||
/// </summary>
|
||||
public bool ShowLinkLevelsToggle => LinkedLevelsActive || Monitors.Count(m => m.SupportsBrightness) >= 2;
|
||||
|
||||
public bool ShowLinkLevelsInactiveIcon => !LinkedLevelsActive;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to show the profile switcher button.
|
||||
/// Combines settings value with HasProfiles check.
|
||||
@@ -173,6 +281,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
_isLoading = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsInteractionEnabled));
|
||||
OnPropertyChanged(nameof(IsLinkedBrightnessSliderEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +375,16 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
// Cancel all async operations first
|
||||
_cancellationTokenSource?.Cancel();
|
||||
|
||||
// Stop the linked-brightness debounce timer so its Tick handler does not fire after
|
||||
// we have already cleared Monitors below (broadcast would iterate an empty list).
|
||||
try
|
||||
{
|
||||
_linkedBrightnessCommitTimer?.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
// Dispose each resource independently to ensure all get cleaned up
|
||||
try
|
||||
{
|
||||
@@ -361,6 +480,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
|
||||
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
|
||||
|
||||
// Load the linked-brightness exclusion set before applying LinkedLevelsActive. If this
|
||||
// method runs after monitors are already discovered, the toggle hook can seed the master
|
||||
// slider immediately and must see the persisted exclusions.
|
||||
LoadExcludedMonitorIds(settings.Properties.ExcludedFromSyncMonitorIds);
|
||||
|
||||
LinkedLevelsActive = settings.Properties.LinkedLevelsActive;
|
||||
|
||||
// Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models)
|
||||
CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List<CustomVcpValueMapping>();
|
||||
Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings");
|
||||
@@ -381,6 +507,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
/// </summary>
|
||||
private void OnDisplayChanging(object? sender, EventArgs e)
|
||||
{
|
||||
CancelPendingLinkedBrightnessCommit();
|
||||
|
||||
if (!IsScanning)
|
||||
{
|
||||
Logger.LogInfo("[MainViewModel] Display change detected — locking UI ahead of rediscovery");
|
||||
|
||||
@@ -50,6 +50,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
|
||||
// Visibility settings (controlled by Settings UI)
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowBrightnessSlider))]
|
||||
public partial bool ShowBrightness { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -85,7 +86,6 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
OnPropertyChanged(nameof(Brightness));
|
||||
}
|
||||
|
||||
// Apply to hardware
|
||||
await ApplyPropertyToHardwareAsync(nameof(Brightness), brightness, _monitorManager.SetBrightnessAsync);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
/// <param name="propertyName">Name of the property being set (for logging and state persistence)</param>
|
||||
/// <param name="value">Value to apply</param>
|
||||
/// <param name="setAsyncFunc">Async function to call on MonitorManager</param>
|
||||
private async Task ApplyPropertyToHardwareAsync(
|
||||
private async Task<bool> ApplyPropertyToHardwareAsync(
|
||||
string propertyName,
|
||||
int value,
|
||||
Func<string, int, CancellationToken, Task<MonitorOperationResult>> setAsyncFunc)
|
||||
@@ -192,6 +192,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, propertyName, value);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -202,6 +203,8 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
Logger.LogError($"[{Id}] Exception setting {propertyName.ToLowerInvariant()}: {ex.Message}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Property to access IsInteractionEnabled from parent ViewModel
|
||||
@@ -299,6 +302,64 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
|
||||
public bool SupportsBrightness => _monitor.SupportsBrightness;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this monitor's brightness is currently driven by linked
|
||||
/// mode rather than its own slider. True when the parent has link mode on, this monitor
|
||||
/// supports brightness, and the user has not excluded it. When true the per-card brightness
|
||||
/// row shows a disabled linked-mode hint — the "All Displays" master slider broadcasts the
|
||||
/// value instead.
|
||||
/// </summary>
|
||||
public bool IsBrightnessLinked => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness && !IsExcludedFromSync;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the per-card brightness slider accepts input. Disabled both
|
||||
/// while the app is busy (<see cref="IsInteractionEnabled"/>) and while link mode owns this
|
||||
/// monitor's brightness (<see cref="IsBrightnessLinked"/>). Excluded monitors keep an active
|
||||
/// slider, so this is true for them whenever interaction is enabled.
|
||||
/// </summary>
|
||||
public bool IsBrightnessSliderEnabled => IsInteractionEnabled && !IsBrightnessLinked;
|
||||
|
||||
public bool ShowBrightnessSlider => ShowBrightness && !IsBrightnessLinked;
|
||||
|
||||
public bool ShowIncludedInSyncIcon => !IsExcludedFromSync;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this monitor is excluded from linked brightness.
|
||||
/// Backed by the parent's shared exclusion set (keyed by <see cref="Id"/>), so the state
|
||||
/// survives monitor-list rebuilds and is persisted to settings.json. An excluded monitor keeps
|
||||
/// its own independent brightness slider while link mode is on.
|
||||
/// </summary>
|
||||
public bool IsExcludedFromSync
|
||||
{
|
||||
get => _mainViewModel?.IsMonitorExcludedFromSync(Id) ?? false;
|
||||
set
|
||||
{
|
||||
if (IsExcludedFromSync != value)
|
||||
{
|
||||
_mainViewModel?.SetMonitorExcludedFromSync(Id, value);
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsBrightnessLinked));
|
||||
OnPropertyChanged(nameof(IsBrightnessSliderEnabled));
|
||||
OnPropertyChanged(nameof(ShowBrightnessSlider));
|
||||
OnPropertyChanged(nameof(ShowIncludedInSyncIcon));
|
||||
OnPropertyChanged(nameof(SyncToggleToolTip));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the per-card exclude toggle is shown. Only relevant while
|
||||
/// link mode is on and the monitor supports brightness — otherwise exclusion has no effect.
|
||||
/// </summary>
|
||||
public bool ShowExcludeButton => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tooltip for the per-card linked-brightness toggle. The action reverses with the
|
||||
/// current exclusion state: linked monitors can be excluded, excluded monitors can be included.
|
||||
/// </summary>
|
||||
public string SyncToggleToolTip => ResourceLoaderInstance.ResourceLoader.GetString(
|
||||
IsExcludedFromSync ? "IncludeInSyncToggleToolTip" : "ExcludeFromSyncToggleToolTip");
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this monitor supports volume control via VCP 0x62
|
||||
/// </summary>
|
||||
@@ -426,6 +487,24 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the brightness backing field and notify the UI without scheduling a per-VM commit
|
||||
/// or hardware write. Used by <c>MainViewModel.LinkedBrightness</c> to keep each linked
|
||||
/// monitor's stored brightness aligned with the master value, so it is correct if the
|
||||
/// monitor is later excluded or link mode is disabled. The master broadcast is the single
|
||||
/// source of hardware writes while link mode is on, so this path must not schedule its own
|
||||
/// debounced commit.
|
||||
/// </summary>
|
||||
public void UpdateBrightnessDisplay(int value)
|
||||
{
|
||||
value = Math.Clamp(value, 0, 100);
|
||||
if (_brightness != value)
|
||||
{
|
||||
_brightness = value;
|
||||
OnPropertyChanged(nameof(Brightness));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets color temperature VCP preset value (from VCP code 0x14).
|
||||
/// Read-only in flyout UI - controlled via Settings UI.
|
||||
@@ -784,12 +863,21 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
if (e.PropertyName == nameof(MainViewModel.IsInteractionEnabled))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsInteractionEnabled));
|
||||
OnPropertyChanged(nameof(IsBrightnessSliderEnabled));
|
||||
}
|
||||
else if (e.PropertyName == nameof(MainViewModel.HasMonitors))
|
||||
{
|
||||
// Monitor count changed, update display name to show/hide number suffix
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
}
|
||||
else if (e.PropertyName == nameof(MainViewModel.LinkedLevelsActive))
|
||||
{
|
||||
// Link mode toggled — refresh the linked hint, exclude button, and slider state.
|
||||
OnPropertyChanged(nameof(IsBrightnessLinked));
|
||||
OnPropertyChanged(nameof(IsBrightnessSliderEnabled));
|
||||
OnPropertyChanged(nameof(ShowBrightnessSlider));
|
||||
OnPropertyChanged(nameof(ShowExcludeButton));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMonitorPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
@@ -814,16 +902,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
// reads the latest field at fire-time so intermediate values are coalesced.
|
||||
private void ScheduleCommit(ref DispatcherQueueTimer? timer, Func<Task> commit)
|
||||
{
|
||||
if (timer == null)
|
||||
{
|
||||
timer = DispatcherQueue.GetForCurrentThread().CreateTimer();
|
||||
timer.IsRepeating = false;
|
||||
timer.Interval = TimeSpan.FromMilliseconds(AppConstants.UI.SliderCommitDebounceMs);
|
||||
timer.Tick += (_, _) => _ = commit();
|
||||
}
|
||||
|
||||
timer.Stop();
|
||||
timer.Start();
|
||||
SliderCommitScheduler.Schedule(ref timer, DispatcherQueue.GetForCurrentThread(), commit);
|
||||
}
|
||||
|
||||
// Rotation button handlers — one per orientation to avoid Tag string parsing
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
ShowProfileSwitcher = true;
|
||||
ShowIdentifyMonitorsButton = true;
|
||||
MaxCompatibilityMode = false;
|
||||
LinkedLevelsActive = false;
|
||||
ExcludedFromSyncMonitorIds = new List<string>();
|
||||
CustomVcpMappings = new List<CustomVcpValueMapping>();
|
||||
|
||||
// Note: saved_monitor_settings has been moved to monitor_state.json
|
||||
@@ -79,6 +81,26 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("show_identify_monitors_button")]
|
||||
public bool ShowIdentifyMonitorsButton { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether brightness slider changes are broadcast
|
||||
/// to all connected monitors as a single linked level. When false (default), each
|
||||
/// monitor's slider operates independently. The toggle is only meaningful when two
|
||||
/// or more monitors are connected; the UI hides the entry point otherwise.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linked_levels_active")]
|
||||
public bool LinkedLevelsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of monitor <c>Id</c> values excluded from linked brightness.
|
||||
/// Keyed by <c>Monitor.Id</c> (the DevicePath-based identifier, unique per physical
|
||||
/// device × port — the same key profiles use), so three identical monitors of the same
|
||||
/// model are distinguished. An excluded monitor keeps its own independent brightness
|
||||
/// slider while link mode is on. Monitors not present here are linked by default,
|
||||
/// including newly connected ones.
|
||||
/// </summary>
|
||||
[JsonPropertyName("excluded_from_sync_monitor_ids")]
|
||||
public List<string> ExcludedFromSyncMonitorIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom VCP value name mappings shared across all monitors.
|
||||
/// Allows users to define custom names for color temperature presets and input sources.
|
||||
|
||||
@@ -988,6 +988,26 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
SignalSettingsUpdated();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-read the flyout-owned runtime fields (linked brightness enabled + per-monitor
|
||||
/// exclusion list) from disk into <see cref="_settings"/> so a save originating from an
|
||||
/// unrelated Settings toggle does not overwrite them with the page's stale snapshot.
|
||||
/// These fields have no Settings UI surface — the PowerDisplay flyout is their sole editor.
|
||||
/// </summary>
|
||||
private void PreserveFlyoutOwnedState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
_settings.Properties.LinkedLevelsActive = current.Properties.LinkedLevelsActive;
|
||||
_settings.Properties.ExcludedFromSyncMonitorIds = current.Properties.ExcludedFromSyncMonitorIds;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to preserve flyout-owned PowerDisplay state before save: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifySettingsChanged()
|
||||
{
|
||||
// Skip during initialization when SendConfigMSG is not yet set
|
||||
@@ -996,6 +1016,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
return;
|
||||
}
|
||||
|
||||
// linked_levels_active and excluded_from_sync_monitor_ids are owned by the PowerDisplay
|
||||
// flyout (the only UI that toggles them); the Settings page has no surface for them.
|
||||
// _settings was loaded once at page construction, so serializing it would otherwise
|
||||
// clobber flyout changes made meanwhile — both on disk and in the IPC config pushed
|
||||
// to the module. Re-read the current on-disk values and carry them forward untouched.
|
||||
PreserveFlyoutOwnedState();
|
||||
|
||||
// Persist locally first so settings survive even if the module DLL isn't loaded yet.
|
||||
SettingsUtils.SaveSettings(_settings.ToJsonString(), PowerDisplaySettings.ModuleName);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user