mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-03 09:00:04 +02:00
Compare commits
35 Commits
stable
...
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 |
7
.github/actions/spell-check/expect.txt
vendored
7
.github/actions/spell-check/expect.txt
vendored
@@ -416,6 +416,7 @@ DISPLAYFLAGS
|
||||
DISPLAYFREQUENCY
|
||||
displayname
|
||||
DISPLAYORIENTATION
|
||||
DISPLAYPORT
|
||||
divyan
|
||||
DLGFRAME
|
||||
dlgmodalframe
|
||||
@@ -862,6 +863,7 @@ jjw
|
||||
jobject
|
||||
JOBOBJECT
|
||||
jpe
|
||||
JPN
|
||||
jpnime
|
||||
jrsoftware
|
||||
Jsons
|
||||
@@ -994,6 +996,7 @@ LTM
|
||||
LTRREADING
|
||||
luid
|
||||
lusrmgr
|
||||
LVDS
|
||||
LWA
|
||||
LWIN
|
||||
LZero
|
||||
@@ -1058,6 +1061,8 @@ MINIMIZESTART
|
||||
MINMAXINFO
|
||||
minwindef
|
||||
Mip
|
||||
Miracast
|
||||
miracast
|
||||
mkdn
|
||||
mlcfg
|
||||
mmc
|
||||
@@ -1386,7 +1391,6 @@ popups
|
||||
POPUPWINDOW
|
||||
portfile
|
||||
POSITIONITEM
|
||||
Postbot
|
||||
POWERBROADCAST
|
||||
powerdisplay
|
||||
POWERDISPLAYMODULEINTERFACE
|
||||
@@ -1817,6 +1821,7 @@ svchost
|
||||
SVGIn
|
||||
SVGIO
|
||||
svgz
|
||||
SVIDEO
|
||||
SVSI
|
||||
SWFO
|
||||
swp
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -1004,10 +1004,6 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ShortcutGuide.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj" Id="e487304a-b1fb-4e6b-8e70-014051af5b99" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/">
|
||||
@@ -1032,6 +1028,8 @@
|
||||
<Project Path="src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj" Id="45285df2-9742-4eca-9ac9-58951fc26489" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj" Id="3d63307b-9d27-44fd-b033-b26f39245b85" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj" Id="37d07516-4185-43a4-924f-3c7a5d95ecf6" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSettingsService/WorkspacesSettingsService.vcxproj" Id="8b6a7c32-5c8d-4ad1-9f60-7e1b3d17a220" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSettingsClient/WorkspacesSettingsClient.vcxproj" Id="d24e2c12-9911-4e51-b102-39e7b62b22f1" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/Tests/">
|
||||
<Project Path="src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj">
|
||||
@@ -1039,6 +1037,7 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj" Id="a85d4d9f-9a39-4b5d-8b5a-9f2d5c9a8b4c" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesSettingsService/smoketest/WorkspacesSvcSmokeTest.vcxproj" Id="8b6a7c32-5c8d-4ad1-9f60-7e1b3d17a221" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/Workspaces/WindowProperties/">
|
||||
<File Path="src/modules/Workspaces/WindowProperties/WorkspacesWindowPropertyUtils.h" />
|
||||
|
||||
93
TESTING.md
Normal file
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 |
|
||||
@@ -195,18 +195,10 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
|
||||
|
||||
A string array of all the keys that need to be pressed. If a number is supplied, it should be read as a [KeyCode](https://learn.microsoft.com/windows/win32/inputdev/virtual-key-codes) and displayed accordingly (based on the Keyboard Layout of the user).
|
||||
|
||||
**Literal digit keys**:
|
||||
|
||||
Because a bare number is interpreted as a virtual-key code, a literal digit key must be authored using the `<N>` notation (the digit enclosed between `<` and `>`), where `N` is `0`–`9`. For example, `<9>` represents the literal `9` key (as in the "switch to the last tab" shortcut), not the virtual-key code `9` (which is `Tab`). The interpreter strips the brackets and displays just the digit.
|
||||
|
||||
This applies only to a single literal digit. A range such as `1 - 8` is a free-form label, not a key, and is supplied verbatim (the brackets would only be trimmed from the ends, so `<1> - <8>` would not render as intended).
|
||||
|
||||
**Special keys**:
|
||||
|
||||
Special keys are enclosed between `<` and `>` and correspond to a key that should be displayed in a certain way. If the interpreter of the manifest file can't understand the content, the brackets should be left out.
|
||||
|
||||
By convention these tokens are written as double-quoted strings in the YAML (for example `"<Enter>"` and `"<9>"`), matching the quoting used for punctuation key values. YAML treats the quoted and unquoted forms identically, so quoting is for consistency rather than a strict requirement for bracketed tokens.
|
||||
|
||||
|Name|Description|
|
||||
|----|-----------|
|
||||
|`<Office>`| Corresponds to the Office key on some Windows keyboards |
|
||||
|
||||
@@ -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>
|
||||
@@ -210,7 +210,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- SectionName: Formatting
|
||||
Properties:
|
||||
- Name: Bold
|
||||
|
||||
@@ -1542,7 +1542,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Move earlier or later by number of frames specified for stroke Duration
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -642,7 +642,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Show document template
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -810,7 +810,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Release guides
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -818,7 +818,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Show/ hide smart guides
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -925,7 +925,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: Select the object above the current selection
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -965,7 +965,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Unlock a selection
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -973,7 +973,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Hide a selection
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -981,7 +981,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Show all selections
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -989,7 +989,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Move selection in user-defined increments
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1013,7 +1013,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Bring a selection forward
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1071,7 +1071,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: Release a compound path
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1079,7 +1079,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: Edit a pattern
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1261,7 +1261,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<4>"
|
||||
- 4
|
||||
- Name: Move an object
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1285,7 +1285,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Release a clipping mask
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1293,7 +1293,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Toggle between fill and stroke
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1641,7 +1641,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: Insert copyright symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1665,7 +1665,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Insert section symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1673,7 +1673,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: Insert trademark symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1681,7 +1681,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Insert registered trademark symbol
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -1036,7 +1036,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Redraw screen
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1060,7 +1060,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Switch to next/previous document window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1155,7 +1155,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: Toggle Character/Paragraph text attributes mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1163,7 +1163,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Display the pop-up menu that has focus
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1301,7 +1301,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Show Magenta plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1309,7 +1309,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Show Yellow plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1317,7 +1317,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Show Black plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1325,7 +1325,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<4>"
|
||||
- 4
|
||||
- Name: Show 1st Spot plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1333,7 +1333,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Show 2nd Spot plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1341,7 +1341,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: Show 3rd Spot plate
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1349,7 +1349,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- SectionName: Transform panel
|
||||
Properties:
|
||||
- Name: Apply value and copy object
|
||||
|
||||
@@ -803,7 +803,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Switch to Hand tool (when not in text-edit mode)
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1309,7 +1309,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Tone Curve panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1317,7 +1317,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Detail panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1325,7 +1325,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: HSL/Grayscale panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1333,7 +1333,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<4>"
|
||||
- 4
|
||||
- Name: Split Toning panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1341,7 +1341,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Lens Corrections panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1349,7 +1349,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: Camera Calibration panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1357,7 +1357,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Presets panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1365,7 +1365,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Open Snapshots panel
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1373,7 +1373,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Parametric Curve Targeted Adjustment tool
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1665,7 +1665,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: (Filmstrip mode) Add yellow label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1673,7 +1673,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: (Filmstrip mode) Add green label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1681,7 +1681,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: (Filmstrip mode) Add blue label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1689,7 +1689,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: (Filmstrip mode) Add purple label
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1697,7 +1697,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Camera Raw preferences
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -1936,7 +1936,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Cycle through blending modes
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -2433,7 +2433,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Delete adjustment layer
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -407,7 +407,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Edge select mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -415,7 +415,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Face select mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -423,7 +423,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Extrude region
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -806,7 +806,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Set opacity to 50
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -814,7 +814,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Set opacity to 100
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -822,7 +822,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- SectionName: Arrange
|
||||
Properties:
|
||||
- Name: Bring forward
|
||||
|
||||
@@ -489,7 +489,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- SectionName: Edit
|
||||
Properties:
|
||||
- Name: Undo
|
||||
|
||||
@@ -63,7 +63,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Jump to rightmost tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -71,7 +71,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Open home page in current tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -424,7 +424,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Scroll down a screen
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -21,7 +21,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Show Intention Actions
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
@@ -778,7 +778,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Show Bookmarks window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -786,7 +786,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Show Find window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -794,7 +794,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Show Run window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -802,7 +802,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<4>"
|
||||
- 4
|
||||
- Name: Show Debug window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -810,7 +810,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<5>"
|
||||
- 5
|
||||
- Name: Show Problems window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -818,7 +818,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<6>"
|
||||
- 6
|
||||
- Name: Show Structure window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -826,7 +826,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Show Services window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -834,7 +834,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: Show Version Control window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -842,7 +842,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Show Commit window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -850,7 +850,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Show Terminal window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -45,7 +45,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Switch to the last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -53,7 +53,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Close the current tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -479,7 +479,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Stop loading page; close dialog or pop-up
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -492,7 +492,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Focus into Second Editor Group
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -500,7 +500,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Focus into Third Editor Group
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -508,7 +508,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Move Editor Left
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -202,7 +202,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- SectionName: Editing
|
||||
Properties:
|
||||
- Name: Copy
|
||||
@@ -485,7 +485,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Go to last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -493,7 +493,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Move tab left
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
PackageName: Postman.Postman
|
||||
Name: Postman
|
||||
WindowFilter: "Postman.exe"
|
||||
BackgroundProcess: false
|
||||
Shortcuts:
|
||||
- SectionName: Tabs
|
||||
Properties:
|
||||
- Name: Close tab
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- W
|
||||
- Name: Force close tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- W
|
||||
- Name: Switch to next tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Tab
|
||||
- Name: Switch to previous tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Tab
|
||||
- Name: Switch to tab at position (1–8)
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- '1 - 8'
|
||||
- Name: Switch to last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- Name: Reopen last closed tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- T
|
||||
- Name: New runner tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Search tabs
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- SectionName: Sidebar
|
||||
Properties:
|
||||
- Name: Search sidebar
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Next item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Down>"
|
||||
- Name: Previous item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Up>"
|
||||
- Name: Expand item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Right>"
|
||||
- Name: Expand all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Right>"
|
||||
- Name: Collapse item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Left>"
|
||||
- Name: Collapse all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Left>"
|
||||
- Name: Select item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- Name: Rename item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Cut item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Copy item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Paste item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Duplicate item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Delete item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Delete>"
|
||||
- SectionName: Request
|
||||
Properties:
|
||||
- Name: Request URL
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- L
|
||||
- Name: Save request
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Save request as
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Send request
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- Name: Send and download request
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- SectionName: Interface
|
||||
Properties:
|
||||
- Name: Zoom in
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Plus
|
||||
- Name: Zoom out
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Minus
|
||||
- Name: Reset zoom
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- Name: Toggle two-pane view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- V
|
||||
- Name: Toggle left sidebar
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "\\"
|
||||
- Name: Toggle right sidebar
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "\\"
|
||||
- Name: Toggle workbench
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- M
|
||||
- Name: Swap sidebars
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- S
|
||||
- Name: Reset layout
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- R
|
||||
- Name: Environment selector
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- E
|
||||
- SectionName: Window and modals
|
||||
Properties:
|
||||
- Name: New…
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: New Postman window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: New console window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- C
|
||||
- Name: Find
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Import
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- O
|
||||
- Name: Settings
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- ","
|
||||
- Name: Open shortcut help
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "/"
|
||||
- Name: Search
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Search in current workspace
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- K
|
||||
- Name: Open Postbot
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- P
|
||||
- Name: Open Vault
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Open browser tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Cancel conversation
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Accept all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Y
|
||||
- Name: Reject all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Escape>"
|
||||
- SectionName: Console
|
||||
Properties:
|
||||
- Name: Clear console
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Show/hide console
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "`"
|
||||
@@ -241,7 +241,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Browse DMs
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -249,7 +249,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Open the Activity view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -265,7 +265,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Open the Threads view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -525,7 +525,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- 9
|
||||
- Name: Inline code selected text
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -549,7 +549,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: Numbered list
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -557,7 +557,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Apply markdown formatting
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -583,7 +583,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Big heading
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -591,7 +591,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<1>"
|
||||
- 1
|
||||
- Name: Medium heading
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -599,7 +599,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- 2
|
||||
- Name: Small heading
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -607,7 +607,7 @@ Shortcuts:
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- 3
|
||||
- Name: Checklist
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -615,7 +615,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<0>"
|
||||
- 0
|
||||
- Name: Bulleted list
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -623,7 +623,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<8>"
|
||||
- 8
|
||||
- Name: Numbered list
|
||||
Shortcut:
|
||||
- Win: false
|
||||
@@ -631,7 +631,7 @@ Shortcuts:
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<7>"
|
||||
- 7
|
||||
- Name: Toggle heading and list styles
|
||||
Shortcut:
|
||||
- Win: false
|
||||
|
||||
@@ -6,7 +6,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using ShortcutGuide.Models;
|
||||
|
||||
@@ -33,27 +32,16 @@ namespace ShortcutGuide.Helpers
|
||||
list.Add(shortcutEntry);
|
||||
}
|
||||
|
||||
// Persist on a best-effort basis. The in-memory pinned list is the source of truth
|
||||
// for the rest of the session; failing to write should not crash the overlay
|
||||
// (Pin/Unpin runs from a synchronous UI handler).
|
||||
Save();
|
||||
PinnedShortcutsChanged?.Invoke(null, appName);
|
||||
}
|
||||
|
||||
public static void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
string serialized = JsonSerializer.Serialize(App.PinnedShortcuts);
|
||||
string pinnedPath = SettingsUtils.Default.GetSettingsFilePath(ShortcutGuideSettings.ModuleName, "Pinned.json");
|
||||
File.WriteAllText(pinnedPath, serialized);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException)
|
||||
{
|
||||
Logger.LogError("Failed to persist Shortcut Guide pinned shortcuts; keeping in-memory state.", ex);
|
||||
}
|
||||
string serialized = JsonSerializer.Serialize(App.PinnedShortcuts);
|
||||
|
||||
string pinnedPath = SettingsUtils.Default.GetSettingsFilePath(ShortcutGuideSettings.ModuleName, "Pinned.json");
|
||||
File.WriteAllText(pinnedPath, serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI.Xaml;
|
||||
@@ -34,39 +31,21 @@ namespace ShortcutGuide
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
|
||||
// Register process-wide exception handlers so a stray exception (e.g. an IO failure
|
||||
// during a fire-and-forget UI handler, or a background Task fault) gets logged
|
||||
// instead of taking the overlay down with an unhandled access violation in coreclr.
|
||||
// Without these the runtime tears the process down before our local catches can run.
|
||||
this.UnhandledException += App_UnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
try
|
||||
this.LoadData();
|
||||
MainWindow = new MainWindow();
|
||||
TaskBarWindow = new TaskbarWindow();
|
||||
MainWindow.Activate();
|
||||
MainWindow.Closed += (_, _) =>
|
||||
{
|
||||
this.LoadData();
|
||||
MainWindow = new MainWindow();
|
||||
TaskBarWindow = new TaskbarWindow();
|
||||
MainWindow.Activate();
|
||||
MainWindow.Closed += (_, _) =>
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new ShortcutGuideSessionEvent(
|
||||
MainWindow.SessionDurationMs,
|
||||
MainWindow.CloseType));
|
||||
TaskBarWindow.Close();
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Any failure in launch is fatal for this short-lived overlay; log and exit
|
||||
// cleanly rather than letting WinUI surface a generic crash dialog.
|
||||
Logger.LogError("Failed to launch Shortcut Guide.", ex);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
PowerToysTelemetry.Log.WriteEvent(new ShortcutGuideSessionEvent(
|
||||
MainWindow.SessionDurationMs,
|
||||
MainWindow.CloseType));
|
||||
TaskBarWindow.Close();
|
||||
};
|
||||
}
|
||||
|
||||
private void LoadData()
|
||||
@@ -84,53 +63,18 @@ namespace ShortcutGuide
|
||||
PinnedShortcuts = loaded;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException
|
||||
or IOException
|
||||
or UnauthorizedAccessException)
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fall back to the empty default if the file is corrupt or unreadable.
|
||||
Logger.LogWarning($"Failed to load pinned shortcuts from '{pinnedPath}'. Falling back to empty list. Reason: {ex.Message}");
|
||||
// Fall back to the empty default if the file is corrupt.
|
||||
}
|
||||
}
|
||||
|
||||
ShortcutGuideSettings = SettingsRepository<ShortcutGuideSettings>.GetInstance(settingsUtils).SettingsConfig;
|
||||
ShortcutGuideProperties = ShortcutGuideSettings.Properties;
|
||||
|
||||
try
|
||||
{
|
||||
#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances
|
||||
settingsUtils.SaveSettings(JsonSerializer.Serialize(App.ShortcutGuideSettings, new JsonSerializerOptions { WriteIndented = true }), "Shortcut Guide");
|
||||
settingsUtils.SaveSettings(JsonSerializer.Serialize(App.ShortcutGuideSettings, new JsonSerializerOptions { WriteIndented = true }), "Shortcut Guide");
|
||||
#pragma warning restore CA1869 // Cache and reuse 'JsonSerializerOptions' instances
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Persisting the round-tripped settings is best-effort; the in-memory copy is still valid.
|
||||
Logger.LogWarning($"Failed to persist Shortcut Guide settings on launch. Reason: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// Exceptions raised on the UI thread land here. Mark handled so the runtime
|
||||
// does not terminate the process; the overlay can usually continue.
|
||||
Logger.LogError("Unhandled UI exception in Shortcut Guide.", e.Exception);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static void CurrentDomain_UnhandledException(object sender, System.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// Background-thread exceptions reach here as a last resort; we cannot prevent
|
||||
// termination when IsTerminating is true, but at least we leave a log trail.
|
||||
if (e.ExceptionObject is Exception ex)
|
||||
{
|
||||
Logger.LogError($"Unhandled background exception in Shortcut Guide (IsTerminating={e.IsTerminating}).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
Logger.LogError("Unobserved Task exception in Shortcut Guide.", e.Exception);
|
||||
e.SetObserved();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,54 +237,37 @@ namespace ShortcutGuide
|
||||
|
||||
private void SetWindowPosition()
|
||||
{
|
||||
try
|
||||
if (!this._hasMovedToRightMonitor)
|
||||
{
|
||||
if (!this._hasMovedToRightMonitor)
|
||||
{
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT lpPoint);
|
||||
AppWindow.Move(new NativeMethods.POINT { Y = lpPoint.Y - ((int)Height / 2), X = lpPoint.X - ((int)Width / 2) });
|
||||
this._hasMovedToRightMonitor = true;
|
||||
}
|
||||
|
||||
var hwnd = WindowNative.GetWindowHandle(this);
|
||||
float dpi = DpiHelper.GetDPIScaleForWindow(hwnd);
|
||||
Rect monitorRect = DisplayHelper.GetWorkAreaForDisplayWithWindow(hwnd);
|
||||
|
||||
var windowPosition = (ShortcutGuideWindowPosition)App.ShortcutGuideProperties.WindowPosition.Value;
|
||||
|
||||
// App.TaskBarWindow / its AppWindow can briefly be null during the reentrant
|
||||
// Hide → Activate → BringToFront chain triggered from SelectionChanged. When the
|
||||
// taskbar window is not currently observable, skip the overlap adjustment instead
|
||||
// of crashing the overlay (issue #48448).
|
||||
var taskbarWindow = App.TaskBarWindow?.AppWindow;
|
||||
bool taskbarOnLeft = false;
|
||||
bool taskbarOnRight = false;
|
||||
if (taskbarWindow is not null)
|
||||
{
|
||||
taskbarOnLeft = taskbarWindow.IsVisible && taskbarWindow.Position.X < AppWindow.Position.X + Width && windowPosition == ShortcutGuideWindowPosition.Left;
|
||||
taskbarOnRight = taskbarWindow.IsVisible && taskbarWindow.Position.X + taskbarWindow.Size.Width > AppWindow.Position.X && windowPosition == ShortcutGuideWindowPosition.Right;
|
||||
}
|
||||
|
||||
double newHeight = monitorRect.Height / dpi;
|
||||
if (taskbarWindow is not null && (taskbarOnLeft || taskbarOnRight))
|
||||
{
|
||||
newHeight -= taskbarWindow.Size.Height;
|
||||
}
|
||||
|
||||
MaxHeight = newHeight;
|
||||
MinHeight = newHeight;
|
||||
Height = newHeight;
|
||||
|
||||
int xPosition = windowPosition == ShortcutGuideWindowPosition.Right
|
||||
? (int)(monitorRect.X + monitorRect.Width) - (int)(Width * dpi)
|
||||
: (int)monitorRect.X;
|
||||
|
||||
this.MoveAndResize(xPosition, (int)monitorRect.Y, Width, Height);
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT lpPoint);
|
||||
AppWindow.Move(new NativeMethods.POINT { Y = lpPoint.Y - ((int)Height / 2), X = lpPoint.X - ((int)Width / 2) });
|
||||
this._hasMovedToRightMonitor = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var hwnd = WindowNative.GetWindowHandle(this);
|
||||
float dpi = DpiHelper.GetDPIScaleForWindow(hwnd);
|
||||
Rect monitorRect = DisplayHelper.GetWorkAreaForDisplayWithWindow(hwnd);
|
||||
|
||||
var windowPosition = (ShortcutGuideWindowPosition)App.ShortcutGuideProperties.WindowPosition.Value;
|
||||
var taskbarWindow = App.TaskBarWindow.AppWindow;
|
||||
bool taskbarOnLeft = taskbarWindow.IsVisible && taskbarWindow.Position.X < AppWindow.Position.X + Width && windowPosition == ShortcutGuideWindowPosition.Left;
|
||||
bool taskbarOnRight = taskbarWindow.IsVisible && taskbarWindow.Position.X + taskbarWindow.Size.Width > AppWindow.Position.X && windowPosition == ShortcutGuideWindowPosition.Right;
|
||||
|
||||
double newHeight = monitorRect.Height / dpi;
|
||||
if (taskbarOnLeft || taskbarOnRight)
|
||||
{
|
||||
Logger.LogError("Failed to set Shortcut Guide window position; keeping previous layout.", ex);
|
||||
newHeight -= taskbarWindow.Size.Height;
|
||||
}
|
||||
|
||||
MaxHeight = newHeight;
|
||||
MinHeight = newHeight;
|
||||
Height = newHeight;
|
||||
|
||||
int xPosition = windowPosition == ShortcutGuideWindowPosition.Right
|
||||
? (int)(monitorRect.X + monitorRect.Width) - (int)(Width * dpi)
|
||||
: (int)monitorRect.X;
|
||||
|
||||
this.MoveAndResize(xPosition, (int)monitorRect.Y, Width, Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -299,35 +282,25 @@ namespace ShortcutGuide
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this._selectedAppName = selectedItem.Name;
|
||||
App.CurrentAppName = this._selectedAppName;
|
||||
this._shortcutFile = ManifestInterpreter.GetShortcutsOfApplication(this._selectedAppName);
|
||||
this._selectedAppName = selectedItem.Name;
|
||||
App.CurrentAppName = this._selectedAppName;
|
||||
this._shortcutFile = ManifestInterpreter.GetShortcutsOfApplication(this._selectedAppName);
|
||||
|
||||
App.TaskBarWindow?.Hide();
|
||||
if (this._shortcutFile is ShortcutFile file)
|
||||
App.TaskBarWindow.Hide();
|
||||
if (this._shortcutFile is ShortcutFile file)
|
||||
{
|
||||
// Show the taskbar button window only when the selected app exposes the <TASKBAR1-9> section.
|
||||
if (file.Shortcuts is not null && file.Shortcuts.Any(c => c.SectionName?.StartsWith("<TASKBAR1-9>", StringComparison.Ordinal) == true))
|
||||
{
|
||||
// Show the taskbar button window only when the selected app exposes the <TASKBAR1-9> section.
|
||||
if (file.Shortcuts is not null && file.Shortcuts.Any(c => c.SectionName?.StartsWith("<TASKBAR1-9>", StringComparison.Ordinal) == true))
|
||||
{
|
||||
this._taskBarWindowActivated = true;
|
||||
App.TaskBarWindow?.Activate();
|
||||
}
|
||||
|
||||
// Reposition before navigating so the taskbar window does not clip into the main window.
|
||||
this.SetWindowPosition();
|
||||
this.ContentFrame.Navigate(
|
||||
typeof(ShortcutsPage),
|
||||
new ShortcutPageNavParam { ShortcutFile = file, AppName = this._selectedAppName });
|
||||
this._taskBarWindowActivated = true;
|
||||
App.TaskBarWindow.Activate();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Guard against exceptions during section navigation so the overlay does not close on the user.
|
||||
// InitializeNavItemsAsync's catch interprets any exception bubbling out of the initial
|
||||
// SelectedItem assignment as a fatal init failure and closes the window (issue #48448).
|
||||
Logger.LogError($"Failed to handle Shortcut Guide section selection '{selectedItem.Name}'.", ex);
|
||||
|
||||
// Reposition before navigating so the taskbar window does not clip into the main window.
|
||||
this.SetWindowPosition();
|
||||
this.ContentFrame.Navigate(
|
||||
typeof(ShortcutsPage),
|
||||
new ShortcutPageNavParam { ShortcutFile = file, AppName = this._selectedAppName });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,73 +30,54 @@ namespace ShortcutGuide.ShortcutGuideXAML
|
||||
|
||||
public void UpdateTasklistButtons()
|
||||
{
|
||||
// Wrap the entire body: this method runs from the ctor and from `Activated`,
|
||||
// both of which can fire while MainWindow is closing or AppWindow is in a
|
||||
// transient null state. An exception here used to crash the overlay because
|
||||
// there was no caller-side try/catch (issue #48441).
|
||||
// This move ensures the window spawns on the same monitor as the main window
|
||||
AppWindow.MoveInZOrderAtBottom();
|
||||
AppWindow.Move(App.MainWindow.AppWindow.Position);
|
||||
TasklistButton[] buttons = [];
|
||||
try
|
||||
{
|
||||
// This move ensures the window spawns on the same monitor as the main window.
|
||||
// App.MainWindow / its AppWindow can briefly be null during the reentrant
|
||||
// Hide → Activate → BringToFront chain triggered from SelectionChanged.
|
||||
var mainAppWindow = App.MainWindow?.AppWindow;
|
||||
if (mainAppWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppWindow.MoveInZOrderAtBottom();
|
||||
AppWindow.Move(mainAppWindow.Position);
|
||||
TasklistButton[] buttons = [];
|
||||
try
|
||||
{
|
||||
buttons = TasklistPositions.GetButtons();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to enumerate taskbar buttons via TasklistPositions.GetButtons.", ex);
|
||||
}
|
||||
|
||||
if (buttons.Length == 0)
|
||||
{
|
||||
AppWindow.Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
float dpi = this.DPI;
|
||||
double windowsLogoColumnWidth = this.WindowsLogoColumnWidth.Width.Value;
|
||||
double windowHeight = 58;
|
||||
double windowMargin = 8 * dpi;
|
||||
double windowWidth = windowsLogoColumnWidth;
|
||||
double xPosition = buttons[0].X - (windowsLogoColumnWidth * dpi);
|
||||
double yPosition = this.WorkArea.Bottom - (windowHeight * dpi);
|
||||
|
||||
this.KeyHolder.Children.Clear();
|
||||
|
||||
foreach (TasklistButton b in buttons)
|
||||
{
|
||||
TaskbarIndicator indicator = new()
|
||||
{
|
||||
Label = b.Keynum >= 10 ? "0" : b.Keynum.ToString(CultureInfo.InvariantCulture),
|
||||
Height = b.Height / dpi,
|
||||
Width = b.Width / dpi,
|
||||
};
|
||||
|
||||
windowWidth += indicator.Width;
|
||||
|
||||
this.KeyHolder.Children.Add(indicator);
|
||||
|
||||
double indicatorPos = (b.X - xPosition) / dpi;
|
||||
Canvas.SetLeft(indicator, indicatorPos - windowsLogoColumnWidth);
|
||||
}
|
||||
|
||||
this.MoveAndResize(xPosition - windowMargin, yPosition, windowWidth + (2 * windowMargin), windowHeight);
|
||||
AppWindow.MoveInZOrderAtTop();
|
||||
buttons = TasklistPositions.GetButtons();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to update Shortcut Guide taskbar indicator window.", ex);
|
||||
Logger.LogError("Failed to enumerate taskbar buttons via TasklistPositions.GetButtons.", ex);
|
||||
}
|
||||
|
||||
if (buttons.Length == 0)
|
||||
{
|
||||
AppWindow.Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
float dpi = this.DPI;
|
||||
double windowsLogoColumnWidth = this.WindowsLogoColumnWidth.Width.Value;
|
||||
double windowHeight = 58;
|
||||
double windowMargin = 8 * dpi;
|
||||
double windowWidth = windowsLogoColumnWidth;
|
||||
double xPosition = buttons[0].X - (windowsLogoColumnWidth * dpi);
|
||||
double yPosition = this.WorkArea.Bottom - (windowHeight * dpi);
|
||||
|
||||
this.KeyHolder.Children.Clear();
|
||||
|
||||
foreach (TasklistButton b in buttons)
|
||||
{
|
||||
TaskbarIndicator indicator = new()
|
||||
{
|
||||
Label = b.Keynum >= 10 ? "0" : b.Keynum.ToString(CultureInfo.InvariantCulture),
|
||||
Height = b.Height / dpi,
|
||||
Width = b.Width / dpi,
|
||||
};
|
||||
|
||||
windowWidth += indicator.Width;
|
||||
|
||||
this.KeyHolder.Children.Add(indicator);
|
||||
|
||||
double indicatorPos = (b.X - xPosition) / dpi;
|
||||
Canvas.SetLeft(indicator, indicatorPos - windowsLogoColumnWidth);
|
||||
}
|
||||
|
||||
this.MoveAndResize(xPosition - windowMargin, yPosition, windowWidth + (2 * windowMargin), windowHeight);
|
||||
AppWindow.MoveInZOrderAtTop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using ShortcutGuide.Converters;
|
||||
using ShortcutGuide.Models;
|
||||
|
||||
namespace ShortcutGuide.UnitTests.ConvertersTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ShortcutDescriptionToKeysConverterTests
|
||||
{
|
||||
private static List<object> Convert(ShortcutDescription description)
|
||||
=> new ShortcutDescriptionToKeysConverter().GetKeysList(description);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<0>")]
|
||||
[DataRow("<1>")]
|
||||
[DataRow("<8>")]
|
||||
[DataRow("<9>")]
|
||||
public void GetKeysList_LiteralDigitKey_IsPassedThroughVerbatim(string key)
|
||||
{
|
||||
// A literal digit key (e.g. Ctrl+9 "switch to last tab") is authored with the
|
||||
// <N> convention so it is not parsed as a virtual-key code (VK 9 is Tab, VK 1 is
|
||||
// the left mouse button, VK 0 is undefined). The converter forwards the token
|
||||
// unchanged; KeyVisual strips the angle brackets when rendering.
|
||||
var result = Convert(new ShortcutDescription(ctrl: true, shift: false, alt: false, win: false, keys: [key]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { "Ctrl", key }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetKeysList_Modifiers_AreEmittedBeforeKeysInWinCtrlAltShiftOrder()
|
||||
{
|
||||
// Win -> 92, Ctrl -> "Ctrl", Alt -> "Alt", Shift -> 16, then the keys.
|
||||
var result = Convert(new ShortcutDescription(ctrl: true, shift: true, alt: true, win: true, keys: ["A"]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { 92, "Ctrl", "Alt", 16, "A" }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetKeysList_NonNumericKey_IsPassedThroughVerbatim()
|
||||
{
|
||||
// Non-numeric key strings (e.g. the "1 - 8" tab-range) render as-is.
|
||||
var result = Convert(new ShortcutDescription(ctrl: true, shift: false, alt: false, win: false, keys: ["1 - 8"]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { "Ctrl", "1 - 8" }, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetKeysList_ArrowNameKey_MapsToVirtualKeyCode()
|
||||
{
|
||||
// Named arrow keys map to their VK codes (Up -> 38), independent of the digit handling.
|
||||
var result = Convert(new ShortcutDescription(ctrl: false, shift: false, alt: false, win: false, keys: ["Up"]));
|
||||
|
||||
CollectionAssert.AreEqual(new object[] { 38 }, result);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier>
|
||||
<RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\ShortcutGuide.UnitTests\</OutputPath>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ShortcutGuide.Ui\ShortcutGuide.Ui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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();
|
||||
|
||||
@@ -1089,10 +1089,13 @@ VideoRecordingSession::VideoRecordingSession(
|
||||
// Store frame interval for timeout-based frame production when webcam is active.
|
||||
m_frameIntervalTicks = ( frameRate > 0 ) ? ( 10'000'000LL / frameRate ) : 333'333LL;
|
||||
|
||||
// NOTE: Audio encoding profile (m_encodingProfile.Audio) is set in
|
||||
// StartAsync() after the audio graph is fully initialized, not here.
|
||||
// Calling GetEncodingProperties() before InitializeAsync completes
|
||||
// would crash because m_audioOutputNode is still null.
|
||||
if (captureAudio || captureSystemAudio)
|
||||
{
|
||||
// Always set up audio profile for loopback capture (stereo AAC)
|
||||
auto audio = m_encodingProfile.Audio();
|
||||
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
|
||||
m_encodingProfile.Audio(audio);
|
||||
}
|
||||
|
||||
// Describe our input: uncompressed BGRA8 buffers
|
||||
auto properties = winrt::VideoEncodingProperties::CreateUncompressed(
|
||||
@@ -1173,16 +1176,7 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync()
|
||||
RecDiag( L"StartAsync: co_await InitializeAsync...\n" );
|
||||
co_await m_audioGenerator->InitializeAsync();
|
||||
RecDiag( L"StartAsync: audio initialized\n" );
|
||||
|
||||
// Set up the audio encoding profile now that the audio graph is
|
||||
// fully initialized. GetEncodingProperties() requires
|
||||
// m_audioOutputNode to be valid, which is only guaranteed after
|
||||
// InitializeAsync completes.
|
||||
auto audioProps = m_audioGenerator->GetEncodingProperties();
|
||||
m_encodingProfile.Audio(winrt::AudioEncodingProperties::CreateAac(
|
||||
audioProps.SampleRate(), audioProps.ChannelCount(), 192000));
|
||||
|
||||
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(audioProps));
|
||||
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(m_audioGenerator->GetEncodingProperties()));
|
||||
}
|
||||
else {
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
@@ -19,9 +20,6 @@ public record AppStateModel
|
||||
init => _recentCommands = value;
|
||||
}
|
||||
|
||||
// HERE BE DRAGONS: Using an ImmutableList<T> for a setting may explode in
|
||||
// AOT builds. Make sure to test IN AOT setting this setting to null, [],
|
||||
// and and array with values.
|
||||
private ImmutableList<string>? _runHistory = ImmutableList<string>.Empty;
|
||||
|
||||
public ImmutableList<string> RunHistory
|
||||
|
||||
@@ -427,39 +427,12 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
|
||||
var title = _commandItemViewModel?.Title ?? string.Empty;
|
||||
var subtitle = _commandItemViewModel?.Subtitle ?? string.Empty;
|
||||
var icon = _commandItemViewModel?.Icon;
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSide = dockSettings.Side;
|
||||
IReadOnlyList<MonitorInfo>? monitors = GetDockEnabledMonitors(_monitorService, dockSettings);
|
||||
var dockSide = _settingsService.Settings.DockSettings.Side;
|
||||
IReadOnlyList<MonitorInfo>? monitors = _monitorService?.GetMonitors();
|
||||
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide, monitors);
|
||||
WeakReferenceMessenger.Default.Send(message);
|
||||
}
|
||||
|
||||
// Only list monitors where the dock is currently enabled, so users can't
|
||||
// pin a command to a display that has no dock visible.
|
||||
private static IReadOnlyList<MonitorInfo>? GetDockEnabledMonitors(IMonitorService? monitorService, DockSettings dockSettings)
|
||||
{
|
||||
var monitors = monitorService?.GetMonitors();
|
||||
if (monitors is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var configs = dockSettings.MonitorConfigs;
|
||||
|
||||
// When there are no per-monitor configs (legacy / first-run), the dock
|
||||
// is only shown on the primary monitor.
|
||||
if (configs.Count == 0)
|
||||
{
|
||||
return monitors.Where(m => m.IsPrimary).ToList();
|
||||
}
|
||||
|
||||
return monitors
|
||||
.Where(m => configs.Any(c =>
|
||||
string.Equals(c.MonitorDeviceId, m.StableId, System.StringComparison.OrdinalIgnoreCase) &&
|
||||
c.Enabled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void UnpinFromDock()
|
||||
{
|
||||
PinToDockMessage message = new(_providerId, _commandId, false);
|
||||
|
||||
@@ -24,21 +24,9 @@ internal sealed class RunHistoryService : IRunHistoryService
|
||||
if (_appStateService.State.RunHistory.IsEmpty)
|
||||
{
|
||||
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
|
||||
|
||||
// Copy the WinRT-projected IVector<string> into a plain List<string>
|
||||
// before building the ImmutableList. ImmutableList.CreateRange tries to
|
||||
// cast the source to IReadOnlyCollection<string>, which requires a WinRT
|
||||
// helper type that isn't available in AOT builds and throws
|
||||
// NotSupportedException.
|
||||
var historyList = new List<string>(history.Count);
|
||||
for (var i = 0; i < history.Count; i++)
|
||||
{
|
||||
historyList.Add(history[i]);
|
||||
}
|
||||
|
||||
_appStateService.UpdateState(state => state with
|
||||
{
|
||||
RunHistory = historyList.ToImmutableList(),
|
||||
RunHistory = history.ToImmutableList(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -134,25 +134,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
|
||||
if (isBandPage)
|
||||
{
|
||||
_networkUpItem = new ListItem(_networkPage)
|
||||
{
|
||||
Title = $"{_networkUpSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
|
||||
Icon = Icons.NetworkUpIcon,
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
|
||||
_networkDownItem = new ListItem(_networkPage)
|
||||
{
|
||||
Title = $"{_networkDownSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
|
||||
Icon = Icons.NetworkDownIcon,
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
}
|
||||
|
||||
_networkPage.Updated += (s, e) =>
|
||||
{
|
||||
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
|
||||
@@ -272,6 +253,22 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
}
|
||||
else
|
||||
{
|
||||
_networkUpItem = new ListItem(_networkPage!)
|
||||
{
|
||||
Title = $"{_networkUpSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
|
||||
Icon = Icons.NetworkUpIcon,
|
||||
MoreCommands = _networkPage!.Commands,
|
||||
};
|
||||
|
||||
_networkDownItem = new ListItem(_networkPage!)
|
||||
{
|
||||
Title = $"{_networkDownSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
|
||||
Icon = Icons.NetworkDownIcon,
|
||||
MoreCommands = _networkPage!.Commands,
|
||||
};
|
||||
|
||||
return _batteryItem is not null
|
||||
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
|
||||
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };
|
||||
|
||||
@@ -94,7 +94,7 @@ internal static class Commands
|
||||
})
|
||||
{
|
||||
Title = Resources.Microsoft_plugin_sys_hibernate,
|
||||
Icon = Icons.HibernateIcon,
|
||||
Icon = Icons.SleepIcon, // Icon change needed
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,4 @@ internal sealed class Icons
|
||||
internal static IconInfo ShutdownIcon { get; } = new IconInfo("\uE7E8");
|
||||
|
||||
internal static IconInfo SleepIcon { get; } = new IconInfo("\uE708");
|
||||
|
||||
internal static IconInfo HibernateIcon { get; } = new IconInfo("\uE823");
|
||||
}
|
||||
|
||||
@@ -243,7 +243,5 @@ namespace ColorPicker.Helpers
|
||||
lpPoint.Y += yOffset;
|
||||
SetCursorPos(lpPoint.X, lpPoint.Y);
|
||||
}
|
||||
|
||||
internal IntPtr GetMainWindowHandle() => _hwndSource?.Handle ?? IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
|
||||
namespace ColorPicker.Helpers;
|
||||
|
||||
internal static class WindowCaptureExclusionHelper
|
||||
{
|
||||
// Windows 10 version 2004 (build 19041) is the minimum supported version. PowerToys
|
||||
// itself requires the same version, so this check is not strictly required, but is
|
||||
// useful as a safeguard.
|
||||
private static readonly bool IsSupported =
|
||||
Environment.OSVersion.Version >= new Version(10, 0, 19041);
|
||||
|
||||
// Only logging once per session to avoid repeated identical warnings, as the zoom
|
||||
// window may be used very often.
|
||||
private static bool hasLoggedFailure;
|
||||
|
||||
internal static bool Exclude(IntPtr hwnd) =>
|
||||
SetWindowAffinity(hwnd, NativeMethods.WDA_EXCLUDEFROMCAPTURE);
|
||||
|
||||
internal static bool Include(IntPtr hwnd) =>
|
||||
SetWindowAffinity(hwnd, NativeMethods.WDA_NONE);
|
||||
|
||||
private static bool SetWindowAffinity(nint hwnd, uint affinity)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = NativeMethods.SetWindowDisplayAffinity(hwnd, affinity);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
int errorCode = Marshal.GetLastWin32Error();
|
||||
if (!hasLoggedFailure)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"Failed to set window display affinity. Error code: {errorCode}");
|
||||
hasLoggedFailure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
@@ -79,30 +79,12 @@ namespace ColorPicker.Helpers
|
||||
// we just started zooming, copy screen area
|
||||
if (_previousZoomLevel == 0)
|
||||
{
|
||||
// First, exclude the color picker window from the capture; otherwise its
|
||||
// corner will be included in the zoomed-in image.
|
||||
var mainWindowHandle = _appStateHandler.GetMainWindowHandle();
|
||||
bool exclusionSuccess =
|
||||
WindowCaptureExclusionHelper.Exclude(mainWindowHandle);
|
||||
var x = (int)point.X - (BaseZoomImageSize / 2);
|
||||
var y = (int)point.Y - (BaseZoomImageSize / 2);
|
||||
|
||||
try
|
||||
{
|
||||
var x = (int)point.X - (BaseZoomImageSize / 2);
|
||||
var y = (int)point.Y - (BaseZoomImageSize / 2);
|
||||
_graphics.CopyFromScreen(x, y, 0, 0, _bmp.Size, CopyPixelOperation.SourceCopy);
|
||||
|
||||
_graphics.CopyFromScreen(x, y, 0, 0, _bmp.Size, CopyPixelOperation.SourceCopy);
|
||||
|
||||
_zoomViewModel.ZoomArea = BitmapToImageSource(_bmp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore the color picker window to normal display affinity so that
|
||||
// it can be captured again.
|
||||
if (exclusionSuccess)
|
||||
{
|
||||
WindowCaptureExclusionHelper.Include(mainWindowHandle);
|
||||
}
|
||||
}
|
||||
_zoomViewModel.ZoomArea = BitmapToImageSource(_bmp);
|
||||
}
|
||||
|
||||
_zoomViewModel.ZoomFactor = Math.Pow(ZoomFactor, _currentZoomLevel - 1);
|
||||
|
||||
@@ -231,17 +231,5 @@ namespace ColorPicker
|
||||
var hwnd = new WindowInteropHelper(win).Handle;
|
||||
_ = SetWindowLong(hwnd, GWL_EX_STYLE, GetWindowLong(hwnd, GWL_EX_STYLE) | WS_EX_TOOLWINDOW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the display affinity of a window, which controls how the window is
|
||||
/// displayed on a monitor. Used to exclude the picker window from ZoomWindow's
|
||||
/// source bitmap.
|
||||
/// </summary>
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint dwAffinity);
|
||||
|
||||
internal const uint WDA_NONE = 0x00000000;
|
||||
internal const uint WDA_EXCLUDEFROMCAPTURE = 0x00000011;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,14 +139,6 @@ namespace KeyboardEventHandlers
|
||||
if (data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN)
|
||||
{
|
||||
ResetIfModifierKeyForLowerLevelKeyHandlers(ii, it->first, target);
|
||||
|
||||
// If a Ctrl/Alt/Shift key is remapped to a non-modifier key, reset the modifier state to prevent the injected key from being delivered as WM_SYSKEYDOWN instead of WM_KEYDOWN
|
||||
if (Helpers::IsModifierKey(it->first) && !Helpers::IsModifierKey(target) && target != VK_CAPITAL && !(it->first == VK_LWIN || it->first == VK_RWIN || it->first == CommonSharedConstants::VK_WIN_BOTH))
|
||||
{
|
||||
std::vector<INPUT> suppressList;
|
||||
Helpers::SetKeyEvent(suppressList, INPUT_KEYBOARD, static_cast<WORD>(it->first), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG);
|
||||
ii.SendVirtualInput(suppressList);
|
||||
}
|
||||
}
|
||||
|
||||
if (remapToKey)
|
||||
|
||||
@@ -226,27 +226,6 @@ namespace RemappingLogicTests
|
||||
Assert::AreEqual(1, mockedInputHandler.GetSendVirtualInputCallCount());
|
||||
}
|
||||
|
||||
// Test if SendVirtualInput is sent exactly once with the suppress flag when a Ctrl/Alt/Shift key is remapped to a non-modifier key
|
||||
TEST_METHOD (HandleSingleKeyRemapEvent_ShouldSendVirtualInputWithSuppressFlagExactlyOnce_WhenCtrlAltShiftIsMappedToNonModifierKey)
|
||||
{
|
||||
mockedInputHandler.SetSendVirtualInputTestHandler([](LowlevelKeyboardEvent* data) {
|
||||
if (data->lParam->dwExtraInfo == KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
});
|
||||
|
||||
testState.AddSingleKeyRemap(VK_LMENU, (DWORD)VK_BACK);
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = VK_LMENU } },
|
||||
};
|
||||
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
Assert::AreEqual(1, mockedInputHandler.GetSendVirtualInputCallCount());
|
||||
}
|
||||
|
||||
// Test if correct keyboard states are set for a single key to two key shortcut remap
|
||||
TEST_METHOD (RemappedKeyToTwoKeyShortcut_ShouldSetTargetKeyState_OnKeyEvent)
|
||||
{
|
||||
|
||||
@@ -22,8 +22,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
|
||||
public string AppData { get; set; } = string.Empty;
|
||||
|
||||
public string SharedStorageDbPath { get; set; } = string.Empty;
|
||||
|
||||
public ImageSource WorkspaceIcon() => WorkspaceIconBitMap;
|
||||
|
||||
public ImageSource RemoteIcon() => RemoteIconBitMap;
|
||||
|
||||
@@ -16,7 +16,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
public static class VSCodeInstances
|
||||
{
|
||||
private static readonly string _userAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
private static readonly string _userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
public static List<VSCodeInstance> Instances { get; set; } = new List<VSCodeInstance>();
|
||||
|
||||
@@ -130,7 +129,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
|
||||
var portableData = Path.Join(iconPath, "data");
|
||||
instance.AppData = Directory.Exists(portableData) ? Path.Join(portableData, "user-data") : Path.Combine(_userAppDataPath, version);
|
||||
instance.SharedStorageDbPath = GetSharedStorageDbPath(version, iconPath, Directory.Exists(portableData));
|
||||
var vsCodeIconPath = Path.Join(iconPath, $"{version}.exe");
|
||||
if (!File.Exists(vsCodeIconPath))
|
||||
{
|
||||
@@ -159,30 +157,5 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.VSCodeHelper
|
||||
Instances.Add(instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSharedStorageDbPath(string version, string iconPath, bool isPortable)
|
||||
{
|
||||
if (isPortable)
|
||||
{
|
||||
return Path.Join(iconPath, "data-shared", "sharedStorage", "state.vscdb");
|
||||
}
|
||||
|
||||
var sharedStorageDirectory = version switch
|
||||
{
|
||||
"Code" => ".vscode-shared",
|
||||
"Code - Insiders" => ".vscode-insiders-shared",
|
||||
"Code - Exploration" => ".vscode-exploration-shared",
|
||||
"VSCodium" => ".vscodium-shared",
|
||||
"VSCodium - Insiders" => ".vscodium-insiders-shared",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(sharedStorageDirectory))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.Combine(_userProfilePath, sharedStorageDirectory, "sharedStorage", "state.vscdb");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
|
||||
|
||||
// User/globalStorage/state.vscdb - history.recentlyOpenedPathsList - vscode v1.64 or later
|
||||
var vscode_storage_db = Path.Combine(vscodeInstance.AppData, "User/globalStorage/state.vscdb");
|
||||
var vscode_shared_storage_db = vscodeInstance.SharedStorageDbPath;
|
||||
|
||||
if (File.Exists(vscode_storage))
|
||||
{
|
||||
@@ -105,37 +104,17 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
|
||||
results.AddRange(storageResults);
|
||||
}
|
||||
|
||||
var storageDbPaths = new[] { vscode_storage_db, vscode_shared_storage_db }
|
||||
.Where(filePath => !string.IsNullOrEmpty(filePath))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var storageDbPath in storageDbPaths)
|
||||
if (File.Exists(vscode_storage_db))
|
||||
{
|
||||
if (File.Exists(storageDbPath))
|
||||
{
|
||||
var storageDbResults = GetWorkspacesInVscdb(vscodeInstance, storageDbPath);
|
||||
results.AddRange(storageDbResults);
|
||||
}
|
||||
var storageDbResults = GetWorkspacesInVscdb(vscodeInstance, vscode_storage_db);
|
||||
results.AddRange(storageDbResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.Where(workspace => workspace != null)
|
||||
.GroupBy(GetWorkspaceKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(workspaceGroup => workspaceGroup.First())
|
||||
.ToList();
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetWorkspaceKey(VSCodeWorkspace workspace)
|
||||
{
|
||||
return string.Join(
|
||||
"|",
|
||||
workspace.VSCodeInstance?.ExecutablePath ?? string.Empty,
|
||||
workspace.WorkspaceType,
|
||||
workspace.Path ?? string.Empty);
|
||||
}
|
||||
|
||||
private List<VSCodeWorkspace> GetWorkspacesInJson(VSCodeInstance vscodeInstance, string filePath)
|
||||
{
|
||||
var storageFileResults = new List<VSCodeWorkspace>();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Drivers;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class DisplayClassifierTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
|
||||
// Internal: INTERNAL high-bit flag
|
||||
[DataRow(0x80000000u, true, DisplayName = "INTERNAL bit only")]
|
||||
[DataRow(0x8000000Bu, true, DisplayName = "INTERNAL | DISPLAYPORT_EMBEDDED")]
|
||||
|
||||
// Internal: documented embedded subtypes
|
||||
[DataRow(11u, true, DisplayName = "DISPLAYPORT_EMBEDDED")]
|
||||
[DataRow(13u, true, DisplayName = "UDI_EMBEDDED")]
|
||||
|
||||
// External: LVDS is not classified internal per docs
|
||||
[DataRow(6u, false, DisplayName = "LVDS (not classified internal per docs)")]
|
||||
|
||||
// External: documented external connectors
|
||||
[DataRow(5u, false, DisplayName = "HDMI")]
|
||||
[DataRow(10u, false, DisplayName = "DISPLAYPORT_EXTERNAL")]
|
||||
[DataRow(12u, false, DisplayName = "UDI_EXTERNAL")]
|
||||
|
||||
// External: virtual / wireless
|
||||
[DataRow(15u, false, DisplayName = "MIRACAST")]
|
||||
[DataRow(17u, false, DisplayName = "INDIRECT_VIRTUAL")]
|
||||
|
||||
// External: OTHER (-1) cast to uint
|
||||
[DataRow(0xFFFFFFFFu, false, DisplayName = "OTHER (-1 cast to uint)")]
|
||||
|
||||
// External: unrecognized values default to external
|
||||
[DataRow(0xDEADBEEFu, false, DisplayName = "Unknown value defaults to external")]
|
||||
|
||||
// External: INTERNAL flag combined with an undocumented subtype is treated as external
|
||||
// (locks in the docstring's "INTERNAL | unknown subtype = external" rule).
|
||||
[DataRow(0x80000007u, false, DisplayName = "INTERNAL | unknown subtype 7 (treated as external)")]
|
||||
public void IsInternal_ReturnsExpectedClassification(uint outputTechnology, bool expected)
|
||||
{
|
||||
Assert.AreEqual(expected, DisplayClassifier.IsInternal(outputTechnology));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user