Compare commits

..

5 Commits

Author SHA1 Message Date
vanzue
38d460cc2b Semantic search refactor and experiment 2026-01-21 16:51:35 +08:00
Den Delimarsky
27dcd1e5bc Awake and DevEx improvements (#44795)
This PR contains a set of bug fixes and general improvements to
[Awake](https://awake.den.dev/) and developer experience tooling for
building the module.

### Awake Fixes

- **#32544** - Fixed an issue where Awake settings became non-functional
after the PC wakes from sleep. Added `WM_POWERBROADCAST` handling to
detect system resume events (`PBT_APMRESUMEAUTOMATIC`,
`PBT_APMRESUMESUSPEND`) and re-apply `SetThreadExecutionState` to
restore the awake state.

- **#36150** - Fixed an issue where Awake would not prevent sleep when
AC power is connected. Added `PBT_APMPOWERSTATUSCHANGE` handling to
re-apply `SetThreadExecutionState` when the power source changes
(AC/battery transitions).

- **#41674** - Fixed silent failure when `SetThreadExecutionState`
fails. The monitor thread now handles the return value, logs an error,
and reverts to passive mode with updated tray icon.

- **#41738** - Fixed `--display-on` CLI flag default from `true` to
`false` to align with documentation and PowerToys settings behavior.
This is a breaking change for scripts relying on the undocumented
default.

- **#41918** - Fixed `WM_COMMAND` message processing flaw in
`TrayHelper.WndProc` that incorrectly compared enum values against enum
count. Added proper bounds checking for custom tray time entries.

- **#44134** - Documented that `ES_DISPLAY_REQUIRED` (used when "Keep
display on" is enabled) blocks Task Scheduler idle detection, preventing
scheduled maintenance tasks like SSD TRIM. Workaround: disable "Keep
display on" or manually run `Optimize-Volume -DriveLetter C -ReTrim`.

- **#38770** - Fixed tray icon failing to appear after Windows updates.
Increased retry attempts and delays for icon Add operations (10
attempts, up to ~15.5 seconds total) while keeping existing fast retry
behavior for Update/Delete operations.

- **#40501** - Fixed tray icon not disappearing when Awake is disabled.
The `SetShellIcon` function was incorrectly requiring an icon for Delete
operations, causing the `NIM_DELETE` message to never be sent.

- Fixed an issue where toggling "Keep screen on" during an active timed
session would disrupt the countdown timer. The display setting now
updates directly without restarting the timer, preserving the exact
remaining time.

### Performance Optimizations

- Fixed O(n²) loop in `TrayHelper.CreateAwakeTimeSubMenu` by replacing
`ElementAt(i)` with `foreach` iteration.

- Fixed Observable subscription leak in `Manager.cs` by storing
`IDisposable` and disposing in `CancelExistingThread()`. Also removed
dead `_tokenSource` code that was no longer used.

- Reduced allocations in `SingleThreadSynchronizationContext` by
changing `Tuple<>` to `ValueTuple`.

- Replaced dedicated exit event thread with
`ThreadPool.RegisterWaitForSingleObject()` to reduce resource usage.

### Code Quality

- Replaced `Console.WriteLine` with `Logger.LogError` in `TrayHelper.cs`
for consistent logging.

- Added proper error logging to silent exception catches in
`AwakeService.cs`.

- Removed dead `Math.Min(minutes, int.MaxValue)` code where `minutes` is
already an `int`.

- Extracted hardcoded tray icon ID to named constant `TrayIconId`.

- Standardized null coalescing for `GetSettings<AwakeSettings>()` calls
across all files.

### Debugging Experience Fixes

- Fixed first-chance exceptions in `settings_window.cpp` during
debugging. Added `HasKey()` check before accessing `hotkey_changed`
property to prevent `hresult_error` exceptions when the property doesn't
exist in module settings.

- Fixed first-chance exceptions in FindMyMouse `parse_settings` during
debugging. Refactored to extract the properties object once and added
`HasKey()` checks before all `GetNamedObject()` calls. This prevents
`winrt::hresult_error` exceptions when optional settings keys (like
legacy `overlay_opacity`) don't exist, improving the debugging
experience by eliminating spurious exception breaks.

- Fixed LightSwitch.UITests build failures when building from a clean
state. Added missing project references (`ManagedCommon`,
`LightSwitchModuleInterface`) with `ReferenceOutputAssembly=false` to
ensure proper build ordering, and added existence check for the native
DLL copy operation.

### Developer Experience

- Added `setup-dev-environment.ps1` script to automate development
environment setup.
- Added `clean-artifacts.ps1` script to resolve build errors from
corrupted build state or missing image files.
- Added build script that allows standalone command line build of the
Awake module.
- Added troubleshooting section to
`doc/devdocs/development/debugging.md` with guidance on resolving common
build errors.
2026-01-20 21:27:45 -08:00
leileizhang
2a0d0a1210 [CI] Remove tests folder before code signing to reduce signing workload (#44800)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Remove the tests folder before ESRP code signing step to significantly
reduce the number of files being signed.

## Problem
The current code signing process signs **8,148 files**, with
approximately **5,000+ files** coming from the `tests/` directory. These
test files (primarily localized `.resources.dll` files from test
frameworks) do not need to be signed since they are not included in the
final release package.

The excessive file count causes:
- HTTP 429 (Too Many Requests) throttling from ESRP Gateway
- Signing operations getting stuck in "Inprogress" status
- Pipeline timeouts after ~57 minutes
<img width="1173" height="59" alt="image"
src="https://github.com/user-attachments/assets/ab077293-7efd-4ffe-a788-cbb6e72df3a1"
/>
<img width="1527" height="140" alt="image"
src="https://github.com/user-attachments/assets/7828474e-8bcc-471b-acbe-c898cf70bc34"
/>
<img width="1487" height="357" alt="image"
src="https://github.com/user-attachments/assets/2239d178-6e71-4349-a114-7e5216cbd8cf"
/>

Fix: 
Reduce signing time by 10+ minutes
From
<img width="480" height="58" alt="image"
src="https://github.com/user-attachments/assets/fb40e768-5700-4c00-af6b-6b7d9bf40360"
/>

to
<img width="480" height="63" alt="image"
src="https://github.com/user-attachments/assets/b7ab02fb-3616-4c7e-8e83-7d3afcbd1df3"
/>


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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-21 13:18:51 +08:00
Kai Tao
662bbf0033 Dev: give back the context window to dev's choice (#44817)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
I'd rather myself to configure my llm's context window, so this script
is used to clear some instruction temporarily.

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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Before :
<img width="498" height="156" alt="image"
src="https://github.com/user-attachments/assets/3a9f9827-8fde-4744-946e-f7c7db0eae05"
/>

After:
After : 
<img width="500" height="445" alt="image"
src="https://github.com/user-attachments/assets/38363e18-32fc-4690-99d6-6d0fdfeddaef"
/>
2026-01-21 13:17:57 +08:00
leileizhang
b7a94eb48d Update README to note PowerToys extension requires Windows 11 (#44863)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Updates the README.md to clarify that the PowerToys extension feature in
Command Palette requires Windows 11.

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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-21 11:25:04 +08:00
57 changed files with 4045 additions and 647 deletions

View File

@@ -64,6 +64,9 @@ apidl
APIENTRY
APIIs
Apm
APMPOWERSTATUSCHANGE
APMRESUMEAUTOMATIC
APMRESUMESUSPEND
APPBARDATA
APPEXECLINK
appext
@@ -1345,6 +1348,7 @@ Pomodoro
Popups
POPUPWINDOW
POSITIONITEM
POWERBROADCAST
POWERRENAMECONTEXTMENU
powerrenameinput
POWERRENAMETEST

View File

@@ -6,15 +6,8 @@ description: 'PowerToys AI contributor guidance'
Concise guidance for AI contributions. For complete details, see [AGENTS.md](../AGENTS.md).
## Quick Reference
- **Build**: `tools\build\build-essentials.cmd` (first time), then `tools\build\build.cmd`
- **Tests**: Find `<Product>*UnitTests` project, build it, run via VS Test Explorer
- **Exit code 0 = success** do not proceed if build fails
## Key Rules
- One terminal per operation (build → test)
- Atomic PRs: one logical change, no drive-by refactors
- Add tests when changing behavior
- Keep hot paths quiet (no logging in hooks/tight loops)
@@ -39,7 +32,5 @@ These are auto-applied based on file location:
## Detailed Documentation
- [AGENTS.md](../AGENTS.md) Full contributor guide
- [Build Guidelines](../tools/build/BUILD-GUIDELINES.md)
- [Architecture](../doc/devdocs/core/architecture.md)
- [Coding Style](../doc/devdocs/development/style.md)

View File

@@ -1,61 +0,0 @@
---
description: 'Guidelines for shared libraries including logging, IPC, settings, DPI, telemetry, and utilities consumed by multiple modules'
applyTo: 'src/common/**'
---
# Common Libraries Shared Code Guidance
Guidelines for modifying shared code in `src/common/`. Changes here can have wide-reaching impact across the entire PowerToys codebase.
## Scope
- Logging infrastructure (`src/common/logger/`)
- IPC primitives and named pipe utilities
- Settings serialization and management
- DPI awareness and scaling utilities
- Telemetry helpers
- General utilities (JSON parsing, string helpers, etc.)
## Guidelines
### API Stability
- Avoid breaking public headers/APIs; if changed, search & update all callers
- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility
- When modifying public interfaces, grep the entire codebase for usages
### Performance
- Watch perf in hot paths (hooks, timers, serialization)
- Avoid avoidable allocations in frequently called code
- Profile changes that touch performance-sensitive areas
### Dependencies
- Ask before adding third-party deps or changing serialization formats
- New dependencies must be MIT-licensed or approved by PM team
- Add any new external packages to `NOTICE.md`
### Logging
- C++ logging uses spdlog (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`)
- Initialize with `init_logger()` early in startup
- Keep hot paths quiet no logging in tight loops or hooks
## Acceptance Criteria
- No unintended ABI breaks
- No noisy logs in hot paths
- New non-obvious symbols briefly commented
- All callers updated when interfaces change
## Code Style
- **C++**: Follow `.clang-format` in `src/`; use Modern C++ patterns per C++ Core Guidelines
- **C#**: Follow `src/.editorconfig`; enforce StyleCop.Analyzers
## Validation
- Build: `tools\build\build.cmd` from `src/common/` folder
- Verify no ABI breaks: grep for changed function/struct names across codebase
- Check logs: ensure no new logging in performance-critical paths

View File

@@ -1,68 +0,0 @@
---
description: 'Guidelines for Runner and Settings UI components that communicate via named pipes and manage module lifecycle'
applyTo: 'src/runner/**,src/settings-ui/**'
---
# Runner & Settings UI Core Components Guidance
Guidelines for modifying the Runner (tray/module loader) and Settings UI (configuration app). These components communicate via Windows Named Pipes using JSON messages.
## Runner (`src/runner/`)
### Scope
- Module bootstrap, hotkey management, settings bridge, update/elevation handling
### Guidelines
- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`
- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules
- Keep startup lean: avoid blocking/network calls in early init path
- Preserve GPO & elevation behaviors; confirm no regression in policy handling
- Ask before modifying update workflow or elevation logic
### Acceptance Criteria
- Stable startup, consistent contracts, no unnecessary logging noise
## Settings UI (`src/settings-ui/`)
### Scope
- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema
### Guidelines
- Don't break settings schema silently; add migration when shape changes
- If IPC/JSON contracts change, align with `src/runner/**` implementation
- Keep UI responsive: marshal to UI thread for UI-bound operations
- Reuse existing styles/resources; avoid duplicate theme keys
- Add/adjust migration or serialization tests when changing persisted settings
### Acceptance Criteria
- Schema integrity preserved, responsive UI, consistent contracts, no style duplication
## Shared Concerns
### IPC Contract Changes
When modifying the JSON message format between Runner and Settings UI:
1. Update both `src/runner/` and `src/settings-ui/` in the same PR
2. Preserve backward compatibility where possible
3. Add migration logic for settings schema changes
4. Test both directions of communication
### Code Style
- **C++ (Runner)**: Follow `.clang-format` in `src/`
- **C# (Settings UI)**: Follow `src/.editorconfig`, use StyleCop.Analyzers
- **XAML**: Use XamlStyler or run `.\.pipelines\applyXamlStyling.ps1 -Main`
## Validation
- Build Runner: `tools\build\build.cmd` from `src/runner/`
- Build Settings UI: `tools\build\build.cmd` from `src/settings-ui/`
- Test IPC: Launch both Runner and Settings UI, verify communication works
- Schema changes: Run serialization tests if settings shape changed

1
.gitignore vendored
View File

@@ -359,3 +359,4 @@ src/common/Telemetry/*.etl
# PowerToysInstaller Build Temp Files
installer/*/*.wxs.bk
/src/modules/awake/.claude

View File

@@ -504,6 +504,14 @@ jobs:
Remove-Item -Force -Recurse "$(JobOutputDirectory)/_appx" -ErrorAction:Ignore
displayName: Re-pack the new CmdPal package after signing
- pwsh: |
$testsPath = "$(Build.SourcesDirectory)/$(BuildPlatform)/$(BuildConfiguration)/tests"
if (Test-Path $testsPath) {
Remove-Item -Path $testsPath -Recurse -Force
Write-Host "Removed tests folder to reduce signing workload: $testsPath"
}
displayName: Remove tests folder before signing
- template: steps-esrp-signing.yml
parameters:
displayName: Sign Core PowerToys

165
AGENTS.md
View File

@@ -1,165 +0,0 @@
---
description: 'Top-level AI contributor guidance for developing PowerToys - a collection of Windows productivity utilities'
applyTo: '**'
---
# PowerToys AI Contributor Guide
This is the top-level guidance for AI contributions to PowerToys. Keep changes atomic, follow existing patterns, and cite exact paths in PRs.
## Overview
PowerToys is a set of utilities for power users to tune and streamline their Windows experience.
| Area | Location | Description |
|------|----------|-------------|
| Runner | `src/runner/` | Main executable, tray icon, module loader, hotkey management |
| Settings UI | `src/settings-ui/` | WinUI/WPF configuration app communicating via named pipes |
| Modules | `src/modules/` | Individual PowerToys utilities (each in its own subfolder) |
| Common Libraries | `src/common/` | Shared code: logging, IPC, settings, DPI, telemetry, utilities |
| Build Tools | `tools/build/` | Build scripts and automation |
| Documentation | `doc/devdocs/` | Developer documentation |
| Installer | `installer/` | WiX-based installer projects |
For architecture details and module types, see [Architecture Overview](doc/devdocs/core/architecture.md).
## Conventions
For detailed coding conventions, see:
- [Coding Guidelines](doc/devdocs/development/guidelines.md) Dependencies, testing, PR management
- [Coding Style](doc/devdocs/development/style.md) Formatting, C++/C#/XAML style rules
- [Logging](doc/devdocs/development/logging.md) C++ spdlog and C# Logger usage
### Component-Specific Instructions
These instruction files are automatically applied when working in their respective areas:
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) IPC contracts, schema migrations
- [Common Libraries](.github/instructions/common-libraries.instructions.md) ABI stability, shared code guidelines
## Build
### Prerequisites
- Visual Studio 2022 17.4+
- Windows 10 1803+ (April 2018 Update or newer)
- Initialize submodules once: `git submodule update --init --recursive`
### Build Commands
| Task | Command |
|------|---------|
| First build / NuGet restore | `tools\build\build-essentials.cmd` |
| Build current folder | `tools\build\build.cmd` |
| Build with options | `build.ps1 -Platform x64 -Configuration Release` |
### Build Discipline
1. One terminal per operation (build → test). Do not switch or open new ones mid-flow
2. After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`)
3. Use scripts to build: `tools/build/build.ps1` or `tools/build/build.cmd`
4. For first build or missing NuGet packages, run `build-essentials.cmd` first
5. **Exit code 0 = success; non-zero = failure** treat this as absolute
6. On failure, read the errors log: `build.<config>.<platform>.errors.log`
7. Do not start tests or launch Runner until the build succeeds
### Build Logs
Located next to the solution/project being built:
- `build.<configuration>.<platform>.errors.log` errors only (check this first)
- `build.<configuration>.<platform>.all.log` full log
- `build.<configuration>.<platform>.trace.binlog` for MSBuild Structured Log Viewer
For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md).
## Tests
### Test Discovery
- Find test projects by product code prefix (e.g., `FancyZones`, `AdvancedPaste`)
- Look for sibling folders or 1-2 levels up named `<Product>*UnitTests` or `<Product>*UITests`
### Running Tests
1. **Build the test project first**, wait for exit code 0
2. Run via VS Test Explorer (`Ctrl+E, T`) or `vstest.console.exe` with filters
3. **Avoid `dotnet test`** in this repo use VS Test Explorer or vstest.console.exe
### Test Types
| Type | Requirements | Setup |
|------|--------------|-------|
| Unit Tests | Standard dev environment | None |
| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) |
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
### Test Discipline
1. Add or adjust tests when changing behavior
2. If tests skipped, state why (e.g., comment-only change, string rename)
3. New modules handling file I/O or user input **must** implement fuzzing tests
### Special Requirements
- **Mouse Without Borders**: Requires 2+ physical computers (not VMs)
- **Multi-monitor utilities**: Test with 2+ monitors, different DPI settings
For UI test setup details, see [UI Tests](doc/devdocs/development/ui-tests.md).
## Boundaries
### Ask for Clarification When
- Ambiguous spec after scanning relevant docs
- Cross-module impact (shared enum/struct) is unclear
- Security, elevation, or installer changes involved
- GPO or policy handling modifications needed
### Areas Requiring Extra Care
| Area | Concern | Reference |
|------|---------|-----------|
| `src/common/` | ABI breaks | [Common Libraries Instructions](.github/instructions/common-libraries.instructions.md) |
| `src/runner/`, `src/settings-ui/` | IPC contracts, schema | [Runner & Settings UI Instructions](.github/instructions/runner-settings-ui.instructions.md) |
| Installer files | Release impact | Careful review required |
| Elevation/GPO logic | Security | Confirm no regression in policy handling |
### What NOT to Do
- Don't merge incomplete features into main (use feature branches)
- Don't break IPC/JSON contracts without updating both runner and settings-ui
- Don't add noisy logs in hot paths
- Don't introduce third-party deps without PM approval and `NOTICE.md` update
## Validation Checklist
Before finishing, verify:
- [ ] Build clean with exit code 0
- [ ] Tests updated and passing locally
- [ ] No unintended ABI breaks or schema changes
- [ ] IPC contracts consistent between runner and settings-ui
- [ ] New dependencies added to `NOTICE.md`
- [ ] PR is atomic (one logical change), with issue linked
## Documentation Index
### Core Architecture
- [Architecture Overview](doc/devdocs/core/architecture.md)
- [Runner](doc/devdocs/core/runner.md)
- [Settings System](doc/devdocs/core/settings/readme.md)
- [Module Interface](doc/devdocs/modules/interface.md)
### Development
- [Coding Guidelines](doc/devdocs/development/guidelines.md)
- [Coding Style](doc/devdocs/development/style.md)
- [Logging](doc/devdocs/development/logging.md)
- [UI Tests](doc/devdocs/development/ui-tests.md)
- [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md)
### Build & Tools
- [Build Guidelines](tools/build/BUILD-GUIDELINES.md)
- [Tools Overview](doc/devdocs/tools/readme.md)
### Instructions (Auto-Applied)
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
- [Common Libraries](.github/instructions/common-libraries.instructions.md)

View File

@@ -108,7 +108,7 @@ There are <a href="https://learn.microsoft.com/windows/powertoys/install#communi
For an in-depth look at the latest changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog).
**✨ Highlights**
- **Command Palette**: Major expansion with PowerToys extension, Remote Desktop built-in extension, theme customization, drag-and-drop support, fallback ranking controls, sections/separators for pages, pinyin Chinese matching, and many UX refinements
- **Command Palette**: Major expansion with PowerToys extension (Windows 11 only), Remote Desktop built-in extension, theme customization, drag-and-drop support, fallback ranking controls, sections/separators for pages, pinyin Chinese matching, and many UX refinements.
- **Settings**: Quick Access flyout is now a standalone process for significantly faster startup, theme-adaptive tray icon, AOT serialization, and multiple UI/accessibility fixes
- **CursorWrap (New!)**: New mouse utility that lets your cursor wrap around screen edges, making multi-monitor navigation faster and more seamless.
- **Advanced Paste**: Image input for AI, color detection in clipboard history, Foundry Local improvements, Azure AI icons, and multiple bug fixes

View File

@@ -0,0 +1,244 @@
# Windows App SDK Semantic Search API 总结
## 1. 环境与依赖
| 项目 | 版本/值 |
|------|---------|
| **Windows App SDK** | `2.0.0-experimental3` |
| **.NET** | `net9.0-windows10.0.26100.0` |
| **AI Search NuGet** | `Microsoft.WindowsAppSDK.AI` (2.0.57-experimental) |
| **命名空间** | `Microsoft.Windows.AI.Search.Experimental.AppContentIndex` |
| **应用类型** | WinUI 3 MSIX 打包应用 |
---
## 2. 核心 API
### 2.1 索引管理
```csharp
// 创建/打开索引
var result = AppContentIndexer.GetOrCreateIndex("indexName");
if (result.Succeeded) {
_indexer = result.Indexer;
// result.Status: CreatedNew | OpenedExisting
}
// 等待索引能力就绪
await _indexer.WaitForIndexCapabilitiesAsync();
// 等待索引空闲(建索引完成)
await _indexer.WaitForIndexingIdleAsync(TimeSpan.FromSeconds(120));
// 清理
_indexer.RemoveAll(); // 删除所有索引
_indexer.Remove(id); // 删除单个
_indexer.Dispose();
```
### 2.2 添加内容到索引
```csharp
// 索引文本 → 自动建立 TextLexical + TextSemantic 索引
IndexableAppContent textContent = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer.AddOrUpdate(textContent);
// 索引图片 → 自动建立 ImageSemantic + ImageOcr 索引
IndexableAppContent imageContent = AppManagedIndexableAppContent.CreateFromBitmap(id, softwareBitmap);
_indexer.AddOrUpdate(imageContent);
```
### 2.3 查询
```csharp
// 文本查询
TextQueryOptions options = new TextQueryOptions {
Language = "en-US", // 可选
MatchScope = QueryMatchScope.Unconstrained, // 匹配范围
TextMatchType = TextLexicalMatchType.Fuzzy // Fuzzy | Exact
};
AppIndexTextQuery query = _indexer.CreateTextQuery(searchText, options);
IReadOnlyList<TextQueryMatch> matches = query.GetNextMatches(5);
// 图片查询
ImageQueryOptions imgOptions = new ImageQueryOptions {
MatchScope = QueryMatchScope.Unconstrained,
ImageOcrTextMatchType = TextLexicalMatchType.Fuzzy
};
AppIndexImageQuery imgQuery = _indexer.CreateImageQuery(searchText, imgOptions);
IReadOnlyList<ImageQueryMatch> imgMatches = imgQuery.GetNextMatches(5);
```
### 2.4 能力检查(只读)
```csharp
IndexCapabilities capabilities = _indexer.GetIndexCapabilities();
bool textLexicalOK = capabilities.GetCapabilityState(IndexCapability.TextLexical)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
bool textSemanticOK = capabilities.GetCapabilityState(IndexCapability.TextSemantic)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
bool imageSemanticOK = capabilities.GetCapabilityState(IndexCapability.ImageSemantic)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
bool imageOcrOK = capabilities.GetCapabilityState(IndexCapability.ImageOcr)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
```
---
## 3. 四种索引能力
| 能力 | 说明 | 触发方式 |
|------|------|----------|
| `TextLexical` | 词法/关键词搜索 | CreateFromString() 自动 |
| `TextSemantic` | AI 语义搜索 (Embedding) | CreateFromString() 自动 |
| `ImageSemantic` | 图像语义搜索 | CreateFromBitmap() 自动 |
| `ImageOcr` | 图片 OCR 文字搜索 | CreateFromBitmap() 自动 |
---
## 4. 可控选项(有限)
### TextQueryOptions
| 属性 | 类型 | 说明 |
|------|------|------|
| `Language` | string | 查询语言(可选,如 "en-US"|
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
| `TextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact仅影响 Lexical|
### ImageQueryOptions
| 属性 | 类型 | 说明 |
|------|------|------|
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
| `ImageOcrTextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact仅影响 OCR|
### 枚举值说明
**QueryMatchScope:**
- `Unconstrained` - 无约束,同时使用 Lexical + Semantic
- `Region` - 限制在特定区域
- `ContentItem` - 限制在单个内容项
**TextLexicalMatchType:**
- `Fuzzy` - 模糊匹配,允许拼写错误、近似词
- `Exact` - 精确匹配,必须完全一致
---
## 5. 关键限制 ⚠️
| 限制 | 说明 |
|------|------|
| **不能单独指定 Semantic/Lexical** | 系统自动同时使用所有可用能力 |
| **Fuzzy/Exact 只影响 Lexical** | 对 Semantic 搜索无效 |
| **能力检查是只读的** | `GetIndexCapabilities()` 只能查看,不能控制 |
| **无相似度阈值** | 不能设置 Semantic 匹配的阈值 |
| **无结果排序控制** | 无法指定按相关度或其他方式排序 |
| **语言需手动传** | 不会自动检测,需开发者指定 |
| **无相关度分数** | 查询结果不返回匹配分数 |
---
## 6. 典型使用流程
```
┌─────────────────────────────────────────────────────────┐
│ App 启动时 │
├─────────────────────────────────────────────────────────┤
│ 1. GetOrCreateIndex("name") // 创建/打开索引 │
│ 2. WaitForIndexCapabilitiesAsync() // 等待能力就绪 │
│ 3. GetIndexCapabilities() // 检查可用能力 │
│ 4. IndexAll() // 索引所有数据 │
│ ├─ CreateFromString() × N │
│ └─ CreateFromBitmap() × N │
│ 5. WaitForIndexingIdleAsync() // 等待索引完成 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 运行时查询 │
├─────────────────────────────────────────────────────────┤
│ 1. CreateTextQuery(text, options) // 创建查询 │
│ 2. query.GetNextMatches(N) // 获取结果 │
│ 3. 处理 TextQueryMatch / ImageQueryMatch │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ App 退出时 │
├─────────────────────────────────────────────────────────┤
│ 1. _indexer.RemoveAll() // 清理索引 │
│ 2. _indexer.Dispose() // 释放资源 │
└─────────────────────────────────────────────────────────┘
```
---
## 7. 查询结果处理
```csharp
// 文本查询结果
foreach (var match in textMatches)
{
if (match.ContentKind == QueryMatchContentKind.AppManagedText)
{
AppManagedTextQueryMatch textResult = (AppManagedTextQueryMatch)match;
string contentId = match.ContentId; // 内容 ID
int offset = textResult.TextOffset; // 匹配文本偏移
int length = textResult.TextLength; // 匹配文本长度
}
}
// 图片查询结果
foreach (var match in imageMatches)
{
if (match.ContentKind == QueryMatchContentKind.AppManagedImage)
{
AppManagedImageQueryMatch imageResult = (AppManagedImageQueryMatch)match;
string contentId = imageResult.ContentId; // 图片 ID
}
}
```
---
## 8. 能力变化监听
```csharp
// 监听索引能力变化
_indexer.Listener.IndexCapabilitiesChanged += (indexer, capabilities) =>
{
// 重新检查能力状态,更新 UI
LoadAppIndexCapabilities();
};
```
---
## 9. 结论
这是一个**高度封装的黑盒 API**
### 优点 ✅
- 简单易用,几行代码即可实现搜索
- 自动处理 Lexical + Semantic
- 支持文本和图片多模态搜索
- 系统级集成,无需额外部署模型
### 缺点 ❌
- 无法精细控制搜索类型
- 不能只用 Semantic Search
- 选项有限,缺乏高级配置
- 实验性 API可能变更
### 替代方案
**如果需要纯 Semantic Search向量搜索**,建议:
- 直接使用 Embedding 模型生成向量
- 配合向量数据库Azure Cosmos DB、FAISS、Qdrant 等)
---
## 10. 相关 NuGet 包
```xml
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
<PackageReference Include="Microsoft.WindowsAppSDK.AI" Version="2.0.57-experimental" />
```
---
*文档生成日期: 2026-01-21*

View File

@@ -96,3 +96,40 @@ The Shell Process Debugging Tool is a Visual Studio extension that helps debug m
- Logs are stored in the local app directory: `%LOCALAPPDATA%\Microsoft\PowerToys`
- Check Event Viewer for application crashes related to `PowerToys.Settings.exe`
- Crash dumps can be obtained from Event Viewer
## Troubleshooting Build Errors
### Missing Image Files or Corrupted Build State
If you encounter build errors about missing image files (e.g., `.png`, `.ico`, or other assets), this typically indicates a corrupted build state. To resolve:
1. **Clean the solution in Visual Studio**: Build > Clean Solution
Or from the command line (Developer Command Prompt for VS 2022):
```pwsh
msbuild PowerToys.slnx /t:Clean /p:Platform=x64 /p:Configuration=Debug
```
2. **Delete build output and package folders** from the repository root:
- `x64/`
- `ARM64/`
- `Debug/`
- `Release/`
- `packages/`
3. **Rebuild the solution**
#### Helper Script
A PowerShell script is available to automate this cleanup:
```pwsh
.\tools\build\clean-artifacts.ps1
```
This script will run MSBuild Clean and remove the build folders listed above. Use `-SkipMSBuildClean` if you only want to delete the folders without running MSBuild Clean.
After cleaning, rebuild with:
```pwsh
msbuild -restore -p:RestorePackagesConfig=true -p:Platform=x64 -m PowerToys.slnx
```

View File

@@ -83,14 +83,40 @@ Once you've discussed your proposed feature/fix/etc. with a team member, and an
1. A local clone of the PowerToys repository
1. Enable long paths in Windows (see [Enable Long Paths](https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation#enabling-long-paths-in-windows-10-version-1607-and-later) for details)
### Install Visual Studio dependencies
### Automated Setup (Recommended)
Run the setup script to automatically configure your development environment:
```powershell
.\tools\build\setup-dev-environment.ps1
```
This script will:
- Enable Windows long path support (requires administrator privileges)
- Enable Windows Developer Mode (requires administrator privileges)
- Guide you through installing required Visual Studio components from `.vsconfig`
- Initialize git submodules
Run with `-Help` to see all available options:
```powershell
.\tools\build\setup-dev-environment.ps1 -Help
```
### Manual Setup
If you prefer to set up manually, follow these steps:
#### Install Visual Studio dependencies
1. Open the `PowerToys.slnx` file.
1. If you see a dialog that says `install extra components` in the solution explorer pane, click `install`
### Get Submodules to compile
Alternatively, import the `.vsconfig` file from the repository root using Visual Studio Installer to install all required workloads.
We have submodules that need to be initialized before you can compile most parts of PowerToys. This should be a one-time step.
#### Get Submodules to compile
We have submodules that need to be initialized before you can compile most parts of PowerToys. This should be a one-time step.
1. Open a terminal
1. Navigate to the folder you cloned PowerToys to.
@@ -98,12 +124,32 @@ We have submodules that need to be initialized before you can compile most parts
### Compiling Source Code
#### Using Visual Studio
- Open `PowerToys.slnx` in Visual Studio.
- In the `Solutions Configuration` drop-down menu select `Release` or `Debug`.
- From the `Build` menu choose `Build Solution`, or press <kbd>Control</kbd>+<kbd>Shift</kbd>+<kbd>b</kbd> on your keyboard.
- The build process may take several minutes depending on your computer's performance. Once it completes, the PowerToys binaries will be in your repo under `x64\Release\`.
- You can run `x64\Release\PowerToys.exe` directly without installing PowerToys, but some modules (i.e. PowerRename, ImageResizer, File Explorer extension etc.) will not be available unless you also build the installer and install PowerToys.
#### Using Command Line
You can also build from the command line using the provided scripts in `tools\build\`:
```powershell
# Build the full solution (auto-detects platform)
.\tools\build\build.ps1
# Build with specific configuration
.\tools\build\build.ps1 -Platform x64 -Configuration Release
# Build only essential projects (runner + settings) for faster iteration
.\tools\build\build-essentials.ps1
# Build everything including the installer (Release only)
.\tools\build\build-installer.ps1
```
## Compile the installer
Our installer is two parts, an EXE and an MSI. The EXE (Bootstrapper) contains the MSI and handles more complex installation logic.

View File

@@ -1,5 +1,5 @@
---
last-update: 7-16-2024
last-update: 1-18-2026
---
# PowerToys Awake Changelog
@@ -12,6 +12,7 @@ The build ID moniker is made up of two components - a reference to a [Halo](http
| Build ID | Build Date |
|:-------------------------------------------------------------------|:------------------|
| [`DIDACT_01182026`](#DIDACT_01182026-january-18-2026) | January 18, 2026 |
| [`TILLSON_11272024`](#TILLSON_11272024-november-27-2024) | November 27, 2024 |
| [`PROMETHEAN_09082024`](#PROMETHEAN_09082024-september-8-2024) | September 8, 2024 |
| [`VISEGRADRELAY_08152024`](#VISEGRADRELAY_08152024-august-15-2024) | August 15, 2024 |
@@ -20,6 +21,22 @@ The build ID moniker is made up of two components - a reference to a [Halo](http
| [`LIBRARIAN_03202022`](#librarian_03202022-march-20-2022) | March 20, 2022 |
| `ARBITER_01312022` | January 31, 2022 |
### `DIDACT_01182026` (January 18, 2026)
>[!NOTE]
>See pull request: [Awake - `DIDACT_01182026`](https://github.com/microsoft/PowerToys/pull/44795)
- [#32544](https://github.com/microsoft/PowerToys/issues/32544) Fixed an issue where Awake settings became non-functional after the PC wakes from sleep. Added `WM_POWERBROADCAST` handling to detect system resume events (`PBT_APMRESUMEAUTOMATIC`, `PBT_APMRESUMESUSPEND`) and re-apply `SetThreadExecutionState` to restore the awake state.
- [#36150](https://github.com/microsoft/PowerToys/issues/36150) Fixed an issue where Awake would not prevent sleep when AC power is connected. Added `PBT_APMPOWERSTATUSCHANGE` handling to re-apply `SetThreadExecutionState` when the power source changes (AC/battery transitions).
- Fixed an issue where toggling "Keep screen on" during an active timed session would disrupt the countdown timer. The display setting now updates directly without restarting the timer, preserving the exact remaining time.
- [#41918](https://github.com/microsoft/PowerToys/issues/41918) Fixed `WM_COMMAND` message processing flaw in `TrayHelper.WndProc` that incorrectly compared enum values against enum count. Added proper bounds checking for custom tray time entries.
- Investigated [#44134](https://github.com/microsoft/PowerToys/issues/44134) - documented that `ES_DISPLAY_REQUIRED` (used when "Keep display on" is enabled) blocks Task Scheduler idle detection, preventing scheduled maintenance tasks like SSD TRIM. Workaround: disable "Keep display on" or manually run `Optimize-Volume -DriveLetter C -ReTrim`. Additional investigation needed for potential "idle window" feature.
- [#41738](https://github.com/microsoft/PowerToys/issues/41738) Fixed `--display-on` CLI flag default from `true` to `false` to align with documentation and PowerToys settings behavior. This is a breaking change for scripts relying on the undocumented default.
- [#41674](https://github.com/microsoft/PowerToys/issues/41674) Fixed silent failure when `SetThreadExecutionState` fails. The monitor thread now handles the return value, logs an error, and reverts to passive mode with updated tray icon.
- [#38770](https://github.com/microsoft/PowerToys/issues/38770) Fixed tray icon failing to appear after Windows updates. Increased retry attempts and delays for icon Add operations (10 attempts, up to ~15.5 seconds total) while keeping existing fast retry behavior for Update/Delete operations.
- [#40501](https://github.com/microsoft/PowerToys/issues/40501) Fixed tray icon not disappearing when Awake is disabled. The `SetShellIcon` function was incorrectly requiring an icon for Delete operations, causing the `NIM_DELETE` message to never be sent.
- [#40659](https://github.com/microsoft/PowerToys/issues/40659) Fixed potential stack overflow crash in EXPIRABLE mode. Added early return after SaveSettings when correcting past expiration times, matching the pattern used by other mode handlers to prevent reentrant execution.
### `TILLSON_11272024` (November 27, 2024)
>[!NOTE]

View File

@@ -0,0 +1,496 @@
# Common.Search Library Specification
## Overview
本文档描述 `Common.Search` 库的重构设计目标是提供一个通用的、可插拔的搜索框架支持多种搜索引擎实现Fuzzy Match、Semantic Search 等)。
## Goals
1. **解耦** - 搜索引擎与数据源完全解耦
2. **可插拔** - 支持替换不同的搜索引擎实现
3. **泛型** - 不绑定特定业务类型(如 SettingEntry
4. **可组合** - 支持多引擎组合(即时 Fuzzy + 延迟 Semantic
5. **可复用** - 可被 PowerToys 多个模块使用
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Consumer (e.g., Settings.UI) │
├─────────────────────────────────────────────────────────────────┤
│ SettingsDataProvider ← 业务特定的数据加载 │
│ SettingsSearchService ← 业务特定的搜索服务 │
│ SettingEntry : ISearchable ← 业务实体实现搜索契约 │
└─────────────────────────────────────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────────┐
│ Common.Search (Library) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Core Abstractions │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ ISearchable ← 可搜索内容契约 │ │
│ │ ISearchEngine<T> ← 搜索引擎接口 │ │
│ │ SearchResult<T> ← 统一结果模型 │ │
│ │ SearchOptions ← 搜索选项 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Implementations │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ FuzzSearch/ │ │
│ │ ├── FuzzSearchEngine<T> ← 内存 Fuzzy 搜索 │ │
│ │ ├── StringMatcher ← 现有的模糊匹配算法 │ │
│ │ └── MatchResult ← Fuzzy 匹配结果 │ │
│ │ │ │
│ │ SemanticSearch/ │ │
│ │ ├── SemanticSearchEngine ← Windows AI Search 封装 │ │
│ │ └── SemanticSearchCapabilities │ │
│ │ │ │
│ │ CompositeSearch/ │ │
│ │ └── CompositeSearchEngine<T> ← 多引擎组合 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Core Interfaces
### ISearchable
定义可搜索内容的最小契约。
```csharp
namespace Common.Search;
/// <summary>
/// Defines a searchable item that can be indexed and searched.
/// </summary>
public interface ISearchable
{
/// <summary>
/// Gets the unique identifier for this item.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the primary searchable text (e.g., title, header).
/// </summary>
string SearchableText { get; }
/// <summary>
/// Gets optional secondary searchable text (e.g., description).
/// Returns null if not available.
/// </summary>
string? SecondarySearchableText { get; }
}
```
### ISearchEngine&lt;T&gt;
搜索引擎核心接口。
```csharp
namespace Common.Search;
/// <summary>
/// Defines a pluggable search engine that can index and search items.
/// </summary>
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
public interface ISearchEngine<T> : IDisposable
where T : ISearchable
{
/// <summary>
/// Gets a value indicating whether the engine is ready to search.
/// </summary>
bool IsReady { get; }
/// <summary>
/// Gets the engine capabilities.
/// </summary>
SearchEngineCapabilities Capabilities { get; }
/// <summary>
/// Initializes the search engine.
/// </summary>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Indexes a single item.
/// </summary>
Task IndexAsync(T item, CancellationToken cancellationToken = default);
/// <summary>
/// Indexes multiple items in batch.
/// </summary>
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an item from the index by its ID.
/// </summary>
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Clears all indexed items.
/// </summary>
Task ClearAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Searches for items matching the query.
/// </summary>
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default);
}
```
### SearchResult&lt;T&gt;
统一的搜索结果模型。
```csharp
namespace Common.Search;
/// <summary>
/// Represents a search result with the matched item and scoring information.
/// </summary>
public sealed class SearchResult<T>
where T : ISearchable
{
/// <summary>
/// Gets the matched item.
/// </summary>
public required T Item { get; init; }
/// <summary>
/// Gets the relevance score (higher is more relevant).
/// </summary>
public required double Score { get; init; }
/// <summary>
/// Gets the type of match that produced this result.
/// </summary>
public required SearchMatchKind MatchKind { get; init; }
/// <summary>
/// Gets the match details for highlighting (optional).
/// </summary>
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}
/// <summary>
/// Represents a span of matched text for highlighting.
/// </summary>
public readonly record struct MatchSpan(int Start, int Length);
/// <summary>
/// Specifies the kind of match that produced a search result.
/// </summary>
public enum SearchMatchKind
{
/// <summary>Exact text match.</summary>
Exact,
/// <summary>Fuzzy/approximate text match.</summary>
Fuzzy,
/// <summary>Semantic/AI-based match.</summary>
Semantic,
/// <summary>Combined match from multiple engines.</summary>
Composite,
}
```
### SearchOptions
搜索配置选项。
```csharp
namespace Common.Search;
/// <summary>
/// Options for configuring search behavior.
/// </summary>
public sealed class SearchOptions
{
/// <summary>
/// Gets or sets the maximum number of results to return.
/// Default is 20.
/// </summary>
public int MaxResults { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum score threshold (0.0 to 1.0).
/// Results below this score are filtered out.
/// Default is 0.0 (no filtering).
/// </summary>
public double MinScore { get; set; } = 0.0;
/// <summary>
/// Gets or sets the language hint for the search (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include match spans for highlighting.
/// Default is false.
/// </summary>
public bool IncludeMatchSpans { get; set; } = false;
}
```
### SearchEngineCapabilities
引擎能力描述。
```csharp
namespace Common.Search;
/// <summary>
/// Describes the capabilities of a search engine.
/// </summary>
public sealed class SearchEngineCapabilities
{
/// <summary>
/// Gets a value indicating whether the engine supports fuzzy matching.
/// </summary>
public bool SupportsFuzzyMatch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports semantic search.
/// </summary>
public bool SupportsSemanticSearch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine persists the index to disk.
/// </summary>
public bool PersistsIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports incremental indexing.
/// </summary>
public bool SupportsIncrementalIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports match span highlighting.
/// </summary>
public bool SupportsMatchSpans { get; init; }
}
```
## Implementations
### FuzzSearchEngine&lt;T&gt;
基于现有 StringMatcher 的内存搜索引擎。
**特点:**
- 纯内存,无持久化
- 即时响应(毫秒级)
- 支持 match spans 高亮
- 基于字符的模糊匹配
**Capabilities**
```csharp
new SearchEngineCapabilities
{
SupportsFuzzyMatch = true,
SupportsSemanticSearch = false,
PersistsIndex = false,
SupportsIncrementalIndex = true,
SupportsMatchSpans = true,
}
```
### SemanticSearchEngine
基于 Windows App SDK AI Search API 的语义搜索引擎。
**特点:**
- 系统管理的持久化索引
- AI 驱动的语义理解
- 需要模型初始化(可能较慢)
- 可能不可用(依赖系统支持)
**Capabilities**
```csharp
new SearchEngineCapabilities
{
SupportsFuzzyMatch = true, // API 同时提供 lexical + semantic
SupportsSemanticSearch = true,
PersistsIndex = true,
SupportsIncrementalIndex = true,
SupportsMatchSpans = false, // API 不返回详细位置
}
```
**注意:** SemanticSearchEngine 不是泛型的,因为它需要将内容转换为字符串存入系统索引。实现时通过 `ISearchable` 接口提取文本。
### CompositeSearchEngine&lt;T&gt;
组合多个搜索引擎,支持 fallback 和结果合并。
```csharp
namespace Common.Search;
/// <summary>
/// A search engine that combines results from multiple engines.
/// </summary>
public sealed class CompositeSearchEngine<T> : ISearchEngine<T>
where T : ISearchable
{
/// <summary>
/// Strategy for combining results from multiple engines.
/// </summary>
public enum CombineStrategy
{
/// <summary>Use first ready engine only.</summary>
FirstReady,
/// <summary>Merge results from all ready engines.</summary>
MergeAll,
/// <summary>Use primary, fallback to secondary if primary not ready.</summary>
PrimaryWithFallback,
}
}
```
**典型用法:** Fuzzy 作为即时响应Semantic 准备好后增强结果。
## Directory Structure
```
src/common/Common.Search/
├── Common.Search.csproj
├── GlobalSuppressions.cs
├── ISearchable.cs
├── ISearchEngine.cs
├── SearchResult.cs
├── SearchOptions.cs
├── SearchEngineCapabilities.cs
├── SearchMatchKind.cs
├── MatchSpan.cs
├── FuzzSearch/
│ ├── FuzzSearchEngine.cs
│ ├── StringMatcher.cs (existing)
│ ├── MatchOption.cs (existing)
│ ├── MatchResult.cs (existing)
│ └── SearchPrecisionScore.cs (existing)
├── SemanticSearch/
│ ├── SemanticSearchEngine.cs
│ ├── SemanticSearchCapabilities.cs
│ └── SemanticSearchAdapter.cs (adapts ISearchable to Windows API)
└── CompositeSearch/
└── CompositeSearchEngine.cs
```
## Consumer Usage (Settings.UI)
### SettingEntry 实现 ISearchable
```csharp
// Settings.UI.Library/SettingEntry.cs
public struct SettingEntry : ISearchable
{
// Existing properties...
// ISearchable implementation
public string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public string SearchableText => Header ?? string.Empty;
public string? SecondarySearchableText => Description;
}
```
### SettingsSearchService
```csharp
// Settings.UI/Services/SettingsSearchService.cs
public sealed class SettingsSearchService : IDisposable
{
private readonly ISearchEngine<SettingEntry> _engine;
public SettingsSearchService(ISearchEngine<SettingEntry> engine)
{
_engine = engine;
}
public async Task InitializeAsync(IEnumerable<SettingEntry> entries)
{
await _engine.InitializeAsync();
await _engine.IndexBatchAsync(entries);
}
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken ct = default)
{
var results = await _engine.SearchAsync(query, cancellationToken: ct);
return results.Select(r => r.Item).ToList();
}
}
```
### Startup Configuration
```csharp
// Option 1: Fuzzy only (default, immediate)
var engine = new FuzzSearchEngine<SettingEntry>();
// Option 2: Semantic only (requires Windows AI)
var engine = new SemanticSearchAdapter<SettingEntry>("PowerToysSettings");
// Option 3: Composite (best of both worlds)
var engine = new CompositeSearchEngine<SettingEntry>(
primary: new SemanticSearchAdapter<SettingEntry>("PowerToysSettings"),
fallback: new FuzzSearchEngine<SettingEntry>(),
strategy: CombineStrategy.PrimaryWithFallback
);
var searchService = new SettingsSearchService(engine);
await searchService.InitializeAsync(settingEntries);
```
## Migration Plan
### Phase 1: Core Abstractions
1. 创建 `ISearchable`, `ISearchEngine<T>`, `SearchResult<T>` 等核心接口
2. 保持现有 FuzzSearch 代码不变
### Phase 2: FuzzSearchEngine&lt;T&gt;
1. 创建泛型 `FuzzSearchEngine<T>` 实现
2. 内部复用现有 `StringMatcher`
### Phase 3: SemanticSearchEngine
1. 完善现有 `SemanticSearchEngine` 实现
2. 创建 `SemanticSearchAdapter<T>` 桥接泛型接口
### Phase 4: Settings.UI Migration
1. `SettingEntry` 实现 `ISearchable`
2. 创建 `SettingsSearchService`
3. 迁移 `SearchIndexService` 到新架构
4. 保持 API 兼容,逐步废弃旧方法
### Phase 5: CompositeSearchEngine (Optional)
1. 实现组合引擎
2. 支持 Fuzzy + Semantic 混合搜索
## Open Questions
1. **是否需要支持图片搜索?** 当前 SemanticSearchEngine 支持 `IndexImage`,但 `ISearchable` 只有文本。如果需要图片,可能需要 `IImageSearchable` 扩展。
2. **结果去重策略?** CompositeEngine 合并结果时,同一个 Item 可能被多个引擎匹配,如何去重和合并分数?
3. **异步 vs 同步?** FuzzSearch 完全可以同步执行,但接口统一用 `Task` 是否合适?考虑提供同步重载?
4. **索引更新策略?** 当 Settings 内容变化时(例如用户切换语言),如何高效更新索引?
---
*Document Version: 1.0*
*Last Updated: 2026-01-21*

View File

@@ -4,5 +4,11 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- Exclude SemanticSearch until experimental SDK is available -->
<ItemGroup>
<Compile Remove="SemanticSearch\**" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,306 @@
// 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.Concurrent;
using System.Globalization;
using System.Text;
namespace Common.Search.FuzzSearch;
/// <summary>
/// A search engine that uses fuzzy string matching for search.
/// </summary>
/// <typeparam name="T">The type of items to search.</typeparam>
public sealed class FuzzSearchEngine<T> : ISearchEngine<T>
where T : ISearchable
{
private readonly object _lockObject = new();
private readonly Dictionary<string, T> _itemsById = new();
private readonly Dictionary<string, (string PrimaryNorm, string? SecondaryNorm)> _normalizedCache = new();
private bool _isReady;
private bool _disposed;
/// <inheritdoc/>
public bool IsReady
{
get
{
lock (_lockObject)
{
return _isReady;
}
}
}
/// <inheritdoc/>
public SearchEngineCapabilities Capabilities { get; } = new()
{
SupportsFuzzyMatch = true,
SupportsSemanticSearch = false,
PersistsIndex = false,
SupportsIncrementalIndex = true,
SupportsMatchSpans = true,
};
/// <inheritdoc/>
public Task InitializeAsync(CancellationToken cancellationToken = default)
{
lock (_lockObject)
{
_isReady = true;
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById[item.Id] = item;
_normalizedCache[item.Id] = (
NormalizeString(item.SearchableText),
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
);
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(items);
ThrowIfDisposed();
lock (_lockObject)
{
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
_itemsById[item.Id] = item;
_normalizedCache[item.Id] = (
NormalizeString(item.SearchableText),
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
);
}
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Remove(id);
_normalizedCache.Remove(id);
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task ClearAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Clear();
_normalizedCache.Clear();
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
options ??= new SearchOptions();
var normalizedQuery = NormalizeString(query);
List<KeyValuePair<string, T>> snapshot;
lock (_lockObject)
{
if (_itemsById.Count == 0)
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
snapshot = _itemsById.ToList();
}
var bag = new ConcurrentBag<SearchResult<T>>();
var po = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
};
try
{
Parallel.ForEach(snapshot, po, kvp =>
{
var (primaryNorm, secondaryNorm) = GetNormalizedTexts(kvp.Key);
var primaryResult = StringMatcher.FuzzyMatch(normalizedQuery, primaryNorm);
double score = primaryResult.Score;
List<int>? matchData = primaryResult.MatchData;
if (!string.IsNullOrEmpty(secondaryNorm))
{
var secondaryResult = StringMatcher.FuzzyMatch(normalizedQuery, secondaryNorm);
if (secondaryResult.Success && secondaryResult.Score * 0.8 > score)
{
score = secondaryResult.Score * 0.8;
matchData = null; // Secondary matches don't have primary text spans
}
}
if (score > options.MinScore)
{
var result = new SearchResult<T>
{
Item = kvp.Value,
Score = score,
MatchKind = SearchMatchKind.Fuzzy,
MatchSpans = options.IncludeMatchSpans && matchData != null
? ConvertToMatchSpans(matchData)
: null,
};
bag.Add(result);
}
});
}
catch (OperationCanceledException)
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
var results = bag
.OrderByDescending(r => r.Score)
.Take(options.MaxResults)
.ToList();
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
lock (_lockObject)
{
_itemsById.Clear();
_normalizedCache.Clear();
_isReady = false;
}
_disposed = true;
}
private (string PrimaryNorm, string? SecondaryNorm) GetNormalizedTexts(string id)
{
lock (_lockObject)
{
if (_normalizedCache.TryGetValue(id, out var cached))
{
return cached;
}
}
return (string.Empty, null);
}
private static IReadOnlyList<MatchSpan> ConvertToMatchSpans(List<int> matchData)
{
if (matchData == null || matchData.Count == 0)
{
return Array.Empty<MatchSpan>();
}
// Convert individual match indices to spans
var spans = new List<MatchSpan>();
var sortedIndices = matchData.OrderBy(i => i).ToList();
int start = sortedIndices[0];
int length = 1;
for (int i = 1; i < sortedIndices.Count; i++)
{
if (sortedIndices[i] == sortedIndices[i - 1] + 1)
{
// Consecutive index, extend the span
length++;
}
else
{
// Gap found, save current span and start new one
spans.Add(new MatchSpan(start, length));
start = sortedIndices[i];
length = 1;
}
}
// Add the last span
spans.Add(new MatchSpan(start, length));
return spans;
}
private static string NormalizeString(string? input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var sb = new StringBuilder(normalized.Length);
foreach (var c in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(c);
if (category != UnicodeCategory.NonSpacingMark)
{
sb.Append(c);
}
}
return sb.ToString();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}

View File

@@ -25,9 +25,9 @@ public class StringMatcher
/// 6. Move onto the next substring's characters until all substrings are checked.
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
/// </summary>
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null)
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption? opt = null)
{
opt = opt ?? new MatchOption();
opt ??= new MatchOption();
if (string.IsNullOrEmpty(stringToCompare))
{

View File

@@ -11,6 +11,10 @@ using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._indexName")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._indexer")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._disposed")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._capabilities")]
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")]
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")]
[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]

View File

@@ -0,0 +1,73 @@
// 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.
namespace Common.Search;
/// <summary>
/// Defines a pluggable search engine that can index and search items.
/// </summary>
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
public interface ISearchEngine<T> : IDisposable
where T : ISearchable
{
/// <summary>
/// Gets a value indicating whether the engine is ready to search.
/// </summary>
bool IsReady { get; }
/// <summary>
/// Gets the engine capabilities.
/// </summary>
SearchEngineCapabilities Capabilities { get; }
/// <summary>
/// Initializes the search engine.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Indexes a single item.
/// </summary>
/// <param name="item">The item to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task IndexAsync(T item, CancellationToken cancellationToken = default);
/// <summary>
/// Indexes multiple items in batch.
/// </summary>
/// <param name="items">The items to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an item from the index by its ID.
/// </summary>
/// <param name="id">The ID of the item to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Clears all indexed items.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task ClearAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Searches for items matching the query.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="options">Optional search options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of search results ordered by relevance.</returns>
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Defines a searchable item that can be indexed and searched.
/// </summary>
public interface ISearchable
{
/// <summary>
/// Gets the unique identifier for this item.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the primary searchable text (e.g., title, header).
/// </summary>
string SearchableText { get; }
/// <summary>
/// Gets optional secondary searchable text (e.g., description).
/// Returns null if not available.
/// </summary>
string? SecondarySearchableText { get; }
}

View File

@@ -0,0 +1,12 @@
// 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.
namespace Common.Search;
/// <summary>
/// Represents a span of matched text for highlighting.
/// </summary>
/// <param name="Start">The starting index of the match.</param>
/// <param name="Length">The length of the match.</param>
public readonly record struct MatchSpan(int Start, int Length);

View File

@@ -0,0 +1,36 @@
// 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.
namespace Common.Search;
/// <summary>
/// Describes the capabilities of a search engine.
/// </summary>
public sealed class SearchEngineCapabilities
{
/// <summary>
/// Gets a value indicating whether the engine supports fuzzy matching.
/// </summary>
public bool SupportsFuzzyMatch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports semantic search.
/// </summary>
public bool SupportsSemanticSearch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine persists the index to disk.
/// </summary>
public bool PersistsIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports incremental indexing.
/// </summary>
public bool SupportsIncrementalIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports match span highlighting.
/// </summary>
public bool SupportsMatchSpans { get; init; }
}

View File

@@ -0,0 +1,31 @@
// 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.
namespace Common.Search;
/// <summary>
/// Specifies the kind of match that produced a search result.
/// </summary>
public enum SearchMatchKind
{
/// <summary>
/// Exact text match.
/// </summary>
Exact,
/// <summary>
/// Fuzzy/approximate text match.
/// </summary>
Fuzzy,
/// <summary>
/// Semantic/AI-based match.
/// </summary>
Semantic,
/// <summary>
/// Combined match from multiple engines.
/// </summary>
Composite,
}

View File

@@ -0,0 +1,35 @@
// 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.
namespace Common.Search;
/// <summary>
/// Options for configuring search behavior.
/// </summary>
public sealed class SearchOptions
{
/// <summary>
/// Gets or sets the maximum number of results to return.
/// Default is 20.
/// </summary>
public int MaxResults { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum score threshold.
/// Results below this score are filtered out.
/// Default is 0.0 (no filtering).
/// </summary>
public double MinScore { get; set; }
/// <summary>
/// Gets or sets the language hint for the search (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include match spans for highlighting.
/// Default is false.
/// </summary>
public bool IncludeMatchSpans { get; set; }
}

View File

@@ -0,0 +1,33 @@
// 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.
namespace Common.Search;
/// <summary>
/// Represents a search result with the matched item and scoring information.
/// </summary>
/// <typeparam name="T">The type of the matched item.</typeparam>
public sealed class SearchResult<T>
where T : ISearchable
{
/// <summary>
/// Gets the matched item.
/// </summary>
public required T Item { get; init; }
/// <summary>
/// Gets the relevance score (higher is more relevant).
/// </summary>
public required double Score { get; init; }
/// <summary>
/// Gets the type of match that produced this result.
/// </summary>
public required SearchMatchKind MatchKind { get; init; }
/// <summary>
/// Gets the match details for highlighting (optional).
/// </summary>
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}

View File

@@ -0,0 +1,234 @@
// 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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Adapts the SemanticSearchEngine to the generic ISearchEngine interface.
/// </summary>
/// <typeparam name="T">The type of items to search.</typeparam>
public sealed class SemanticSearchAdapter<T> : ISearchEngine<T>
where T : ISearchable
{
private readonly SemanticSearchEngine _engine;
private readonly Dictionary<string, T> _itemsById = new();
private readonly object _lockObject = new();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchAdapter{T}"/> class.
/// </summary>
/// <param name="indexName">The name of the search index.</param>
public SemanticSearchAdapter(string indexName)
{
_engine = new SemanticSearchEngine(indexName);
}
/// <inheritdoc/>
public bool IsReady => _engine.IsInitialized;
/// <inheritdoc/>
public SearchEngineCapabilities Capabilities { get; } = new()
{
SupportsFuzzyMatch = true,
SupportsSemanticSearch = true,
PersistsIndex = true,
SupportsIncrementalIndex = true,
SupportsMatchSpans = false,
};
/// <summary>
/// Gets the underlying semantic search capabilities.
/// </summary>
public SemanticSearchCapabilities? SemanticCapabilities => _engine.Capabilities;
/// <summary>
/// Occurs when the semantic search capabilities change.
/// </summary>
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged
{
add => _engine.CapabilitiesChanged += value;
remove => _engine.CapabilitiesChanged -= value;
}
/// <inheritdoc/>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
await _engine.InitializeAsync();
}
/// <inheritdoc/>
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
var text = BuildSearchableText(item);
if (string.IsNullOrWhiteSpace(text))
{
return Task.CompletedTask;
}
lock (_lockObject)
{
_itemsById[item.Id] = item;
}
_engine.IndexText(item.Id, text);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(items);
ThrowIfDisposed();
var batch = new List<(string Id, string Text)>();
lock (_lockObject)
{
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var text = BuildSearchableText(item);
if (!string.IsNullOrWhiteSpace(text))
{
_itemsById[item.Id] = item;
batch.Add((item.Id, text));
}
}
}
_engine.IndexTextBatch(batch);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Remove(id);
}
_engine.Remove(id);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task ClearAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Clear();
}
_engine.RemoveAll();
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
options ??= new SearchOptions();
var semanticOptions = new SemanticSearchOptions
{
MaxResults = options.MaxResults,
Language = options.Language,
MatchScope = SemanticSearchMatchScope.Unconstrained,
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
};
var matches = _engine.SearchText(query, semanticOptions);
var results = new List<SearchResult<T>>();
lock (_lockObject)
{
foreach (var match in matches)
{
if (_itemsById.TryGetValue(match.ContentId, out var item))
{
results.Add(new SearchResult<T>
{
Item = item,
Score = 100.0, // Semantic search doesn't return scores, use fixed value
MatchKind = SearchMatchKind.Semantic,
MatchSpans = null,
});
}
}
}
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
}
/// <summary>
/// Waits for the indexing process to complete.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task representing the async operation.</returns>
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
{
ThrowIfDisposed();
await _engine.WaitForIndexingCompleteAsync(timeout);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_engine.Dispose();
lock (_lockObject)
{
_itemsById.Clear();
}
_disposed = true;
}
private static string BuildSearchableText(T item)
{
var primary = item.SearchableText ?? string.Empty;
var secondary = item.SecondarySearchableText;
if (string.IsNullOrWhiteSpace(secondary))
{
return primary;
}
return $"{primary} {secondary}";
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}

View File

@@ -0,0 +1,46 @@
// 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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Represents the capabilities of the semantic search index.
/// </summary>
public class SemanticSearchCapabilities
{
/// <summary>
/// Gets or sets a value indicating whether text lexical (keyword) search is available.
/// </summary>
public bool TextLexicalAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether text semantic (AI embedding) search is available.
/// </summary>
public bool TextSemanticAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image semantic search is available.
/// </summary>
public bool ImageSemanticAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image OCR search is available.
/// </summary>
public bool ImageOcrAvailable { get; set; }
/// <summary>
/// Gets a value indicating whether any search capability is available.
/// </summary>
public bool AnyAvailable => TextLexicalAvailable || TextSemanticAvailable || ImageSemanticAvailable || ImageOcrAvailable;
/// <summary>
/// Gets a value indicating whether text search (lexical or semantic) is available.
/// </summary>
public bool TextSearchAvailable => TextLexicalAvailable || TextSemanticAvailable;
/// <summary>
/// Gets a value indicating whether image search (semantic or OCR) is available.
/// </summary>
public bool ImageSearchAvailable => ImageSemanticAvailable || ImageOcrAvailable;
}

View File

@@ -0,0 +1,21 @@
// 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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Specifies the kind of content in a semantic search result.
/// </summary>
public enum SemanticSearchContentKind
{
/// <summary>
/// Text content.
/// </summary>
Text,
/// <summary>
/// Image content.
/// </summary>
Image,
}

View File

@@ -0,0 +1,347 @@
// 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.Windows.AI.Search.Experimental;
using Windows.Graphics.Imaging;
namespace Common.Search.SemanticSearch;
/// <summary>
/// A semantic search engine powered by Windows App SDK AI Search APIs.
/// Provides text and image indexing with lexical and semantic search capabilities.
/// </summary>
public sealed class SemanticSearchEngine : IDisposable
{
private readonly string _indexName;
private AppContentIndexer? _indexer;
private bool _disposed;
private SemanticSearchCapabilities? _capabilities;
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchEngine"/> class.
/// </summary>
/// <param name="indexName">The name of the search index.</param>
public SemanticSearchEngine(string indexName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(indexName);
_indexName = indexName;
}
/// <summary>
/// Occurs when the index capabilities change.
/// </summary>
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged;
/// <summary>
/// Gets a value indicating whether the search engine is initialized.
/// </summary>
public bool IsInitialized => _indexer != null;
/// <summary>
/// Gets the current index capabilities, or null if not initialized.
/// </summary>
public SemanticSearchCapabilities? Capabilities => _capabilities;
/// <summary>
/// Initializes the search engine and creates or opens the index.
/// </summary>
/// <returns>A task that represents the asynchronous operation. Returns true if initialization succeeded.</returns>
public async Task<bool> InitializeAsync()
{
ThrowIfDisposed();
if (_indexer != null)
{
return true;
}
var result = AppContentIndexer.GetOrCreateIndex(_indexName);
if (!result.Succeeded)
{
return false;
}
_indexer = result.Indexer;
// Wait for index capabilities to be ready
await _indexer.WaitForIndexCapabilitiesAsync();
// Load capabilities
_capabilities = LoadCapabilities();
// Subscribe to capability changes
_indexer.Listener.IndexCapabilitiesChanged += OnIndexCapabilitiesChanged;
return true;
}
/// <summary>
/// Waits for the indexing process to complete.
/// </summary>
/// <param name="timeout">The maximum time to wait for indexing to complete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
await _indexer!.WaitForIndexingIdleAsync(timeout);
}
/// <summary>
/// Gets the current index capabilities.
/// </summary>
/// <returns>The current capabilities of the search index.</returns>
public SemanticSearchCapabilities GetCapabilities()
{
ThrowIfDisposed();
ThrowIfNotInitialized();
return _capabilities ?? LoadCapabilities();
}
/// <summary>
/// Adds or updates text content in the index.
/// </summary>
/// <param name="id">The unique identifier for the content.</param>
/// <param name="text">The text content to index.</param>
public void IndexText(string id, string text)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer!.AddOrUpdate(content);
}
/// <summary>
/// Adds or updates multiple text contents in the index.
/// </summary>
/// <param name="items">A collection of id-text pairs to index.</param>
public void IndexTextBatch(IEnumerable<(string Id, string Text)> items)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentNullException.ThrowIfNull(items);
foreach (var (id, text) in items)
{
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(text))
{
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer!.AddOrUpdate(content);
}
}
}
/// <summary>
/// Adds or updates image content in the index.
/// </summary>
/// <param name="id">The unique identifier for the image.</param>
/// <param name="bitmap">The image bitmap to index.</param>
public void IndexImage(string id, SoftwareBitmap bitmap)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(bitmap);
var content = AppManagedIndexableAppContent.CreateFromBitmap(id, bitmap);
_indexer!.AddOrUpdate(content);
}
/// <summary>
/// Removes content from the index by its identifier.
/// </summary>
/// <param name="id">The unique identifier of the content to remove.</param>
public void Remove(string id)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
_indexer!.Remove(id);
}
/// <summary>
/// Removes all content from the index.
/// </summary>
public void RemoveAll()
{
ThrowIfDisposed();
ThrowIfNotInitialized();
_indexer!.RemoveAll();
}
/// <summary>
/// Searches for text content in the index.
/// </summary>
/// <param name="searchText">The text to search for.</param>
/// <param name="options">Optional search options.</param>
/// <returns>A list of search results.</returns>
public IReadOnlyList<SemanticSearchResult> SearchText(string searchText, SemanticSearchOptions? options = null)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
options ??= new SemanticSearchOptions();
var queryOptions = new TextQueryOptions
{
MatchScope = ConvertMatchScope(options.MatchScope),
TextMatchType = ConvertTextMatchType(options.TextMatchType),
};
if (!string.IsNullOrEmpty(options.Language))
{
queryOptions.Language = options.Language;
}
var query = _indexer!.CreateTextQuery(searchText, queryOptions);
var matches = query.GetNextMatches((uint)options.MaxResults);
return ConvertTextMatches(matches);
}
/// <summary>
/// Searches for image content in the index using text.
/// </summary>
/// <param name="searchText">The text to search for in images.</param>
/// <param name="options">Optional search options.</param>
/// <returns>A list of search results.</returns>
public IReadOnlyList<SemanticSearchResult> SearchImages(string searchText, SemanticSearchOptions? options = null)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
options ??= new SemanticSearchOptions();
var queryOptions = new ImageQueryOptions
{
MatchScope = ConvertMatchScope(options.MatchScope),
ImageOcrTextMatchType = ConvertTextMatchType(options.TextMatchType),
};
var query = _indexer!.CreateImageQuery(searchText, queryOptions);
var matches = query.GetNextMatches((uint)options.MaxResults);
return ConvertImageMatches(matches);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
if (_indexer != null)
{
_indexer.Listener.IndexCapabilitiesChanged -= OnIndexCapabilitiesChanged;
_indexer.Dispose();
_indexer = null;
}
_disposed = true;
}
private SemanticSearchCapabilities LoadCapabilities()
{
var capabilities = _indexer!.GetIndexCapabilities();
return new SemanticSearchCapabilities
{
TextLexicalAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextLexical),
TextSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextSemantic),
ImageSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageSemantic),
ImageOcrAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageOcr),
};
}
private static bool IsCapabilityInitialized(IndexCapabilities capabilities, IndexCapability capability)
{
var state = capabilities.GetCapabilityState(capability);
return state.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
}
private void OnIndexCapabilitiesChanged(AppContentIndexer indexer, IndexCapabilities capabilities)
{
_capabilities = LoadCapabilities();
CapabilitiesChanged?.Invoke(this, _capabilities);
}
private static QueryMatchScope ConvertMatchScope(SemanticSearchMatchScope scope)
{
return scope switch
{
SemanticSearchMatchScope.Unconstrained => QueryMatchScope.Unconstrained,
SemanticSearchMatchScope.Region => QueryMatchScope.Region,
SemanticSearchMatchScope.ContentItem => QueryMatchScope.ContentItem,
_ => QueryMatchScope.Unconstrained,
};
}
private static TextLexicalMatchType ConvertTextMatchType(SemanticSearchTextMatchType matchType)
{
return matchType switch
{
SemanticSearchTextMatchType.Fuzzy => TextLexicalMatchType.Fuzzy,
SemanticSearchTextMatchType.Exact => TextLexicalMatchType.Exact,
_ => TextLexicalMatchType.Fuzzy,
};
}
private static IReadOnlyList<SemanticSearchResult> ConvertTextMatches(IReadOnlyList<TextQueryMatch> matches)
{
var results = new List<SemanticSearchResult>();
foreach (var match in matches)
{
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Text);
if (match.ContentKind == QueryMatchContentKind.AppManagedText &&
match is AppManagedTextQueryMatch textMatch)
{
result.TextOffset = textMatch.TextOffset;
result.TextLength = textMatch.TextLength;
}
results.Add(result);
}
return results;
}
private static IReadOnlyList<SemanticSearchResult> ConvertImageMatches(IReadOnlyList<ImageQueryMatch> matches)
{
var results = new List<SemanticSearchResult>();
foreach (var match in matches)
{
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Image);
results.Add(result);
}
return results;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private void ThrowIfNotInitialized()
{
if (_indexer == null)
{
throw new InvalidOperationException("Search engine is not initialized. Call InitializeAsync() first.");
}
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Specifies the scope for semantic search matching.
/// </summary>
public enum SemanticSearchMatchScope
{
/// <summary>
/// No constraints, uses both Lexical and Semantic matching.
/// </summary>
Unconstrained,
/// <summary>
/// Restrict matching to a specific region.
/// </summary>
Region,
/// <summary>
/// Restrict matching to a single content item.
/// </summary>
ContentItem,
}

View File

@@ -0,0 +1,31 @@
// 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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Options for configuring semantic search queries.
/// </summary>
public class SemanticSearchOptions
{
/// <summary>
/// Gets or sets the language for the search query (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets the match scope for the search.
/// </summary>
public SemanticSearchMatchScope MatchScope { get; set; } = SemanticSearchMatchScope.Unconstrained;
/// <summary>
/// Gets or sets the text match type for lexical matching.
/// </summary>
public SemanticSearchTextMatchType TextMatchType { get; set; } = SemanticSearchTextMatchType.Fuzzy;
/// <summary>
/// Gets or sets the maximum number of results to return.
/// </summary>
public int MaxResults { get; set; } = 10;
}

View File

@@ -0,0 +1,42 @@
// 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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Represents a search result from the semantic search engine.
/// </summary>
public class SemanticSearchResult
{
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchResult"/> class.
/// </summary>
/// <param name="contentId">The unique identifier of the matched content.</param>
/// <param name="contentKind">The kind of content matched (text or image).</param>
public SemanticSearchResult(string contentId, SemanticSearchContentKind contentKind)
{
ContentId = contentId;
ContentKind = contentKind;
}
/// <summary>
/// Gets the unique identifier of the matched content.
/// </summary>
public string ContentId { get; }
/// <summary>
/// Gets the kind of content that was matched.
/// </summary>
public SemanticSearchContentKind ContentKind { get; }
/// <summary>
/// Gets or sets the text offset where the match was found (for text matches only).
/// </summary>
public int TextOffset { get; set; }
/// <summary>
/// Gets or sets the length of the matched text (for text matches only).
/// </summary>
public int TextLength { get; set; }
}

View File

@@ -0,0 +1,21 @@
// 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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Specifies the type of text matching for lexical searches.
/// </summary>
public enum SemanticSearchTextMatchType
{
/// <summary>
/// Fuzzy matching allows spelling errors and approximate words.
/// </summary>
Fuzzy,
/// <summary>
/// Exact matching requires exact text matches.
/// </summary>
Exact,
}

View File

@@ -18,10 +18,16 @@
<ItemGroup>
<PackageReference Include="MSTest" />
<ProjectReference Include="..\..\..\..\common\UITestAutomation\UITestAutomation.csproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
<ProjectReference Include="..\..\LightSwitchModuleInterface\LightSwitchModuleInterface.vcxproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<Target Name="CopyNativeDll" AfterTargets="Build">
<Copy SourceFiles="$(SolutionDir)$(Platform)\$(Configuration)\PowerToys.LightSwitchModuleInterface.dll" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
<Copy SourceFiles="$(SolutionDir)$(Platform)\$(Configuration)\PowerToys.LightSwitchModuleInterface.dll" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(SolutionDir)$(Platform)\$(Configuration)\PowerToys.LightSwitchModuleInterface.dll')" />
<Copy SourceFiles="$(SolutionDir)$(Platform)\$(Configuration)\LightSwitchLib\LightSwitchLib.lib" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(SolutionDir)$(Platform)\$(Configuration)\LightSwitchLib\LightSwitchLib.lib')" ContinueOnError="true" />
</Target>
</Project>

View File

@@ -238,12 +238,30 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
auto settingsObject = settings.get_raw_json();
FindMyMouseSettings findMyMouseSettings;
if (settingsObject.GetView().Size())
if (!settingsObject.GetView().Size())
{
Logger::info("Find My Mouse settings are empty");
m_findMyMouseSettings = findMyMouseSettings;
return;
}
// Early exit if no properties object exists
if (!settingsObject.HasKey(JSON_KEY_PROPERTIES))
{
Logger::info("Find My Mouse settings have no properties");
m_findMyMouseSettings = findMyMouseSettings;
return;
}
auto properties = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
// Parse Activation Method
if (properties.HasKey(JSON_KEY_ACTIVATION_METHOD))
{
try
{
// Parse Activation Method
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_METHOD);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ACTIVATION_METHOD);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value < static_cast<int>(FindMyMouseActivationMethod::EnumElements) && value >= 0)
{
@@ -266,34 +284,50 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Activation Method from settings. Will use default value");
}
}
// Parse Include Win Key
if (properties.HasKey(JSON_KEY_INCLUDE_WIN_KEY))
{
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_INCLUDE_WIN_KEY);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_INCLUDE_WIN_KEY);
findMyMouseSettings.includeWinKey = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to get 'include windows key with ctrl' setting");
}
}
// Parse Do Not Activate On Game Mode
if (properties.HasKey(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE))
{
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE);
findMyMouseSettings.doNotActivateOnGameMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to get 'do not activate on game mode' setting");
}
// Colors + legacy overlay opacity migration
// Desired behavior:
// - Old schema: colors stored as RGB (no alpha) + separate overlay_opacity (0-100). We should migrate by applying that opacity as alpha.
// - New schema: colors stored as ARGB (alpha embedded). Ignore overlay_opacity even if still present.
int legacyOverlayOpacity = -1;
bool backgroundColorHadExplicitAlpha = false;
bool spotlightColorHadExplicitAlpha = false;
}
// Colors + legacy overlay opacity migration
// Desired behavior:
// - Old schema: colors stored as RGB (no alpha) + separate overlay_opacity (0-100). We should migrate by applying that opacity as alpha.
// - New schema: colors stored as ARGB (alpha embedded). Ignore overlay_opacity even if still present.
int legacyOverlayOpacity = -1;
bool backgroundColorHadExplicitAlpha = false;
bool spotlightColorHadExplicitAlpha = false;
// Parse Legacy Overlay Opacity (may not exist in newer settings)
if (properties.HasKey(JSON_KEY_OVERLAY_OPACITY))
{
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_OVERLAY_OPACITY);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_OVERLAY_OPACITY);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0 && value <= 100)
{
@@ -302,11 +336,16 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
}
catch (...)
{
// overlay_opacity may not exist anymore
// overlay_opacity may have invalid data
}
}
// Parse Background Color
if (properties.HasKey(JSON_KEY_BACKGROUND_COLOR))
{
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_BACKGROUND_COLOR);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_BACKGROUND_COLOR);
auto backgroundColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t a = 255, r, g, b;
bool parsed = false;
@@ -333,9 +372,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize background color from settings. Will use default value");
}
}
// Parse Spotlight Color
if (properties.HasKey(JSON_KEY_SPOTLIGHT_COLOR))
{
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR);
auto spotlightColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t a = 255, r, g, b;
bool parsed = false;
@@ -362,10 +406,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize spotlight color from settings. Will use default value");
}
}
// Parse Spotlight Radius
if (properties.HasKey(JSON_KEY_SPOTLIGHT_RADIUS))
{
try
{
// Parse Spotlight Radius
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
@@ -380,10 +428,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Spotlight Radius from settings. Will use default value");
}
}
// Parse Animation Duration
if (properties.HasKey(JSON_KEY_ANIMATION_DURATION_MS))
{
try
{
// Parse Animation Duration
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ANIMATION_DURATION_MS);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ANIMATION_DURATION_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
@@ -398,10 +450,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Animation Duration from settings. Will use default value");
}
}
// Parse Spotlight Initial Zoom
if (properties.HasKey(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM))
{
try
{
// Parse Spotlight Initial Zoom
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
@@ -416,10 +472,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Spotlight Initial Zoom from settings. Will use default value");
}
}
// Parse Excluded Apps
if (properties.HasKey(JSON_KEY_EXCLUDED_APPS))
{
try
{
// Parse Excluded Apps
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_EXCLUDED_APPS);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_EXCLUDED_APPS);
std::wstring apps = jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE).c_str();
std::vector<std::wstring> excludedApps;
auto excludedUppercase = apps;
@@ -441,10 +501,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Excluded Apps from settings. Will use default value");
}
}
// Parse Shaking Minimum Distance
if (properties.HasKey(JSON_KEY_SHAKING_MINIMUM_DISTANCE))
{
try
{
// Parse Shaking Minimum Distance
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_MINIMUM_DISTANCE);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_MINIMUM_DISTANCE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
@@ -459,10 +523,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Shaking Minimum Distance from settings. Will use default value");
}
}
// Parse Shaking Interval Milliseconds
if (properties.HasKey(JSON_KEY_SHAKING_INTERVAL_MS))
{
try
{
// Parse Shaking Interval Milliseconds
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_INTERVAL_MS);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_INTERVAL_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
@@ -477,10 +545,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Shaking Interval Milliseconds from settings. Will use default value");
}
}
// Parse Shaking Factor
if (properties.HasKey(JSON_KEY_SHAKING_FACTOR))
{
try
{
// Parse Shaking Factor
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_FACTOR);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_FACTOR);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
@@ -495,11 +567,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Shaking Factor from settings. Will use default value");
}
}
// Parse HotKey
if (properties.HasKey(JSON_KEY_ACTIVATION_SHORTCUT))
{
try
{
// Parse HotKey
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
m_hotkey = HotkeyEx();
if (hotkey.win_pressed())
@@ -528,18 +603,15 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
Logger::warn("Failed to initialize Activation Shortcut from settings. Will use default value");
}
}
if (!m_hotkey.modifiersMask)
{
Logger::info("Using default Activation Shortcut");
m_hotkey.modifiersMask = MOD_SHIFT | MOD_WIN;
m_hotkey.vkCode = 0x46; // F key
}
}
else
if (!m_hotkey.modifiersMask)
{
Logger::info("Find My Mouse settings are empty");
Logger::info("Using default Activation Shortcut");
m_hotkey.modifiersMask = MOD_SHIFT | MOD_WIN;
m_hotkey.vkCode = 0x46; // F key
}
m_findMyMouseSettings = findMyMouseSettings;
}

View File

@@ -5,6 +5,7 @@
using System.Diagnostics;
using System.Text.Json;
using Common.UI;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using PowerToys.ModuleContracts;
@@ -82,10 +83,9 @@ public sealed class AwakeService : ModuleServiceBase, IAwakeService
return UpdateSettingsAsync(
settings =>
{
var totalMinutes = Math.Min(minutes, int.MaxValue);
settings.Properties.Mode = AwakeMode.TIMED;
settings.Properties.IntervalHours = (uint)(totalMinutes / 60);
settings.Properties.IntervalMinutes = (uint)(totalMinutes % 60);
settings.Properties.IntervalHours = (uint)(minutes / 60);
settings.Properties.IntervalMinutes = (uint)(minutes % 60);
},
cancellationToken);
}
@@ -130,8 +130,9 @@ public sealed class AwakeService : ModuleServiceBase, IAwakeService
{
return Process.GetProcessesByName("PowerToys.Awake").Length > 0;
}
catch
catch (Exception ex)
{
Logger.LogError($"Failed to check Awake process status: {ex.Message}");
return false;
}
}
@@ -143,8 +144,9 @@ public sealed class AwakeService : ModuleServiceBase, IAwakeService
var settingsUtils = SettingsUtils.Default;
return settingsUtils.GetSettingsOrDefault<AwakeSettings>(AwakeSettings.ModuleName);
}
catch
catch (Exception ex)
{
Logger.LogError($"Failed to read Awake settings: {ex.Message}");
return null;
}
}

View File

@@ -17,6 +17,6 @@ namespace Awake.Core
// Format of the build ID is: CODENAME_MMDDYYYY, where MMDDYYYY
// is representative of the date when the last change was made before
// the pull request is issued.
internal const string BuildId = "TILLSON_11272024";
internal const string BuildId = "DIDACT_01182026";
}
}

View File

@@ -22,14 +22,13 @@ namespace Awake.Core
public static string ToHumanReadableString(this TimeSpan timeSpan)
{
// Get days, hours, minutes, and seconds from the TimeSpan
int days = timeSpan.Days;
int hours = timeSpan.Hours;
int minutes = timeSpan.Minutes;
int seconds = timeSpan.Seconds;
// Format as H:MM:SS or M:SS depending on total hours
if (timeSpan.TotalHours >= 1)
{
return $"{(int)timeSpan.TotalHours}:{timeSpan.Minutes:D2}:{timeSpan.Seconds:D2}";
}
// Format the string based on the presence of days, hours, minutes, and seconds
return $"{days:D2}{Properties.Resources.AWAKE_LABEL_DAYS} {hours:D2}{Properties.Resources.AWAKE_LABEL_HOURS} {minutes:D2}{Properties.Resources.AWAKE_LABEL_MINUTES} {seconds:D2}{Properties.Resources.AWAKE_LABEL_SECONDS}";
return $"{timeSpan.Minutes}:{timeSpan.Seconds:D2}";
}
}
}

View File

@@ -37,7 +37,7 @@ namespace Awake.Core
internal static SettingsUtils? ModuleSettings { get; set; }
private static AwakeMode CurrentOperatingMode { get; set; }
internal static AwakeMode CurrentOperatingMode { get; private set; }
private static bool IsDisplayOn { get; set; }
@@ -54,11 +54,12 @@ namespace Awake.Core
private static readonly CompositeFormat AwakeHour = CompositeFormat.Parse(Resources.AWAKE_HOUR);
private static readonly CompositeFormat AwakeHours = CompositeFormat.Parse(Resources.AWAKE_HOURS);
private static readonly BlockingCollection<ExecutionState> _stateQueue;
private static CancellationTokenSource _tokenSource;
private static CancellationTokenSource _monitorTokenSource;
private static IDisposable? _timerSubscription;
static Manager()
{
_tokenSource = new CancellationTokenSource();
_monitorTokenSource = new CancellationTokenSource();
_stateQueue = [];
ModuleSettings = SettingsUtils.Default;
}
@@ -68,18 +69,36 @@ namespace Awake.Core
Thread monitorThread = new(() =>
{
Thread.CurrentThread.IsBackground = false;
while (true)
try
{
ExecutionState state = _stateQueue.Take();
while (!_monitorTokenSource.Token.IsCancellationRequested)
{
ExecutionState state = _stateQueue.Take(_monitorTokenSource.Token);
Logger.LogInfo($"Setting state to {state}");
Logger.LogInfo($"Setting state to {state}");
SetAwakeState(state);
if (!SetAwakeState(state))
{
Logger.LogError($"Failed to set execution state to {state}. Reverting to passive mode.");
CurrentOperatingMode = AwakeMode.PASSIVE;
SetModeShellIcon();
}
}
}
catch (OperationCanceledException)
{
Logger.LogInfo("Monitor thread received cancellation signal. Exiting gracefully.");
}
});
monitorThread.Start();
}
internal static void StopMonitor()
{
_monitorTokenSource.Cancel();
_monitorTokenSource.Dispose();
}
internal static void SetConsoleControlHandler(ConsoleEventHandler handler, bool addHandler)
{
Bridge.SetConsoleCtrlHandler(handler, addHandler);
@@ -110,8 +129,9 @@ namespace Awake.Core
ExecutionState stateResult = Bridge.SetThreadExecutionState(state);
return stateResult != 0;
}
catch
catch (Exception ex)
{
Logger.LogError($"Failed to set awake state to {state}: {ex.Message}");
return false;
}
}
@@ -123,26 +143,34 @@ namespace Awake.Core
: ExecutionState.ES_SYSTEM_REQUIRED | ExecutionState.ES_CONTINUOUS;
}
/// <summary>
/// Re-applies the current awake state after a power event.
/// Called when WM_POWERBROADCAST indicates system wake or power source change.
/// </summary>
internal static void ReapplyAwakeState()
{
if (CurrentOperatingMode == AwakeMode.PASSIVE)
{
// No need to reapply in passive mode
return;
}
Logger.LogInfo($"Power event received. Reapplying awake state for mode: {CurrentOperatingMode}");
_stateQueue.Add(ComputeAwakeState(IsDisplayOn));
}
internal static void CancelExistingThread()
{
Logger.LogInfo("Ensuring the thread is properly cleaned up...");
Logger.LogInfo("Canceling existing timer and resetting state...");
// Reset the thread state and handle cancellation.
// Reset the thread state.
_stateQueue.Add(ExecutionState.ES_CONTINUOUS);
if (_tokenSource != null)
{
_tokenSource.Cancel();
_tokenSource.Dispose();
}
else
{
Logger.LogWarning("Token source is null.");
}
// Dispose the timer subscription to stop any running timer.
_timerSubscription?.Dispose();
_timerSubscription = null;
_tokenSource = new CancellationTokenSource();
Logger.LogInfo("New token source and thread token instantiated.");
Logger.LogInfo("Timer subscription disposed.");
}
internal static void SetModeShellIcon(bool forceAdd = false)
@@ -153,25 +181,25 @@ namespace Awake.Core
switch (CurrentOperatingMode)
{
case AwakeMode.INDEFINITE:
string processText = ProcessId == 0
string pidLine = ProcessId == 0
? string.Empty
: $" - {Resources.AWAKE_TRAY_TEXT_PID_BINDING}: {ProcessId}";
iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_INDEFINITE}{processText}][{ScreenStateString}]";
: $"\nPID: {ProcessId}";
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_INDEFINITE}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}{pidLine}";
icon = TrayHelper.IndefiniteIcon;
break;
case AwakeMode.PASSIVE:
iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_OFF}]";
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_SCREEN_OFF}";
icon = TrayHelper.DisabledIcon;
break;
case AwakeMode.EXPIRABLE:
iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_EXPIRATION}][{ScreenStateString}][{ExpireAt:yyyy-MM-dd HH:mm:ss}]";
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_UNTIL} {ExpireAt:MMM d, h:mm tt}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}";
icon = TrayHelper.ExpirableIcon;
break;
case AwakeMode.TIMED:
iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}]";
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_TIMED}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}";
icon = TrayHelper.TimedIcon;
break;
}
@@ -280,9 +308,8 @@ namespace Awake.Core
TimeSpan remainingTime = expireAt - DateTimeOffset.Now;
Observable.Timer(remainingTime).Subscribe(
_ => HandleTimerCompletion("expirable"),
_tokenSource.Token);
_timerSubscription = Observable.Timer(remainingTime).Subscribe(
_ => HandleTimerCompletion("expirable"));
}
internal static void SetTimedKeepAwake(uint seconds, bool keepDisplayOn = true, [CallerMemberName] string callerName = "")
@@ -300,6 +327,8 @@ namespace Awake.Core
TimeSpan timeSpan = TimeSpan.FromSeconds(seconds);
uint totalHours = (uint)timeSpan.TotalHours;
// Round up partial minutes to prevent timer from expiring before intended duration
uint remainingMinutes = (uint)Math.Ceiling(timeSpan.TotalMinutes % 60);
bool settingsChanged = currentSettings.Properties.Mode != AwakeMode.TIMED ||
@@ -336,7 +365,7 @@ namespace Awake.Core
var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds);
Observable.Interval(TimeSpan.FromSeconds(1))
_timerSubscription = Observable.Interval(TimeSpan.FromSeconds(1))
.Select(_ => targetExpiryTime - DateTimeOffset.Now)
.TakeWhile(remaining => remaining.TotalSeconds > 0)
.Subscribe(
@@ -346,12 +375,11 @@ namespace Awake.Core
TrayHelper.SetShellIcon(
TrayHelper.WindowHandle,
$"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}][{remainingTimeSpan.ToHumanReadableString()}]",
$"{Constants.FullAppName}\n{remainingTimeSpan.ToHumanReadableString()} {Resources.AWAKE_TRAY_REMAINING}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}",
TrayHelper.TimedIcon,
TrayIconAction.Update);
},
() => HandleTimerCompletion("timed"),
_tokenSource.Token);
() => HandleTimerCompletion("timed"));
}
/// <summary>
@@ -384,6 +412,16 @@ namespace Awake.Core
{
SetPassiveKeepAwake(updateSettings: false);
// Stop the monitor thread gracefully
StopMonitor();
// Dispose the timer subscription
_timerSubscription?.Dispose();
_timerSubscription = null;
// Dispose tray icons
TrayHelper.DisposeIcons();
if (TrayHelper.WindowHandle != IntPtr.Zero)
{
// Delete the icon.
@@ -496,15 +534,21 @@ namespace Awake.Core
AwakeSettings currentSettings = ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings();
currentSettings.Properties.KeepDisplayOn = !currentSettings.Properties.KeepDisplayOn;
// We want to make sure that if the display setting changes (e.g., through the tray)
// then we do not reset the counter from zero. Because the settings are only storing
// hours and minutes, we round up the minutes value up when changes occur.
// For TIMED mode: update state directly without restarting timer
// This preserves the existing timer Observable subscription and targetExpiryTime
if (CurrentOperatingMode == AwakeMode.TIMED && TimeRemaining > 0)
{
TimeSpan timeSpan = TimeSpan.FromSeconds(TimeRemaining);
// Update internal state
IsDisplayOn = currentSettings.Properties.KeepDisplayOn;
currentSettings.Properties.IntervalHours = (uint)timeSpan.TotalHours;
currentSettings.Properties.IntervalMinutes = (uint)Math.Ceiling(timeSpan.TotalMinutes % 60);
// Update execution state without canceling timer
_stateQueue.Add(ComputeAwakeState(IsDisplayOn));
// Save settings - ProcessSettings will skip reinitialization
// since we're already in TIMED mode
ModuleSettings!.SaveSettings(JsonSerializer.Serialize(currentSettings), Constants.AppName);
return;
}
ModuleSettings!.SaveSettings(JsonSerializer.Serialize(currentSettings), Constants.AppName);

View File

@@ -15,6 +15,12 @@ namespace Awake.Core.Native
internal const int WM_DESTROY = 0x0002;
internal const int WM_LBUTTONDOWN = 0x0201;
internal const int WM_RBUTTONDOWN = 0x0204;
internal const uint WM_POWERBROADCAST = 0x0218;
// Power Broadcast Event Types
internal const int PBT_APMRESUMEAUTOMATIC = 0x0012;
internal const int PBT_APMRESUMESUSPEND = 0x0007;
internal const int PBT_APMPOWERSTATUSCHANGE = 0x000A;
// Menu Flags
internal const uint MF_BYPOSITION = 1024;

View File

@@ -11,7 +11,7 @@ namespace Awake.Core.Threading
{
internal sealed class SingleThreadSynchronizationContext : SynchronizationContext
{
private readonly Queue<Tuple<SendOrPostCallback, object?>?> queue = new();
private readonly Queue<(SendOrPostCallback Callback, object? State)?> queue = new();
public override void Post(SendOrPostCallback d, object? state)
{
@@ -19,7 +19,7 @@ namespace Awake.Core.Threading
lock (queue)
{
queue.Enqueue(Tuple.Create(d, state));
queue.Enqueue((d, state));
Monitor.Pulse(queue);
}
}
@@ -28,7 +28,7 @@ namespace Awake.Core.Threading
{
while (true)
{
Tuple<SendOrPostCallback, object?>? work;
(SendOrPostCallback Callback, object? State)? work;
lock (queue)
{
while (queue.Count == 0)
@@ -46,7 +46,7 @@ namespace Awake.Core.Threading
try
{
work.Item1(work.Item2);
work.Value.Callback(work.Value.State);
}
catch (Exception e)
{

View File

@@ -45,12 +45,26 @@ namespace Awake.Core
internal static readonly Icon IndefiniteIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/indefinite.ico"));
internal static readonly Icon DisabledIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/disabled.ico"));
private const int TrayIconId = 1000;
static TrayHelper()
{
TrayMenu = IntPtr.Zero;
WindowHandle = IntPtr.Zero;
}
/// <summary>
/// Disposes of all icon resources to prevent GDI handle leaks.
/// </summary>
internal static void DisposeIcons()
{
DefaultAwakeIcon?.Dispose();
TimedIcon?.Dispose();
ExpirableIcon?.Dispose();
IndefiniteIcon?.Dispose();
DisabledIcon?.Dispose();
}
private static void ShowContextMenu(IntPtr hWnd)
{
if (TrayMenu == IntPtr.Zero)
@@ -172,7 +186,11 @@ namespace Awake.Core
internal static void SetShellIcon(IntPtr hWnd, string text, Icon? icon, TrayIconAction action = TrayIconAction.Add, [CallerMemberName] string callerName = "")
{
if (hWnd != IntPtr.Zero && icon != null)
// For Delete operations, we don't need an icon - only hWnd is required
// For Add/Update operations, we need both hWnd and icon
bool canProceed = hWnd != IntPtr.Zero && (action == TrayIconAction.Delete || icon != null);
if (canProceed)
{
int message = Native.Constants.NIM_ADD;
@@ -195,7 +213,7 @@ namespace Awake.Core
{
CbSize = Marshal.SizeOf<NotifyIconData>(),
HWnd = hWnd,
UId = 1000,
UId = TrayIconId,
UFlags = Native.Constants.NIF_ICON | Native.Constants.NIF_TIP | Native.Constants.NIF_MESSAGE,
UCallbackMessage = (int)Native.Constants.WM_USER,
HIcon = icon?.Handle ?? IntPtr.Zero,
@@ -208,29 +226,54 @@ namespace Awake.Core
{
CbSize = Marshal.SizeOf<NotifyIconData>(),
HWnd = hWnd,
UId = 1000,
UId = TrayIconId,
UFlags = 0,
};
}
for (int attempt = 1; attempt <= 3; attempt++)
// Retry configuration based on action type
// Add operations need longer delays as Explorer may still be initializing after Windows updates
int maxRetryAttempts;
int baseDelayMs;
if (action == TrayIconAction.Add)
{
maxRetryAttempts = 10;
baseDelayMs = 500; // 500, 1000, 2000, 2000, 2000... (capped)
}
else
{
maxRetryAttempts = 3;
baseDelayMs = 100; // 100, 200, 400 (existing behavior)
}
const int maxDelayMs = 2000; // Cap delay at 2 seconds
for (int attempt = 1; attempt <= maxRetryAttempts; attempt++)
{
if (Bridge.Shell_NotifyIcon(message, ref _notifyIconData))
{
if (attempt > 1)
{
Logger.LogInfo($"Successfully set shell icon on attempt {attempt}. Action: {action}");
}
break;
}
else
{
int errorCode = Marshal.GetLastWin32Error();
Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}.");
Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}, attempt: {attempt}/{maxRetryAttempts}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}.");
if (attempt == 3)
if (attempt == maxRetryAttempts)
{
Logger.LogError($"Failed to change tray icon after 3 attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}.");
Logger.LogError($"Failed to change tray icon after {maxRetryAttempts} attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}.");
break;
}
Thread.Sleep(100);
// Exponential backoff with cap
int delayMs = Math.Min(baseDelayMs * (1 << (attempt - 1)), maxDelayMs);
Thread.Sleep(delayMs);
}
}
@@ -241,7 +284,7 @@ namespace Awake.Core
}
else
{
Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero or icon is not available. Text: {text} Action: {action}");
Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero{(action != TrayIconAction.Delete && icon == null ? " or icon is not available" : string.Empty)}. Text: {text} Action: {action}");
}
}
@@ -280,11 +323,9 @@ namespace Awake.Core
Bridge.PostQuitMessage(0);
break;
case Native.Constants.WM_COMMAND:
int trayCommandsSize = Enum.GetNames<TrayCommands>().Length;
long targetCommandValue = wParam.ToInt64() & 0xFFFF;
long targetCommandIndex = wParam.ToInt64() & 0xFFFF;
switch (targetCommandIndex)
switch (targetCommandValue)
{
case (uint)TrayCommands.TC_EXIT:
{
@@ -300,7 +341,7 @@ namespace Awake.Core
case (uint)TrayCommands.TC_MODE_INDEFINITE:
{
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName);
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings();
Manager.SetIndefiniteKeepAwake(keepDisplayOn: settings.Properties.KeepDisplayOn);
break;
}
@@ -313,23 +354,43 @@ namespace Awake.Core
default:
{
if (targetCommandIndex >= trayCommandsSize)
// Custom tray time commands start at TC_TIME and increment by 1 for each entry.
// Check if this command falls within the custom time range.
if (targetCommandValue >= (uint)TrayCommands.TC_TIME)
{
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName);
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings();
if (settings.Properties.CustomTrayTimes.Count == 0)
{
settings.Properties.CustomTrayTimes.AddRange(Manager.GetDefaultTrayOptions());
}
int index = (int)targetCommandIndex - (int)TrayCommands.TC_TIME;
uint targetTime = settings.Properties.CustomTrayTimes.ElementAt(index).Value;
Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn);
int index = (int)targetCommandValue - (int)TrayCommands.TC_TIME;
if (index >= 0 && index < settings.Properties.CustomTrayTimes.Count)
{
uint targetTime = settings.Properties.CustomTrayTimes.Values.Skip(index).First();
Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn);
}
else
{
Logger.LogError($"Custom tray time index {index} is out of range. Available entries: {settings.Properties.CustomTrayTimes.Count}");
}
}
break;
}
}
break;
case Native.Constants.WM_POWERBROADCAST:
int eventType = wParam.ToInt32();
if (eventType == Native.Constants.PBT_APMRESUMEAUTOMATIC ||
eventType == Native.Constants.PBT_APMRESUMESUSPEND ||
eventType == Native.Constants.PBT_APMPOWERSTATUSCHANGE)
{
Manager.ReapplyAwakeState();
}
break;
default:
if (message == _taskbarCreatedMessage)
@@ -357,7 +418,7 @@ namespace Awake.Core
}
catch (Exception e)
{
Console.WriteLine("Error: " + e.Message);
Logger.LogError($"Error in tray thread execution: {e.Message}");
}
},
null);
@@ -439,9 +500,11 @@ namespace Awake.Core
private static void CreateAwakeTimeSubMenu(Dictionary<string, uint> trayTimeShortcuts, bool isChecked = false)
{
nint awakeTimeMenu = Bridge.CreatePopupMenu();
for (int i = 0; i < trayTimeShortcuts.Count; i++)
int i = 0;
foreach (var shortcut in trayTimeShortcuts)
{
Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, trayTimeShortcuts.ElementAt(i).Key);
Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, shortcut.Key);
i++;
}
Bridge.InsertMenu(TrayMenu, 0, Native.Constants.MF_BYPOSITION | Native.Constants.MF_POPUP | (isChecked ? Native.Constants.MF_CHECKED : Native.Constants.MF_UNCHECKED), (uint)awakeTimeMenu, Resources.AWAKE_KEEP_ON_INTERVAL);

View File

@@ -39,18 +39,20 @@ namespace Awake
private static FileSystemWatcher? _watcher;
private static SettingsUtils? _settingsUtils;
private static EventWaitHandle? _exitEventHandle;
private static RegisteredWaitHandle? _registeredWaitHandle;
private static bool _startedFromPowerToys;
public static Mutex? LockMutex { get; set; }
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
private static ConsoleEventHandler _handler;
private static ConsoleEventHandler? _handler;
private static SystemPowerCapabilities _powerCapabilities;
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
private static async Task<int> Main(string[] args)
{
Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs"));
var rootCommand = BuildRootCommand();
Bridge.AttachConsole(Core.Native.Constants.ATTACH_PARENT_PROCESS);
@@ -73,8 +75,6 @@ namespace Awake
LockMutex = new Mutex(true, Core.Constants.AppName, out bool instantiated);
Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs"));
try
{
string appLanguage = LanguageHelper.LoadLanguage();
@@ -140,7 +140,7 @@ namespace Awake
IsRequired = false,
};
Option<bool> displayOption = new(_aliasesDisplayOption, () => true, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION)
Option<bool> displayOption = new(_aliasesDisplayOption, () => false, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION)
{
Arity = ArgumentArity.ZeroOrOne,
IsRequired = false,
@@ -235,10 +235,23 @@ namespace Awake
private static void Exit(string message, int exitCode)
{
_etwTrace?.Dispose();
DisposeFileSystemWatcher();
_registeredWaitHandle?.Unregister(null);
_exitEventHandle?.Dispose();
Logger.LogInfo(message);
Manager.CompleteExit(exitCode);
}
private static void DisposeFileSystemWatcher()
{
if (_watcher != null)
{
_watcher.EnableRaisingEvents = false;
_watcher.Dispose();
_watcher = null;
}
}
private static bool ProcessExists(int processId)
{
if (processId <= 0)
@@ -252,8 +265,15 @@ namespace Awake
using var p = Process.GetProcessById(processId);
return !p.HasExited;
}
catch
catch (ArgumentException)
{
// Process with the specified ID is not running
return false;
}
catch (InvalidOperationException ex)
{
// Process has exited or cannot be accessed
Logger.LogInfo($"Process {processId} cannot be accessed: {ex.Message}");
return false;
}
}
@@ -282,12 +302,13 @@ namespace Awake
// Start the monitor thread that will be used to track the current state.
Manager.StartMonitor();
EventWaitHandle eventHandle = new(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent());
new Thread(() =>
{
WaitHandle.WaitAny([eventHandle]);
Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0);
}).Start();
_exitEventHandle = new EventWaitHandle(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent());
_registeredWaitHandle = ThreadPool.RegisterWaitForSingleObject(
_exitEventHandle,
(state, timedOut) => Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0),
null,
Timeout.Infinite,
executeOnlyOnce: true);
if (usePtConfig)
{
@@ -432,7 +453,7 @@ namespace Awake
{
Manager.AllocateConsole();
_handler += new ConsoleEventHandler(ExitHandler);
_handler = new ConsoleEventHandler(ExitHandler);
Manager.SetConsoleControlHandler(_handler, true);
Trace.Listeners.Add(new ConsoleTraceListener());
@@ -528,6 +549,11 @@ namespace Awake
{
settings.Properties.ExpirationDateTime = DateTimeOffset.Now.AddMinutes(5);
_settingsUtils.SaveSettings(JsonSerializer.Serialize(settings), Core.Constants.AppName);
// Return here - the FileSystemWatcher will re-trigger ProcessSettings
// with the corrected expiration time, which will then call SetExpirableKeepAwake.
// This matches the pattern used by mode setters (e.g., SetExpirableKeepAwake line 292).
return;
}
Manager.SetExpirableKeepAwake(settings.Properties.ExpirationDateTime, settings.Properties.KeepDisplayOn);

View File

@@ -60,15 +60,6 @@ namespace Awake.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Checked.
/// </summary>
internal static string AWAKE_CHECKED {
get {
return ResourceManager.GetString("AWAKE_CHECKED", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Specifies whether Awake will be using the PowerToys configuration file for managing the state..
/// </summary>
@@ -240,42 +231,6 @@ namespace Awake.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to d.
/// </summary>
internal static string AWAKE_LABEL_DAYS {
get {
return ResourceManager.GetString("AWAKE_LABEL_DAYS", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to h.
/// </summary>
internal static string AWAKE_LABEL_HOURS {
get {
return ResourceManager.GetString("AWAKE_LABEL_HOURS", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to m.
/// </summary>
internal static string AWAKE_LABEL_MINUTES {
get {
return ResourceManager.GetString("AWAKE_LABEL_MINUTES", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to s.
/// </summary>
internal static string AWAKE_LABEL_SECONDS {
get {
return ResourceManager.GetString("AWAKE_LABEL_SECONDS", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} minute.
/// </summary>
@@ -320,7 +275,16 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_SCREEN_ON", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Screen.
/// </summary>
internal static string AWAKE_TRAY_DISPLAY {
get {
return ResourceManager.GetString("AWAKE_TRAY_DISPLAY", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Expiring.
/// </summary>
@@ -329,7 +293,7 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_EXPIRATION", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Indefinite.
/// </summary>
@@ -338,7 +302,7 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_INDEFINITE", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Passive.
/// </summary>
@@ -347,31 +311,31 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_OFF", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Bound to.
/// </summary>
internal static string AWAKE_TRAY_TEXT_PID_BINDING {
get {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_PID_BINDING", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Interval.
/// Looks up a localized string similar to Timed.
/// </summary>
internal static string AWAKE_TRAY_TEXT_TIMED {
get {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_TIMED", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unchecked.
/// Looks up a localized string similar to Until.
/// </summary>
internal static string AWAKE_UNCHECKED {
internal static string AWAKE_TRAY_UNTIL {
get {
return ResourceManager.GetString("AWAKE_UNCHECKED", resourceCulture);
return ResourceManager.GetString("AWAKE_TRAY_UNTIL", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to remaining.
/// </summary>
internal static string AWAKE_TRAY_REMAINING {
get {
return ResourceManager.GetString("AWAKE_TRAY_REMAINING", resourceCulture);
}
}
}

View File

@@ -117,9 +117,6 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AWAKE_CHECKED" xml:space="preserve">
<value>Checked</value>
</data>
<data name="AWAKE_EXIT" xml:space="preserve">
<value>Exit</value>
</data>
@@ -158,9 +155,6 @@
<value>Off (keep using the selected power plan)</value>
<comment>Don't keep the system awake, use the selected system power plan</comment>
</data>
<data name="AWAKE_UNCHECKED" xml:space="preserve">
<value>Unchecked</value>
</data>
<data name="AWAKE_CMD_HELP_CONFIG_OPTION" xml:space="preserve">
<value>Specifies whether Awake will be using the PowerToys configuration file for managing the state.</value>
</data>
@@ -195,31 +189,11 @@
<value>Passive</value>
</data>
<data name="AWAKE_TRAY_TEXT_TIMED" xml:space="preserve">
<value>Interval</value>
</data>
<data name="AWAKE_LABEL_DAYS" xml:space="preserve">
<value>d</value>
<comment>Used to display number of days in the system tray tooltip.</comment>
</data>
<data name="AWAKE_LABEL_HOURS" xml:space="preserve">
<value>h</value>
<comment>Used to display number of hours in the system tray tooltip.</comment>
</data>
<data name="AWAKE_LABEL_MINUTES" xml:space="preserve">
<value>m</value>
<comment>Used to display number of minutes in the system tray tooltip.</comment>
</data>
<data name="AWAKE_LABEL_SECONDS" xml:space="preserve">
<value>s</value>
<comment>Used to display number of seconds in the system tray tooltip.</comment>
<value>Timed</value>
</data>
<data name="AWAKE_CMD_PARENT_PID_OPTION" xml:space="preserve">
<value>Uses the parent process as the bound target - once the process terminates, Awake stops.</value>
</data>
<data name="AWAKE_TRAY_TEXT_PID_BINDING" xml:space="preserve">
<value>Bound to</value>
<comment>Describes the process ID Awake is bound to when running.</comment>
</data>
<data name="AWAKE_SCREEN_ON" xml:space="preserve">
<value>On</value>
</data>
@@ -235,4 +209,16 @@
<data name="AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE" xml:space="preserve">
<value>Exiting because the provided process ID is Awake's own.</value>
</data>
<data name="AWAKE_TRAY_DISPLAY" xml:space="preserve">
<value>Screen</value>
<comment>Label for the screen/display line in tray tooltip.</comment>
</data>
<data name="AWAKE_TRAY_UNTIL" xml:space="preserve">
<value>Until</value>
<comment>Label for expiration mode showing end date/time.</comment>
</data>
<data name="AWAKE_TRAY_REMAINING" xml:space="preserve">
<value>remaining</value>
<comment>Suffix for timed mode showing time remaining, e.g. "1:30:00 remaining".</comment>
</data>
</root>

168
src/modules/awake/README.md Normal file
View File

@@ -0,0 +1,168 @@
# PowerToys Awake Module
A PowerToys utility that prevents Windows from sleeping and/or turning off the display.
**Author:** [Den Delimarsky](https://den.dev)
## Resources
- [Awake Website](https://awake.den.dev) - Official documentation and guides
- [Microsoft Learn Documentation](https://learn.microsoft.com/windows/powertoys/awake) - Usage instructions and feature overview
- [GitHub Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+label%3AProduct-Awake) - Report bugs or request features
## Overview
The Awake module consists of three projects:
| Project | Purpose |
|---------|---------|
| `Awake/` | Main WinExe application with CLI support |
| `Awake.ModuleServices/` | Service layer for PowerToys integration |
| `AwakeModuleInterface/` | C++ native module bridge |
## How It Works
The module uses the Win32 `SetThreadExecutionState()` API to signal Windows that the system should remain awake:
- `ES_SYSTEM_REQUIRED` - Prevents system sleep
- `ES_DISPLAY_REQUIRED` - Prevents display sleep
- `ES_CONTINUOUS` - Maintains state until explicitly changed
## Operating Modes
| Mode | Description |
|------|-------------|
| **PASSIVE** | Normal power behavior (off) |
| **INDEFINITE** | Keep awake until manually stopped |
| **TIMED** | Keep awake for a specified duration |
| **EXPIRABLE** | Keep awake until a specific date/time |
## Command-Line Usage
Awake can be run standalone with the following options:
```
PowerToys.Awake.exe [options]
Options:
-c, --use-pt-config Use PowerToys configuration file
-d, --display-on Keep display on (default: false)
-t, --time-limit Time limit in seconds
-p, --pid Process ID to bind to
-e, --expire-at Expiration date/time
-u, --use-parent-pid Bind to parent process
```
### Examples
Keep system awake indefinitely:
```powershell
PowerToys.Awake.exe
```
Keep awake for 1 hour with display on:
```powershell
PowerToys.Awake.exe --time-limit 3600 --display-on
```
Keep awake until a specific time:
```powershell
PowerToys.Awake.exe --expire-at "2024-12-31 23:59:59"
```
Keep awake while another process is running:
```powershell
PowerToys.Awake.exe --pid 1234
```
## Architecture
### Design Highlights
1. **Pure Win32 API for Tray UI** - No WPF/WinForms dependencies, keeping the binary small. Uses direct `Shell_NotifyIcon` API for tray icon management.
2. **Reactive Extensions (Rx.NET)** - Used for timed operations via `Observable.Interval()` and `Observable.Timer()`. File system watching uses 25ms throttle to debounce rapid config changes.
3. **Custom SynchronizationContext** - Queue-based message dispatch ensures tray operations run on a dedicated thread for thread-safe UI updates.
4. **Dual-Mode Operation**
- Standalone: Command-line arguments only
- Integrated: PowerToys settings file + process binding
5. **Process Binding** - The `--pid` parameter keeps the system awake only while a target process runs, with auto-exit when the parent PowerToys runner terminates.
## Key Files
| File | Purpose |
|------|---------|
| `Program.cs` | Entry point & CLI parsing |
| `Core/Manager.cs` | State orchestration & power management |
| `Core/TrayHelper.cs` | System tray UI management |
| `Core/Native/Bridge.cs` | Win32 P/Invoke declarations |
| `Core/Threading/SingleThreadSynchronizationContext.cs` | Threading utilities |
## Building
### Prerequisites
- Visual Studio 2022 with C++ and .NET workloads
- Windows SDK 10.0.26100.0 or later
### Build Commands
From the `src/modules/awake` directory:
```powershell
# Using the build script
.\scripts\Build-Awake.ps1
# Or with specific configuration
.\scripts\Build-Awake.ps1 -Configuration Debug -Platform x64
```
Or using MSBuild directly:
```powershell
msbuild Awake\Awake.csproj /p:Configuration=Release /p:Platform=x64
```
## Dependencies
- **System.CommandLine** - Command-line parsing
- **System.Reactive** - Rx.NET for timer management
- **PowerToys.ManagedCommon** - Shared PowerToys utilities
- **PowerToys.Settings.UI.Lib** - Settings integration
- **PowerToys.Interop** - Native interop layer
## Configuration
When running with PowerToys (`--use-pt-config`), settings are stored in:
```
%LOCALAPPDATA%\Microsoft\PowerToys\Awake\settings.json
```
## Known Limitations
### Task Scheduler Idle Detection ([#44134](https://github.com/microsoft/PowerToys/issues/44134))
When "Keep display on" is enabled, Awake uses the `ES_DISPLAY_REQUIRED` flag which blocks Windows Task Scheduler from detecting the system as idle. This prevents scheduled maintenance tasks (like SSD TRIM, disk defragmentation, and other idle-triggered tasks) from running.
Per [Microsoft's documentation](https://learn.microsoft.com/en-us/windows/win32/taskschd/task-idle-conditions):
> "An exception would be for any presentation type application that sets the ES_DISPLAY_REQUIRED flag. This flag forces Task Scheduler to not consider the system as being idle, regardless of user activity or resource consumption."
**Workarounds:**
1. **Disable "Keep display on"** - With this setting off, Awake only uses `ES_SYSTEM_REQUIRED` which still prevents sleep but allows Task Scheduler to detect idle state.
2. **Manually run maintenance tasks** - For example, to run TRIM manually:
```powershell
# Run as Administrator
Optimize-Volume -DriveLetter C -ReTrim -Verbose
```
## Telemetry
The module emits telemetry events for:
- Keep-awake mode changes (indefinite, timed, expirable, passive)
- Privacy-compliant event tagging via `Microsoft.PowerToys.Telemetry`

View File

@@ -0,0 +1,456 @@
<#
.SYNOPSIS
Builds the PowerToys Awake module.
.DESCRIPTION
This script builds the Awake module and its dependencies using MSBuild.
It automatically locates the Visual Studio installation and uses the
appropriate MSBuild version.
.PARAMETER Configuration
The build configuration. Valid values are 'Debug' or 'Release'.
Default: Release
.PARAMETER Platform
The target platform. Valid values are 'x64' or 'ARM64'.
Default: x64
.PARAMETER Clean
If specified, cleans the build output before building.
.PARAMETER Restore
If specified, restores NuGet packages before building.
.EXAMPLE
.\Build-Awake.ps1
Builds Awake in Release configuration for x64.
.EXAMPLE
.\Build-Awake.ps1 -Configuration Debug
Builds Awake in Debug configuration for x64.
.EXAMPLE
.\Build-Awake.ps1 -Clean -Restore
Cleans, restores packages, and builds Awake.
.EXAMPLE
.\Build-Awake.ps1 -Platform ARM64
Builds Awake for ARM64 architecture.
#>
[CmdletBinding()]
param(
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release',
[ValidateSet('x64', 'ARM64')]
[string]$Platform = 'x64',
[switch]$Clean,
[switch]$Restore
)
# Force UTF-8 output for Unicode characters
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'Stop'
$script:StartTime = Get-Date
# Get script directory and project paths
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ModuleDir = Split-Path -Parent $ScriptDir
$RepoRoot = Resolve-Path (Join-Path $ModuleDir "..\..\..") | Select-Object -ExpandProperty Path
$AwakeProject = Join-Path $ModuleDir "Awake\Awake.csproj"
$ModuleServicesProject = Join-Path $ModuleDir "Awake.ModuleServices\Awake.ModuleServices.csproj"
# ============================================================================
# Modern UI Components
# ============================================================================
$script:Colors = @{
Primary = "Cyan"
Success = "Green"
Error = "Red"
Warning = "Yellow"
Muted = "DarkGray"
Accent = "Magenta"
White = "White"
}
# Box drawing characters (not emojis)
$script:UI = @{
BoxH = [char]0x2500 # Horizontal line
BoxV = [char]0x2502 # Vertical line
BoxTL = [char]0x256D # Top-left corner (rounded)
BoxTR = [char]0x256E # Top-right corner (rounded)
BoxBL = [char]0x2570 # Bottom-left corner (rounded)
BoxBR = [char]0x256F # Bottom-right corner (rounded)
TreeL = [char]0x2514 # Tree last item
TreeT = [char]0x251C # Tree item
}
# Braille spinner frames (the npm-style spinner)
$script:SpinnerFrames = @(
[char]0x280B, # ⠋
[char]0x2819, # ⠙
[char]0x2839, # ⠹
[char]0x2838, # ⠸
[char]0x283C, # ⠼
[char]0x2834, # ⠴
[char]0x2826, # ⠦
[char]0x2827, # ⠧
[char]0x2807, # ⠇
[char]0x280F # ⠏
)
function Get-ElapsedTime {
$elapsed = (Get-Date) - $script:StartTime
if ($elapsed.TotalSeconds -lt 60) {
return "$([math]::Round($elapsed.TotalSeconds, 1))s"
} else {
return "$([math]::Floor($elapsed.TotalMinutes))m $($elapsed.Seconds)s"
}
}
function Write-Header {
Write-Host ""
Write-Host " Awake Build" -ForegroundColor $Colors.White
Write-Host " $Platform / $Configuration" -ForegroundColor $Colors.Muted
Write-Host ""
}
function Write-Phase {
param([string]$Name)
Write-Host ""
Write-Host " $Name" -ForegroundColor $Colors.Accent
Write-Host ""
}
function Write-Task {
param([string]$Name, [switch]$Last)
$tree = if ($Last) { $UI.TreeL } else { $UI.TreeT }
Write-Host " $tree$($UI.BoxH)$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted
Write-Host $Name -NoNewline -ForegroundColor $Colors.White
}
function Write-TaskStatus {
param([string]$Status, [string]$Time, [switch]$Failed)
if ($Failed) {
Write-Host " FAIL" -ForegroundColor $Colors.Error
} else {
Write-Host " " -NoNewline
Write-Host $Status -NoNewline -ForegroundColor $Colors.Success
if ($Time) {
Write-Host " ($Time)" -ForegroundColor $Colors.Muted
} else {
Write-Host ""
}
}
}
function Write-BuildTree {
param([string[]]$Items)
$count = $Items.Count
for ($i = 0; $i -lt $count; $i++) {
$isLast = ($i -eq $count - 1)
$tree = if ($isLast) { $UI.TreeL } else { $UI.TreeT }
Write-Host " $tree$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted
Write-Host $Items[$i] -ForegroundColor $Colors.Muted
}
}
function Write-SuccessBox {
param([string]$Time, [string]$Output, [string]$Size)
$width = 44
$lineChar = [string]$UI.BoxH
$line = $lineChar * ($width - 2)
Write-Host ""
Write-Host " $($UI.BoxTL)$line$($UI.BoxTR)" -ForegroundColor $Colors.Success
# Title row
$title = " BUILD SUCCESSFUL"
$titlePadding = $width - 2 - $title.Length
Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success
Write-Host $title -NoNewline -ForegroundColor $Colors.White
Write-Host (" " * $titlePadding) -NoNewline
Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success
# Empty row
Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success
Write-Host (" " * ($width - 2)) -NoNewline
Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success
# Time row
$timeText = " Completed in $Time"
$timePadding = $width - 2 - $timeText.Length
Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success
Write-Host $timeText -NoNewline -ForegroundColor $Colors.Muted
Write-Host (" " * $timePadding) -NoNewline
Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success
# Output row
$outText = " Output: $Output ($Size)"
if ($outText.Length -gt ($width - 2)) {
$outText = $outText.Substring(0, $width - 5) + "..."
}
$outPadding = $width - 2 - $outText.Length
if ($outPadding -lt 0) { $outPadding = 0 }
Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success
Write-Host $outText -NoNewline -ForegroundColor $Colors.Muted
Write-Host (" " * $outPadding) -NoNewline
Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success
Write-Host " $($UI.BoxBL)$line$($UI.BoxBR)" -ForegroundColor $Colors.Success
Write-Host ""
}
function Write-ErrorBox {
param([string]$Message)
$width = 44
$lineChar = [string]$UI.BoxH
$line = $lineChar * ($width - 2)
Write-Host ""
Write-Host " $($UI.BoxTL)$line$($UI.BoxTR)" -ForegroundColor $Colors.Error
$title = " BUILD FAILED"
$titlePadding = $width - 2 - $title.Length
Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Error
Write-Host $title -NoNewline -ForegroundColor $Colors.White
Write-Host (" " * $titlePadding) -NoNewline
Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Error
Write-Host " $($UI.BoxBL)$line$($UI.BoxBR)" -ForegroundColor $Colors.Error
Write-Host ""
}
# ============================================================================
# Build Functions
# ============================================================================
function Find-MSBuild {
$vsWherePath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vsWherePath) {
$vsPath = & $vsWherePath -latest -requires Microsoft.Component.MSBuild -property installationPath
if ($vsPath) {
$msbuildPath = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe"
if (Test-Path $msbuildPath) {
return $msbuildPath
}
}
}
$commonPaths = @(
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe"
)
foreach ($path in $commonPaths) {
if (Test-Path $path) {
return $path
}
}
throw "MSBuild not found. Please install Visual Studio 2022."
}
function Invoke-BuildWithSpinner {
param(
[string]$TaskName,
[string]$MSBuildPath,
[string[]]$Arguments,
[switch]$ShowProjects,
[switch]$IsLast
)
$taskStart = Get-Date
$isInteractive = [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected
# Only write initial task line in interactive mode (will be overwritten by spinner)
if ($isInteractive) {
Write-Task $TaskName -Last:$IsLast
Write-Host " " -NoNewline
}
# Start MSBuild process
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $MSBuildPath
$psi.Arguments = $Arguments -join " "
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.WorkingDirectory = $RepoRoot
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
# Collect output asynchronously
$outputBuilder = [System.Text.StringBuilder]::new()
$errorBuilder = [System.Text.StringBuilder]::new()
$outputHandler = {
if (-not [String]::IsNullOrEmpty($EventArgs.Data)) {
$Event.MessageData.AppendLine($EventArgs.Data)
}
}
$outputEvent = Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $outputHandler -MessageData $outputBuilder
$errorEvent = Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $outputHandler -MessageData $errorBuilder
$process.Start() | Out-Null
$process.BeginOutputReadLine()
$process.BeginErrorReadLine()
# Animate spinner while process is running
$frameIndex = 0
while (-not $process.HasExited) {
if ($isInteractive) {
$frame = $script:SpinnerFrames[$frameIndex]
Write-Host "`r $($UI.TreeL)$($UI.BoxH)$($UI.BoxH) $TaskName $frame " -NoNewline
$frameIndex = ($frameIndex + 1) % $script:SpinnerFrames.Count
}
Start-Sleep -Milliseconds 80
}
$process.WaitForExit()
Unregister-Event -SourceIdentifier $outputEvent.Name
Unregister-Event -SourceIdentifier $errorEvent.Name
Remove-Job -Name $outputEvent.Name -Force -ErrorAction SilentlyContinue
Remove-Job -Name $errorEvent.Name -Force -ErrorAction SilentlyContinue
$exitCode = $process.ExitCode
$output = $outputBuilder.ToString() -split "`n"
$errors = $errorBuilder.ToString()
$taskElapsed = (Get-Date) - $taskStart
$elapsed = "$([math]::Round($taskElapsed.TotalSeconds, 1))s"
# Write final status line
$tree = if ($IsLast) { $UI.TreeL } else { $UI.TreeT }
if ($isInteractive) {
Write-Host "`r" -NoNewline
}
Write-Host " $tree$($UI.BoxH)$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted
Write-Host $TaskName -NoNewline -ForegroundColor $Colors.White
if ($exitCode -ne 0) {
Write-TaskStatus "FAIL" -Failed
Write-Host ""
foreach ($line in $output) {
if ($line -match "error\s+\w+\d*:") {
Write-Host " x $line" -ForegroundColor $Colors.Error
}
}
return @{ Success = $false; Output = $output; ExitCode = $exitCode }
}
Write-TaskStatus "done" $elapsed
# Show built projects
if ($ShowProjects) {
$projects = @()
foreach ($line in $output) {
if ($line -match "^\s*(\S+)\s+->\s+(.+)$") {
$project = $Matches[1]
$fileName = Split-Path $Matches[2] -Leaf
$projects += "$project -> $fileName"
}
}
if ($projects.Count -gt 0) {
Write-BuildTree $projects
}
}
return @{ Success = $true; Output = $output; ExitCode = 0 }
}
# ============================================================================
# Main
# ============================================================================
# Verify project exists
if (-not (Test-Path $AwakeProject)) {
Write-Host ""
Write-Host " x Project not found: $AwakeProject" -ForegroundColor $Colors.Error
exit 1
}
$MSBuild = Find-MSBuild
# Display header
Write-Header
# Build arguments base
$BaseArgs = @(
"/p:Configuration=$Configuration",
"/p:Platform=$Platform",
"/v:minimal",
"/nologo",
"/m"
)
# Clean phase
if ($Clean) {
Write-Phase "Cleaning"
$cleanArgs = @($AwakeProject) + $BaseArgs + @("/t:Clean")
$result = Invoke-BuildWithSpinner -TaskName "Build artifacts" -MSBuildPath $MSBuild -Arguments $cleanArgs -IsLast
if (-not $result.Success) {
Write-ErrorBox
exit $result.ExitCode
}
}
# Restore phase
if ($Restore) {
Write-Phase "Restoring"
$restoreArgs = @($AwakeProject) + $BaseArgs + @("/t:Restore")
$result = Invoke-BuildWithSpinner -TaskName "NuGet packages" -MSBuildPath $MSBuild -Arguments $restoreArgs -IsLast
if (-not $result.Success) {
Write-ErrorBox
exit $result.ExitCode
}
}
# Build phase
Write-Phase "Building"
$hasModuleServices = Test-Path $ModuleServicesProject
# Build Awake
$awakeArgs = @($AwakeProject) + $BaseArgs + @("/t:Build")
$result = Invoke-BuildWithSpinner -TaskName "Awake" -MSBuildPath $MSBuild -Arguments $awakeArgs -ShowProjects -IsLast:(-not $hasModuleServices)
if (-not $result.Success) {
Write-ErrorBox
exit $result.ExitCode
}
# Build ModuleServices
if ($hasModuleServices) {
$servicesArgs = @($ModuleServicesProject) + $BaseArgs + @("/t:Build")
$result = Invoke-BuildWithSpinner -TaskName "Awake.ModuleServices" -MSBuildPath $MSBuild -Arguments $servicesArgs -ShowProjects -IsLast
if (-not $result.Success) {
Write-ErrorBox
exit $result.ExitCode
}
}
# Summary
$OutputDir = Join-Path $RepoRoot "$Platform\$Configuration"
$AwakeDll = Join-Path $OutputDir "PowerToys.Awake.dll"
$elapsed = Get-ElapsedTime
if (Test-Path $AwakeDll) {
$size = "$([math]::Round((Get-Item $AwakeDll).Length / 1KB, 1)) KB"
Write-SuccessBox -Time $elapsed -Output "PowerToys.Awake.dll" -Size $size
} else {
Write-SuccessBox -Time $elapsed -Output $OutputDir -Size "N/A"
}

View File

@@ -181,7 +181,10 @@ void dispatch_json_config_to_modules(const json::JsonObject& powertoys_configs)
const auto properties = settings.GetNamedObject(L"properties");
// Currently, only PowerToys Run settings use the 'hotkey_changed' property.
json::get(properties, L"hotkey_changed", hotkeyUpdated, true);
if (properties.HasKey(L"hotkey_changed"))
{
json::get(properties, L"hotkey_changed", hotkeyUpdated, true);
}
}
send_json_config_to_module(powertoy_element.Key().c_str(), element.c_str(), hotkeyUpdated);

View File

@@ -2,6 +2,10 @@
// 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
using Common.Search;
namespace Settings.UI.Library
{
public enum EntryType
@@ -11,7 +15,7 @@ namespace Settings.UI.Library
SettingsExpander,
}
public struct SettingEntry
public struct SettingEntry : ISearchable
{
public EntryType Type { get; set; }
@@ -29,16 +33,23 @@ namespace Settings.UI.Library
public string Icon { get; set; }
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null)
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string? parentElementName = null, string? description = null, string? icon = null)
{
Type = type;
Header = header;
PageTypeName = pageTypeName;
ElementName = elementName;
ElementUid = elementUid;
ParentElementName = parentElementName;
Description = description;
Icon = icon;
ParentElementName = parentElementName ?? string.Empty;
Description = description ?? string.Empty;
Icon = icon ?? string.Empty;
}
// ISearchable implementation
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public readonly string SearchableText => Header ?? string.Empty;
public readonly string? SecondarySearchableText => Description;
}
}

View File

@@ -23,6 +23,7 @@
<ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" />
<ProjectReference Include="..\..\common\Common.Search\Common.Search.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,174 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Common.Search.FuzzSearch;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services.Search
{
/// <summary>
/// A search provider that uses fuzzy string matching for settings search.
/// </summary>
public sealed class FuzzSearchProvider : ISearchProvider
{
private readonly object _lockObject = new();
private readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
private IReadOnlyList<SettingEntry> _entries = Array.Empty<SettingEntry>();
private bool _isReady;
/// <inheritdoc/>
public bool IsReady
{
get
{
lock (_lockObject)
{
return _isReady;
}
}
}
/// <inheritdoc/>
public Task InitializeAsync(IReadOnlyList<SettingEntry> entries, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
lock (_lockObject)
{
_normalizedTextCache.Clear();
_entries = entries;
_isReady = true;
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public List<SettingEntry> Search(string query, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
IReadOnlyList<SettingEntry> currentEntries;
lock (_lockObject)
{
if (!_isReady || _entries.Count == 0)
{
return [];
}
currentEntries = _entries;
}
var normalizedQuery = NormalizeString(query);
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
var po = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
};
try
{
Parallel.ForEach(currentEntries, po, entry =>
{
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
double score = captionScoreResult.Score;
if (!string.IsNullOrEmpty(descNorm))
{
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
if (descriptionScoreResult.Success)
{
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
}
}
if (score > 0)
{
bag.Add((entry, score));
}
});
}
catch (OperationCanceledException)
{
return [];
}
return bag
.OrderByDescending(r => r.Score)
.Select(r => r.Hit)
.ToList();
}
/// <inheritdoc/>
public void Clear()
{
lock (_lockObject)
{
_normalizedTextCache.Clear();
_entries = Array.Empty<SettingEntry>();
_isReady = false;
}
}
private (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
{
if (entry.ElementUid == null && entry.Header == null)
{
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
}
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
lock (_lockObject)
{
if (_normalizedTextCache.TryGetValue(key, out var cached))
{
return cached;
}
}
var headerNorm = NormalizeString(entry.Header);
var descNorm = NormalizeString(entry.Description);
lock (_lockObject)
{
_normalizedTextCache[key] = (headerNorm, descNorm);
}
return (headerNorm, descNorm);
}
private static string NormalizeString(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var stringBuilder = new StringBuilder();
foreach (var c in normalized)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services.Search
{
/// <summary>
/// Defines a pluggable search provider interface for settings search.
/// </summary>
public interface ISearchProvider
{
/// <summary>
/// Gets a value indicating whether the provider is ready to search.
/// </summary>
bool IsReady { get; }
/// <summary>
/// Initializes the search provider with the given index entries.
/// </summary>
/// <param name="entries">The setting entries to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task InitializeAsync(IReadOnlyList<SettingEntry> entries, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for settings matching the query.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching setting entries ordered by relevance.</returns>
List<SettingEntry> Search(string query, CancellationToken cancellationToken = default);
/// <summary>
/// Clears the index and releases resources.
/// </summary>
void Clear();
}
}

View File

@@ -3,20 +3,15 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Common.Search.FuzzSearch;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Services.Search;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.Windows.ApplicationModel.Resources;
using Settings.UI.Library;
@@ -27,14 +22,43 @@ namespace Microsoft.PowerToys.Settings.UI.Services
{
private static readonly object _lockObject = new();
private static readonly Dictionary<string, string> _pageNameCache = [];
private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
private static readonly Dictionary<string, Type> _pageTypeCache = new();
private static ImmutableArray<SettingEntry> _index = [];
private static ISearchProvider _searchProvider;
private static bool _isIndexBuilt;
private static bool _isIndexBuilding;
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Gets or sets the search provider. Defaults to FuzzSearchProvider if not set.
/// </summary>
public static ISearchProvider SearchProvider
{
get
{
lock (_lockObject)
{
_searchProvider ??= new FuzzSearchProvider();
return _searchProvider;
}
}
set
{
lock (_lockObject)
{
_searchProvider = value;
// If index is already built, reinitialize the new provider
if (_isIndexBuilt && _searchProvider != null)
{
_searchProvider.InitializeAsync(_index).ConfigureAwait(false);
}
}
}
}
public static ImmutableArray<SettingEntry> Index
{
get
@@ -52,7 +76,7 @@ namespace Microsoft.PowerToys.Settings.UI.Services
{
lock (_lockObject)
{
return _isIndexBuilt;
return _isIndexBuilt && SearchProvider.IsReady;
}
}
}
@@ -69,7 +93,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services
_isIndexBuilding = true;
// Clear caches on rebuild
_normalizedTextCache.Clear();
_pageTypeCache.Clear();
}
@@ -78,12 +101,17 @@ namespace Microsoft.PowerToys.Settings.UI.Services
var builder = ImmutableArray.CreateBuilder<SettingEntry>();
LoadIndexFromPrebuiltData(builder);
ImmutableArray<SettingEntry> builtIndex;
lock (_lockObject)
{
_index = builder.ToImmutable();
builtIndex = _index;
_isIndexBuilt = true;
_isIndexBuilding = false;
}
// Initialize the search provider with the index
SearchProvider.InitializeAsync(builtIndex).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -209,57 +237,32 @@ namespace Microsoft.PowerToys.Settings.UI.Services
return [];
}
var currentIndex = Index;
if (currentIndex.IsEmpty)
if (!IsIndexReady)
{
Debug.WriteLine("[SearchIndexService] Search called but index is empty.");
Debug.WriteLine("[SearchIndexService] Search called but index is not ready.");
return [];
}
var normalizedQuery = NormalizeString(query);
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
var po = new ParallelOptions
{
CancellationToken = token,
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
};
// Delegate search to the pluggable search provider
var results = SearchProvider.Search(query, token);
try
// Filter results to ensure page types are valid
return FilterValidPageTypes(results);
}
private static List<SettingEntry> FilterValidPageTypes(List<SettingEntry> results)
{
var filtered = new List<SettingEntry>();
foreach (var entry in results)
{
Parallel.ForEach(currentIndex, po, entry =>
var pageType = GetPageTypeFromName(entry.PageTypeName);
if (pageType != null)
{
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
double score = captionScoreResult.Score;
if (!string.IsNullOrEmpty(descNorm))
{
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
if (descriptionScoreResult.Success)
{
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
}
}
if (score > 0)
{
var pageType = GetPageTypeFromName(entry.PageTypeName);
if (pageType != null)
{
bag.Add((entry, score));
}
}
});
}
catch (OperationCanceledException)
{
return [];
filtered.Add(entry);
}
}
return bag
.OrderByDescending(r => r.Score)
.Select(r => r.Hit)
.ToList();
return filtered;
}
private static Type GetPageTypeFromName(string pageTypeName)
@@ -283,53 +286,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services
}
}
private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
{
if (entry.ElementUid == null && entry.Header == null)
{
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
}
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
lock (_lockObject)
{
if (_normalizedTextCache.TryGetValue(key, out var cached))
{
return cached;
}
}
var headerNorm = NormalizeString(entry.Header);
var descNorm = NormalizeString(entry.Description);
lock (_lockObject)
{
_normalizedTextCache[key] = (headerNorm, descNorm);
}
return (headerNorm, descNorm);
}
private static string NormalizeString(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var stringBuilder = new StringBuilder();
foreach (var c in normalized)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
public static string GetLocalizedPageName(string pageTypeName)
{
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;

View File

@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Common.Search;
using Common.Search.FuzzSearch;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services
{
/// <summary>
/// A service that provides search functionality for settings using pluggable search engines.
/// </summary>
public sealed class SettingsSearchService : IDisposable
{
private readonly ISearchEngine<SettingEntry> _searchEngine;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SettingsSearchService"/> class with the default FuzzSearchEngine.
/// </summary>
public SettingsSearchService()
: this(new FuzzSearchEngine<SettingEntry>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SettingsSearchService"/> class with a custom search engine.
/// </summary>
/// <param name="searchEngine">The search engine to use.</param>
public SettingsSearchService(ISearchEngine<SettingEntry> searchEngine)
{
ArgumentNullException.ThrowIfNull(searchEngine);
_searchEngine = searchEngine;
}
/// <summary>
/// Gets a value indicating whether the search service is ready.
/// </summary>
public bool IsReady => _searchEngine.IsReady;
/// <summary>
/// Gets the search engine capabilities.
/// </summary>
public SearchEngineCapabilities Capabilities => _searchEngine.Capabilities;
/// <summary>
/// Initializes the search service with the given setting entries.
/// </summary>
/// <param name="entries">The setting entries to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
public async Task InitializeAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ThrowIfDisposed();
await _searchEngine.InitializeAsync(cancellationToken);
await _searchEngine.IndexBatchAsync(entries, cancellationToken);
}
/// <summary>
/// Rebuilds the index with new entries.
/// </summary>
/// <param name="entries">The new setting entries to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
public async Task RebuildIndexAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ThrowIfDisposed();
await _searchEngine.ClearAsync(cancellationToken);
await _searchEngine.IndexBatchAsync(entries, cancellationToken);
}
/// <summary>
/// Searches for settings matching the query.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching setting entries.</returns>
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var results = await _searchEngine.SearchAsync(query, cancellationToken: cancellationToken);
return results.Select(r => r.Item).ToList();
}
/// <summary>
/// Searches for settings matching the query with detailed results.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="options">Search options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of search results with scoring information.</returns>
public async Task<IReadOnlyList<SearchResult<SettingEntry>>> SearchWithScoresAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Array.Empty<SearchResult<SettingEntry>>();
}
return await _searchEngine.SearchAsync(query, options, cancellationToken);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_searchEngine.Dispose();
_disposed = true;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
}

View File

@@ -76,19 +76,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
GeneralSettingsConfig = settingsRepository.SettingsConfig;
// To obtain the settings configurations of Advanced Paste.
ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository);
// To obtain the settings configurations of Fancy zones.
ArgumentNullException.ThrowIfNull(settingsRepository);
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils));
_advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig ?? throw new ArgumentException("SettingsConfig cannot be null", nameof(advancedPasteSettingsRepository));
ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository);
if (_advancedPasteSettings.Properties is null)
{
throw new ArgumentException("AdvancedPasteSettings.Properties cannot be null", nameof(advancedPasteSettingsRepository));
}
_advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig;
AttachConfigurationHandlers();

View File

@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Cleans PowerToys build artifacts to resolve build errors.
.DESCRIPTION
Use this script when you encounter build errors about missing image files or corrupted
build state. It removes build output folders and optionally runs MSBuild Clean.
.PARAMETER SkipMSBuildClean
Skip running MSBuild Clean target, only delete folders.
.EXAMPLE
.\tools\build\clean-artifacts.ps1
.EXAMPLE
.\tools\build\clean-artifacts.ps1 -SkipMSBuildClean
#>
param (
[switch]$SkipMSBuildClean
)
$ErrorActionPreference = 'Continue'
$scriptDir = $PSScriptRoot
$repoRoot = (Resolve-Path "$scriptDir\..\..").Path
Write-Host "Cleaning build artifacts..."
Write-Host ""
# Run MSBuild Clean
if (-not $SkipMSBuildClean) {
$vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vsWhere) {
$vsPath = & $vsWhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath
if ($vsPath) {
$msbuildPath = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe"
if (Test-Path $msbuildPath) {
$solutionFile = Join-Path $repoRoot "PowerToys.sln"
if (-not (Test-Path $solutionFile)) {
$solutionFile = Join-Path $repoRoot "PowerToys.slnx"
}
if (Test-Path $solutionFile) {
Write-Host " Running MSBuild Clean..."
foreach ($plat in @('x64', 'ARM64')) {
foreach ($config in @('Debug', 'Release')) {
& $msbuildPath $solutionFile /t:Clean /p:Platform=$plat /p:Configuration=$config /verbosity:quiet 2>&1 | Out-Null
}
}
Write-Host " Done."
}
}
}
}
}
# Delete build folders
$folders = @('x64', 'ARM64', 'Debug', 'Release', 'packages')
$deleted = @()
foreach ($folder in $folders) {
$fullPath = Join-Path $repoRoot $folder
if (Test-Path $fullPath) {
Write-Host " Removing $folder/"
try {
Remove-Item -Path $fullPath -Recurse -Force -ErrorAction Stop
$deleted += $folder
} catch {
Write-Host " Failed to remove $folder/: $_"
}
}
}
Write-Host ""
if ($deleted.Count -gt 0) {
Write-Host "Removed: $($deleted -join ', ')"
} else {
Write-Host "No build folders found to remove."
}
Write-Host ""
Write-Host "To rebuild, run:"
Write-Host " msbuild -restore -p:RestorePackagesConfig=true -p:Platform=x64 -m PowerToys.slnx"

View File

@@ -0,0 +1,291 @@
<#
.SYNOPSIS
Sets up the development environment for building PowerToys.
.DESCRIPTION
This script automates the setup of prerequisites needed to build PowerToys locally:
- Enables Windows long path support (requires elevation)
- Enables Windows Developer Mode (requires elevation)
- Installs required Visual Studio workloads from .vsconfig
- Initializes git submodules
Run this script once after cloning the repository to prepare your development environment.
.PARAMETER SkipLongPaths
Skip enabling long path support in Windows.
.PARAMETER SkipDevMode
Skip enabling Windows Developer Mode.
.PARAMETER SkipVSComponents
Skip installing Visual Studio components from .vsconfig.
.PARAMETER SkipSubmodules
Skip initializing git submodules.
.PARAMETER VSInstallPath
Path to Visual Studio installation. Default: auto-detected.
.PARAMETER Help
Show this help message.
.EXAMPLE
.\tools\build\setup-dev-environment.ps1
Runs the full setup process.
.EXAMPLE
.\tools\build\setup-dev-environment.ps1 -SkipVSComponents
Runs setup but skips Visual Studio component installation.
.EXAMPLE
.\tools\build\setup-dev-environment.ps1 -VSInstallPath "C:\Program Files\Microsoft Visual Studio\2022\Enterprise"
Runs setup with a custom Visual Studio installation path.
.NOTES
- Some operations require administrator privileges (long paths, VS component installation).
- If not running as administrator, the script will prompt for elevation for those steps.
- The script is idempotent and safe to run multiple times.
#>
param (
[switch]$SkipLongPaths,
[switch]$SkipDevMode,
[switch]$SkipVSComponents,
[switch]$SkipSubmodules,
[string]$VSInstallPath = '',
[switch]$Help
)
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Detailed
exit 0
}
$ErrorActionPreference = 'Stop'
# Find repository root
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = $scriptDir
while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "PowerToys.slnx"))) {
$parent = Split-Path -Parent $repoRoot
if ($parent -eq $repoRoot) {
Write-Error "Could not find PowerToys repository root. Ensure this script is in the PowerToys repository."
exit 1
}
$repoRoot = $parent
}
Write-Host "Repository: $repoRoot"
Write-Host ""
function Test-Administrator {
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
$isAdmin = Test-Administrator
# Step 1: Enable Long Paths
if (-not $SkipLongPaths) {
Write-Host "[1/4] Checking Windows long path support"
$longPathsEnabled = $false
try {
$regValue = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -ErrorAction SilentlyContinue
$longPathsEnabled = ($regValue.LongPathsEnabled -eq 1)
} catch {
$longPathsEnabled = $false
}
if ($longPathsEnabled) {
Write-Host " Long paths already enabled" -ForegroundColor Green
} elseif ($isAdmin) {
Write-Host " Enabling long paths..."
try {
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -Type DWord
Write-Host " Long paths enabled" -ForegroundColor Green
} catch {
Write-Warning " Failed to enable long paths: $_"
}
} else {
Write-Warning " Long paths not enabled. Run as Administrator to enable, or run manually:"
Write-Host " Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1" -ForegroundColor DarkGray
}
} else {
Write-Host "[1/4] Skipping long path check" -ForegroundColor DarkGray
}
Write-Host ""
# Step 2: Enable Developer Mode
if (-not $SkipDevMode) {
Write-Host "[2/4] Checking Windows Developer Mode"
$devModeEnabled = $false
try {
$regValue = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" -Name "AllowDevelopmentWithoutDevLicense" -ErrorAction SilentlyContinue
$devModeEnabled = ($regValue.AllowDevelopmentWithoutDevLicense -eq 1)
} catch {
$devModeEnabled = $false
}
if ($devModeEnabled) {
Write-Host " Developer Mode already enabled" -ForegroundColor Green
} elseif ($isAdmin) {
Write-Host " Enabling Developer Mode..."
try {
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
if (-not (Test-Path $regPath)) {
New-Item -Path $regPath -Force | Out-Null
}
Set-ItemProperty -Path $regPath -Name "AllowDevelopmentWithoutDevLicense" -Value 1 -Type DWord
Write-Host " Developer Mode enabled" -ForegroundColor Green
} catch {
Write-Warning " Failed to enable Developer Mode: $_"
}
} else {
Write-Warning " Developer Mode not enabled. Run as Administrator to enable, or enable manually:"
Write-Host " Settings > System > For developers > Developer Mode" -ForegroundColor DarkGray
}
} else {
Write-Host "[2/4] Skipping Developer Mode check" -ForegroundColor DarkGray
}
Write-Host ""
# Step 3: Install Visual Studio Components
if (-not $SkipVSComponents) {
Write-Host "[3/4] Checking Visual Studio components"
$vsConfigPath = Join-Path $repoRoot ".vsconfig"
if (-not (Test-Path $vsConfigPath)) {
Write-Warning " .vsconfig not found at $vsConfigPath"
} else {
$vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (-not $VSInstallPath -and (Test-Path $vsWhere)) {
$VSInstallPath = & $vsWhere -latest -property installationPath 2>$null
}
if (-not $VSInstallPath) {
$commonPaths = @(
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Community"
)
foreach ($path in $commonPaths) {
if (Test-Path $path) {
$VSInstallPath = $path
break
}
}
}
if (-not $VSInstallPath -or -not (Test-Path $VSInstallPath)) {
Write-Warning " Could not find Visual Studio 2022 installation"
Write-Warning " Please install Visual Studio 2022 and try again, or import .vsconfig manually"
} else {
Write-Host " Found: $VSInstallPath" -ForegroundColor DarkGray
$vsInstaller = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vs_installer.exe"
if (Test-Path $vsInstaller) {
Write-Host ""
Write-Host " To install required components:"
Write-Host ""
Write-Host " Option A - Visual Studio Installer GUI:"
Write-Host " 1. Open Visual Studio Installer"
Write-Host " 2. Click 'More' > 'Import configuration'"
Write-Host " 3. Select: $vsConfigPath"
Write-Host ""
Write-Host " Option B - Command line (close VS first):"
Write-Host " & `"$vsInstaller`" modify --installPath `"$VSInstallPath`" --config `"$vsConfigPath`"" -ForegroundColor DarkGray
Write-Host ""
$choices = @(
[System.Management.Automation.Host.ChoiceDescription]::new("&Install", "Run VS Installer now"),
[System.Management.Automation.Host.ChoiceDescription]::new("&Skip", "Continue without installing")
)
try {
$decision = $Host.UI.PromptForChoice("", "Install VS components now?", $choices, 1)
if ($decision -eq 0) {
# Check if VS Installer is already running (it runs as setup.exe from the Installer folder)
$vsInstallerDir = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
$vsInstallerRunning = Get-Process -Name "setup" -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and $_.Path.StartsWith($vsInstallerDir, [System.StringComparison]::OrdinalIgnoreCase) }
if ($vsInstallerRunning) {
Write-Warning " Visual Studio Installer is already running"
Write-Host " Close it and run this script again, or import .vsconfig manually" -ForegroundColor DarkGray
} else {
Write-Host " Launching Visual Studio Installer..."
Write-Host " Close Visual Studio if it's running." -ForegroundColor DarkGray
$process = Start-Process -FilePath $vsInstaller -ArgumentList "modify", "--installPath", "`"$VSInstallPath`"", "--config", "`"$vsConfigPath`"" -Wait -PassThru
if ($process.ExitCode -eq 0) {
Write-Host " VS component installation completed" -ForegroundColor Green
} elseif ($process.ExitCode -eq 3010) {
Write-Host " VS component installation completed (restart may be required)" -ForegroundColor Green
} else {
Write-Warning " VS Installer exited with code $($process.ExitCode)"
Write-Host " You may need to run the installer manually" -ForegroundColor DarkGray
}
}
} else {
Write-Host " Skipped VS component installation"
}
} catch {
Write-Host " Non-interactive mode. Run the command above manually if needed." -ForegroundColor DarkGray
}
} else {
Write-Warning " Visual Studio Installer not found"
}
}
}
} else {
Write-Host "[3/4] Skipping VS component check" -ForegroundColor DarkGray
}
Write-Host ""
# Step 4: Initialize Git Submodules
if (-not $SkipSubmodules) {
Write-Host "[4/4] Initializing git submodules"
Push-Location $repoRoot
try {
$submoduleStatus = git submodule status 2>&1
$uninitializedCount = ($submoduleStatus | Where-Object { $_ -match '^\-' }).Count
if ($uninitializedCount -eq 0 -and $submoduleStatus) {
Write-Host " Submodules already initialized" -ForegroundColor Green
} else {
Write-Host " Running: git submodule update --init --recursive" -ForegroundColor DarkGray
git submodule update --init --recursive
if ($LASTEXITCODE -eq 0) {
Write-Host " Submodules initialized" -ForegroundColor Green
} else {
Write-Warning " Submodule initialization may have encountered issues (exit code: $LASTEXITCODE)"
}
}
} catch {
Write-Warning " Failed to initialize submodules: $_"
} finally {
Pop-Location
}
} else {
Write-Host "[4/4] Skipping submodule initialization" -ForegroundColor DarkGray
}
Write-Host ""
Write-Host "Setup complete" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Open PowerToys.slnx in Visual Studio 2022"
Write-Host " 2. If prompted to install additional components, click Install"
Write-Host " 3. Build the solution (Ctrl+Shift+B)"
Write-Host ""
Write-Host "Or build from command line:"
Write-Host " .\tools\build\build.ps1" -ForegroundColor DarkGray
Write-Host ""

View File

@@ -0,0 +1,25 @@
# Clear Copilot context files
# This script removes AGENTS.md and related copilot instruction files
$repoRoot = Split-Path -Parent $PSScriptRoot
if (-not $repoRoot) {
$repoRoot = (Get-Location).Path
}
$filesToRemove = @(
"AGENTS.md",
".github\instructions\runner-settings-ui.instructions.md",
".github\instructions\common-libraries.instructions.md"
)
foreach ($file in $filesToRemove) {
$filePath = Join-Path $repoRoot $file
if (Test-Path $filePath) {
Remove-Item $filePath -Force
Write-Host "Removed: $filePath" -ForegroundColor Green
} else {
Write-Host "Not found: $filePath" -ForegroundColor Yellow
}
}
Write-Host "Done." -ForegroundColor Cyan