mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-21 11:37:20 +01:00
Compare commits
5 Commits
shawn/fixA
...
dev/vanzue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38d460cc2b | ||
|
|
27dcd1e5bc | ||
|
|
2a0d0a1210 | ||
|
|
662bbf0033 | ||
|
|
b7a94eb48d |
4
.github/actions/spell-check/expect.txt
vendored
4
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||
|
||||
9
.github/copilot-instructions.md
vendored
9
.github/copilot-instructions.md
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
1
.gitignore
vendored
@@ -359,3 +359,4 @@ src/common/Telemetry/*.etl
|
||||
|
||||
# PowerToysInstaller Build Temp Files
|
||||
installer/*/*.wxs.bk
|
||||
/src/modules/awake/.claude
|
||||
|
||||
@@ -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
165
AGENTS.md
@@ -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)
|
||||
@@ -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
|
||||
|
||||
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal 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*
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
496
doc/specs/common-search-library.md
Normal file
496
doc/specs/common-search-library.md
Normal 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<T>
|
||||
|
||||
搜索引擎核心接口。
|
||||
|
||||
```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<T>
|
||||
|
||||
统一的搜索结果模型。
|
||||
|
||||
```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<T>
|
||||
|
||||
基于现有 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<T>
|
||||
|
||||
组合多个搜索引擎,支持 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<T>
|
||||
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*
|
||||
@@ -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>
|
||||
|
||||
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
|
||||
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
73
src/common/Common.Search/ISearchEngine`1.cs
Normal 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);
|
||||
}
|
||||
27
src/common/Common.Search/ISearchable.cs
Normal file
27
src/common/Common.Search/ISearchable.cs
Normal 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; }
|
||||
}
|
||||
12
src/common/Common.Search/MatchSpan.cs
Normal file
12
src/common/Common.Search/MatchSpan.cs
Normal 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);
|
||||
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal 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; }
|
||||
}
|
||||
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
31
src/common/Common.Search/SearchMatchKind.cs
Normal 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,
|
||||
}
|
||||
35
src/common/Common.Search/SearchOptions.cs
Normal file
35
src/common/Common.Search/SearchOptions.cs
Normal 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; }
|
||||
}
|
||||
33
src/common/Common.Search/SearchResult`1.cs
Normal file
33
src/common/Common.Search/SearchResult`1.cs
Normal 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; }
|
||||
}
|
||||
234
src/common/Common.Search/SemanticSearch/SemanticSearchAdapter.cs
Normal file
234
src/common/Common.Search/SemanticSearch/SemanticSearchAdapter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
347
src/common/Common.Search/SemanticSearch/SemanticSearchEngine.cs
Normal file
347
src/common/Common.Search/SemanticSearch/SemanticSearchEngine.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
168
src/modules/awake/README.md
Normal 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`
|
||||
456
src/modules/awake/scripts/Build-Awake.ps1
Normal file
456
src/modules/awake/scripts/Build-Awake.ps1
Normal 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"
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
140
src/settings-ui/Settings.UI/Services/SettingsSearchService.cs
Normal file
140
src/settings-ui/Settings.UI/Services/SettingsSearchService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
84
tools/build/clean-artifacts.ps1
Normal file
84
tools/build/clean-artifacts.ps1
Normal 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"
|
||||
291
tools/build/setup-dev-environment.ps1
Normal file
291
tools/build/setup-dev-environment.ps1
Normal 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 ""
|
||||
25
tools/clear-copilot-context.ps1
Normal file
25
tools/clear-copilot-context.ps1
Normal 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
|
||||
Reference in New Issue
Block a user