Compare commits

..

13 Commits

Author SHA1 Message Date
vanzue
9d4a65bf32 setting search evaluation 2026-02-09 10:54:59 +08:00
vanzue
032b6163cc move resources to sparse root 2026-02-07 09:12:20 +08:00
vanzue
1ee721c306 fix build 2026-02-06 18:54:21 +08:00
vanzue
ee702f9edf evaluation for the semantic search 2026-02-06 17:26:24 +08:00
vanzue
916182e47d merge mainn 2026-02-06 10:40:54 +08:00
vanzue
0e4d1c1496 absolutely package dependent 2026-01-26 10:31:33 +08:00
vanzue
d6bebf8423 not self contain to make settings start 2026-01-25 23:20:50 +08:00
vanzue
ebf36a324a Add trace and logs 2026-01-23 20:51:56 +08:00
vanzue
eba7760ee1 dev 2026-01-23 13:48:50 +08:00
vanzue
0998bed0d4 Merge remote-tracking branch 'origin/main' into dev/vanzue/ss 2026-01-23 11:22:37 +08:00
vanzue
ad958759fa fix 2026-01-23 09:20:38 +08:00
vanzue
dbf16cf62a refactor 2026-01-22 15:39:13 +08:00
vanzue
38d460cc2b Semantic search refactor and experiment 2026-01-21 16:51:35 +08:00
85 changed files with 20656 additions and 714 deletions

View File

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

View File

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

View File

@@ -63,7 +63,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.10" />
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
@@ -77,10 +77,8 @@
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.231216.1"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="1.8.251104000" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="1.8.39" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="1.8.251106002" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental4" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.0.130-experimental" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="2.0.9" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageVersion Include="ModernWpfUI" Version="0.9.4" />

View File

@@ -1059,6 +1059,16 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/tools/SettingsSearchEvaluation/">
<Project Path="tools/SettingsSearchEvaluation/SettingsSearchEvaluation.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="tools/SettingsSearchEvaluation.Tests/SettingsSearchEvaluation.Tests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/Solution Items/">
<File Path=".vsconfig" />
<File Path="Cpp.Build.props" />

View File

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

View File

@@ -0,0 +1,325 @@
# Sparse Package + WinUI 3 调查报告
## 背景
PowerToys 希望使用 Windows App SDK 的 Semantic Search API (`AppContentIndexer.GetOrCreateIndex`) 来实现语义搜索功能。该 API 要求应用具有 **Package Identity**
## 问题现象
### 1. Semantic Search API 调用失败
在 [SemanticSearchIndex.cs](../../src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs) 中调用 `AppContentIndexer.GetOrCreateIndex(_indexName)` 时,抛出 COM 异常:
```
System.Runtime.InteropServices.COMException (0x80004005): Error HRESULT E_FAIL has been returned from a call to a COM component.
at WinRT.ExceptionHelpers.<ThrowExceptionForHR>g__Throw|39_0(Int32 hr)
at Microsoft.Windows.AI.Search.AppContentIndexer.GetOrCreateIndex(String indexName)
```
### 2. API 要求
根据 [Windows App SDK 文档](https://learn.microsoft.com/en-us/windows/ai/apis/content-search)Semantic Search API 需要:
- Windows 11 24H2 或更高版本
- NPU 硬件支持
- **Package Identity**(应用需要有 MSIX 包标识)
## Sparse Package 方案
### 什么是 Sparse Package
Sparse Package稀疏包是一种为非打包unpackagedWin32 应用提供 Package Identity 的技术,无需完整的 MSIX 打包。
参考:[Grant package identity by packaging with external location](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps)
### 实现架构
```
PowerToysSparse.msix (仅包含 manifest 和图标)
├── AppxManifest.xml (声明应用和依赖)
├── Square44x44Logo.png
├── Square150x150Logo.png
└── StoreLogo.png
ExternalLocation (指向实际应用目录)
└── ARM64\Debug\
├── PowerToys.Settings.exe
├── PowerToys.Settings.pri
└── ... (其他应用文件)
```
### 关键组件
| 文件 | 位置 | 作用 |
|------|------|------|
| AppxManifest.xml | src/PackageIdentity/ | 定义 sparse package 的应用、依赖和能力 |
| app.manifest | src/settings-ui/Settings.UI/ | 嵌入 exe 中,声明与 sparse package 的关联 |
| BuildSparsePackage.ps1 | src/PackageIdentity/ | 构建和签名脚本 |
### Publisher 配置
**重要**app.manifest 和 AppxManifest.xml 中的 Publisher 必须匹配。
| 环境 | Publisher |
|------|-----------|
| 开发环境 | `CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US` |
| 生产环境 | `CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US` |
BuildSparsePackage.ps1 会在本地构建时**自动**将 AppxManifest.xml 的 Publisher 替换为开发环境值,无需手动修改源码。
## 当前问题WinUI 3 + Sparse Package 崩溃
### 现象
当 Settings.exeWinUI 3 应用)通过 sparse package 启动时,立即崩溃:
```
Microsoft.UI.Xaml.Markup.XamlParseException (-2144665590):
Cannot locate resource from 'ms-appx:///Microsoft.UI.Xaml/Themes/themeresources.xaml'. [Line: 11 Position: 40]
```
### 新观察2026-01-25
对齐 WinAppSDK 版本并恢复 app-local 运行时后,仍可复现**更早期**的崩溃(未写入 Settings 日志):
- Application Error / WERAUMID 启动):
- Faulting module: `CoreMessagingXP.dll`
- Exception code: `0xc0000602`
- Faulting module path: `C:\PowerToys\ARM64\Debug\WinUI3Apps\CoreMessagingXP.dll`
- 暂时移除 `CoreMessagingXP.dll` 后,出现 .NET Runtime 1026
- `COMException (0x80040111): ClassFactory cannot supply requested class`
- 发生在 `Microsoft.UI.Xaml.Application.Start(...)`
这说明 **“themeresources.xaml 无法解析”并不是唯一/必现的失败模式**app-local 运行时在 sparse identity 下可能存在更底层的初始化问题。
### 新观察2026-01-25 晚间)
framework-dependent + bootstrap 方向有实质进展:
- 设置 `WindowsAppSDKSelfContained=false`(仅在 `UseSparseIdentity=true` 时生效)
- 添加 `WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp=true`
- **从 ExternalLocation 根目录与 `WinUI3Apps` 目录移除 app-local WinAppSDK 运行时文件**
- 尤其是 `CoreMessagingXP.dll`,否则会优先加载并导致 `0xc0000602`
- **保留/放回 bootstrap DLL**
- 必需:`Microsoft.WindowsAppRuntime.Bootstrap.Net.dll`
- 建议同时保留:`Microsoft.WindowsAppRuntime.Bootstrap.dll`
按以上处理后Settings 通过 AUMID 启动不再崩溃,日志写入恢复。
### 根本原因分析
1. **ms-appx:/// URI 机制**
- WinUI 3 使用 `ms-appx:///` URI 加载 XAML 资源
- 这个 URI scheme 依赖于 MSIX 包的资源索引系统
2. **框架资源位置**
- `themeresources.xaml` 等主题资源在 Windows App Runtime 框架包中
- 框架包位置:`C:\Program Files\WindowsApps\Microsoft.WindowsAppRuntime.2.0-experimental4_*\`(应与 WinAppSDK 版本匹配)
- 资源编译在框架包的 `resources.pri`
3. **WinAppSDK 版本/依赖不一致(更可能的原因)**
- 仓库当前引用 `Microsoft.WindowsAppSDK` **2.0.0-experimental4**(见 `Directory.Packages.props`
- Sparse manifest 仍依赖 **Microsoft.WindowsAppRuntime.2.0-experimental3**`MinVersion=0.676.658.0`
- 通过包标识启动时会走框架包资源图如果依赖版本不匹配WinUI 资源解析可能失败,从而触发上述 `XamlParseException`
- 需要先对齐依赖版本,再判断是否是 sparse 本身限制
4. **app-local 运行时在 sparse identity 下崩溃(已观测)**
- 即使对齐 WinAppSDK 版本,也可能在 `CoreMessagingXP.dll` 处崩溃(`0xc0000602`
- 此时 Settings 日志不一定写入,需查看 Application Event Log
4. **Sparse Package 的限制(待验证)**
- 之前推断 `ms-appx:///` 在 sparse package 下无法解析框架依赖资源
- 但在修正依赖版本之前无法下结论
### 对比WPF 应用可以工作
WPF 应用(如 ImageResizer使用 sparse package 时**可以正常工作**,因为:
- WPF 不依赖 `ms-appx:///` URI
- WPF 资源加载使用不同的机制
## 已尝试的解决方案
| 方案 | 结果 | 原因 |
|------|------|------|
| 复制 PRI 文件到根目录 | ❌ 失败 | `ms-appx:///` 不查找本地 PRI |
| 复制 themeresources 到本地 | ❌ 失败 | 资源在 PRI 中,不是独立文件 |
| 修改 Settings OutputPath 到根目录 | ❌ 失败 | 问题不在于应用资源位置 |
| 复制框架 resources.pri | ❌ 失败 | `ms-appx:///` 机制问题 |
| 对齐 WindowsAppRuntime 依赖版本 | ⏳ 待验证 | 先排除依赖不一致导致的资源解析失败 |
| app-local 运行时self-contained+ sparse identity | ❌ 失败 | Application Error: `CoreMessagingXP.dll` / `0xc0000602` |
| 移除 `CoreMessagingXP.dll` | ❌ 失败 | .NET Runtime 1026: `ClassFactory cannot supply requested class` |
| framework-dependent + bootstrap + 清理 ExternalLocation 中 app-local 运行时 | ✅ 成功 | 需保留 `Microsoft.WindowsAppRuntime.Bootstrap*.dll` |
| 将 resources.pri 打进 sparse MSIX | ✅ 成功 | MRT 可从包内 resources.pri 正常解析字符串 |
## 当前代码状态
### 已修正(建议保留)
1. **Settings.UI 输出路径**
- 文件:`src/settings-ui/Settings.UI/PowerToys.Settings.csproj`
- 修改:恢复为 `WinUI3Apps`(避免破坏 runner/installer/脚本路径假设)
2. **AppxManifest.xml 的 Executable 路径**
- 文件:`src/PackageIdentity/AppxManifest.xml`
- 修改:恢复为 `WinUI3Apps\PowerToys.Settings.exe`
3. **AppxManifest.xml 的 WindowsAppRuntime 依赖**
- 文件:`src/PackageIdentity/AppxManifest.xml`
- 修改:更新为 `Microsoft.WindowsAppRuntime.2.0-experimental4``MinVersion=0.738.2207.0`(与 `Microsoft.WindowsAppSDK.Runtime` 2.0.0-experimental4 对齐)
### 未修改(源码中保持生产配置)
- AppxManifest.xml 的 Publisher 保持 Microsoft Corporation脚本会自动替换
### 验证步骤(建议)
1. **确认 WindowsAppRuntime 版本已安装**
- `Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2.0-experimental4`
- 如缺失,可从 NuGet 缓存安装:
`Add-AppxPackage -Path "$env:USERPROFILE\.nuget\packages\microsoft.windowsappsdk.runtime\2.0.0-experimental4\tools\MSIX\win10-x64\Microsoft.WindowsAppRuntime.2.0-experimental4.msix"`
2. **构建并注册 sparse package**
- `.\src\PackageIdentity\BuildSparsePackage.ps1 -Platform x64 -Configuration Debug`
- `Add-AppxPackage -Path ".\x64\Debug\PowerToysSparse.msix" -ExternalLocation ".\x64\Debug"`
3. **用包标识启动 Settings**
- AUMID`Microsoft.PowerToys.SparseApp!PowerToys.SettingsUI`
- 预期:不再触发 `themeresources.xaml` 解析错误
## 可能的解决方向
### 方向 1等待 Windows App SDK 修复
- 可能是 Windows App SDK 的已知限制或 bug
- 需要在 GitHub issues 中搜索或提交新 issue
### 方向 2使用完整 MSIX 打包
- 不使用 sparse package而是完整打包
- 影响:改变部署模型,增加复杂性
### 方向 3创建非 WinUI 3 的 Helper 进程
- 创建一个 Console App 或 WPF App 作为 helper
- 该 helper 具有 package identity专门调用 Semantic Search API
- Settings 通过 IPC 与 helper 通信
- 优点:不影响现有 Settings 架构
### 方向 4进一步调查 ms-appx:/// 解析
- 研究是否有配置选项让 sparse package 正确解析框架资源
- 可能需要深入 Windows App SDK 源码或联系微软
### 方向 5切换为 framework-dependent + Bootstrap待验证
- 设置 `WindowsAppSDKSelfContained=false` 并**重新构建** Settings
- 确保外部目录不再携带 app-local WinAppSDK 运行时
-`Bootstrap.TryInitialize(...)` 生效,走框架包动态依赖
## 可复现的工作流(已验证 2026-01-25
目标Settings 使用 sparse identity 启动WinUI 资源/字符串正常加载。
### 1) 构建 Settingsframework-dependent + bootstrap no-op
`PowerToys.Settings.csproj` 中添加(仅在 `UseSparseIdentity=true` 时生效):
```
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
</PropertyGroup>
```
构建:
```
MSBuild.exe src\settings-ui\Settings.UI\PowerToys.Settings.csproj /p:Platform=ARM64 /p:Configuration=Debug /p:UseSparseIdentity=true /m:1 /p:CL_MPCount=1 /nodeReuse:false
```
### 2) 清理 ExternalLocation 的 app-local WinAppSDK 运行时
**必须移除** app-local WinAppSDK 运行时文件,否则会优先加载并崩溃(`CoreMessagingXP.dll` / `0xc0000602`)。
需清理的目录:
- `ARM64\Debug`ExternalLocation 根)
- `ARM64\Debug\WinUI3Apps`
建议只移除 app-local WinAppSDK 相关文件(保留业务 DLL
**保留/放回 bootstrap DLL必要**
- `Microsoft.WindowsAppRuntime.Bootstrap.dll`
- `Microsoft.WindowsAppRuntime.Bootstrap.Net.dll`
### 3) 生成与包名一致的 resources.pri
关键点resources.pri 的 **ResourceMap name 必须与包名一致**
使用 `makepri.exe new` 生成,确保 `/mn` 指向 sparse 包的 `AppxManifest.xml`
```
makepri.exe new ^
/pr C:\PowerToys\src\settings-ui\Settings.UI ^
/cf C:\PowerToys\src\settings-ui\Settings.UI\obj\ARM64\Debug\priconfig.xml ^
/mn C:\PowerToys\src\PackageIdentity\AppxManifest.xml ^
/of C:\PowerToys\ARM64\Debug\resources.pri ^
/o
```
### 4) 将 resources.pri 打进 sparse MSIX
`BuildSparsePackage.ps1` 中把 `resources.pri` 放入 staging脚本已更新
- 优先取 `ARM64\Debug\resources.pri`
- 如果不存在则回退 `ARM64\Debug\WinUI3Apps\PowerToys.Settings.pri`
重新打包:
```
.\src\PackageIdentity\BuildSparsePackage.ps1 -Platform ARM64 -Configuration Debug
```
### 5) 重新注册 sparse 包(如需先卸载)
如果因为内容变更被阻止,先卸载再安装:
```
Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Remove-AppxPackage
Add-AppxPackage -Path .\ARM64\Debug\PowerToysSparse.msix -ExternalLocation .\ARM64\Debug -ForceApplicationShutdown
```
### 6) 启动 Settings验证
```
Start-Process "shell:AppsFolder\Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8!PowerToys.SettingsUI"
```
验证要点:
- Settings 正常启动UI 文本显示
- 日志正常写入:`%LOCALAPPDATA%\Microsoft\PowerToys\Settings\Logs\0.0.1.0\`
### 备注(可选)
如果出现 `ms-appx:///CommunityToolkit...` 资源缺失,可将对应的 `.pri`(从 NuGet 缓存)复制到 `ARM64\Debug\WinUI3Apps`,但在 **resources.pri 已正确打包** 后通常不再需要。
## 待确认事项
1. [ ] WinUI 3 + Sparse Package 的兼容性问题是否有官方文档说明?
2. [ ] 是否有其他项目成功实现 WinUI 3 + Sparse Package
3. [ ] Windows App SDK GitHub 上是否有相关 issue
4. [ ] 修正依赖版本后Settings 是否能在 sparse identity 下正常启动?
5. [ ] framework-dependentBootstrap方式是否能在 sparse identity 下启动?
## 相关文件
- [SemanticSearchIndex.cs](../../src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs) - Semantic Search 实现
- [AppxManifest.xml](../../src/PackageIdentity/AppxManifest.xml) - Sparse package manifest
- [BuildSparsePackage.ps1](../../src/PackageIdentity/BuildSparsePackage.ps1) - 构建脚本
- [app.manifest](../../src/settings-ui/Settings.UI/app.manifest) - Settings 应用 manifest
- [PowerToys.Settings.csproj](../../src/settings-ui/Settings.UI/PowerToys.Settings.csproj) - Settings 项目文件
## 参考链接
- [Grant package identity by packaging with external location](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps)
- [Windows App SDK - Content Search API](https://learn.microsoft.com/en-us/windows/ai/apis/content-search)
- [Windows App SDK GitHub Issues](https://github.com/microsoft/WindowsAppSDK/issues)

View File

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

View File

@@ -29,6 +29,7 @@
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.26226.0" />
<PackageDependency Name="Microsoft.WindowsAppRuntime.2.0-experimental4" MinVersion="0.738.2207.0" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
</Dependencies>
<Capabilities>
<Capability Name="internetClient" />
@@ -80,6 +81,7 @@
<Extensions>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Microsoft.CmdPal.Ext.PowerToys.exe" Arguments="-RegisterProcessAsComServer" DisplayName="PowerToys Command Palette Extension">
<com:Class Id="7EC02C7D-8F98-4A2E-9F23-B58C2C2F2B17" DisplayName="PowerToys Command Palette Extension" />
</com:ExeServer>
@@ -105,5 +107,15 @@
</uap3:Extension>
</Extensions>
</Application>
<Application Id="PowerToys.SettingsSearchEvaluation" Executable="SettingsSearchEvaluation.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="PowerToys.SettingsSearchEvaluation"
Description="PowerToys Settings search evaluator"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
Square44x44Logo="Images\Square44x44Logo.png"
AppListEntry="none">
</uap:VisualElements>
</Application>
</Applications>
</Package>

View File

@@ -320,6 +320,19 @@ try {
}
}
}
# Include resources.pri in the sparse MSIX if available to enable MRT in packaged mode.
# Prefer a prebuilt resources.pri in output root; fall back to Settings pri.
$resourcesPriSource = Join-Path $outDir "resources.pri"
if (-not (Test-Path $resourcesPriSource)) {
$resourcesPriSource = Join-Path $outDir "WinUI3Apps\\PowerToys.Settings.pri"
}
if (Test-Path $resourcesPriSource) {
Copy-Item -Path $resourcesPriSource -Destination (Join-Path $stagingDir "resources.pri") -Force -ErrorAction SilentlyContinue
Write-BuildLog "Including resources.pri from: $resourcesPriSource" -Level Info
} else {
Write-BuildLog "resources.pri not found; strings may be missing in sparse identity." -Level Warning
}
# Ensure publisher matches the dev certificate for local builds
$manifestStagingPath = Join-Path $stagingDir 'AppxManifest.xml'

View File

@@ -4,5 +4,16 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS8305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ManagedCommon\ManagedCommon.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Represents a span of matched text for highlighting.
/// </summary>
/// <param name="Start">The starting index of the match.</param>
/// <param name="Length">The length of the match.</param>
public readonly record struct MatchSpan(int Start, int Length);

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Describes the capabilities of a search engine.
/// </summary>
public sealed class SearchEngineCapabilities
{
/// <summary>
/// Gets a value indicating whether the engine supports fuzzy matching.
/// </summary>
public bool SupportsFuzzyMatch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports semantic search.
/// </summary>
public bool SupportsSemanticSearch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine persists the index to disk.
/// </summary>
public bool PersistsIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports incremental indexing.
/// </summary>
public bool SupportsIncrementalIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports match span highlighting.
/// </summary>
public bool SupportsMatchSpans { get; init; }
}

View File

@@ -0,0 +1,134 @@
// 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 an error that occurred during a search operation.
/// </summary>
public sealed class SearchError
{
/// <summary>
/// Initializes a new instance of the <see cref="SearchError"/> class.
/// </summary>
/// <param name="code">The error code.</param>
/// <param name="message">The error message.</param>
/// <param name="details">Optional additional details.</param>
/// <param name="exception">Optional exception that caused the error.</param>
public SearchError(SearchErrorCode code, string message, string? details = null, Exception? exception = null)
{
Code = code;
Message = message;
Details = details;
Exception = exception;
Timestamp = DateTime.UtcNow;
}
/// <summary>
/// Gets the error code.
/// </summary>
public SearchErrorCode Code { get; }
/// <summary>
/// Gets the error message.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets additional details about the error.
/// </summary>
public string? Details { get; }
/// <summary>
/// Gets the exception that caused the error, if any.
/// </summary>
public Exception? Exception { get; }
/// <summary>
/// Gets the timestamp when the error occurred.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Creates an error for initialization failure.
/// </summary>
/// <param name="indexName">The name of the index.</param>
/// <param name="details">Optional details.</param>
/// <param name="exception">Optional exception.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError InitializationFailed(string indexName, string? details = null, Exception? exception = null)
=> new(SearchErrorCode.InitializationFailed, $"Failed to initialize search index '{indexName}'.", details, exception);
/// <summary>
/// Creates an error for indexing failure.
/// </summary>
/// <param name="contentId">The ID of the content that failed to index.</param>
/// <param name="details">Optional details.</param>
/// <param name="exception">Optional exception.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError IndexingFailed(string contentId, string? details = null, Exception? exception = null)
=> new(SearchErrorCode.IndexingFailed, $"Failed to index content '{contentId}'.", details, exception);
/// <summary>
/// Creates an error for search query failure.
/// </summary>
/// <param name="query">The search query that failed.</param>
/// <param name="details">Optional details.</param>
/// <param name="exception">Optional exception.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError SearchFailed(string query, string? details = null, Exception? exception = null)
=> new(SearchErrorCode.SearchFailed, $"Search query '{query}' failed.", details, exception);
/// <summary>
/// Creates an error for engine not ready.
/// </summary>
/// <param name="operation">The operation that was attempted.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError EngineNotReady(string operation)
=> new(SearchErrorCode.EngineNotReady, $"Search engine is not ready. Operation '{operation}' cannot be performed.");
/// <summary>
/// Creates an error for capability unavailable.
/// </summary>
/// <param name="capability">The capability that is unavailable.</param>
/// <param name="details">Optional details.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError CapabilityUnavailable(string capability, string? details = null)
=> new(SearchErrorCode.CapabilityUnavailable, $"Search capability '{capability}' is not available.", details);
/// <summary>
/// Creates an error for timeout.
/// </summary>
/// <param name="operation">The operation that timed out.</param>
/// <param name="timeout">The timeout duration.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError Timeout(string operation, TimeSpan timeout)
=> new(SearchErrorCode.Timeout, $"Operation '{operation}' timed out after {timeout.TotalSeconds:F1} seconds.");
/// <summary>
/// Creates an error for an unexpected error.
/// </summary>
/// <param name="operation">The operation that failed.</param>
/// <param name="exception">The exception that occurred.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError Unexpected(string operation, Exception exception)
=> new(SearchErrorCode.Unexpected, $"Unexpected error during '{operation}'.", exception.Message, exception);
/// <inheritdoc/>
public override string ToString()
{
var result = $"[{Code}] {Message}";
if (!string.IsNullOrEmpty(Details))
{
result += $" Details: {Details}";
}
if (Exception != null)
{
result += $" Exception: {Exception.GetType().Name}: {Exception.Message}";
}
return result;
}
}

View File

@@ -0,0 +1,51 @@
// 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 error codes for search operations.
/// </summary>
public enum SearchErrorCode
{
/// <summary>
/// No error occurred.
/// </summary>
None = 0,
/// <summary>
/// The search engine failed to initialize.
/// </summary>
InitializationFailed = 1,
/// <summary>
/// Failed to index content.
/// </summary>
IndexingFailed = 2,
/// <summary>
/// The search query failed to execute.
/// </summary>
SearchFailed = 3,
/// <summary>
/// The search engine is not ready to perform the operation.
/// </summary>
EngineNotReady = 4,
/// <summary>
/// A required capability is not available.
/// </summary>
CapabilityUnavailable = 5,
/// <summary>
/// The operation timed out.
/// </summary>
Timeout = 6,
/// <summary>
/// An unexpected error occurred.
/// </summary>
Unexpected = 99,
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Specifies the kind of match that produced a search result.
/// </summary>
public enum SearchMatchKind
{
/// <summary>
/// Exact text match.
/// </summary>
Exact,
/// <summary>
/// Fuzzy/approximate text match.
/// </summary>
Fuzzy,
/// <summary>
/// Semantic/AI-based match.
/// </summary>
Semantic,
/// <summary>
/// Combined match from multiple engines.
/// </summary>
Composite,
}

View File

@@ -0,0 +1,53 @@
// 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 the result of a search operation that may have errors.
/// </summary>
public sealed class SearchOperationResult
{
private SearchOperationResult(bool success, SearchError? error = null)
{
IsSuccess = success;
Error = error;
}
/// <summary>
/// Gets a value indicating whether the operation was successful.
/// </summary>
public bool IsSuccess { get; }
/// <summary>
/// Gets a value indicating whether the operation failed.
/// </summary>
public bool IsFailure => !IsSuccess;
/// <summary>
/// Gets the error if the operation failed, null otherwise.
/// </summary>
public SearchError? Error { get; }
/// <summary>
/// Creates a successful result.
/// </summary>
/// <returns>A successful SearchOperationResult.</returns>
public static SearchOperationResult Success() => new(true);
/// <summary>
/// Creates a failed result with the specified error.
/// </summary>
/// <param name="error">The error that caused the failure.</param>
/// <returns>A failed SearchOperationResult.</returns>
public static SearchOperationResult Failure(SearchError error)
{
ArgumentNullException.ThrowIfNull(error);
return new SearchOperationResult(false, error);
}
/// <inheritdoc/>
public override string ToString()
=> IsSuccess ? "Success" : $"Failure: {Error}";
}

View File

@@ -0,0 +1,87 @@
// 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.Diagnostics.CodeAnalysis;
#pragma warning disable SA1649 // File name should match first type name - Generic type file naming convention
namespace Common.Search;
/// <summary>
/// Represents the result of a search operation that returns a value and may have errors.
/// </summary>
/// <typeparam name="T">The type of the result value.</typeparam>
public sealed class SearchOperationResult<T>
{
private SearchOperationResult(bool success, T? value, SearchError? error)
{
IsSuccess = success;
Value = value;
Error = error;
}
/// <summary>
/// Gets a value indicating whether the operation was successful.
/// </summary>
public bool IsSuccess { get; }
/// <summary>
/// Gets a value indicating whether the operation failed.
/// </summary>
public bool IsFailure => !IsSuccess;
/// <summary>
/// Gets the result value if the operation was successful.
/// </summary>
public T? Value { get; }
/// <summary>
/// Gets the error if the operation failed, null otherwise.
/// </summary>
public SearchError? Error { get; }
/// <summary>
/// Gets the value or a default if the operation failed.
/// </summary>
/// <param name="defaultValue">The default value to return if the operation failed.</param>
/// <returns>The value if successful, otherwise the default value.</returns>
public T GetValueOrDefault(T defaultValue) => IsSuccess && Value is not null ? Value : defaultValue;
/// <inheritdoc/>
public override string ToString()
=> IsSuccess ? $"Success: {Value}" : $"Failure: {Error}";
/// <summary>
/// Creates a successful result with the specified value.
/// </summary>
/// <param name="value">The result value.</param>
/// <returns>A successful SearchOperationResult.</returns>
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
public static SearchOperationResult<T> Success(T value) => new(true, value, null);
/// <summary>
/// Creates a failed result with the specified error.
/// </summary>
/// <param name="error">The error that caused the failure.</param>
/// <returns>A failed SearchOperationResult.</returns>
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
public static SearchOperationResult<T> Failure(SearchError error)
{
ArgumentNullException.ThrowIfNull(error);
return new SearchOperationResult<T>(false, default, error);
}
/// <summary>
/// Creates a failed result with the specified error and a fallback value.
/// </summary>
/// <param name="error">The error that caused the failure.</param>
/// <param name="fallbackValue">A fallback value to use despite the failure.</param>
/// <returns>A failed SearchOperationResult with a fallback value.</returns>
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
public static SearchOperationResult<T> FailureWithFallback(SearchError error, T fallbackValue)
{
ArgumentNullException.ThrowIfNull(error);
return new SearchOperationResult<T>(false, fallbackValue, error);
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Options for configuring search behavior.
/// </summary>
public sealed class SearchOptions
{
/// <summary>
/// Gets or sets the maximum number of results to return.
/// Default is 20.
/// </summary>
public int MaxResults { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum score threshold.
/// Results below this score are filtered out.
/// Default is 0.0 (no filtering).
/// </summary>
public double MinScore { get; set; }
/// <summary>
/// Gets or sets the language hint for the search (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include match spans for highlighting.
/// Default is false.
/// </summary>
public bool IncludeMatchSpans { get; set; }
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Represents a search result with the matched item and scoring information.
/// </summary>
/// <typeparam name="T">The type of the matched item.</typeparam>
public sealed class SearchResult<T>
where T : ISearchable
{
/// <summary>
/// Gets the matched item.
/// </summary>
public required T Item { get; init; }
/// <summary>
/// Gets the relevance score (higher is more relevant).
/// </summary>
public required double Score { get; init; }
/// <summary>
/// Gets the type of match that produced this result.
/// </summary>
public required SearchMatchKind MatchKind { get; init; }
/// <summary>
/// Gets the match details for highlighting (optional).
/// </summary>
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Represents the capabilities of the semantic search index.
/// </summary>
public class SemanticSearchCapabilities
{
/// <summary>
/// Gets or sets a value indicating whether text lexical (keyword) search is available.
/// </summary>
public bool TextLexicalAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether text semantic (AI embedding) search is available.
/// </summary>
public bool TextSemanticAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image semantic search is available.
/// </summary>
public bool ImageSemanticAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image OCR search is available.
/// </summary>
public bool ImageOcrAvailable { get; set; }
/// <summary>
/// Gets a value indicating whether any search capability is available.
/// </summary>
public bool AnyAvailable => TextLexicalAvailable || TextSemanticAvailable || ImageSemanticAvailable || ImageOcrAvailable;
/// <summary>
/// Gets a value indicating whether text search (lexical or semantic) is available.
/// </summary>
public bool TextSearchAvailable => TextLexicalAvailable || TextSemanticAvailable;
/// <summary>
/// Gets a value indicating whether image search (semantic or OCR) is available.
/// </summary>
public bool ImageSearchAvailable => ImageSemanticAvailable || ImageOcrAvailable;
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Specifies the kind of content in a semantic search result.
/// </summary>
public enum SemanticSearchContentKind
{
/// <summary>
/// Text content.
/// </summary>
Text,
/// <summary>
/// Image content.
/// </summary>
Image,
}

View File

@@ -0,0 +1,406 @@
// 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 ManagedCommon;
namespace Common.Search.SemanticSearch;
/// <summary>
/// A semantic search engine that implements the common search interface.
/// </summary>
/// <typeparam name="T">The type of items to search.</typeparam>
public sealed class SemanticSearchEngine<T> : ISearchEngine<T>
where T : ISearchable
{
private readonly SemanticSearchIndex _index;
private readonly Dictionary<string, T> _itemsById = new();
private readonly object _lockObject = new();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchEngine{T}"/> class.
/// </summary>
/// <param name="indexName">The name of the search index.</param>
public SemanticSearchEngine(string indexName)
{
Logger.LogDebug($"[SemanticSearchEngine] Creating engine. IndexName={indexName}, ItemType={typeof(T).Name}");
_index = new SemanticSearchIndex(indexName);
}
/// <inheritdoc/>
public bool IsReady => _index.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 => _index.Capabilities;
/// <summary>
/// Gets the last error that occurred during a search operation, or null if no error occurred.
/// </summary>
public SearchError? LastError => _index.LastError;
/// <summary>
/// Occurs when the semantic search capabilities change.
/// </summary>
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged
{
add => _index.CapabilitiesChanged += value;
remove => _index.CapabilitiesChanged -= value;
}
/// <inheritdoc/>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync starting. ItemType={typeof(T).Name}");
var result = await _index.InitializeAsync().ConfigureAwait(false);
if (result.IsFailure)
{
Logger.LogWarning($"[SemanticSearchEngine] InitializeAsync failed. ItemType={typeof(T).Name}, Error={result.Error?.Message}");
}
else
{
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync completed. ItemType={typeof(T).Name}");
}
// Note: We don't throw here to maintain backward compatibility,
// but callers can check LastError for details if initialization failed.
}
/// <summary>
/// Initializes the search engine and returns the result with error details if any.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public async Task<SearchOperationResult> InitializeWithResultAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return await _index.InitializeAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
var text = BuildSearchableText(item);
if (string.IsNullOrWhiteSpace(text))
{
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync skipped (empty text). Id={item.Id}");
return Task.CompletedTask;
}
lock (_lockObject)
{
_itemsById[item.Id] = item;
}
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync. Id={item.Id}, TextLength={text.Length}");
// Note: Errors are captured in LastError for external logging
_ = _index.IndexText(item.Id, text);
return Task.CompletedTask;
}
/// <summary>
/// Indexes a single item and returns the result with error details if any.
/// </summary>
/// <param name="item">The item to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public Task<SearchOperationResult> IndexWithResultAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
var text = BuildSearchableText(item);
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult(SearchOperationResult.Success());
}
lock (_lockObject)
{
_itemsById[item.Id] = item;
}
return Task.FromResult(_index.IndexText(item.Id, text));
}
/// <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)
{
Logger.LogDebug($"[SemanticSearchEngine] IndexBatchAsync cancelled. ItemsProcessed={batch.Count}");
break;
}
var text = BuildSearchableText(item);
if (!string.IsNullOrWhiteSpace(text))
{
_itemsById[item.Id] = item;
batch.Add((item.Id, text));
}
}
}
Logger.LogInfo($"[SemanticSearchEngine] IndexBatchAsync. BatchSize={batch.Count}");
// Note: Errors are captured in LastError for external logging
_ = _index.IndexTextBatch(batch);
return Task.CompletedTask;
}
/// <summary>
/// Indexes multiple items in batch and returns the result with error details if any.
/// </summary>
/// <param name="items">The items to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public Task<SearchOperationResult> IndexBatchWithResultAsync(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));
}
}
}
return Task.FromResult(_index.IndexTextBatch(batch));
}
/// <inheritdoc/>
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Remove(id);
}
Logger.LogDebug($"[SemanticSearchEngine] RemoveAsync. Id={id}");
// Note: Errors are captured in LastError for external logging
_ = _index.Remove(id);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task ClearAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
int count;
lock (_lockObject)
{
count = _itemsById.Count;
_itemsById.Clear();
}
Logger.LogInfo($"[SemanticSearchEngine] ClearAsync. ItemsCleared={count}");
// Note: Errors are captured in LastError for external logging
_ = _index.RemoveAll();
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync skipped (empty query).");
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
options ??= new SearchOptions();
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync starting. Query={query}, MaxResults={options.MaxResults}");
var semanticOptions = new SemanticSearchOptions
{
MaxResults = options.MaxResults,
Language = options.Language,
MatchScope = SemanticSearchMatchScope.Unconstrained,
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
};
var searchResult = _index.SearchText(query, semanticOptions);
// Note: Errors are captured in LastError for external logging
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
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,
});
}
}
}
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync completed. Query={query}, Matches={matches.Count}, Results={results.Count}");
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
}
/// <summary>
/// Searches for items matching the query and returns the result with error details if any.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="options">Optional search options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result containing search results or error details.</returns>
public Task<SearchOperationResult<IReadOnlyList<SearchResult<T>>>> SearchWithResultAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(Array.Empty<SearchResult<T>>()));
}
options ??= new SearchOptions();
var semanticOptions = new SemanticSearchOptions
{
MaxResults = options.MaxResults,
Language = options.Language,
MatchScope = SemanticSearchMatchScope.Unconstrained,
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
};
var searchResult = _index.SearchText(query, semanticOptions);
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
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,
MatchKind = SearchMatchKind.Semantic,
MatchSpans = null,
});
}
}
}
if (searchResult.IsFailure)
{
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.FailureWithFallback(searchResult.Error!, results));
}
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(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 _index.WaitForIndexingCompleteAsync(timeout).ConfigureAwait(false);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
Logger.LogDebug($"[SemanticSearchEngine] Disposing. ItemType={typeof(T).Name}");
_index.Dispose();
lock (_lockObject)
{
_itemsById.Clear();
}
_disposed = true;
}
private static string BuildSearchableText(T item)
{
var primary = item.SearchableText ?? string.Empty;
var secondary = item.SecondarySearchableText;
if (string.IsNullOrWhiteSpace(secondary))
{
return primary;
}
return $"{primary} {secondary}";
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}

View File

@@ -0,0 +1,455 @@
// 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 ManagedCommon;
using Microsoft.Windows.Search.AppContentIndex;
using Windows.Graphics.Imaging;
using SearchOperationResult = Common.Search.SearchOperationResult;
using SearchOperationResultT = Common.Search.SearchOperationResult<System.Collections.Generic.IReadOnlyList<Common.Search.SemanticSearch.SemanticSearchResult>>;
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 SemanticSearchIndex : IDisposable
{
private readonly string _indexName;
private AppContentIndexer? _indexer;
private bool _disposed;
private SemanticSearchCapabilities? _capabilities;
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchIndex"/> class.
/// </summary>
/// <param name="indexName">The name of the search index.</param>
public SemanticSearchIndex(string indexName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(indexName);
_indexName = indexName;
}
/// <summary>
/// Gets the last error that occurred during an operation, or null if no error occurred.
/// </summary>
public SearchError? LastError { get; private set; }
/// <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 a result indicating success or failure with error details.</returns>
public async Task<SearchOperationResult> InitializeAsync()
{
ThrowIfDisposed();
LastError = null;
if (_indexer != null)
{
Logger.LogDebug($"[SemanticSearchIndex] Already initialized. IndexName={_indexName}");
return SearchOperationResult.Success();
}
Logger.LogInfo($"[SemanticSearchIndex] Initializing. IndexName={_indexName}");
try
{
var result = AppContentIndexer.GetOrCreateIndex(_indexName);
if (!result.Succeeded)
{
var errorDetails = $"Succeeded={result.Succeeded}, ExtendedError={result.ExtendedError}";
Logger.LogError($"[SemanticSearchIndex] GetOrCreateIndex failed. IndexName={_indexName}, {errorDetails}");
LastError = SearchError.InitializationFailed(_indexName, errorDetails);
return SearchOperationResult.Failure(LastError);
}
_indexer = result.Indexer;
// Wait for index capabilities to be ready
Logger.LogDebug($"[SemanticSearchIndex] Waiting for index capabilities. IndexName={_indexName}");
await _indexer.WaitForIndexCapabilitiesAsync();
// Load capabilities
_capabilities = LoadCapabilities();
Logger.LogInfo($"[SemanticSearchIndex] Initialized successfully. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
// Subscribe to capability changes
_indexer.Listener.IndexCapabilitiesChanged += OnIndexCapabilitiesChanged;
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] Initialization failed with exception. IndexName={_indexName}", ex);
LastError = SearchError.InitializationFailed(_indexName, ex.Message, ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <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>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult IndexText(string id, string text)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(text);
try
{
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer!.AddOrUpdate(content);
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] IndexText failed. Id={id}", ex);
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Adds or updates multiple text contents in the index.
/// </summary>
/// <param name="items">A collection of id-text pairs to index.</param>
/// <returns>A result indicating success or failure with error details. Contains the first error encountered if any.</returns>
public SearchOperationResult IndexTextBatch(IEnumerable<(string Id, string Text)> items)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentNullException.ThrowIfNull(items);
SearchError? firstError = null;
foreach (var (id, text) in items)
{
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(text))
{
try
{
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer!.AddOrUpdate(content);
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] IndexTextBatch item failed. Id={id}", ex);
firstError ??= SearchError.IndexingFailed(id, ex.Message, ex);
}
}
}
if (firstError != null)
{
LastError = firstError;
return SearchOperationResult.Failure(firstError);
}
return SearchOperationResult.Success();
}
/// <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>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult IndexImage(string id, SoftwareBitmap bitmap)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(bitmap);
try
{
var content = AppManagedIndexableAppContent.CreateFromBitmap(id, bitmap);
_indexer!.AddOrUpdate(content);
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] IndexImage failed. Id={id}", ex);
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Removes content from the index by its identifier.
/// </summary>
/// <param name="id">The unique identifier of the content to remove.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult Remove(string id)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
try
{
_indexer!.Remove(id);
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] Remove failed. Id={id}", ex);
LastError = SearchError.Unexpected("Remove", ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Removes all content from the index.
/// </summary>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult RemoveAll()
{
ThrowIfDisposed();
ThrowIfNotInitialized();
try
{
_indexer!.RemoveAll();
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] RemoveAll failed.", ex);
LastError = SearchError.Unexpected("RemoveAll", ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <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 result containing search results or error details.</returns>
public SearchOperationResultT SearchText(string searchText, SemanticSearchOptions? options = null)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
options ??= new SemanticSearchOptions();
try
{
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(options.MaxResults);
return SearchOperationResultT.Success(ConvertTextMatches(matches));
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] SearchText failed. Query={searchText}", ex);
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
}
}
/// <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 result containing search results or error details.</returns>
public SearchOperationResultT SearchImages(string searchText, SemanticSearchOptions? options = null)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
options ??= new SemanticSearchOptions();
try
{
var queryOptions = new ImageQueryOptions
{
MatchScope = ConvertMatchScope(options.MatchScope),
ImageOcrTextMatchType = ConvertTextMatchType(options.TextMatchType),
};
var query = _indexer!.CreateImageQuery(searchText, queryOptions);
var matches = query.GetNextMatches(options.MaxResults);
return SearchOperationResultT.Success(ConvertImageMatches(matches));
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] SearchImages failed. Query={searchText}", ex);
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
}
}
/// <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();
Logger.LogInfo($"[SemanticSearchIndex] Capabilities changed. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
CapabilitiesChanged?.Invoke(this, _capabilities);
}
private static QueryMatchScope ConvertMatchScope(SemanticSearchMatchScope scope)
{
return scope switch
{
SemanticSearchMatchScope.Unconstrained => QueryMatchScope.Unconstrained,
SemanticSearchMatchScope.Region => QueryMatchScope.Region,
SemanticSearchMatchScope.ContentItem => QueryMatchScope.ContentItem,
_ => QueryMatchScope.Unconstrained,
};
}
private static TextLexicalMatchType ConvertTextMatchType(SemanticSearchTextMatchType matchType)
{
return matchType switch
{
SemanticSearchTextMatchType.Fuzzy => TextLexicalMatchType.Fuzzy,
SemanticSearchTextMatchType.Exact => TextLexicalMatchType.Exact,
_ => TextLexicalMatchType.Fuzzy,
};
}
private static IReadOnlyList<SemanticSearchResult> ConvertTextMatches(IReadOnlyList<TextQueryMatch> matches)
{
var results = new List<SemanticSearchResult>();
foreach (var match in matches)
{
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Text);
if (match.ContentKind == QueryMatchContentKind.AppManagedText &&
match is AppManagedTextQueryMatch textMatch)
{
result.TextOffset = textMatch.TextOffset;
result.TextLength = textMatch.TextLength;
}
results.Add(result);
}
return results;
}
private static IReadOnlyList<SemanticSearchResult> ConvertImageMatches(IReadOnlyList<ImageQueryMatch> matches)
{
var results = new List<SemanticSearchResult>();
foreach (var match in matches)
{
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Image);
results.Add(result);
}
return results;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private void ThrowIfNotInitialized()
{
if (_indexer == null)
{
throw new InvalidOperationException("Search engine is not initialized. Call InitializeAsync() first.");
}
}
}

View File

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

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Options for configuring semantic search queries.
/// </summary>
public class SemanticSearchOptions
{
/// <summary>
/// Gets or sets the language for the search query (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets the match scope for the search.
/// </summary>
public SemanticSearchMatchScope MatchScope { get; set; } = SemanticSearchMatchScope.Unconstrained;
/// <summary>
/// Gets or sets the text match type for lexical matching.
/// </summary>
public SemanticSearchTextMatchType TextMatchType { get; set; } = SemanticSearchTextMatchType.Fuzzy;
/// <summary>
/// Gets or sets the maximum number of results to return.
/// </summary>
public int MaxResults { get; set; } = 10;
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Represents a search result from the semantic search engine.
/// </summary>
public class SemanticSearchResult
{
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchResult"/> class.
/// </summary>
/// <param name="contentId">The unique identifier of the matched content.</param>
/// <param name="contentKind">The kind of content matched (text or image).</param>
public SemanticSearchResult(string contentId, SemanticSearchContentKind contentKind)
{
ContentId = contentId;
ContentKind = contentKind;
}
/// <summary>
/// Gets the unique identifier of the matched content.
/// </summary>
public string ContentId { get; }
/// <summary>
/// Gets the kind of content that was matched.
/// </summary>
public SemanticSearchContentKind ContentKind { get; }
/// <summary>
/// Gets or sets the text offset where the match was found (for text matches only).
/// </summary>
public int TextOffset { get; set; }
/// <summary>
/// Gets or sets the length of the matched text (for text matches only).
/// </summary>
public int TextLength { get; set; }
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Specifies the type of text matching for lexical searches.
/// </summary>
public enum SemanticSearchTextMatchType
{
/// <summary>
/// Fuzzy matching allows spelling errors and approximate words.
/// </summary>
Fuzzy,
/// <summary>
/// Exact matching requires exact text matches.
/// </summary>
Exact,
}

View File

@@ -614,166 +614,4 @@ void AlwaysOnTop::RefreshBorders()
}
}
}
}
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
{
if (!window || !IsWindow(window))
{
return nullptr;
}
// Only allow transparency changes on pinned windows
if (!IsPinned(window))
{
return nullptr;
}
return window;
}
void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
{
HWND targetWindow = ResolveTransparencyTargetWindow(window);
if (!targetWindow)
{
return;
}
int currentTransparency = Settings::maxTransparencyPercentage;
LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE);
if (exStyle & WS_EX_LAYERED)
{
BYTE alpha = 255;
if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr))
{
currentTransparency = (alpha * 100) / 255;
}
}
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
if (newTransparency != currentTransparency)
{
ApplyWindowAlpha(targetWindow, newTransparency);
if (AlwaysOnTopSettings::settings().enableSound)
{
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
}
Logger::debug(L"Transparency adjusted to {}%", newTransparency);
}
}
void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage)
{
if (!window || !IsWindow(window))
{
return;
}
percentage = (std::max)(Settings::minTransparencyPercentage,
(std::min)(Settings::maxTransparencyPercentage, percentage));
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
// Cache original state on first transparency application
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
{
WindowLayeredState state;
state.hadLayeredStyle = isCurrentlyLayered;
if (isCurrentlyLayered)
{
BYTE alpha = 255;
COLORREF colorKey = 0;
DWORD flags = 0;
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
{
state.originalAlpha = alpha;
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
state.colorKey = colorKey;
}
else
{
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
return;
}
}
m_windowOriginalLayeredState[window] = state;
}
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
if (isCurrentlyLayered)
{
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
exStyle = GetWindowLong(window, GWL_EXSTYLE);
}
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
}
void AlwaysOnTop::RestoreWindowAlpha(HWND window)
{
if (!window || !IsWindow(window))
{
return;
}
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
auto it = m_windowOriginalLayeredState.find(window);
if (it != m_windowOriginalLayeredState.end())
{
const auto& originalState = it->second;
if (originalState.hadLayeredStyle)
{
// Window originally had WS_EX_LAYERED - restore original attributes
// Clear and re-add to ensure clean state
if (exStyle & WS_EX_LAYERED)
{
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
exStyle = GetWindowLong(window, GWL_EXSTYLE);
}
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
// Restore original alpha and/or color key
DWORD flags = LWA_ALPHA;
if (originalState.usedColorKey)
{
flags |= LWA_COLORKEY;
}
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
else
{
// Window originally didn't have WS_EX_LAYERED - remove it completely
if (exStyle & WS_EX_LAYERED)
{
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
}
m_windowOriginalLayeredState.erase(it);
}
else
{
// Fallback: no cached state, just remove layered style
if (exStyle & WS_EX_LAYERED)
{
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
}
}
}

View File

@@ -5,12 +5,12 @@
<ItemGroup>
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />

View File

@@ -8,6 +8,7 @@
IsMaximizable="False"
IsMinimizable="False"
IsResizable="False"
IsShownInSwitchers="False"
IsTitleBarVisible="False"
mc:Ignorable="d">
<Grid Background="#1A000000">

View File

@@ -2,7 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.UI.Windowing;
using Windows.Graphics;
@@ -25,17 +24,6 @@ namespace PowerDisplay.PowerDisplayXAML
{
InitializeComponent();
NumberText.Text = displayText;
try
{
this.SetIsShownInSwitchers(false);
}
catch (NotImplementedException)
{
// WinUI will throw if explorer is not running, safely ignore
}
catch (Exception)
{
}
// Configure window style
ConfigureWindow();

View File

@@ -15,6 +15,7 @@
IsMaximizable="False"
IsMinimizable="False"
IsResizable="False"
IsShownInSwitchers="False"
IsTitleBarVisible="False">
<winuiex:WindowEx.SystemBackdrop>
<DesktopAcrylicBackdrop />

View File

@@ -71,10 +71,6 @@ namespace PowerDisplay
_hotkeyService.Initialize(this);
Logger.LogTrace("MainWindow constructor: HotkeyService initialized");
Logger.LogTrace("MainWindow constructor: Setting IsShownInSwitchers property");
this.SetIsShownInSwitchers(false);
Logger.LogTrace("MainWindow constructor: Set IsShownInSwitchers property successfully");
// Note: ViewModel handles all async initialization internally.
// We listen to InitializationCompleted event to know when data is ready.
// No duplicate initialization here - single responsibility in ViewModel.

View File

@@ -7,6 +7,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<UseWindowsForms>true</UseWindowsForms>
<!-- Force classic WebView2 managed references (Core/WinForms/Wpf) instead of CsWinRT projection. -->
<WebView2EnableCsWinRTProjection>false</WebView2EnableCsWinRTProjection>
<AssemblyTitle>PowerToys.SvgPreviewHandler</AssemblyTitle>
<AssemblyDescription>PowerToys SvgPreviewHandler</AssemblyDescription>
<Description>PowerToys SvgPreviewHandler</Description>

View File

@@ -3,6 +3,7 @@
#include <Sddl.h>
#include <sstream>
#include <aclapi.h>
#include <shobjidl.h>
#include "powertoy_module.h"
#include <common/interop/two_way_pipe_message_ipc.h>
@@ -64,6 +65,74 @@ json::JsonObject get_all_settings()
return result;
}
namespace
{
constexpr wchar_t SettingsApplicationId[] = L"PowerToys.SettingsUI";
constexpr wchar_t SparseAppFamilyDev[] = L"Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8";
constexpr wchar_t SparseAppFamilyStore[] = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe";
bool try_activate_settings_with_identity(const std::wstring& arguments, PROCESS_INFORMATION& process_info)
{
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
const bool should_uninit = SUCCEEDED(hr);
if (hr == RPC_E_CHANGED_MODE)
{
hr = S_OK;
}
if (FAILED(hr))
{
Logger::warn(L"Settings: CoInitializeEx failed. hr=0x{:x}", hr);
return false;
}
IApplicationActivationManager* activation_manager = nullptr;
hr = CoCreateInstance(CLSID_ApplicationActivationManager,
nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&activation_manager));
if (FAILED(hr))
{
Logger::warn(L"Settings: CoCreateInstance(ApplicationActivationManager) failed. hr=0x{:x}", hr);
if (should_uninit)
{
CoUninitialize();
}
return false;
}
auto try_activate = [&](const wchar_t* family) -> bool {
std::wstring aumid = std::wstring(family) + L"!" + SettingsApplicationId;
DWORD pid = 0;
HRESULT hr_activate = activation_manager->ActivateApplication(aumid.c_str(),
arguments.c_str(),
AO_NONE,
&pid);
if (SUCCEEDED(hr_activate) && pid != 0)
{
process_info = {};
process_info.dwProcessId = pid;
process_info.hProcess = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
Logger::info(L"Settings: Activated via AUMID {} (pid {}).", aumid, pid);
return true;
}
Logger::warn(L"Settings: ActivateApplication failed for {}. hr=0x{:x}", aumid, hr_activate);
return false;
};
const bool activated = try_activate(SparseAppFamilyDev) || try_activate(SparseAppFamilyStore);
activation_manager->Release();
if (should_uninit)
{
CoUninitialize();
}
return activated;
}
}
std::optional<std::wstring> dispatch_json_action_to_module(const json::JsonObject& powertoys_configs)
{
std::optional<std::wstring> result;
@@ -522,15 +591,34 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op
settings_showOobe,
settings_showScoobe,
settings_containsSettingsWindow);
std::wstring activation_args = fmt::format(L"{} {} {} {} {} {} {} {} {}",
powertoys_pipe_name,
settings_pipe_name,
std::to_wstring(powertoys_pid),
settings_theme,
settings_elevatedStatus,
settings_isUserAnAdmin,
settings_showOobe,
settings_showScoobe,
settings_containsSettingsWindow);
if (settings_window.has_value())
{
executable_args.append(L" ");
executable_args.append(settings_window.value());
activation_args.append(L" ");
activation_args.append(settings_window.value());
}
BOOL process_created = false;
// Prefer activating via package identity so the package graph (framework deps) is applied.
if (try_activate_settings_with_identity(activation_args, process_info))
{
process_created = true;
g_isLaunchInProgress = false;
}
// Commented out to fix #22659
// Running settings non-elevated and modules elevated when PowerToys is running elevated results
// in settings making changes in one file (non-elevated user dir) and modules are reading settings

View File

@@ -2,6 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using Common.Search;
namespace Settings.UI.Library
{
public enum EntryType
@@ -11,7 +15,7 @@ namespace Settings.UI.Library
SettingsExpander,
}
public struct SettingEntry
public struct SettingEntry : ISearchable
{
public EntryType Type { get; set; }
@@ -29,16 +33,23 @@ namespace Settings.UI.Library
public string Icon { get; set; }
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null)
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string? parentElementName = null, string? description = null, string? icon = null)
{
Type = type;
Header = header;
PageTypeName = pageTypeName;
ElementName = elementName;
ElementUid = elementUid;
ParentElementName = parentElementName;
Description = description;
Icon = icon;
ParentElementName = parentElementName ?? string.Empty;
Description = description ?? string.Empty;
Icon = icon ?? string.Empty;
}
// ISearchable implementation
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public readonly string SearchableText => Header ?? string.Empty;
public readonly string? SecondarySearchableText => Description;
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System.Collections.Generic;
using Common.Search;
namespace Settings.UI.Library
{
/// <summary>
/// Represents a search result for a settings entry with scoring metadata.
/// </summary>
public sealed class SettingSearchResult
{
public required SettingEntry Entry { get; init; }
public required double Score { get; init; }
public required SearchMatchKind MatchKind { get; init; }
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}
}

View File

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

View File

@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Common.Search;
using Common.Search.FuzzSearch;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.UnitTests.Services
{
[TestClass]
[DoNotParallelize]
public class SettingsSearchTests
{
[TestMethod]
public void LoadIndexFromJson_ReturnsEntries()
{
const string json = @"
[
{
""type"": 0,
""header"": ""General"",
""pageTypeName"": ""GeneralPage"",
""elementName"": """",
""elementUid"": ""General_Page"",
""parentElementName"": """",
""description"": ""General settings"",
""icon"": """"
},
{
""type"": 1,
""header"": ""Mouse Utilities"",
""pageTypeName"": ""MouseUtilsPage"",
""elementName"": ""MouseUtilsSetting"",
""elementUid"": ""MouseUtils_Setting"",
""parentElementName"": """",
""description"": ""Adjust mouse settings"",
""icon"": """"
}
]
";
var entries = SettingsSearch.LoadIndexFromJson(json);
Assert.AreEqual(2, entries.Count);
Assert.AreEqual(EntryType.SettingsPage, entries[0].Type);
Assert.AreEqual("GeneralPage", entries[0].PageTypeName);
Assert.AreEqual("MouseUtilsPage", entries[1].PageTypeName);
}
[TestMethod]
public async Task InitializeIndexAsync_InitializesSearchAndReturnsResults()
{
var entries = new List<SettingEntry>
{
new(EntryType.SettingsPage, "General", "GeneralPage", string.Empty, "General_Page"),
new(EntryType.SettingsCard, "Mouse Utilities", "MouseUtilsPage", "MouseUtilsSetting", "MouseUtils_Setting", description: "Adjust mouse settings"),
};
using var search = new SettingsSearch(new FuzzSearchEngine<SettingEntry>());
await search.InitializeIndexAsync(entries);
Assert.IsTrue(search.IsReady);
var results = await search.SearchAsync("mouse", options: null);
Assert.IsTrue(results.Count > 0);
Assert.AreEqual("Mouse Utilities", results[0].Entry.Header);
Assert.IsTrue(results[0].Score > 0);
Assert.IsNotNull(results[0].MatchSpans);
}
[TestMethod]
public async Task SearchAsync_UsesFallbackEngine_WhenPrimaryInitializationFails()
{
var entries = new List<SettingEntry>
{
new(EntryType.SettingsPage, "General", "GeneralPage", string.Empty, "General_Page"),
new(EntryType.SettingsCard, "Mouse Utilities", "MouseUtilsPage", "MouseUtilsSetting", "MouseUtils_Setting", description: "Adjust mouse settings"),
};
using var search = new SettingsSearch(new FailingSearchEngine(), new FuzzSearchEngine<SettingEntry>());
await search.InitializeIndexAsync(entries);
// Primary engine should remain unavailable.
Assert.IsFalse(search.IsReady);
// Fallback engine should still return useful results.
var results = await search.SearchAsync("mouse", options: null);
Assert.IsTrue(results.Count > 0);
Assert.AreEqual("Mouse Utilities", results[0].Entry.Header);
}
private sealed class FailingSearchEngine : ISearchEngine<SettingEntry>
{
public bool IsReady => false;
public SearchEngineCapabilities Capabilities { get; } = new();
public Task ClearAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public void Dispose()
{
}
public Task IndexAsync(SettingEntry item, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task IndexBatchAsync(IEnumerable<SettingEntry> items, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task InitializeAsync(CancellationToken cancellationToken = default)
{
throw new System.InvalidOperationException("Primary engine unavailable in test.");
}
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task<IReadOnlyList<SearchResult<SettingEntry>>> SearchAsync(string query, SearchOptions options = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<SearchResult<SettingEntry>>>(new List<SearchResult<SettingEntry>>());
}
}
}
}

View File

@@ -3,6 +3,10 @@
<Import Project="..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\Common.SelfContained.props" />
<PropertyGroup Condition="'$(UseSparseIdentity)'==''">
<UseSparseIdentity>true</UseSparseIdentity>
</PropertyGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>Microsoft.PowerToys.Settings.UI</RootNamespace>
@@ -14,11 +18,15 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<ApplicationIcon>Assets\Settings\icon.ico</ApplicationIcon>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<!-- OutputPath looks like this because it has to be called both by settings and publish.cmd -->
<OutputPath>..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>PowerToys.Settings.pri</ProjectPriFileName>
</PropertyGroup>
<!-- Framework-dependent for sparse identity + no-op bootstrap when identity exists -->
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\Settings\Icons\Models\Azure.svg" />
<None Remove="Assets\Settings\Icons\Models\FoundryLocal.svg" />
@@ -219,4 +227,23 @@
<Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" />
<MSBuild Projects="..\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj" Targets="Build" Properties="Configuration=$(Configuration);Platform=Any CPU;TargetFramework=net9.0;XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\Views;GeneratedJsonFile=$(GeneratedJsonFile)" />
</Target>
</Project>
<!--
When Settings is activated via sparse package identity, package resource lookup can resolve from
the external location root (..\$(Platform)\$(Configuration)). Mirror Settings assets there so
ms-appx:///Assets/Settings/... resolves consistently in both identity and non-identity launches.
-->
<Target Name="MirrorSettingsAssetsToSparseRoot" AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true' and '$(UseSparseIdentity)'=='true'">
<PropertyGroup>
<_SparseRootDir>$([System.IO.Path]::GetFullPath('$(OutputPath)..\'))</_SparseRootDir>
<_SparseSettingsAssetsDir>$(_SparseRootDir)Assets\Settings\</_SparseSettingsAssetsDir>
</PropertyGroup>
<ItemGroup>
<_SettingsAssetsFiles Include="$(MSBuildProjectDirectory)\Assets\Settings\**\*" />
</ItemGroup>
<Copy
SourceFiles="@(_SettingsAssetsFiles)"
DestinationFiles="@(_SettingsAssetsFiles->'$(_SparseSettingsAssetsDir)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -1,338 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.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.Views;
using Microsoft.Windows.ApplicationModel.Resources;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services
{
public static class SearchIndexService
{
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 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 };
public static ImmutableArray<SettingEntry> Index
{
get
{
lock (_lockObject)
{
return _index;
}
}
}
public static bool IsIndexReady
{
get
{
lock (_lockObject)
{
return _isIndexBuilt;
}
}
}
public static void BuildIndex()
{
lock (_lockObject)
{
if (_isIndexBuilt || _isIndexBuilding)
{
return;
}
_isIndexBuilding = true;
// Clear caches on rebuild
_normalizedTextCache.Clear();
_pageTypeCache.Clear();
}
try
{
var builder = ImmutableArray.CreateBuilder<SettingEntry>();
LoadIndexFromPrebuiltData(builder);
lock (_lockObject)
{
_index = builder.ToImmutable();
_isIndexBuilt = true;
_isIndexBuilding = false;
}
}
catch (Exception ex)
{
Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}");
lock (_lockObject)
{
_isIndexBuilding = false;
_isIndexBuilt = false;
}
}
}
private static void LoadIndexFromPrebuiltData(ImmutableArray<SettingEntry>.Builder builder)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
SettingEntry[] metadataList;
Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
try
{
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
if (stream == null)
{
Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
return;
}
using StreamReader reader = new(stream);
string json = reader.ReadToEnd();
if (string.IsNullOrWhiteSpace(json))
{
Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty.");
return;
}
metadataList = JsonSerializer.Deserialize<SettingEntry[]>(json, _serializerOptions);
}
catch (Exception ex)
{
Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}");
return;
}
if (metadataList == null || metadataList.Length == 0)
{
Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed.");
return;
}
foreach (ref var metadata in metadataList.AsSpan())
{
if (metadata.Type == EntryType.SettingsPage)
{
(metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid);
}
else
{
(metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid);
}
if (string.IsNullOrEmpty(metadata.Header))
{
continue;
}
builder.Add(metadata);
// Cache the page name mapping for SettingsPage entries
if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header))
{
_pageNameCache[metadata.PageTypeName] = metadata.Header;
}
}
Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}");
}
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string header = GetString(resourceLoader, $"{elementUid}/Header");
string description = GetString(resourceLoader, $"{elementUid}/Description");
if (string.IsNullOrEmpty(header))
{
header = GetString(resourceLoader, $"{elementUid}/Content");
}
return (header, description);
}
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
return (title, description);
}
private static string GetString(ResourceLoader rl, string key)
{
try
{
string value = rl.GetString(key);
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
}
catch (Exception)
{
return string.Empty;
}
}
public static List<SettingEntry> Search(string query)
{
return Search(query, CancellationToken.None);
}
public static List<SettingEntry> Search(string query, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var currentIndex = Index;
if (currentIndex.IsEmpty)
{
Debug.WriteLine("[SearchIndexService] Search called but index is empty.");
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),
};
try
{
Parallel.ForEach(currentIndex, 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)
{
var pageType = GetPageTypeFromName(entry.PageTypeName);
if (pageType != null)
{
bag.Add((entry, score));
}
}
});
}
catch (OperationCanceledException)
{
return [];
}
return bag
.OrderByDescending(r => r.Score)
.Select(r => r.Hit)
.ToList();
}
private static Type GetPageTypeFromName(string pageTypeName)
{
if (string.IsNullOrEmpty(pageTypeName))
{
return null;
}
lock (_lockObject)
{
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
{
return cached;
}
var assembly = typeof(GeneralPage).Assembly;
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
_pageTypeCache[pageTypeName] = type;
return type;
}
}
private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
{
if (entry.ElementUid == null && entry.Header == null)
{
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
}
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
lock (_lockObject)
{
if (_normalizedTextCache.TryGetValue(key, out var cached))
{
return cached;
}
}
var headerNorm = NormalizeString(entry.Header);
var descNorm = NormalizeString(entry.Description);
lock (_lockObject)
{
_normalizedTextCache[key] = (headerNorm, descNorm);
}
return (headerNorm, descNorm);
}
private static string NormalizeString(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var stringBuilder = new StringBuilder();
foreach (var c in normalized)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
public static string GetLocalizedPageName(string pageTypeName)
{
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
}
}
}

View File

@@ -0,0 +1,495 @@
// 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.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Common.Search;
using Common.Search.FuzzSearch;
using Common.Search.SemanticSearch;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.Windows.ApplicationModel.Resources;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services
{
public sealed class SettingsSearch : IDisposable
{
private static readonly Lazy<SettingsSearch> DefaultInstance = new(() => new SettingsSearch());
private readonly object _lockObject = new();
private readonly Dictionary<string, string> _pageNameCache = [];
private readonly Dictionary<string, Type> _pageTypeCache = new();
private readonly ISearchEngine<SettingEntry> _searchEngine;
private readonly ISearchEngine<SettingEntry> _fallbackSearchEngine;
private ImmutableArray<SettingEntry> _index = [];
private bool _isIndexBuilt;
private bool _isIndexBuilding;
private bool _isFallbackIndexBuilt;
private bool _disposed;
private Task _buildTask;
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true };
private const string DefaultIndexName = "PowerToys.Settings";
public SettingsSearch()
: this(new SemanticSearchEngine<SettingEntry>(DefaultIndexName))
{
}
public SettingsSearch(ISearchEngine<SettingEntry> searchEngine, ISearchEngine<SettingEntry> fallbackSearchEngine = null)
{
ArgumentNullException.ThrowIfNull(searchEngine);
_searchEngine = searchEngine;
_fallbackSearchEngine = fallbackSearchEngine ?? new FuzzSearchEngine<SettingEntry>();
}
public static SettingsSearch Default => DefaultInstance.Value;
public ImmutableArray<SettingEntry> Index
{
get
{
lock (_lockObject)
{
return _index;
}
}
}
public bool IsReady
{
get
{
lock (_lockObject)
{
return _isIndexBuilt && _searchEngine.IsReady;
}
}
}
public Task BuildIndexAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
lock (_lockObject)
{
if (_isIndexBuilt)
{
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index already built.");
return Task.CompletedTask;
}
if (_isIndexBuilding)
{
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index build already in progress.");
return _buildTask ?? Task.CompletedTask;
}
if (_buildTask != null)
{
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: build task already scheduled.");
return _buildTask;
}
Logger.LogInfo("[SettingsSearch] BuildIndexAsync started.");
_buildTask = BuildIndexInternalAsync(cancellationToken);
return _buildTask;
}
}
public async Task InitializeIndexAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ThrowIfDisposed();
var builtIndex = entries is ImmutableArray<SettingEntry> immutableEntries
? immutableEntries
: ImmutableArray.CreateRange(entries);
lock (_lockObject)
{
_isIndexBuilding = true;
_isIndexBuilt = false;
_isFallbackIndexBuilt = false;
_index = builtIndex;
_pageNameCache.Clear();
_pageTypeCache.Clear();
}
Logger.LogInfo($"[SettingsSearch] Initializing index. Entries={builtIndex.Length}, Engine={_searchEngine.GetType().Name}.");
CachePageNames(builtIndex);
try
{
// Build a lightweight fallback index first so search remains available
// while semantic indexing is still warming up.
if (_fallbackSearchEngine.IsReady)
{
await _fallbackSearchEngine.ClearAsync(cancellationToken).ConfigureAwait(false);
}
else
{
await _fallbackSearchEngine.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
if (_fallbackSearchEngine.IsReady)
{
await _fallbackSearchEngine.IndexBatchAsync(builtIndex, cancellationToken).ConfigureAwait(false);
lock (_lockObject)
{
_isFallbackIndexBuilt = true;
}
}
else
{
Logger.LogWarning("[SettingsSearch] Fallback search engine is not ready after initialization.");
}
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] ERROR initializing fallback search engine: {ex.Message}", ex);
}
try
{
if (_searchEngine.IsReady)
{
Logger.LogDebug("[SettingsSearch] Clearing existing search engine index.");
await _searchEngine.ClearAsync(cancellationToken).ConfigureAwait(false);
}
else
{
Logger.LogDebug("[SettingsSearch] Initializing search engine.");
await _searchEngine.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
if (!_searchEngine.IsReady)
{
Logger.LogWarning("[SettingsSearch] Search engine not ready after initialization. Skipping indexing.");
return;
}
await _searchEngine.IndexBatchAsync(builtIndex, cancellationToken).ConfigureAwait(false);
lock (_lockObject)
{
_isIndexBuilt = true;
}
Logger.LogInfo($"[SettingsSearch] Index initialized. Entries={builtIndex.Length}, EngineReady={_searchEngine.IsReady}.");
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] CRITICAL ERROR initializing search engine: {ex.Message}", ex);
}
finally
{
lock (_lockObject)
{
_isIndexBuilding = false;
}
}
}
public async Task<IReadOnlyList<SettingSearchResult>> SearchAsync(
string query,
SearchOptions options = null,
CancellationToken token = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Array.Empty<SettingSearchResult>();
}
bool useFallback = false;
ISearchEngine<SettingEntry> engineToUse = null;
lock (_lockObject)
{
if (_isIndexBuilt && _searchEngine.IsReady)
{
engineToUse = _searchEngine;
}
else if (_isFallbackIndexBuilt && _fallbackSearchEngine.IsReady)
{
engineToUse = _fallbackSearchEngine;
useFallback = true;
}
else
{
engineToUse = null;
}
}
if (engineToUse == null)
{
Logger.LogWarning("[SettingsSearch] Search called but index is not ready.");
return Array.Empty<SettingSearchResult>();
}
var effectiveOptions = options ?? new SearchOptions
{
MaxResults = Index.Length,
IncludeMatchSpans = true,
};
try
{
var sw = Stopwatch.StartNew();
if (useFallback)
{
Logger.LogDebug("[SettingsSearch] Using fallback search engine for query.");
}
Logger.LogDebug($"[SettingsSearch] Search start. QueryLength={query.Length}, MaxResults={effectiveOptions.MaxResults}.");
var results = await Task.Run(
() => engineToUse.SearchAsync(query, effectiveOptions, token),
token).ConfigureAwait(false);
var filtered = FilterValidPageTypes(results);
sw.Stop();
Logger.LogDebug($"[SettingsSearch] Search complete. Results={filtered.Count}, ElapsedMs={sw.ElapsedMilliseconds}.");
return filtered;
}
catch (OperationCanceledException)
{
Logger.LogDebug("[SettingsSearch] Search canceled.");
return Array.Empty<SettingSearchResult>();
}
}
public static IReadOnlyList<SettingEntry> LoadIndexFromJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<SettingEntry>();
}
try
{
return JsonSerializer.Deserialize<SettingEntry[]>(json, SerializerOptions) ?? Array.Empty<SettingEntry>();
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] ERROR: Failed to load index from json: {ex.Message}", ex);
return Array.Empty<SettingEntry>();
}
}
public string GetLocalizedPageName(string pageTypeName)
{
lock (_lockObject)
{
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_searchEngine.Dispose();
_fallbackSearchEngine.Dispose();
_disposed = true;
}
private async Task BuildIndexInternalAsync(CancellationToken cancellationToken)
{
try
{
var sw = Stopwatch.StartNew();
var entries = LoadIndexFromPrebuiltData();
Logger.LogInfo($"[SettingsSearch] Prebuilt index loaded. Entries={entries.Length}.");
await InitializeIndexAsync(entries, cancellationToken).ConfigureAwait(false);
sw.Stop();
Logger.LogInfo($"[SettingsSearch] BuildIndexAsync finished. ElapsedMs={sw.ElapsedMilliseconds}, Ready={IsReady}.");
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] CRITICAL ERROR building search index: {ex.Message}", ex);
}
finally
{
lock (_lockObject)
{
_buildTask = null;
}
}
}
private void CachePageNames(ImmutableArray<SettingEntry> entries)
{
lock (_lockObject)
{
foreach (var entry in entries)
{
if (entry.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(entry.Header))
{
_pageNameCache[entry.PageTypeName] = entry.Header;
}
}
}
}
private ImmutableArray<SettingEntry> LoadIndexFromPrebuiltData()
{
var assembly = Assembly.GetExecutingAssembly();
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
Logger.LogInfo($"[SettingsSearch] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
string json;
try
{
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
if (stream == null)
{
Logger.LogError($"[SettingsSearch] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
return ImmutableArray<SettingEntry>.Empty;
}
using StreamReader reader = new(stream);
json = reader.ReadToEnd();
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] ERROR: Failed to read prebuilt index: {ex.Message}", ex);
return ImmutableArray<SettingEntry>.Empty;
}
if (string.IsNullOrWhiteSpace(json))
{
Logger.LogError("[SettingsSearch] ERROR: Embedded resource was empty.");
return ImmutableArray<SettingEntry>.Empty;
}
var metadataList = LoadIndexFromJson(json);
if (metadataList == null || metadataList.Count == 0)
{
Logger.LogWarning("[SettingsSearch] Prebuilt index is empty or deserialization failed.");
return ImmutableArray<SettingEntry>.Empty;
}
var builder = ImmutableArray.CreateBuilder<SettingEntry>(metadataList.Count);
foreach (var metadata in metadataList)
{
var entry = metadata;
if (entry.Type == EntryType.SettingsPage)
{
(entry.Header, entry.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, entry.ElementUid);
}
else
{
(entry.Header, entry.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, entry.ElementUid);
}
if (string.IsNullOrEmpty(entry.Header))
{
continue;
}
builder.Add(entry);
}
Logger.LogInfo($"[SettingsSearch] Finished loading index. Total entries: {builder.Count}");
return builder.ToImmutable();
}
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string header = GetString(resourceLoader, $"{elementUid}/Header");
string description = GetString(resourceLoader, $"{elementUid}/Description");
if (string.IsNullOrEmpty(header))
{
header = GetString(resourceLoader, $"{elementUid}/Content");
}
return (header, description);
}
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
return (title, description);
}
private static string GetString(ResourceLoader rl, string key)
{
try
{
string value = rl.GetString(key);
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
}
catch (Exception)
{
return string.Empty;
}
}
private IReadOnlyList<SettingSearchResult> FilterValidPageTypes(IReadOnlyList<SearchResult<SettingEntry>> results)
{
var filtered = new List<SettingSearchResult>(results.Count);
foreach (var result in results)
{
var entry = result.Item;
if (GetPageTypeFromName(entry.PageTypeName) != null)
{
filtered.Add(new SettingSearchResult
{
Entry = entry,
Score = result.Score,
MatchKind = result.MatchKind,
MatchSpans = result.MatchSpans,
});
}
}
return filtered;
}
private Type GetPageTypeFromName(string pageTypeName)
{
if (string.IsNullOrEmpty(pageTypeName))
{
return null;
}
lock (_lockObject)
{
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
{
return cached;
}
var assembly = typeof(GeneralPage).Assembly;
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
_pageTypeCache[pageTypeName] = type;
return type;
}
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
}

View File

@@ -32,12 +32,12 @@
IsTabStop="False"
ItemsSource="{x:Bind ViewModel.ModuleResults, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:SettingEntry">
<DataTemplate x:DataType="models:SettingSearchResult">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="ModuleButton_Click"
Header="{x:Bind Header}"
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE8B7;}"
Header="{x:Bind Entry.Header}"
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE8B7;}"
IsClickEnabled="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -52,19 +52,19 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsGroup">
<controls:SettingsGroup Header="{x:Bind GroupName}">
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:SettingEntry">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="SettingButton_Click"
Description="{x:Bind Description}"
Header="{x:Bind Header}"
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE713;}"
IsClickEnabled="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:SettingSearchResult">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="SettingButton_Click"
Description="{x:Bind Entry.Description}"
Header="{x:Bind Entry.Header}"
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE713;}"
IsClickEnabled="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</controls:SettingsGroup>
</DataTemplate>
</ItemsControl.ItemTemplate>

View File

@@ -44,17 +44,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void ModuleButton_Click(object sender, RoutedEventArgs e)
{
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
{
NavigateToModule(tagEntry);
NavigateToModule(tagResult.Entry);
}
}
private void SettingButton_Click(object sender, RoutedEventArgs e)
{
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
{
NavigateToSetting(tagEntry);
NavigateToSetting(tagResult.Entry);
}
}
@@ -98,9 +98,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views
{
public string Query { get; set; }
public List<SettingEntry> Results { get; set; }
public List<SettingSearchResult> Results { get; set; }
public SearchResultsNavigationParams(string query, List<SettingEntry> results)
public SearchResultsNavigationParams(string query, List<SettingSearchResult> results)
{
Query = query;
Results = results;

View File

@@ -8,7 +8,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Common.Search;
using Common.Search.FuzzSearch;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Controls;
using Microsoft.PowerToys.Settings.UI.Helpers;
@@ -385,11 +384,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
{
Task.Run(() =>
{
SearchIndexService.BuildIndex();
})
.ContinueWith(_ => { });
_ = Task.Run(() => SettingsSearch.Default.BuildIndexAsync());
}
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
@@ -427,7 +422,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0);
}
private List<SettingEntry> _lastSearchResults = new();
private List<SettingSearchResult> _lastSearchResults = new();
private string _lastQueryText = string.Empty;
private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
@@ -470,11 +465,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
// Query the index on a background thread to avoid blocking UI
List<SettingEntry> results = null;
List<SettingSearchResult> results = null;
try
{
// If the token is already canceled before scheduling, the task won't start.
results = await Task.Run(() => SearchIndexService.Search(query, token), token);
results = (await SettingsSearch.Default.SearchAsync(query, options: null, token)).ToList();
}
catch (OperationCanceledException)
{
@@ -578,7 +572,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
// Centralized suggestion projection logic used by TextChanged & GotFocus restore.
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results)
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingSearchResult> results)
{
results ??= new();
if (results.Count == 0)
@@ -601,16 +595,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
};
}
var list = results.Take(5).Select(e =>
var list = results.Take(5).Select(result =>
{
var entry = result.Entry;
string subtitle = string.Empty;
if (e.Type != EntryType.SettingsPage)
if (entry.Type != EntryType.SettingsPage)
{
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
subtitle = SettingsSearch.Default.GetLocalizedPageName(entry.PageTypeName);
if (string.IsNullOrEmpty(subtitle))
{
subtitle = SearchIndexService.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
subtitle = SettingsSearch.Default.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == entry.PageTypeName)
.Select(x => x.Header)
.FirstOrDefault() ?? string.Empty;
}
@@ -618,12 +613,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views
return new SuggestionItem
{
Header = e.Header,
Icon = e.Icon,
PageTypeName = e.PageTypeName,
ElementName = e.ElementName,
ParentElementName = e.ParentElementName,
Header = entry.Header,
Icon = entry.Icon,
PageTypeName = entry.PageTypeName,
ElementName = entry.ElementName,
ParentElementName = entry.ParentElementName,
Subtitle = subtitle,
Score = result.Score,
MatchKind = result.MatchKind,
MatchSpans = result.MatchSpans ?? Array.Empty<MatchSpan>(),
IsShowAll = false,
};
}).ToList();
@@ -655,7 +653,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
// Prefer cached results (from live search); if empty, perform a fresh search
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
? _lastSearchResults
: await Task.Run(() => SearchIndexService.Search(queryText));
: (await SettingsSearch.Default.SearchAsync(queryText, options: null)).ToList();
var searchParams = new SearchResultsNavigationParams(queryText, matched);
NavigationService.Navigate<SearchResultsPage>(searchParams);

View File

@@ -14,11 +14,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public class SearchResultsViewModel : INotifyPropertyChanged
{
private ObservableCollection<SettingEntry> _moduleResults = new();
private ObservableCollection<SettingSearchResult> _moduleResults = new();
private ObservableCollection<SettingsGroup> _groupedSettingsResults = new();
private bool _hasNoResults;
public ObservableCollection<SettingEntry> ModuleResults
public ObservableCollection<SettingSearchResult> ModuleResults
{
get => _moduleResults;
set
@@ -48,7 +48,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public void SetSearchResults(string query, List<SettingEntry> results)
public void SetSearchResults(string query, List<SettingSearchResult> results)
{
if (results == null || results.Count == 0)
{
@@ -61,8 +61,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
HasNoResults = false;
// Separate modules and settings
var modules = results.Where(r => r.Type == EntryType.SettingsPage).ToList();
var settings = results.Where(r => r.Type == EntryType.SettingsCard).ToList();
var modules = results.Where(r => r.Entry.Type == EntryType.SettingsPage).ToList();
var settings = results.Where(r => r.Entry.Type == EntryType.SettingsCard).ToList();
// Update module results
ModuleResults.Clear();
@@ -73,11 +73,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Group settings by their page/module
var groupedSettings = settings
.GroupBy(s => SearchIndexService.GetLocalizedPageName(s.PageTypeName))
.GroupBy(s => SettingsSearch.Default.GetLocalizedPageName(s.Entry.PageTypeName))
.Select(g => new SettingsGroup
{
GroupName = g.Key,
Settings = new ObservableCollection<SettingEntry>(g),
Settings = new ObservableCollection<SettingSearchResult>(g),
})
.ToList();
@@ -101,7 +101,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
#pragma warning restore SA1402 // File may only contain a single type
{
private string _groupName;
private ObservableCollection<SettingEntry> _settings;
private ObservableCollection<SettingSearchResult> _settings;
public string GroupName
{
@@ -113,7 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public ObservableCollection<SettingEntry> Settings
public ObservableCollection<SettingSearchResult> Settings
{
get => _settings;
set

View File

@@ -2,6 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Common.Search;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public sealed partial class SuggestionItem
@@ -18,6 +21,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public string Subtitle { get; init; }
public double Score { get; init; }
public SearchMatchKind? MatchKind { get; init; }
public IReadOnlyList<MatchSpan> MatchSpans { get; init; }
public bool IsShowAll { get; init; }
public bool IsNoResults { get; init; }

View File

@@ -2,6 +2,11 @@
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PowerToys.Settings.app"/>
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
packageName="Microsoft.PowerToys.SparseApp"
applicationId="PowerToys.SettingsUI"
publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:

View File

@@ -0,0 +1,211 @@
// 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.IO;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SettingsSearchEvaluation.Tests;
[TestClass]
public class EvaluationDataLoaderTests
{
[TestMethod]
public void LoadEntriesFromJson_UsesResourceStrings_WhenHeaderAndDescriptionAreMissing()
{
const string json = """
[
{
"type": 0,
"header": null,
"pageTypeName": "FancyZonesPage",
"elementName": "",
"elementUid": "FancyZones",
"parentElementName": "",
"description": null,
"icon": null
},
{
"type": 1,
"header": null,
"pageTypeName": "PowerRenamePage",
"elementName": "PowerRenameToggle",
"elementUid": "PowerRename_Toggle_Enable",
"parentElementName": "",
"description": null,
"icon": null
}
]
""";
var resourceMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["FancyZones.ModuleTitle"] = "FancyZones",
["FancyZones.ModuleDescription"] = "Create and manage zone layouts.",
["PowerRename_Toggle_Enable.Header"] = "PowerRename",
["PowerRename_Toggle_Enable.Description"] = "Enable bulk rename integration.",
};
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(json, resourceMap);
Assert.AreEqual(2, entries.Count);
Assert.AreEqual("FancyZones", entries[0].Header);
Assert.AreEqual("Create and manage zone layouts.", entries[0].Description);
Assert.AreEqual("PowerRename", entries[1].Header);
Assert.AreEqual("Enable bulk rename integration.", entries[1].Description);
}
[TestMethod]
public void WriteNormalizedTextCorpusFile_EmitsOnlyNormalizedText()
{
var entries = new[]
{
new SettingEntry(
EntryType.SettingsCard,
"Activation shortcut",
PageTypeName: "MeasureToolPage",
ElementName: "MeasureToolActivationShortcut",
ElementUid: "MeasureTool_ActivationShortcut",
ParentElementName: string.Empty,
Description: "Customize the shortcut to bring up the command bar",
Icon: string.Empty),
};
var outputPath = Path.GetTempFileName();
try
{
EvaluationDataLoader.WriteNormalizedTextCorpusFile(outputPath, entries);
var line = File.ReadAllLines(outputPath).Single();
Assert.AreEqual(
"activation shortcut customize the shortcut to bring up the command bar",
line);
Assert.IsFalse(line.Contains("MeasureTool_ActivationShortcut", StringComparison.OrdinalIgnoreCase));
}
finally
{
File.Delete(outputPath);
}
}
[TestMethod]
public void LoadEntriesFromJson_NormalizesHeaderAndDetectsDuplicates()
{
const string json = """
[
{
"type": 0,
"header": null,
"pageTypeName": "ColorPickerPage",
"elementName": "",
"elementUid": "Activation_Shortcut",
"parentElementName": "",
"description": null,
"icon": null
},
{
"type": 0,
"header": null,
"pageTypeName": "FancyZonesPage",
"elementName": "",
"elementUid": "Activation_Shortcut",
"parentElementName": "",
"description": null,
"icon": null
}
]
""";
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
Assert.AreEqual(2, entries.Count);
Assert.AreEqual("Activation Shortcut", entries[0].Header);
Assert.AreEqual(1, diagnostics.DuplicateIdBucketCount);
Assert.IsTrue(diagnostics.DuplicateIdCounts.TryGetValue("Activation_Shortcut", out var count));
Assert.AreEqual(2, count);
}
[TestMethod]
public void LoadCases_GeneratesFallbackCases_WhenNoCasesFileSpecified()
{
const string json = """
[
{
"type": 0,
"header": "Fancy Zones",
"pageTypeName": "FancyZonesPage",
"elementName": "",
"elementUid": "FancyZones",
"parentElementName": "",
"description": "",
"icon": null
}
]
""";
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(json);
var cases = EvaluationDataLoader.LoadCases(null, entries);
Assert.AreEqual(1, cases.Count);
Assert.AreEqual("Fancy Zones", cases[0].Query);
Assert.AreEqual("FancyZones", cases[0].ExpectedIds[0]);
}
[TestMethod]
public void LoadCases_LoadsAndNormalizesCasesFile()
{
const string entriesJson = """
[
{
"type": 0,
"header": "Fancy Zones",
"pageTypeName": "FancyZonesPage",
"elementName": "",
"elementUid": "FancyZones",
"parentElementName": "",
"description": "",
"icon": null
}
]
""";
const string casesJson = """
[
{
"query": " fancy zones ",
"expectedIds": [ "FancyZones", " fancyzones ", "" ],
"notes": "normalization test"
},
{
"query": "",
"expectedIds": [ "FancyZones" ]
},
{
"query": "missing expected",
"expectedIds": [ "" ]
}
]
""";
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(entriesJson);
var casesFile = Path.GetTempFileName();
try
{
File.WriteAllText(casesFile, casesJson);
var cases = EvaluationDataLoader.LoadCases(casesFile, entries);
Assert.AreEqual(1, cases.Count);
Assert.AreEqual("fancy zones", cases[0].Query);
Assert.AreEqual(1, cases[0].ExpectedIds.Count);
Assert.AreEqual("FancyZones", cases[0].ExpectedIds[0]);
Assert.AreEqual("normalization test", cases[0].Notes);
}
finally
{
File.Delete(casesFile);
}
}
}

View File

@@ -0,0 +1,50 @@
// 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 Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SettingsSearchEvaluation.Tests;
[TestClass]
public class EvaluationMathTests
{
private static readonly double[] LatencySamples = { 10.0, 20.0, 30.0, 40.0, 50.0 };
[TestMethod]
public void FindBestRank_ReturnsExpectedRank()
{
var ranked = new[] { "a", "b", "c", "d" };
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "c" };
var rank = EvaluationMath.FindBestRank(ranked, expected);
Assert.AreEqual(3, rank);
}
[TestMethod]
public void FindBestRank_ReturnsZero_WhenMissing()
{
var ranked = new[] { "a", "b", "c", "d" };
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "x", "y" };
var rank = EvaluationMath.FindBestRank(ranked, expected);
Assert.AreEqual(0, rank);
}
[TestMethod]
public void ComputeLatencySummary_ComputesQuantiles()
{
var summary = EvaluationMath.ComputeLatencySummary(LatencySamples);
Assert.AreEqual(5, summary.Samples);
Assert.AreEqual(10.0, summary.MinMs);
Assert.AreEqual(30.0, summary.P50Ms);
Assert.AreEqual(50.0, summary.P95Ms);
Assert.AreEqual(50.0, summary.MaxMs);
Assert.AreEqual(30.0, summary.AverageMs, 0.0001);
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SettingsSearchEvaluation.Tests;
[TestClass]
public class EvaluatorTests
{
[TestMethod]
public async Task RunAsync_BasicEngine_ReturnsExpectedMetricsForExactSingleEntry()
{
const string json = """
[
{
"type": 0,
"header": "Fancy Zones",
"pageTypeName": "FancyZonesPage",
"elementName": "",
"elementUid": "FancyZones",
"parentElementName": "",
"description": "",
"icon": null
}
]
""";
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
var cases = new[]
{
new EvaluationCase
{
Query = "Fancy Zones",
ExpectedIds = new[] { "FancyZones" },
Notes = "Exact query should be rank 1.",
},
};
var options = new RunnerOptions
{
IndexJsonPath = "test-index.json",
CasesJsonPath = null,
Engines = new[] { SearchEngineKind.Basic },
MaxResults = 5,
TopK = 5,
Iterations = 1,
WarmupIterations = 0,
SemanticIndexTimeout = TimeSpan.FromSeconds(1),
OutputJsonPath = null,
};
var report = await Evaluator.RunAsync(options, entries, diagnostics, cases);
Assert.AreEqual(1, report.Engines.Count);
var engine = report.Engines[0];
Assert.AreEqual(SearchEngineKind.Basic, engine.Engine);
Assert.IsTrue(engine.IsAvailable);
Assert.AreEqual(1, engine.QueryCount);
Assert.AreEqual(1.0, engine.RecallAtK, 0.0001);
Assert.AreEqual(1.0, engine.Mrr, 0.0001);
Assert.AreEqual(1, engine.CaseResults.Count);
Assert.IsTrue(engine.CaseResults[0].HitAtK);
Assert.AreEqual(1, engine.CaseResults[0].BestRank);
}
[TestMethod]
public async Task RunAsync_SemanticEngine_ReturnsReportWithoutThrowing()
{
const string json = """
[
{
"type": 0,
"header": "Fancy Zones",
"pageTypeName": "FancyZonesPage",
"elementName": "",
"elementUid": "FancyZones",
"parentElementName": "",
"description": "",
"icon": null
}
]
""";
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
var cases = new[]
{
new EvaluationCase
{
Query = "Fancy Zones",
ExpectedIds = new[] { "FancyZones" },
Notes = "Semantic smoke test.",
},
};
var options = new RunnerOptions
{
IndexJsonPath = "test-index.json",
CasesJsonPath = null,
Engines = new[] { SearchEngineKind.Semantic },
MaxResults = 5,
TopK = 5,
Iterations = 1,
WarmupIterations = 0,
SemanticIndexTimeout = TimeSpan.FromSeconds(3),
OutputJsonPath = null,
};
var report = await Evaluator.RunAsync(options, entries, diagnostics, cases);
Assert.AreEqual(1, report.Engines.Count);
var engine = report.Engines[0];
Assert.AreEqual(SearchEngineKind.Semantic, engine.Engine);
if (!engine.IsAvailable)
{
Assert.IsFalse(string.IsNullOrWhiteSpace(engine.AvailabilityError));
Assert.AreEqual(0, engine.QueryCount);
}
else
{
Assert.AreEqual(1, engine.QueryCount);
Assert.AreEqual(1, engine.CaseResults.Count);
}
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<OutputPath>..\..\$(Configuration)\$(Platform)\tests\SettingsSearchEvaluationTests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SettingsSearchEvaluation\SettingsSearchEvaluation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,266 @@
# 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.
[CmdletBinding()]
param(
[ValidateSet("arm64", "x64")]
[string]$Platform = "arm64",
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",
[switch]$Install,
[switch]$NoSign
)
$ErrorActionPreference = "Stop"
function Find-WindowsSdkTool {
param(
[Parameter(Mandatory)]
[string]$ToolName,
[string]$Architecture = "x64"
)
$patterns = @(
"${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\$Architecture\$ToolName",
"${env:ProgramFiles}\Windows Kits\10\bin\*\$Architecture\$ToolName"
)
foreach ($pattern in $patterns) {
$match = Get-ChildItem $pattern -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1
if ($match) {
return $match.FullName
}
}
throw "$ToolName was not found in Windows SDK."
}
function Get-MsBuildPath {
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) {
throw "vswhere.exe not found at $vswhere"
}
$msbuild = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe | Select-Object -First 1
if (-not $msbuild) {
throw "MSBuild.exe not found."
}
return $msbuild
}
function Get-Publisher {
param(
[Parameter(Mandatory)]
[string]$RepoRoot
)
$hint = Join-Path $RepoRoot "src\PackageIdentity\.user\PowerToysSparse.publisher.txt"
if (Test-Path $hint) {
return (Get-Content -LiteralPath $hint -Raw).Trim()
}
return "CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US"
}
function Get-Version {
param(
[Parameter(Mandatory)]
[string]$RepoRoot
)
$versionProps = Join-Path $RepoRoot "src\Version.props"
if (-not (Test-Path $versionProps)) {
return "0.0.1.0"
}
[xml]$xml = Get-Content -LiteralPath $versionProps -Raw
$version = $xml.Project.PropertyGroup.Version
if (-not $version) {
return "0.0.1.0"
}
$version = $version.Trim()
if (($version -split '\.').Count -lt 4) {
$version = "$version.0"
}
return $version
}
function Get-PayloadFileNames {
param(
[Parameter(Mandatory)]
[string]$DepsJsonPath
)
$deps = Get-Content -LiteralPath $DepsJsonPath -Raw | ConvertFrom-Json
$target = $deps.runtimeTarget.name
$targetNode = $deps.targets.$target
if (-not $targetNode) {
throw "Unable to resolve deps target '$target' from $DepsJsonPath"
}
$files = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$alwaysInclude = @(
"SettingsSearchEvaluation.exe",
"SettingsSearchEvaluation.dll",
"SettingsSearchEvaluation.deps.json",
"SettingsSearchEvaluation.runtimeconfig.json",
"SettingsSearchEvaluation.pri"
)
foreach ($item in $alwaysInclude) {
[void]$files.Add($item)
}
foreach ($lib in $targetNode.PSObject.Properties) {
foreach ($bucketName in @("runtime", "native")) {
$bucket = $lib.Value.$bucketName
if ($null -eq $bucket) {
continue
}
foreach ($asset in $bucket.PSObject.Properties.Name) {
$leaf = [System.IO.Path]::GetFileName($asset)
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
[void]$files.Add($leaf)
}
}
}
}
return $files
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$outputRoot = Join-Path $repoRoot "$Platform\$Configuration"
$projectPath = Join-Path $repoRoot "tools\SettingsSearchEvaluation\SettingsSearchEvaluation.csproj"
$artifactsRoot = Join-Path $PSScriptRoot "artifacts\full-package"
$stagingDir = Join-Path $artifactsRoot "staging"
$packagePath = Join-Path $artifactsRoot "SettingsSearchEvaluation.msix"
$manifestPath = Join-Path $stagingDir "AppxManifest.xml"
if (Test-Path $stagingDir) {
Remove-Item -LiteralPath $stagingDir -Recurse -Force
}
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
New-Item -ItemType Directory -Path $artifactsRoot -Force | Out-Null
Write-Host "Building evaluator ($Platform/$Configuration) without sparse identity..."
$msbuild = Get-MsBuildPath
& $msbuild $projectPath /t:Build /p:Configuration=$Configuration /p:Platform=$Platform /p:UseSparseIdentity=false /p:WindowsAppSDKSelfContained=false /p:WindowsAppSdkUndockedRegFreeWinRTInitialize=false /m:1 /nologo
$depsPath = Join-Path $outputRoot "SettingsSearchEvaluation.deps.json"
if (-not (Test-Path $depsPath)) {
throw "Missing build output: $depsPath"
}
$payloadFiles = Get-PayloadFileNames -DepsJsonPath $depsPath
foreach ($name in $payloadFiles) {
$source = Join-Path $outputRoot $name
if (Test-Path $source) {
Copy-Item -LiteralPath $source -Destination (Join-Path $stagingDir $name) -Force
}
}
# Ensure the entry point exists.
if (-not (Test-Path (Join-Path $stagingDir "SettingsSearchEvaluation.exe"))) {
throw "Packaging failed: SettingsSearchEvaluation.exe was not copied to staging."
}
$imagesSource = Join-Path $repoRoot "src\PackageIdentity\Images"
Copy-Item -LiteralPath (Join-Path $imagesSource "Square150x150Logo.png") -Destination (Join-Path $stagingDir "Square150x150Logo.png") -Force
Copy-Item -LiteralPath (Join-Path $imagesSource "Square44x44Logo.png") -Destination (Join-Path $stagingDir "Square44x44Logo.png") -Force
Copy-Item -LiteralPath (Join-Path $imagesSource "StoreLogo.png") -Destination (Join-Path $stagingDir "StoreLogo.png") -Force
$publisher = Get-Publisher -RepoRoot $repoRoot
$version = Get-Version -RepoRoot $repoRoot
$manifest = @"
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10"
IgnorableNamespaces="uap rescap desktop systemai">
<Identity
Name="Microsoft.PowerToys.SettingsSearchEvaluation"
Publisher="$publisher"
Version="$version" />
<Properties>
<DisplayName>PowerToys Settings Search Evaluation</DisplayName>
<PublisherDisplayName>PowerToys</PublisherDisplayName>
<Logo>StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.26226.0" />
<PackageDependency Name="Microsoft.WindowsAppRuntime.2.0-experimental4" MinVersion="0.738.2207.0" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
</Dependencies>
<Capabilities>
<Capability Name="internetClient" />
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources" />
<systemai:Capability Name="systemAIModels" />
</Capabilities>
<Applications>
<Application Id="SettingsSearchEvaluation" Executable="SettingsSearchEvaluation.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="PowerToys Settings Search Evaluation"
Description="Settings search performance and recall evaluator"
BackgroundColor="transparent"
Square150x150Logo="Square150x150Logo.png"
Square44x44Logo="Square44x44Logo.png"
AppListEntry="none" />
</Application>
</Applications>
</Package>
"@
Set-Content -LiteralPath $manifestPath -Value $manifest -Encoding UTF8
if (Test-Path $packagePath) {
Remove-Item -LiteralPath $packagePath -Force
}
$makeAppx = Find-WindowsSdkTool -ToolName "makeappx.exe"
Write-Host "Packing MSIX: $packagePath"
& $makeAppx pack /d $stagingDir /p $packagePath /o /nv
if (-not $NoSign) {
$thumbFile = Join-Path $repoRoot "src\PackageIdentity\.user\PowerToysSparse.certificate.sample.thumbprint"
if (-not (Test-Path $thumbFile)) {
throw "Signing certificate thumbprint file not found: $thumbFile"
}
$thumb = (Get-Content -LiteralPath $thumbFile -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($thumb)) {
throw "Signing certificate thumbprint is empty: $thumbFile"
}
$signtool = Find-WindowsSdkTool -ToolName "signtool.exe"
Write-Host "Signing MSIX..."
& $signtool sign /fd SHA256 /sha1 $thumb $packagePath
}
Write-Host "Package created: $packagePath"
if ($Install) {
Write-Host "Installing package..."
Add-AppxPackage -Path $packagePath -ForceApplicationShutdown
$pkg = Get-AppxPackage Microsoft.PowerToys.SettingsSearchEvaluation
if ($pkg) {
Write-Host "Installed PackageFamilyName: $($pkg.PackageFamilyName)"
Write-Host "Launch AUMID: shell:AppsFolder\$($pkg.PackageFamilyName)!SettingsSearchEvaluation"
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
namespace SettingsSearchEvaluation;
internal sealed class DatasetDiagnostics
{
public required int TotalEntries { get; init; }
public required int DistinctIds { get; init; }
public required int DuplicateIdBucketCount { get; init; }
public required IReadOnlyDictionary<string, int> DuplicateIdCounts { get; init; }
public static DatasetDiagnostics Empty { get; } = new()
{
TotalEntries = 0,
DistinctIds = 0,
DuplicateIdBucketCount = 0,
DuplicateIdCounts = new ReadOnlyDictionary<string, int>(new Dictionary<string, int>()),
};
}

View File

@@ -0,0 +1,30 @@
// 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 SettingsSearchEvaluation;
internal sealed class EngineEvaluationReport
{
public required SearchEngineKind Engine { get; init; }
public required bool IsAvailable { get; init; }
public string? AvailabilityError { get; init; }
public string? CapabilitiesSummary { get; init; }
public int IndexedEntries { get; init; }
public int QueryCount { get; init; }
public double IndexingTimeMs { get; init; }
public double RecallAtK { get; init; }
public double Mrr { get; init; }
public required LatencySummary SearchLatencyMs { get; init; }
public required IReadOnlyList<QueryEvaluationResult> CaseResults { get; init; }
}

View File

@@ -0,0 +1,14 @@
// 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 SettingsSearchEvaluation;
internal sealed class EvaluationCase
{
public required string Query { get; init; }
public required IReadOnlyList<string> ExpectedIds { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,696 @@
// 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.ObjectModel;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace SettingsSearchEvaluation;
internal static partial class EvaluationDataLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromFile(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var json = File.ReadAllText(path);
var resources = TryLoadResourceMap(path);
return LoadEntriesFromJson(json, resources);
}
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromJson(string json)
{
return LoadEntriesFromJson(json, resourceMap: null);
}
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromJson(
string json,
IReadOnlyDictionary<string, string>? resourceMap)
{
if (string.IsNullOrWhiteSpace(json))
{
return (Array.Empty<SettingEntry>(), DatasetDiagnostics.Empty);
}
var rawEntries = JsonSerializer.Deserialize<List<RawSettingEntry>>(json, JsonOptions) ?? new List<RawSettingEntry>();
var normalized = new List<SettingEntry>(rawEntries.Count);
foreach (var raw in rawEntries)
{
var pageType = raw.PageTypeName?.Trim() ?? string.Empty;
var elementName = raw.ElementName?.Trim() ?? string.Empty;
var elementUid = raw.ElementUid?.Trim() ?? string.Empty;
var parent = raw.ParentElementName?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(elementUid))
{
elementUid = $"{pageType}|{elementName}";
}
var localized = ResolveLocalizedStrings(raw.Type, elementUid, elementName, parent, resourceMap);
var header = raw.Header?.Trim();
if (string.IsNullOrEmpty(header))
{
header = localized.Header;
}
if (string.IsNullOrEmpty(header))
{
header = BuildFallbackHeader(elementUid, elementName, pageType);
}
var description = raw.Description?.Trim();
if (string.IsNullOrEmpty(description))
{
description = localized.Description;
}
description ??= string.Empty;
var icon = raw.Icon?.Trim() ?? string.Empty;
normalized.Add(new SettingEntry(
raw.Type,
header,
pageType,
elementName,
elementUid,
parent,
description,
icon));
}
return (normalized, BuildDiagnostics(normalized));
}
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromNormalizedCorpusFile(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
return LoadEntriesFromNormalizedCorpusLines(File.ReadLines(path));
}
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromNormalizedCorpusLines(IEnumerable<string> lines)
{
ArgumentNullException.ThrowIfNull(lines);
var entries = new List<SettingEntry>();
var lineNumber = 0;
foreach (var rawLine in lines)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(rawLine))
{
continue;
}
var line = rawLine.Trim();
if (line.StartsWith('#'))
{
continue;
}
var splitIndex = line.IndexOf('\t');
string id;
string normalizedText;
if (splitIndex <= 0)
{
id = $"line:{lineNumber}";
normalizedText = line;
}
else
{
id = line[..splitIndex].Trim();
normalizedText = line[(splitIndex + 1)..].Trim();
if (string.IsNullOrWhiteSpace(id))
{
id = $"line:{lineNumber}";
}
}
if (string.IsNullOrWhiteSpace(normalizedText))
{
continue;
}
entries.Add(new SettingEntry(
EntryType.SettingsCard,
normalizedText,
string.Empty,
string.Empty,
id,
string.Empty,
string.Empty,
string.Empty));
}
return (entries, BuildDiagnostics(entries));
}
public static void WriteNormalizedCorpusFile(string path, IEnumerable<SettingEntry> entries)
{
WriteNormalizedCorpusFile(path, entries, includeId: true);
}
public static void WriteNormalizedTextCorpusFile(string path, IEnumerable<SettingEntry> entries)
{
WriteNormalizedCorpusFile(path, entries, includeId: false);
}
private static void WriteNormalizedCorpusFile(string path, IEnumerable<SettingEntry> entries, bool includeId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(entries);
var outputDirectory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
using var writer = new StreamWriter(path, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
foreach (var entry in entries)
{
var normalizedText = BuildNormalizedSearchText(entry);
if (string.IsNullOrWhiteSpace(normalizedText))
{
continue;
}
if (includeId)
{
var id = SanitizeCorpusId(entry.Id);
writer.Write(id);
writer.Write('\t');
}
writer.WriteLine(normalizedText);
}
}
public static IReadOnlyList<EvaluationCase> LoadCases(string? casesPath, IReadOnlyList<SettingEntry> entries)
{
if (!string.IsNullOrWhiteSpace(casesPath))
{
var json = File.ReadAllText(casesPath);
var parsed = JsonSerializer.Deserialize<List<RawEvaluationCase>>(json, JsonOptions) ?? new List<RawEvaluationCase>();
var normalized = parsed
.Where(c => !string.IsNullOrWhiteSpace(c.Query))
.Select(c => new EvaluationCase
{
Query = c.Query!.Trim(),
ExpectedIds = c.ExpectedIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>(),
Notes = c.Notes,
})
.Where(c => c.ExpectedIds.Count > 0)
.ToList();
if (normalized.Count > 0)
{
return normalized;
}
}
return GenerateFallbackCases(entries);
}
private static DatasetDiagnostics BuildDiagnostics(IReadOnlyList<SettingEntry> entries)
{
var duplicateBuckets = entries
.GroupBy(x => x.Id, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.OrderByDescending(group => group.Count())
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
return new DatasetDiagnostics
{
TotalEntries = entries.Count,
DistinctIds = entries.Select(x => x.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
DuplicateIdBucketCount = duplicateBuckets.Count,
DuplicateIdCounts = new ReadOnlyDictionary<string, int>(duplicateBuckets),
};
}
private static IReadOnlyList<EvaluationCase> GenerateFallbackCases(IReadOnlyList<SettingEntry> entries)
{
return entries
.Where(entry => !string.IsNullOrWhiteSpace(entry.Header) && !string.IsNullOrWhiteSpace(entry.Id))
.GroupBy(entry => entry.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Take(40)
.Select(entry => new EvaluationCase
{
Query = entry.Header,
ExpectedIds = new[] { entry.Id },
Notes = "Autogenerated case from index entry header.",
})
.ToArray();
}
private static string BuildNormalizedSearchText(SettingEntry entry)
{
var combined = string.IsNullOrWhiteSpace(entry.SecondarySearchableText)
? entry.SearchableText
: $"{entry.SearchableText} {entry.SecondarySearchableText}";
return NormalizeForCorpus(combined);
}
private static string NormalizeForCorpus(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
builder.Append(ch);
}
var noMarks = builder.ToString();
var compact = ConsecutiveWhitespaceRegex().Replace(noMarks, " ").Trim();
return compact.Replace('\t', ' ');
}
private static string SanitizeCorpusId(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return "unknown";
}
return id.Trim().Replace('\t', ' ');
}
private static string BuildFallbackHeader(string elementUid, string elementName, string pageTypeName)
{
var candidate = !string.IsNullOrWhiteSpace(elementUid)
? elementUid
: (!string.IsNullOrWhiteSpace(elementName) ? elementName : pageTypeName);
candidate = candidate.Replace('_', ' ').Trim();
candidate = ConsecutiveWhitespaceRegex().Replace(candidate, " ");
candidate = CamelBoundaryRegex().Replace(candidate, "$1 $2");
return candidate;
}
private static (string Header, string Description) ResolveLocalizedStrings(
EntryType type,
string elementUid,
string elementName,
string parentElementName,
IReadOnlyDictionary<string, string>? resourceMap)
{
if (resourceMap == null || string.IsNullOrWhiteSpace(elementUid))
{
return (string.Empty, string.Empty);
}
var uid = elementUid.Trim();
var header = string.Empty;
var description = string.Empty;
if (type == EntryType.SettingsPage)
{
header = GetFirstResourceString(
resourceMap,
$"{uid}.ModuleTitle",
$"{uid}/ModuleTitle",
$"{uid}.Title",
$"{uid}/Title");
description = GetFirstResourceString(
resourceMap,
$"{uid}.ModuleDescription",
$"{uid}/ModuleDescription",
$"{uid}.Description",
$"{uid}/Description");
}
else
{
header = GetFirstResourceString(
resourceMap,
$"{uid}.Header",
$"{uid}/Header",
$"{uid}.Content",
$"{uid}/Content",
$"{uid}.Title",
$"{uid}/Title",
$"{uid}.Text",
$"{uid}/Text",
$"{uid}.PlaceholderText",
$"{uid}/PlaceholderText");
description = GetFirstResourceString(
resourceMap,
$"{uid}.Description",
$"{uid}/Description",
$"{uid}.Message",
$"{uid}/Message",
$"{uid}.Text",
$"{uid}/Text");
}
if (string.IsNullOrWhiteSpace(header))
{
header = TryResolveByTokenAndSuffixes(
resourceMap,
uid,
"header",
"title",
"content",
"text",
"placeholdertext");
}
if (string.IsNullOrWhiteSpace(description))
{
description = TryResolveByTokenAndSuffixes(
resourceMap,
uid,
"description",
"message",
"subtitle",
"text");
}
if (string.IsNullOrWhiteSpace(header) && !string.IsNullOrWhiteSpace(parentElementName))
{
header = TryResolveByTokenAndSuffixes(
resourceMap,
parentElementName,
"header",
"title",
"content",
"text",
"placeholdertext");
}
if (string.IsNullOrWhiteSpace(description) && !string.IsNullOrWhiteSpace(parentElementName))
{
description = TryResolveByTokenAndSuffixes(
resourceMap,
parentElementName,
"description",
"message",
"subtitle",
"text");
}
if (string.IsNullOrWhiteSpace(header) && !string.IsNullOrWhiteSpace(elementName))
{
header = TryResolveByTokenAndSuffixes(
resourceMap,
elementName,
"header",
"title",
"content",
"text",
"placeholdertext");
}
if (string.IsNullOrWhiteSpace(description) && !string.IsNullOrWhiteSpace(elementName))
{
description = TryResolveByTokenAndSuffixes(
resourceMap,
elementName,
"description",
"message",
"subtitle",
"text");
}
return (header, description);
}
private static string TryResolveByTokenAndSuffixes(
IReadOnlyDictionary<string, string> resourceMap,
string token,
params string[] normalizedSuffixes)
{
var normalizedToken = NormalizeLookupToken(token);
if (string.IsNullOrWhiteSpace(normalizedToken))
{
return string.Empty;
}
var bestKey = resourceMap.Keys
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(key => new { Key = key, Normalized = NormalizeLookupToken(key) })
.Where(x => x.Normalized.StartsWith(normalizedToken, StringComparison.OrdinalIgnoreCase))
.Where(x => normalizedSuffixes.Contains(GetNormalizedSuffix(x.Normalized), StringComparer.OrdinalIgnoreCase))
.OrderBy(x => x.Normalized.Length)
.Select(x => x.Key)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(bestKey) &&
resourceMap.TryGetValue(bestKey, out var bestValue) &&
!string.IsNullOrWhiteSpace(bestValue))
{
return bestValue.Trim();
}
return string.Empty;
}
private static string GetNormalizedSuffix(string normalizedKey)
{
if (normalizedKey.EndsWith("placeholdertext", StringComparison.OrdinalIgnoreCase))
{
return "placeholdertext";
}
if (normalizedKey.EndsWith("moduletitle", StringComparison.OrdinalIgnoreCase))
{
return "title";
}
if (normalizedKey.EndsWith("moduledescription", StringComparison.OrdinalIgnoreCase))
{
return "description";
}
foreach (var suffix in new[] { "header", "title", "content", "text", "description", "message", "subtitle" })
{
if (normalizedKey.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return suffix;
}
}
return string.Empty;
}
private static string NormalizeLookupToken(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
var trimmed = text.Trim();
var builder = new StringBuilder(trimmed.Length);
foreach (var ch in trimmed)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToLowerInvariant(ch));
}
}
return builder.ToString();
}
private static string GetFirstResourceString(IReadOnlyDictionary<string, string> resourceMap, params string[] keys)
{
foreach (var key in keys)
{
if (TryGetResourceString(resourceMap, key, out var value))
{
return value;
}
}
return string.Empty;
}
private static bool TryGetResourceString(IReadOnlyDictionary<string, string> resourceMap, string key, out string value)
{
if (resourceMap.TryGetValue(key, out var rawValue) && !string.IsNullOrWhiteSpace(rawValue))
{
value = rawValue.Trim();
return true;
}
var alternate = key.Contains('/')
? key.Replace('/', '.')
: key.Replace('.', '/');
if (!string.Equals(key, alternate, StringComparison.Ordinal) &&
resourceMap.TryGetValue(alternate, out rawValue) &&
!string.IsNullOrWhiteSpace(rawValue))
{
value = rawValue.Trim();
return true;
}
value = string.Empty;
return false;
}
private static IReadOnlyDictionary<string, string>? TryLoadResourceMap(string indexPath)
{
var candidates = new List<string>();
var envPath = Environment.GetEnvironmentVariable("SETTINGS_SEARCH_EVAL_RESW");
if (!string.IsNullOrWhiteSpace(envPath))
{
candidates.Add(Path.GetFullPath(Environment.ExpandEnvironmentVariables(envPath)));
}
var indexDirectory = Path.GetDirectoryName(Path.GetFullPath(indexPath));
if (!string.IsNullOrWhiteSpace(indexDirectory))
{
candidates.Add(Path.GetFullPath(Path.Combine(indexDirectory, "..", "..", "Strings", "en-us", "Resources.resw")));
candidates.Add(Path.GetFullPath(Path.Combine(indexDirectory, "Resources.resw")));
var repoRoot = FindRepoRoot(indexDirectory);
if (!string.IsNullOrWhiteSpace(repoRoot))
{
candidates.Add(Path.Combine(repoRoot, "src", "settings-ui", "Settings.UI", "Strings", "en-us", "Resources.resw"));
}
}
foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (!File.Exists(candidate))
{
continue;
}
var loaded = TryLoadResourceMapFromResw(candidate);
if (loaded.Count > 0)
{
return loaded;
}
}
return null;
}
private static IReadOnlyDictionary<string, string> TryLoadResourceMapFromResw(string path)
{
try
{
var document = XDocument.Load(path, LoadOptions.None);
var root = document.Root;
if (root == null)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var dataElement in root.Elements("data"))
{
var key = dataElement.Attribute("name")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var value = dataElement.Element("value")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
map[key] = value;
var dotKey = key.Replace('/', '.');
map[dotKey] = value;
var slashKey = key.Replace('.', '/');
map[slashKey] = value;
}
return map;
}
catch
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}
private static string? FindRepoRoot(string startingDirectory)
{
var current = new DirectoryInfo(startingDirectory);
while (current != null)
{
var markerPath = Path.Combine(current.FullName, "PowerToys.slnx");
if (File.Exists(markerPath))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
[GeneratedRegex(@"\s+")]
private static partial Regex ConsecutiveWhitespaceRegex();
[GeneratedRegex("([a-z0-9])([A-Z])")]
private static partial Regex CamelBoundaryRegex();
private sealed class RawSettingEntry
{
public EntryType Type { get; init; }
public string? Header { get; init; }
public string? PageTypeName { get; init; }
public string? ElementName { get; init; }
public string? ElementUid { get; init; }
public string? ParentElementName { get; init; }
public string? Description { get; init; }
public string? Icon { get; init; }
}
private sealed class RawEvaluationCase
{
public string? Query { get; init; }
public List<string>? ExpectedIds { get; init; }
public string? Notes { get; init; }
}
}

View File

@@ -0,0 +1,65 @@
// 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 SettingsSearchEvaluation;
internal static class EvaluationMath
{
public static int FindBestRank(IReadOnlyList<string> rankedResultIds, IReadOnlySet<string> expectedIds)
{
ArgumentNullException.ThrowIfNull(rankedResultIds);
ArgumentNullException.ThrowIfNull(expectedIds);
if (expectedIds.Count == 0 || rankedResultIds.Count == 0)
{
return 0;
}
for (int index = 0; index < rankedResultIds.Count; index++)
{
if (expectedIds.Contains(rankedResultIds[index]))
{
return index + 1;
}
}
return 0;
}
public static LatencySummary ComputeLatencySummary(IReadOnlyList<double> samplesMs)
{
ArgumentNullException.ThrowIfNull(samplesMs);
if (samplesMs.Count == 0)
{
return LatencySummary.Empty;
}
var sorted = samplesMs.OrderBy(x => x).ToArray();
var total = samplesMs.Sum();
return new LatencySummary
{
Samples = sorted.Length,
MinMs = sorted[0],
P50Ms = Percentile(sorted, 0.50),
P95Ms = Percentile(sorted, 0.95),
MaxMs = sorted[^1],
AverageMs = total / sorted.Length,
};
}
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
{
if (sortedSamples.Count == 0)
{
return 0;
}
var clamped = Math.Clamp(percentile, 0, 1);
var rank = (int)Math.Ceiling(clamped * sortedSamples.Count) - 1;
rank = Math.Clamp(rank, 0, sortedSamples.Count - 1);
return sortedSamples[rank];
}
}

View File

@@ -0,0 +1,18 @@
// 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 SettingsSearchEvaluation;
internal sealed class EvaluationReport
{
public required DateTimeOffset GeneratedAtUtc { get; init; }
public required string IndexJsonPath { get; init; }
public required DatasetDiagnostics Dataset { get; init; }
public required int CaseCount { get; init; }
public required IReadOnlyList<EngineEvaluationReport> Engines { get; init; }
}

View File

@@ -0,0 +1,291 @@
// 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.Diagnostics;
using Common.Search;
using Common.Search.FuzzSearch;
using Common.Search.SemanticSearch;
namespace SettingsSearchEvaluation;
internal static class Evaluator
{
public static async Task<EvaluationReport> RunAsync(
RunnerOptions options,
IReadOnlyList<SettingEntry> entries,
DatasetDiagnostics dataset,
IReadOnlyList<EvaluationCase> cases,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(dataset);
ArgumentNullException.ThrowIfNull(cases);
var reports = new List<EngineEvaluationReport>(options.Engines.Count);
foreach (var engine in options.Engines)
{
cancellationToken.ThrowIfCancellationRequested();
reports.Add(engine switch
{
SearchEngineKind.Basic => await EvaluateBasicAsync(options, entries, cases, cancellationToken),
SearchEngineKind.Semantic => await EvaluateSemanticAsync(options, entries, cases, cancellationToken),
_ => throw new InvalidOperationException($"Unsupported engine '{engine}'."),
});
}
return new EvaluationReport
{
GeneratedAtUtc = DateTimeOffset.UtcNow,
IndexJsonPath = options.InputDataPath,
Dataset = dataset,
CaseCount = cases.Count,
Engines = reports,
};
}
private static async Task<EngineEvaluationReport> EvaluateBasicAsync(
RunnerOptions options,
IReadOnlyList<SettingEntry> entries,
IReadOnlyList<EvaluationCase> cases,
CancellationToken cancellationToken)
{
using var engine = new FuzzSearchEngine<SettingEntry>();
var indexingStopwatch = Stopwatch.StartNew();
await engine.InitializeAsync(cancellationToken);
await engine.IndexBatchAsync(entries, cancellationToken);
indexingStopwatch.Stop();
var metrics = await EvaluateQueryLoopAsync(
cases,
options,
(query, searchOptions, token) => engine.SearchAsync(query, searchOptions, token),
cancellationToken);
return new EngineEvaluationReport
{
Engine = SearchEngineKind.Basic,
IsAvailable = true,
AvailabilityError = null,
CapabilitiesSummary = "Fuzzy text search engine",
IndexedEntries = entries.Count,
QueryCount = cases.Count,
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
RecallAtK = metrics.RecallAtK,
Mrr = metrics.Mrr,
SearchLatencyMs = metrics.Latency,
CaseResults = metrics.CaseResults,
};
}
private static async Task<EngineEvaluationReport> EvaluateSemanticAsync(
RunnerOptions options,
IReadOnlyList<SettingEntry> entries,
IReadOnlyList<EvaluationCase> cases,
CancellationToken cancellationToken)
{
var indexName = $"PowerToys.Settings.Eval.{Environment.ProcessId}.{Guid.NewGuid():N}";
using var engine = new SemanticSearchEngine<SettingEntry>(indexName);
var initResult = await engine.InitializeWithResultAsync(cancellationToken);
if (initResult.IsFailure || !engine.IsReady)
{
return new EngineEvaluationReport
{
Engine = SearchEngineKind.Semantic,
IsAvailable = false,
AvailabilityError = FormatError(initResult.Error) ?? "Semantic engine is not ready.",
CapabilitiesSummary = null,
IndexedEntries = 0,
QueryCount = 0,
IndexingTimeMs = 0,
RecallAtK = 0,
Mrr = 0,
SearchLatencyMs = LatencySummary.Empty,
CaseResults = Array.Empty<QueryEvaluationResult>(),
};
}
var indexingStopwatch = Stopwatch.StartNew();
var indexResult = await engine.IndexBatchWithResultAsync(entries, cancellationToken);
if (indexResult.IsFailure)
{
return new EngineEvaluationReport
{
Engine = SearchEngineKind.Semantic,
IsAvailable = false,
AvailabilityError = FormatError(indexResult.Error) ?? "Semantic indexing failed.",
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
IndexedEntries = 0,
QueryCount = 0,
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
RecallAtK = 0,
Mrr = 0,
SearchLatencyMs = LatencySummary.Empty,
CaseResults = Array.Empty<QueryEvaluationResult>(),
};
}
try
{
await engine.WaitForIndexingCompleteAsync(options.SemanticIndexTimeout);
}
catch (Exception ex)
{
return new EngineEvaluationReport
{
Engine = SearchEngineKind.Semantic,
IsAvailable = false,
AvailabilityError = $"Semantic indexing did not become idle: {ex.Message}",
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
IndexedEntries = 0,
QueryCount = 0,
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
RecallAtK = 0,
Mrr = 0,
SearchLatencyMs = LatencySummary.Empty,
CaseResults = Array.Empty<QueryEvaluationResult>(),
};
}
indexingStopwatch.Stop();
var metrics = await EvaluateQueryLoopAsync(
cases,
options,
async (query, searchOptions, token) =>
{
var result = await engine.SearchWithResultAsync(query, searchOptions, token);
return result.Value ?? Array.Empty<SearchResult<SettingEntry>>();
},
cancellationToken);
return new EngineEvaluationReport
{
Engine = SearchEngineKind.Semantic,
IsAvailable = true,
AvailabilityError = null,
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
IndexedEntries = entries.Count,
QueryCount = cases.Count,
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
RecallAtK = metrics.RecallAtK,
Mrr = metrics.Mrr,
SearchLatencyMs = metrics.Latency,
CaseResults = metrics.CaseResults,
};
}
private static async Task<QueryRunMetrics> EvaluateQueryLoopAsync(
IReadOnlyList<EvaluationCase> cases,
RunnerOptions options,
Func<string, SearchOptions, CancellationToken, Task<IReadOnlyList<SearchResult<SettingEntry>>>> searchAsync,
CancellationToken cancellationToken)
{
var caseResults = new List<QueryEvaluationResult>(cases.Count);
var latencySamples = new List<double>(Math.Max(1, cases.Count * options.Iterations));
var hits = 0;
var reciprocalRankSum = 0.0;
var searchOptions = new SearchOptions
{
MaxResults = options.MaxResults,
IncludeMatchSpans = false,
};
foreach (var queryCase in cases)
{
cancellationToken.ThrowIfCancellationRequested();
for (int warmup = 0; warmup < options.WarmupIterations; warmup++)
{
_ = await searchAsync(queryCase.Query, searchOptions, cancellationToken);
}
IReadOnlyList<SearchResult<SettingEntry>> firstMeasuredResult = Array.Empty<SearchResult<SettingEntry>>();
for (int iteration = 0; iteration < options.Iterations; iteration++)
{
var sw = Stopwatch.StartNew();
var queryResult = await searchAsync(queryCase.Query, searchOptions, cancellationToken);
sw.Stop();
latencySamples.Add(sw.Elapsed.TotalMilliseconds);
if (iteration == 0)
{
firstMeasuredResult = queryResult;
}
}
var rankedIds = firstMeasuredResult.Select(result => result.Item.Id).ToArray();
var expected = new HashSet<string>(queryCase.ExpectedIds, StringComparer.OrdinalIgnoreCase);
var bestRank = EvaluationMath.FindBestRank(rankedIds, expected);
var hit = bestRank > 0 && bestRank <= options.TopK;
if (hit)
{
hits++;
}
if (bestRank > 0)
{
reciprocalRankSum += 1.0 / bestRank;
}
caseResults.Add(new QueryEvaluationResult
{
Query = queryCase.Query,
ExpectedIds = queryCase.ExpectedIds,
TopResultIds = rankedIds.Take(options.TopK).ToArray(),
BestRank = bestRank,
HitAtK = hit,
Notes = queryCase.Notes,
});
}
var totalCases = Math.Max(1, cases.Count);
return new QueryRunMetrics
{
CaseResults = caseResults,
RecallAtK = hits / (double)totalCases,
Mrr = reciprocalRankSum / totalCases,
Latency = EvaluationMath.ComputeLatencySummary(latencySamples),
};
}
private static string? FormatError(SearchError? error)
{
if (error == null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(error.Details))
{
return $"{error.Message} ({error.Details})";
}
return error.Message;
}
private static string BuildCapabilitiesSummary(SemanticSearchCapabilities? capabilities)
{
if (capabilities == null)
{
return "Capabilities unavailable";
}
return $"TextLexical={capabilities.TextLexicalAvailable}, TextSemantic={capabilities.TextSemanticAvailable}, ImageSemantic={capabilities.ImageSemanticAvailable}, ImageOcr={capabilities.ImageOcrAvailable}";
}
private sealed class QueryRunMetrics
{
public required IReadOnlyList<QueryEvaluationResult> CaseResults { get; init; }
public required double RecallAtK { get; init; }
public required double Mrr { get; init; }
public required LatencySummary Latency { get; init; }
}
}

View File

@@ -0,0 +1,418 @@
# 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.
[CmdletBinding()]
param(
[string]$IndexJson = ".\src\settings-ui\Settings.UI\Assets\Settings\search.index.json",
[string]$ReswPath = ".\src\settings-ui\Settings.UI\Strings\en-us\Resources.resw",
[string]$OutputDir = ".\tools\SettingsSearchEvaluation\cases",
[int]$CasesPerGroup = 200
)
$ErrorActionPreference = "Stop"
$UiArtifactPattern = "(?i)(button|control|header|title|textbox|label|expander|group|page|ui|card|separator|tooltip)"
$FunctionalSignalPattern = "(?i)(enable|toggle|shortcut|hotkey|launch|mode|preview|thumbnail|color|opacity|theme|backup|restore|clipboard|rename|search|language|layout|zone|mouse|keyboard|image|file|power|workspace|zoom|accent|registry|hosts|awake|measure|crop|extract|template|history|monitor|plugin|format|encoding|command|sound|sleep)"
$LowValueIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
[void]$LowValueIds.Add("SearchResults_Title")
[void]$LowValueIds.Add("Activation_Shortcut")
[void]$LowValueIds.Add("Appearance_Behavior")
[void]$LowValueIds.Add("Admin_Mode_Running_As")
function Get-DeterministicIndex {
param(
[string]$Text,
[int]$Modulo
)
if ($Modulo -le 0) {
return 0
}
$sum = 0
foreach ($ch in $Text.ToCharArray()) {
$sum += [int][char]$ch
}
return [Math]::Abs($sum) % $Modulo
}
function Convert-IdToPhrase {
param([string]$Id)
if ([string]::IsNullOrWhiteSpace($Id)) {
return ""
}
$text = $Id -replace "_", " "
$text = [Regex]::Replace($text, "([a-z0-9])([A-Z])", '$1 $2')
$text = [Regex]::Replace($text, "\s+", " ").Trim()
return $text.ToLowerInvariant()
}
function Get-ResourceMap {
param([string]$ReswFile)
if (-not (Test-Path $ReswFile)) {
return @{}
}
[xml]$xml = Get-Content -Raw $ReswFile
$map = @{}
foreach ($data in $xml.root.data) {
$name = "$($data.name)".Trim()
$value = "$($data.value)".Trim()
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($value)) {
continue
}
$map[$name] = $value
$map[$name.Replace('/', '.')] = $value
$map[$name.Replace('.', '/')] = $value
}
return $map
}
function Get-ResourceValue {
param(
[hashtable]$Map,
[string[]]$Keys
)
foreach ($key in $Keys) {
if ($Map.ContainsKey($key) -and -not [string]::IsNullOrWhiteSpace($Map[$key])) {
return "$($Map[$key])".Trim()
}
}
return ""
}
function Get-EntryPhrase {
param(
[object]$Entry,
[hashtable]$ResourceMap
)
$id = "$($Entry.elementUid)".Trim()
if ([string]::IsNullOrWhiteSpace($id)) {
return ""
}
$type = [int]$Entry.type
$text = ""
if ($type -eq 0) {
$text = Get-ResourceValue -Map $ResourceMap -Keys @(
"$id.ModuleTitle",
"$id/ModuleTitle"
)
}
else {
$text = Get-ResourceValue -Map $ResourceMap -Keys @(
"$id.Header",
"$id/Header",
"$id.Content",
"$id/Content"
)
}
if ([string]::IsNullOrWhiteSpace($text)) {
$text = Convert-IdToPhrase -Id $id
}
$text = [Regex]::Replace($text.ToLowerInvariant(), "\s+", " ").Trim()
return $text
}
function Get-SemanticSubject {
param([string]$Phrase)
if ([string]::IsNullOrWhiteSpace($Phrase)) {
return ""
}
$subject = $Phrase
$subject = [Regex]::Replace($subject, "\b(settings|setting|card|control|toggle|header|text|button|expander|group|page|ui|title)\b", " ")
$subject = [Regex]::Replace($subject, "\s+", " ").Trim()
if ([string]::IsNullOrWhiteSpace($subject)) {
return $Phrase
}
return $subject
}
function Get-FuzzyQuery {
param(
[string]$Phrase,
[string]$Id
)
if ([string]::IsNullOrWhiteSpace($Phrase)) {
return $Id.ToLowerInvariant()
}
$words = @($Phrase -split " " | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$mode = Get-DeterministicIndex -Text $Id -Modulo 5
switch ($mode) {
0 {
if ($words.Count -ge 2) {
return "$($words[0])$($words[1])"
}
return ($words -join "")
}
1 {
if ($words.Count -ge 2) {
return "$($words[0]) $($words[-1])"
}
return "$Phrase settings"
}
2 {
$q = $Phrase -replace " and ", " & "
if ($q -ne $Phrase) {
return $q
}
return ($words -join "")
}
3 {
if ($words.Count -ge 3) {
return "$($words[0]) $($words[1])"
}
return "$Phrase option"
}
default {
$q = $Phrase -replace "power toys", "powertoys"
if ($q -ne $Phrase) {
return $q
}
return ($words -join "")
}
}
}
function Get-TypoQuery {
param([string]$Phrase)
if ([string]::IsNullOrWhiteSpace($Phrase)) {
return "typo"
}
$words = @($Phrase -split " " | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($words.Count -eq 0) {
return $Phrase
}
$longestIndex = 0
for ($i = 1; $i -lt $words.Count; $i++) {
if ($words[$i].Length -gt $words[$longestIndex].Length) {
$longestIndex = $i
}
}
$target = $words[$longestIndex]
if ($target.Length -ge 5) {
$removeAt = [Math]::Floor($target.Length / 2)
$target = $target.Remove([int]$removeAt, 1)
}
elseif ($target.Length -ge 3) {
$chars = $target.ToCharArray()
$tmp = $chars[1]
$chars[1] = $chars[2]
$chars[2] = $tmp
$target = -join $chars
}
else {
$target = "$target$target"
}
$words[$longestIndex] = $target
return ($words -join " ")
}
function Get-SemanticQuery {
param(
[string]$Phrase,
[string]$Id
)
$subject = Get-SemanticSubject -Phrase $Phrase
$idLower = $Id.ToLowerInvariant()
$action = "configure"
if ($idLower -match "enable|toggle") {
$action = "enable"
}
elseif ($idLower -match "shortcut|hotkey") {
$action = "change shortcut for"
}
elseif ($idLower -match "launch") {
$action = "launch"
}
elseif ($idLower -match "color|opacity|theme") {
$action = "change"
}
elseif ($idLower -match "preview|thumbnail") {
$action = "set preview for"
}
elseif ($idLower -match "backup|restore") {
$action = "manage backup for"
}
elseif ($idLower -match "rename") {
$action = "bulk rename with"
}
elseif ($idLower -match "awake|sleep") {
$action = "keep pc awake with"
}
$templates = @(
"how do i {0} {1}",
"where can i {0} {1}",
"i want to {0} {1}",
"help me {0} {1}"
)
$templateIndex = Get-DeterministicIndex -Text $Id -Modulo $templates.Count
return [string]::Format($templates[$templateIndex], $action, $subject)
}
function Select-EntriesForCases {
param(
[object[]]$Entries,
[int]$Count
)
$modules = @($Entries | Where-Object { $_.type -eq 0 } | Sort-Object elementUid)
$others = @($Entries | Where-Object { $_.type -ne 0 })
$highSignal = @($others | Where-Object { "$($_.elementUid)" -match $FunctionalSignalPattern } | Sort-Object elementUid)
$remaining = @($others | Where-Object { "$($_.elementUid)" -notmatch $FunctionalSignalPattern } | Sort-Object elementUid)
$ordered = @($modules + $highSignal + $remaining)
if ($ordered.Count -lt $Count) {
throw "Only found $($ordered.Count) candidate entries; need at least $Count."
}
return @($ordered | Select-Object -First $Count)
}
function Test-FunctionalCandidate {
param([object]$Entry)
$id = "$($Entry.elementUid)".Trim()
if ([string]::IsNullOrWhiteSpace($id)) {
return $false
}
if ($LowValueIds.Contains($id)) {
return $false
}
if ($Entry.type -eq 0) {
return $true
}
if ($id -match $UiArtifactPattern) {
return $false
}
if ($id -notmatch $FunctionalSignalPattern) {
return $false
}
return $true
}
$resolvedIndex = (Resolve-Path $IndexJson).Path
$entriesRaw = Get-Content -Raw $resolvedIndex | ConvertFrom-Json
$resolvedResw = $null
if (Test-Path $ReswPath) {
$resolvedResw = (Resolve-Path $ReswPath).Path
}
else {
Write-Warning "Resource file not found: $ReswPath. Falling back to UID-derived phrases."
}
$resourceMap = if ($resolvedResw) { Get-ResourceMap -ReswFile $resolvedResw } else { @{} }
$byId = @{}
foreach ($entry in $entriesRaw) {
$id = "$($entry.elementUid)".Trim()
if ([string]::IsNullOrWhiteSpace($id)) {
continue
}
if (-not $byId.ContainsKey($id)) {
$byId[$id] = $entry
}
}
$candidates = @($byId.Values | Where-Object { Test-FunctionalCandidate $_ })
$selected = Select-EntriesForCases -Entries $candidates -Count $CasesPerGroup
$groups = [ordered]@{
exact = @()
fuzzy = @()
typo = @()
semantic = @()
}
foreach ($entry in $selected) {
$id = "$($entry.elementUid)".Trim()
$phrase = Get-EntryPhrase -Entry $entry -ResourceMap $resourceMap
if ([string]::IsNullOrWhiteSpace($phrase)) {
$phrase = Convert-IdToPhrase -Id $id
}
$groups.exact += [ordered]@{
group = "exact"
query = $phrase
expectedIds = @($id)
notes = "exact:$id"
}
$groups.fuzzy += [ordered]@{
group = "fuzzy"
query = (Get-FuzzyQuery -Phrase $phrase -Id $id)
expectedIds = @($id)
notes = "fuzzy:$id"
}
$groups.typo += [ordered]@{
group = "typo"
query = (Get-TypoQuery -Phrase $phrase)
expectedIds = @($id)
notes = "typo:$id"
}
$groups.semantic += [ordered]@{
group = "semantic"
query = (Get-SemanticQuery -Phrase $phrase -Id $id)
expectedIds = @($id)
notes = "semantic:$id"
}
}
if (-not (Test-Path $OutputDir)) {
New-Item -Path $OutputDir -ItemType Directory -Force | Out-Null
}
foreach ($groupName in $groups.Keys) {
$out = Join-Path $OutputDir ("settings-search-cases.{0}.200.json" -f $groupName)
($groups[$groupName] | ConvertTo-Json -Depth 5) | Set-Content -Path $out -Encoding UTF8
}
$combined = @($groups.exact + $groups.fuzzy + $groups.typo + $groups.semantic)
$combinedPath = Join-Path $OutputDir "settings-search-cases.grouped.800.json"
($combined | ConvertTo-Json -Depth 5) | Set-Content -Path $combinedPath -Encoding UTF8
Write-Host "Generated grouped case files in '$OutputDir'."
Write-Host " exact: $($groups.exact.Count)"
Write-Host " fuzzy: $($groups.fuzzy.Count)"
Write-Host " typo: $($groups.typo.Count)"
Write-Host " semantic: $($groups.semantic.Count)"
Write-Host " combined: $($combined.Count)"
Write-Host "Combined file: $combinedPath"

View File

@@ -0,0 +1,302 @@
# 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.
[CmdletBinding()]
param(
[string[]]$InputReports = @(
".\tools\SettingsSearchEvaluation\artifacts\report.basic.direct.json",
".\tools\SettingsSearchEvaluation\artifacts\report.semantic.aumid.json"
),
[string]$OutputHtml = ".\tools\SettingsSearchEvaluation\artifacts\search-profile-dashboard.html"
)
$ErrorActionPreference = "Stop"
function Get-EngineName {
param([int]$EngineCode)
switch ($EngineCode) {
0 { "Basic" }
1 { "Semantic" }
default { "Engine$EngineCode" }
}
}
$runRows = @()
foreach ($path in $InputReports) {
if (-not (Test-Path $path)) {
Write-Warning "Skipping missing report: $path"
continue
}
$resolvedPath = (Resolve-Path $path).Path
$report = Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json
$engines = @($report.Engines)
foreach ($engine in $engines) {
$caseResults = @($engine.CaseResults)
$hitCount = ($caseResults | Where-Object { $_.HitAtK }).Count
$missCount = ($caseResults | Where-Object { -not $_.HitAtK }).Count
$runRows += [ordered]@{
sourceFile = [System.IO.Path]::GetFileName($resolvedPath)
reportPath = $resolvedPath
generatedAtUtc = [string]$report.GeneratedAtUtc
engineCode = [int]$engine.Engine
engineName = Get-EngineName -EngineCode ([int]$engine.Engine)
isAvailable = [bool]$engine.IsAvailable
availabilityError = [string]$engine.AvailabilityError
capabilities = [string]$engine.CapabilitiesSummary
caseCount = [int]$report.CaseCount
queryCount = [int]$engine.QueryCount
hitCount = [int]$hitCount
missCount = [int]$missCount
recallAtK = [double]$engine.RecallAtK
mrr = [double]$engine.Mrr
indexingTimeMs = [double]$engine.IndexingTimeMs
latency = $engine.SearchLatencyMs
caseResults = $caseResults
}
}
}
if ($runRows.Count -eq 0) {
throw "No report data found. Provide valid -InputReports paths."
}
$runRowsJson = $runRows | ConvertTo-Json -Depth 30 -Compress
$htmlTemplate = @'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Settings Search Profiling Dashboard</title>
<style>
:root {
--bg: #f6f8fb;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--line: #dbe2ea;
--ok: #0f766e;
--bad: #b42318;
--bar: #2563eb;
--bar2: #0ea5e9;
}
body {
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--text);
font-family: Segoe UI, Arial, sans-serif;
}
h1, h2, h3 { margin: 0 0 10px 0; }
.sub { color: var(--muted); margin-bottom: 16px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 7px 8px;
text-align: left;
vertical-align: top;
}
th { color: var(--muted); font-weight: 600; }
.hit { color: var(--ok); font-weight: 600; }
.miss { color: var(--bad); font-weight: 600; }
.bar-row { margin: 8px 0; }
.bar-label {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 3px;
}
.track {
height: 10px;
border-radius: 7px;
background: #e7edf4;
overflow: hidden;
}
.fill {
height: 100%;
background: linear-gradient(90deg, var(--bar), var(--bar2));
}
.mono { font-family: Consolas, "Courier New", monospace; }
</style>
</head>
<body>
<h1>Settings Search Profiling Dashboard</h1>
<div class="sub" id="meta"></div>
<div class="card" style="margin-bottom: 18px;">
<h2>Run Summary</h2>
<table>
<thead>
<tr>
<th>Run</th>
<th>Recall@K</th>
<th>MRR</th>
<th>Hits</th>
<th>Misses</th>
<th>Indexing (ms)</th>
<th>P50 (ms)</th>
<th>P95 (ms)</th>
</tr>
</thead>
<tbody id="summaryRows"></tbody>
</table>
</div>
<div class="grid" id="metricCards"></div>
<div class="card" style="margin-top: 18px;">
<h2>Per-Query Comparison</h2>
<table>
<thead id="queryHead"></thead>
<tbody id="queryBody"></tbody>
</table>
</div>
<div class="grid" id="missCards" style="margin-top: 18px;"></div>
<script>
const runs = __RUN_DATA__;
function esc(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function pct(v) { return `${(Number(v || 0) * 100).toFixed(1)}%`; }
function ms(v) { return Number(v || 0).toFixed(2); }
const runLabel = (r) => `${r.engineName} (${r.sourceFile})`;
document.getElementById("meta").textContent =
`${runs.length} run(s) loaded. Generated from ${runs.map(r => r.sourceFile).join(", ")}`;
const summaryHtml = runs.map(r => `
<tr>
<td>${esc(runLabel(r))}</td>
<td>${pct(r.recallAtK)}</td>
<td>${pct(r.mrr)}</td>
<td>${r.hitCount}/${r.caseCount}</td>
<td>${r.missCount}</td>
<td>${ms(r.indexingTimeMs)}</td>
<td>${ms(r.latency?.P50Ms)}</td>
<td>${ms(r.latency?.P95Ms)}</td>
</tr>
`).join("");
document.getElementById("summaryRows").innerHTML = summaryHtml;
function metricCard(title, getValue, formatValue, widthMode) {
const values = runs.map(getValue);
const max = Math.max(1, ...values);
const rows = runs.map((r, i) => {
const value = values[i];
const width = widthMode === "fraction" ? Math.max(0, Math.min(100, value * 100)) : Math.max(0, Math.min(100, (value / max) * 100));
return `
<div class="bar-row">
<div class="bar-label">
<span>${esc(runLabel(r))}</span>
<span>${formatValue(value)}</span>
</div>
<div class="track"><div class="fill" style="width:${width.toFixed(1)}%"></div></div>
</div>
`;
}).join("");
return `<div class="card"><h3>${esc(title)}</h3>${rows}</div>`;
}
const metricCards = [
metricCard("Recall@K", r => Number(r.recallAtK || 0), v => pct(v), "fraction"),
metricCard("MRR", r => Number(r.mrr || 0), v => pct(v), "fraction"),
metricCard("P95 Latency (ms)", r => Number(r.latency?.P95Ms || 0), v => ms(v), "relative"),
metricCard("Indexing Time (ms)", r => Number(r.indexingTimeMs || 0), v => ms(v), "relative")
].join("");
document.getElementById("metricCards").innerHTML = metricCards;
const allQueries = new Map();
runs.forEach((r, runIndex) => {
(r.caseResults || []).forEach(c => {
if (!allQueries.has(c.Query)) {
allQueries.set(c.Query, {
query: c.Query,
expected: (c.ExpectedIds || []).join(", "),
values: Array(runs.length).fill(null)
});
}
allQueries.get(c.Query).values[runIndex] = c;
});
});
const queryHeadHtml =
`<tr><th>Query</th><th>Expected</th>${runs.map(r => `<th>${esc(runLabel(r))}</th>`).join("")}</tr>`;
document.getElementById("queryHead").innerHTML = queryHeadHtml;
const queryRows = Array.from(allQueries.values())
.sort((a, b) => a.query.localeCompare(b.query))
.map(row => {
const cells = row.values.map(v => {
if (!v) {
return `<td class="mono">-</td>`;
}
const cls = v.HitAtK ? "hit" : "miss";
const rank = Number(v.BestRank || 0);
const top = (v.TopResultIds || []).join(", ");
return `<td><div class="${cls}">${v.HitAtK ? "hit" : "miss"} (rank ${rank})</div><div class="mono">${esc(top || "(none)")}</div></td>`;
}).join("");
return `<tr><td>${esc(row.query)}</td><td class="mono">${esc(row.expected)}</td>${cells}</tr>`;
}).join("");
document.getElementById("queryBody").innerHTML = queryRows;
const missCards = runs.map(r => {
const misses = (r.caseResults || []).filter(c => !c.HitAtK);
const rows = misses.length === 0
? "<div class='hit'>No misses.</div>"
: `<table><thead><tr><th>Query</th><th>Expected</th><th>Top Results</th></tr></thead><tbody>${
misses.map(m => `
<tr>
<td>${esc(m.Query)}</td>
<td class="mono">${esc((m.ExpectedIds || []).join(", "))}</td>
<td class="mono">${esc((m.TopResultIds || []).join(", ") || "(none)")}</td>
</tr>
`).join("")
}</tbody></table>`;
return `<div class="card"><h3>Misses: ${esc(runLabel(r))}</h3>${rows}</div>`;
}).join("");
document.getElementById("missCards").innerHTML = missCards;
</script>
</body>
</html>
'@
$dashboardHtml = $htmlTemplate.Replace("__RUN_DATA__", $runRowsJson)
$outputPath = [System.IO.Path]::GetFullPath($OutputHtml)
$outputDirectory = [System.IO.Path]::GetDirectoryName($outputPath)
if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path $outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory | Out-Null
}
Set-Content -Path $outputPath -Value $dashboardHtml -Encoding UTF8
Write-Host "Dashboard written to: $outputPath"

View File

@@ -0,0 +1,22 @@
// 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 SettingsSearchEvaluation;
internal sealed class LatencySummary
{
public int Samples { get; init; }
public double MinMs { get; init; }
public double P50Ms { get; init; }
public double P95Ms { get; init; }
public double MaxMs { get; init; }
public double AverageMs { get; init; }
public static LatencySummary Empty { get; } = new();
}

View File

@@ -0,0 +1,330 @@
# 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.
[CmdletBinding()]
param(
[string]$CasesJson = ".\tools\SettingsSearchEvaluation\cases\settings-search-cases.grouped.800.json",
[string]$NormalizedCorpus = ".\tools\SettingsSearchEvaluation\artifacts\normalized-settings-corpus.tsv",
[string[]]$Engines = @("basic", "semantic"),
[int]$TopK = 5,
[int]$MaxResults = 10,
[int]$Iterations = 5,
[int]$Warmup = 1,
[int]$SemanticTimeoutMs = 15000,
[int]$SampleIntervalMs = 500,
[int]$TimeoutMinutes = 30,
[string]$OutputJson = "",
[switch]$NoDashboard
)
$ErrorActionPreference = "Stop"
function Write-ArgsFile {
param(
[string]$Path,
[string[]]$Lines
)
$parent = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($parent)) {
[System.IO.Directory]::CreateDirectory($parent) | Out-Null
}
[System.IO.File]::WriteAllLines($Path, $Lines)
}
function Get-EngineCode {
param([string]$Engine)
switch ($Engine.ToLowerInvariant()) {
"basic" { return 0 }
"semantic" { return 1 }
default { throw "Unsupported engine '$Engine'. Use basic or semantic." }
}
}
function Start-ProfiledRun {
param(
[string]$Engine,
[string]$PackageFamilyName,
[string]$RepoRoot,
[string]$CasesPath,
[string]$CorpusPath,
[string]$ArtifactsDir,
[int]$TopKValue,
[int]$MaxResultsValue,
[int]$IterationsValue,
[int]$WarmupValue,
[int]$SemanticTimeoutValue,
[int]$SampleIntervalValue,
[int]$TimeoutMinutesValue
)
$ts = Get-Date -Format "yyyyMMdd-HHmmss"
$engineName = $Engine.ToLowerInvariant()
$reportPath = Join-Path $ArtifactsDir ("report.profile.{0}.{1}.json" -f $engineName, $ts)
$samplesPath = Join-Path $ArtifactsDir ("samples.profile.{0}.{1}.json" -f $engineName, $ts)
$argsLines = @(
"--normalized-corpus",
$CorpusPath,
"--cases-json",
$CasesPath,
"--engine",
$engineName,
"--top-k",
"$TopKValue",
"--max-results",
"$MaxResultsValue",
"--iterations",
"$IterationsValue",
"--warmup",
"$WarmupValue",
"--semantic-timeout-ms",
"$SemanticTimeoutValue",
"--output-json",
$reportPath
)
$userArgs = Join-Path $env:LOCALAPPDATA "PowerToys.SettingsSearchEvaluation\launch.args.txt"
Write-ArgsFile -Path $userArgs -Lines $argsLines
$pkgArgsRoot = Join-Path $env:LOCALAPPDATA ("Packages\{0}\LocalCache\Local\PowerToys.SettingsSearchEvaluation" -f $PackageFamilyName)
[System.IO.Directory]::CreateDirectory($pkgArgsRoot) | Out-Null
Write-ArgsFile -Path (Join-Path $pkgArgsRoot "launch.args.txt") -Lines $argsLines
$aumid = "shell:AppsFolder\{0}!SettingsSearchEvaluation" -f $PackageFamilyName
$deadline = (Get-Date).AddMinutes($TimeoutMinutesValue)
$startWall = Get-Date
$logicalProcessors = [Environment]::ProcessorCount
$samples = [System.Collections.Generic.List[object]]::new()
Start-Process explorer.exe -ArgumentList $aumid | Out-Null
$processStarted = $false
$stableExitCount = 0
while ((Get-Date) -lt $deadline) {
$now = Get-Date
$procs = Get-Process -Name "SettingsSearchEvaluation" -ErrorAction SilentlyContinue
$procCount = @($procs).Count
if ($procCount -gt 0) {
$processStarted = $true
$stableExitCount = 0
$cpuSec = (@($procs | Measure-Object -Property CPU -Sum).Sum)
$wsBytes = (@($procs | Measure-Object -Property WorkingSet64 -Sum).Sum)
$privateBytes = (@($procs | Measure-Object -Property PrivateMemorySize64 -Sum).Sum)
if ($null -eq $cpuSec) { $cpuSec = 0.0 }
if ($null -eq $wsBytes) { $wsBytes = 0.0 }
if ($null -eq $privateBytes) { $privateBytes = 0.0 }
$samples.Add([pscustomobject]@{
TimestampUtc = $now.ToUniversalTime().ToString("o")
CpuSeconds = [double]$cpuSec
WorkingSetMB = [math]::Round(([double]$wsBytes / 1MB), 3)
PrivateMB = [math]::Round(([double]$privateBytes / 1MB), 3)
ProcessCount = $procCount
})
}
elseif (Test-Path $reportPath) {
$stableExitCount++
if ($stableExitCount -ge 3) {
break
}
}
Start-Sleep -Milliseconds $SampleIntervalValue
}
if (-not (Test-Path $reportPath)) {
throw "Timed out waiting for output report for engine '$engineName': $reportPath"
}
$endWall = Get-Date
$report = Get-Content -Raw $reportPath | ConvertFrom-Json
$engineCode = Get-EngineCode -Engine $engineName
$engineReport = @($report.Engines | Where-Object { $_.Engine -eq $engineCode })[0]
if ($null -eq $engineReport) {
throw "Engine report not found for '$engineName' in $reportPath"
}
$firstSample = $null
$lastSample = $null
if ($samples.Count -gt 0) {
$firstSample = $samples[0]
$lastSample = $samples[$samples.Count - 1]
}
$elapsedSeconds = ($endWall - $startWall).TotalSeconds
$cpuSecondsDelta = 0.0
if ($null -ne $firstSample -and $null -ne $lastSample) {
$cpuSecondsDelta = [double]$lastSample.CpuSeconds - [double]$firstSample.CpuSeconds
if ($cpuSecondsDelta -lt 0) {
$cpuSecondsDelta = 0.0
}
}
$avgCpuPercent = 0.0
if ($elapsedSeconds -gt 0 -and $logicalProcessors -gt 0) {
$avgCpuPercent = ($cpuSecondsDelta / $elapsedSeconds / $logicalProcessors) * 100.0
}
$peakWorkingSet = 0.0
$peakPrivate = 0.0
$avgWorkingSet = 0.0
$avgPrivate = 0.0
if ($samples.Count -gt 0) {
$peakWorkingSet = [double](@($samples | Measure-Object -Property WorkingSetMB -Maximum).Maximum)
$peakPrivate = [double](@($samples | Measure-Object -Property PrivateMB -Maximum).Maximum)
$avgWorkingSet = [double](@($samples | Measure-Object -Property WorkingSetMB -Average).Average)
$avgPrivate = [double](@($samples | Measure-Object -Property PrivateMB -Average).Average)
}
($samples | ConvertTo-Json -Depth 5) | Set-Content -Path $samplesPath -Encoding UTF8
return [pscustomobject]@{
Engine = $engineName
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
ReportPath = $reportPath
SamplesPath = $samplesPath
IsAvailable = [bool]$engineReport.IsAvailable
CapabilitiesSummary = [string]$engineReport.CapabilitiesSummary
AvailabilityError = [string]$engineReport.AvailabilityError
IndexingTimeMs = [double]$engineReport.IndexingTimeMs
RecallAtK = [double]$engineReport.RecallAtK
Mrr = [double]$engineReport.Mrr
QueryCount = [int]$engineReport.QueryCount
WallClockSeconds = [math]::Round($elapsedSeconds, 3)
CpuSeconds = [math]::Round($cpuSecondsDelta, 3)
AvgCpuPercent = [math]::Round($avgCpuPercent, 3)
PeakWorkingSetMB = [math]::Round($peakWorkingSet, 3)
PeakPrivateMB = [math]::Round($peakPrivate, 3)
AvgWorkingSetMB = [math]::Round($avgWorkingSet, 3)
AvgPrivateMB = [math]::Round($avgPrivate, 3)
SampleCount = $samples.Count
}
}
$repoRoot = (Resolve-Path ".").Path
$artifacts = Join-Path $repoRoot "tools\SettingsSearchEvaluation\artifacts"
[System.IO.Directory]::CreateDirectory($artifacts) | Out-Null
$resolvedCases = (Resolve-Path $CasesJson).Path
$resolvedCorpus = (Resolve-Path $NormalizedCorpus).Path
$pkg = Get-AppxPackage Microsoft.PowerToys.SettingsSearchEvaluation
if (-not $pkg) {
throw "Package 'Microsoft.PowerToys.SettingsSearchEvaluation' is not installed."
}
$runResults = [System.Collections.Generic.List[object]]::new()
foreach ($engine in $Engines) {
$runResults.Add((Start-ProfiledRun `
-Engine $engine `
-PackageFamilyName $pkg.PackageFamilyName `
-RepoRoot $repoRoot `
-CasesPath $resolvedCases `
-CorpusPath $resolvedCorpus `
-ArtifactsDir $artifacts `
-TopKValue $TopK `
-MaxResultsValue $MaxResults `
-IterationsValue $Iterations `
-WarmupValue $Warmup `
-SemanticTimeoutValue $SemanticTimeoutMs `
-SampleIntervalValue $SampleIntervalMs `
-TimeoutMinutesValue $TimeoutMinutes))
}
$comparison = [ordered]@{
GeneratedAtUtc = (Get-Date).ToUniversalTime().ToString("o")
CasesJson = $resolvedCases
NormalizedCorpus = $resolvedCorpus
Iterations = $Iterations
Warmup = $Warmup
MaxResults = $MaxResults
TopK = $TopK
SampleIntervalMs = $SampleIntervalMs
LogicalProcessors = [Environment]::ProcessorCount
Runs = $runResults
}
if ([string]::IsNullOrWhiteSpace($OutputJson)) {
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$OutputJson = Join-Path $artifacts ("resource-profile.comparison.{0}.json" -f $stamp)
}
($comparison | ConvertTo-Json -Depth 8) | Set-Content -Path $OutputJson -Encoding UTF8
Write-Host "Resource profile written to: $OutputJson"
if (-not $NoDashboard) {
$dashboardPath = [System.IO.Path]::ChangeExtension($OutputJson, ".html")
$rowsJson = ($runResults | ConvertTo-Json -Depth 6 -Compress)
$html = @"
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Settings Search Resource Profile</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 24px; background: #f6f8fb; color: #1f2937; }
table { border-collapse: collapse; width: 100%; background: #fff; }
th, td { border: 1px solid #dbe2ea; padding: 8px; font-size: 13px; text-align: left; }
th { background: #f1f5f9; }
h1 { margin: 0 0 12px 0; }
.muted { color: #64748b; margin-bottom: 14px; }
</style>
</head>
<body>
<h1>Settings Search Resource Profile</h1>
<div class="muted">CPU and memory comparison for evaluator runs.</div>
<table id="t">
<thead>
<tr>
<th>Engine</th>
<th>Available</th>
<th>Indexing ms</th>
<th>Wall sec</th>
<th>CPU sec</th>
<th>Avg CPU %</th>
<th>Peak WS MB</th>
<th>Peak Private MB</th>
<th>Recall@K</th>
<th>MRR</th>
</tr>
</thead>
<tbody></tbody>
</table>
<script>
const rows = $rowsJson;
const tbody = document.querySelector("#t tbody");
for (const r of rows) {
const tr = document.createElement("tr");
const values = [
r.Engine,
r.IsAvailable,
r.IndexingTimeMs?.toFixed(2),
r.WallClockSeconds?.toFixed(2),
r.CpuSeconds?.toFixed(2),
r.AvgCpuPercent?.toFixed(2),
r.PeakWorkingSetMB?.toFixed(2),
r.PeakPrivateMB?.toFixed(2),
r.RecallAtK?.toFixed(4),
r.Mrr?.toFixed(4)
];
for (const v of values) {
const td = document.createElement("td");
td.textContent = String(v);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
</script>
</body>
</html>
"@
Set-Content -Path $dashboardPath -Value $html -Encoding UTF8
Write-Host "Resource profile dashboard written to: $dashboardPath"
}

View File

@@ -0,0 +1,535 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
namespace SettingsSearchEvaluation;
internal static class Program
{
private static readonly JsonSerializerOptions OutputJsonOptions = new()
{
WriteIndented = true,
};
private static int Main(string[] args)
{
try
{
return MainAsync(args).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Unhandled error: {ex.Message}");
return 99;
}
}
private static async Task<int> MainAsync(string[] args)
{
args = ResolveArgs(args);
if (args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase)))
{
PrintUsage();
return 0;
}
if (!TryParseArgs(args, out var options, out var parseError))
{
Console.Error.WriteLine(parseError);
Console.Error.WriteLine();
PrintUsage();
return 2;
}
var sourcePath = options.InputDataPath;
if (!File.Exists(sourcePath))
{
Console.Error.WriteLine($"Input file not found: {sourcePath}");
return 3;
}
if (!string.IsNullOrWhiteSpace(options.CasesJsonPath) && !File.Exists(options.CasesJsonPath))
{
Console.Error.WriteLine($"Cases file not found: {options.CasesJsonPath}");
return 3;
}
var (entries, dataset) = options.UseNormalizedCorpus
? EvaluationDataLoader.LoadEntriesFromNormalizedCorpusFile(sourcePath)
: EvaluationDataLoader.LoadEntriesFromFile(sourcePath);
if (!string.IsNullOrWhiteSpace(options.ExportNormalizedPath))
{
EvaluationDataLoader.WriteNormalizedCorpusFile(options.ExportNormalizedPath, entries);
Console.WriteLine($"Wrote normalized corpus to '{options.ExportNormalizedPath}'.");
var textOnlyPath = GetTextOnlyCorpusPath(options.ExportNormalizedPath);
EvaluationDataLoader.WriteNormalizedTextCorpusFile(textOnlyPath, entries);
Console.WriteLine($"Wrote text-only normalized corpus to '{textOnlyPath}'.");
}
if (options.ExportOnly)
{
return 0;
}
var cases = EvaluationDataLoader.LoadCases(options.CasesJsonPath, entries);
if (cases.Count == 0)
{
Console.Error.WriteLine("No valid evaluation cases were found.");
return 3;
}
Console.WriteLine($"Loaded {entries.Count} entries from '{sourcePath}'.");
Console.WriteLine($"Cases: {cases.Count}");
Console.WriteLine($"Duplicate id buckets: {dataset.DuplicateIdBucketCount}");
if (dataset.DuplicateIdBucketCount > 0)
{
var largest = dataset.DuplicateIdCounts
.OrderByDescending(x => x.Value)
.Take(5)
.Select(x => $"{x.Key} x{x.Value}");
Console.WriteLine($"Top duplicate ids: {string.Join(", ", largest)}");
}
var report = await Evaluator.RunAsync(options, entries, dataset, cases);
PrintSummary(report, options.TopK);
if (!string.IsNullOrWhiteSpace(options.OutputJsonPath))
{
var outputDirectory = Path.GetDirectoryName(options.OutputJsonPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
var json = JsonSerializer.Serialize(report, OutputJsonOptions);
File.WriteAllText(options.OutputJsonPath, json);
Console.WriteLine($"Wrote report to '{options.OutputJsonPath}'.");
}
return report.Engines.Any(engine => engine.IsAvailable) ? 0 : 4;
}
private static bool TryParseArgs(string[] args, out RunnerOptions options, out string error)
{
string defaultIndex = GetDefaultIndexPath();
string? indexPath = null;
string? normalizedCorpusPath = null;
string? exportNormalizedPath = null;
string? casesPath = null;
string? outputPath = null;
var exportOnly = false;
var indexPathExplicitlySet = false;
var maxResults = 10;
var topK = 5;
var iterations = 5;
var warmup = 1;
var semanticTimeoutMs = 15000;
IReadOnlyList<SearchEngineKind> engines = new[] { SearchEngineKind.Basic, SearchEngineKind.Semantic };
for (int i = 0; i < args.Length; i++)
{
var arg = args[i];
switch (arg.ToLowerInvariant())
{
case "--index-json":
if (!TryReadValue(args, ref i, out indexPath))
{
options = null!;
error = "Missing value for --index-json";
return false;
}
indexPathExplicitlySet = true;
break;
case "--normalized-corpus":
if (!TryReadValue(args, ref i, out normalizedCorpusPath))
{
options = null!;
error = "Missing value for --normalized-corpus";
return false;
}
break;
case "--export-normalized":
if (!TryReadValue(args, ref i, out exportNormalizedPath))
{
options = null!;
error = "Missing value for --export-normalized";
return false;
}
break;
case "--export-only":
exportOnly = true;
break;
case "--cases-json":
if (!TryReadValue(args, ref i, out casesPath))
{
options = null!;
error = "Missing value for --cases-json";
return false;
}
break;
case "--output-json":
if (!TryReadValue(args, ref i, out outputPath))
{
options = null!;
error = "Missing value for --output-json";
return false;
}
break;
case "--engine":
if (!TryReadValue(args, ref i, out var engineText))
{
options = null!;
error = "Missing value for --engine";
return false;
}
if (!TryParseEngines(engineText!, out engines))
{
options = null!;
error = "Invalid --engine value. Allowed values: basic, semantic, both.";
return false;
}
break;
case "--max-results":
if (!TryReadInt(args, ref i, out maxResults) || maxResults <= 0)
{
options = null!;
error = "Invalid --max-results value. Must be a positive integer.";
return false;
}
break;
case "--top-k":
if (!TryReadInt(args, ref i, out topK) || topK <= 0)
{
options = null!;
error = "Invalid --top-k value. Must be a positive integer.";
return false;
}
break;
case "--iterations":
if (!TryReadInt(args, ref i, out iterations) || iterations <= 0)
{
options = null!;
error = "Invalid --iterations value. Must be a positive integer.";
return false;
}
break;
case "--warmup":
if (!TryReadInt(args, ref i, out warmup) || warmup < 0)
{
options = null!;
error = "Invalid --warmup value. Must be a non-negative integer.";
return false;
}
break;
case "--semantic-timeout-ms":
if (!TryReadInt(args, ref i, out semanticTimeoutMs) || semanticTimeoutMs <= 0)
{
options = null!;
error = "Invalid --semantic-timeout-ms value. Must be a positive integer.";
return false;
}
break;
default:
options = null!;
error = $"Unknown argument: {arg}";
return false;
}
}
if (!string.IsNullOrWhiteSpace(normalizedCorpusPath) && indexPathExplicitlySet)
{
options = null!;
error = "Use either --index-json or --normalized-corpus, not both.";
return false;
}
if (exportOnly && string.IsNullOrWhiteSpace(exportNormalizedPath))
{
options = null!;
error = "--export-only requires --export-normalized <path>.";
return false;
}
options = new RunnerOptions
{
IndexJsonPath = Path.GetFullPath(indexPath ?? defaultIndex),
NormalizedCorpusPath = string.IsNullOrWhiteSpace(normalizedCorpusPath) ? null : Path.GetFullPath(normalizedCorpusPath),
ExportNormalizedPath = string.IsNullOrWhiteSpace(exportNormalizedPath) ? null : Path.GetFullPath(exportNormalizedPath),
ExportOnly = exportOnly,
CasesJsonPath = string.IsNullOrWhiteSpace(casesPath) ? null : Path.GetFullPath(casesPath),
Engines = engines,
MaxResults = maxResults,
TopK = topK,
Iterations = iterations,
WarmupIterations = warmup,
SemanticIndexTimeout = TimeSpan.FromMilliseconds(semanticTimeoutMs),
OutputJsonPath = string.IsNullOrWhiteSpace(outputPath) ? null : Path.GetFullPath(outputPath),
};
error = string.Empty;
return true;
}
private static string GetDefaultIndexPath()
{
var repoRoot = FindRepoRoot(AppContext.BaseDirectory);
if (!string.IsNullOrWhiteSpace(repoRoot))
{
return Path.GetFullPath(Path.Combine(repoRoot, "src", "settings-ui", "Settings.UI", "Assets", "Settings", "search.index.json"));
}
// Shared packaged app fallback: expect input in current working directory.
return Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "search.index.json"));
}
private static string? FindRepoRoot(string startingDirectory)
{
var current = new DirectoryInfo(startingDirectory);
while (current != null)
{
var markerPath = Path.Combine(current.FullName, "PowerToys.slnx");
if (File.Exists(markerPath))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
private static string[] ResolveArgs(string[] args)
{
if (args.Length > 0)
{
var hasEvaluatorOptions = args.Any(arg => arg.StartsWith("--", StringComparison.Ordinal));
if (hasEvaluatorOptions)
{
return args;
}
var startupArgs = LoadStartupArgs();
if (startupArgs.Length > 0)
{
Console.WriteLine("Activation args were ignored because they are not evaluator options.");
return startupArgs;
}
return args;
}
var startupArgsFromFile = LoadStartupArgs();
return startupArgsFromFile.Length > 0 ? startupArgsFromFile : args;
}
private static string[] LoadStartupArgs()
{
var envArgsPath = Environment.GetEnvironmentVariable("SETTINGS_SEARCH_EVAL_ARGS_FILE");
if (!string.IsNullOrWhiteSpace(envArgsPath))
{
var fromEnv = Path.GetFullPath(Environment.ExpandEnvironmentVariables(envArgsPath));
var loadedFromEnv = LoadArgsFile(fromEnv);
if (loadedFromEnv.Length > 0)
{
Console.WriteLine($"Loaded {loadedFromEnv.Length} startup args from '{fromEnv}'.");
return loadedFromEnv;
}
}
foreach (var candidate in GetStartupArgsCandidates())
{
var loadedArgs = LoadArgsFile(candidate);
if (loadedArgs.Length > 0)
{
Console.WriteLine($"Loaded {loadedArgs.Length} startup args from '{candidate}'.");
return loadedArgs;
}
}
return Array.Empty<string>();
}
private static IEnumerable<string> GetStartupArgsCandidates()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(localAppData))
{
yield return Path.Combine(localAppData, "PowerToys.SettingsSearchEvaluation", "launch.args.txt");
}
var rawLocalAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA");
if (!string.IsNullOrWhiteSpace(rawLocalAppData))
{
yield return Path.Combine(rawLocalAppData, "PowerToys.SettingsSearchEvaluation", "launch.args.txt");
}
yield return Path.Combine(AppContext.BaseDirectory, "launch.args.txt");
var repoRoot = FindRepoRoot(AppContext.BaseDirectory);
if (!string.IsNullOrWhiteSpace(repoRoot))
{
yield return Path.Combine(repoRoot, "tools", "SettingsSearchEvaluation", "artifacts", "launch.args.txt");
}
}
private static string[] LoadArgsFile(string path)
{
if (!File.Exists(path))
{
return Array.Empty<string>();
}
return File.ReadAllLines(path)
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))
.ToArray();
}
private static bool TryParseEngines(string value, out IReadOnlyList<SearchEngineKind> engines)
{
if (string.Equals(value, "both", StringComparison.OrdinalIgnoreCase))
{
engines = new[] { SearchEngineKind.Basic, SearchEngineKind.Semantic };
return true;
}
if (string.Equals(value, "basic", StringComparison.OrdinalIgnoreCase))
{
engines = new[] { SearchEngineKind.Basic };
return true;
}
if (string.Equals(value, "semantic", StringComparison.OrdinalIgnoreCase))
{
engines = new[] { SearchEngineKind.Semantic };
return true;
}
engines = Array.Empty<SearchEngineKind>();
return false;
}
private static bool TryReadValue(string[] args, ref int index, out string? value)
{
if (index + 1 >= args.Length)
{
value = null;
return false;
}
index++;
value = args[index];
return true;
}
private static bool TryReadInt(string[] args, ref int index, out int value)
{
value = 0;
if (!TryReadValue(args, ref index, out var text))
{
return false;
}
return int.TryParse(text, out value);
}
private static string GetTextOnlyCorpusPath(string exportPath)
{
var directory = Path.GetDirectoryName(exportPath) ?? string.Empty;
var fileName = Path.GetFileNameWithoutExtension(exportPath);
var extension = Path.GetExtension(exportPath);
var suffix = string.IsNullOrWhiteSpace(extension) ? ".text" : $".text{extension}";
return Path.Combine(directory, $"{fileName}{suffix}");
}
private static void PrintSummary(EvaluationReport report, int topK)
{
Console.WriteLine();
Console.WriteLine("=== Evaluation Summary ===");
Console.WriteLine($"Generated: {report.GeneratedAtUtc:O}");
Console.WriteLine($"Dataset entries: {report.Dataset.TotalEntries} ({report.Dataset.DistinctIds} distinct ids)");
Console.WriteLine($"Cases: {report.CaseCount}");
Console.WriteLine();
foreach (var engine in report.Engines)
{
Console.WriteLine($"[{engine.Engine}]");
if (!engine.IsAvailable)
{
Console.WriteLine($" Unavailable: {engine.AvailabilityError}");
Console.WriteLine();
continue;
}
Console.WriteLine($" Capabilities: {engine.CapabilitiesSummary}");
Console.WriteLine($" Indexed entries: {engine.IndexedEntries}");
Console.WriteLine($" Indexing time (ms): {engine.IndexingTimeMs:F2}");
Console.WriteLine($" Recall@{topK}: {engine.RecallAtK:F4}");
Console.WriteLine($" MRR: {engine.Mrr:F4}");
Console.WriteLine($" Search latency ms (avg/p50/p95/max): {engine.SearchLatencyMs.AverageMs:F2}/{engine.SearchLatencyMs.P50Ms:F2}/{engine.SearchLatencyMs.P95Ms:F2}/{engine.SearchLatencyMs.MaxMs:F2}");
var misses = engine.CaseResults
.Where(result => !result.HitAtK)
.Take(3)
.ToList();
if (misses.Count > 0)
{
Console.WriteLine(" Sample misses:");
foreach (var miss in misses)
{
var top = miss.TopResultIds.Count == 0 ? "(none)" : string.Join(", ", miss.TopResultIds);
Console.WriteLine($" Query='{miss.Query}', expected='{string.Join("|", miss.ExpectedIds)}', top='{top}'");
}
}
Console.WriteLine();
}
}
private static void PrintUsage()
{
Console.WriteLine("SettingsSearchEvaluation");
Console.WriteLine("Evaluates basic and semantic settings search for recall and performance.");
Console.WriteLine();
Console.WriteLine("Usage:");
Console.WriteLine(" SettingsSearchEvaluation [options]");
Console.WriteLine();
Console.WriteLine("Options:");
Console.WriteLine(" --index-json <path> Path to settings search index JSON.");
Console.WriteLine(" --normalized-corpus <path> Path to normalized corpus file (id<TAB>normalized text).");
Console.WriteLine(" --export-normalized <path> Export normalized corpus from loaded entries.");
Console.WriteLine(" Also writes a text-only companion file '<path>.text<ext>'.");
Console.WriteLine(" --export-only Export normalized corpus and exit.");
Console.WriteLine(" --cases-json <path> Optional path to evaluation cases JSON.");
Console.WriteLine(" --engine <basic|semantic|both> Engine selection. Default: both.");
Console.WriteLine(" --max-results <n> Maximum returned results per query. Default: 10.");
Console.WriteLine(" --top-k <n> Recall cut-off K. Default: 5.");
Console.WriteLine(" --iterations <n> Measured runs per query. Default: 5.");
Console.WriteLine(" --warmup <n> Warmup runs per query. Default: 1.");
Console.WriteLine(" --semantic-timeout-ms <n> Semantic index idle wait timeout in ms. Default: 15000.");
Console.WriteLine(" --output-json <path> Optional output report file.");
Console.WriteLine(" --help Show this help.");
Console.WriteLine();
Console.WriteLine("Startup args file fallback (when no CLI args are passed):");
Console.WriteLine(" %LOCALAPPDATA%\\PowerToys.SettingsSearchEvaluation\\launch.args.txt");
Console.WriteLine(" (Override with env var SETTINGS_SEARCH_EVAL_ARGS_FILE)");
}
}

View File

@@ -0,0 +1,7 @@
// 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.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SettingsSearchEvaluation.Tests")]

View File

@@ -0,0 +1,20 @@
// 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 SettingsSearchEvaluation;
internal sealed class QueryEvaluationResult
{
public required string Query { get; init; }
public required IReadOnlyList<string> ExpectedIds { get; init; }
public required IReadOnlyList<string> TopResultIds { get; init; }
public required int BestRank { get; init; }
public required bool HitAtK { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,163 @@
# Settings Search Evaluation
This tool evaluates Settings search quality and latency for:
- `basic` search (`FuzzSearchEngine`)
- `semantic` search (`SemanticSearchEngine`)
It reports:
- `Recall@K`
- `MRR` (mean reciprocal rank)
- Search latency (`avg`, `p50`, `p95`, `max`)
- Dataset diagnostics including duplicate `SettingEntry.Id` buckets
The evaluator is standalone and does not require building/running `PowerToys.Settings.exe`.
## Run
Build with Visual Studio `MSBuild.exe` (the project references native components):
```powershell
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$msbuild = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
& $msbuild tools\SettingsSearchEvaluation\SettingsSearchEvaluation.csproj `
/t:Build /p:Configuration=Debug /p:Platform=arm64 /m:1 /nologo
```
Run the built executable:
```powershell
.\arm64\Debug\SettingsSearchEvaluation.exe `
--index-json src/settings-ui/Settings.UI/Assets/Settings/search.index.json `
--cases-json tools/SettingsSearchEvaluation/cases/settings-search-cases.sample.json `
--engine both `
--top-k 5 `
--iterations 5 `
--warmup 1 `
--output-json tools/SettingsSearchEvaluation/artifacts/report.json
```
## Normalized corpus workflow
Export normalized corpus lines from `search.index.json`:
```powershell
.\arm64\Debug\SettingsSearchEvaluation.exe `
--index-json src/settings-ui/Settings.UI/Assets/Settings/search.index.json `
--export-normalized tools/SettingsSearchEvaluation/artifacts/normalized-settings-corpus.tsv `
--export-only
```
`normalized-settings-corpus.tsv` format:
- one entry per line
- `<id>\t<normalized text>`
- plus an auto-generated text-only companion file at `normalized-settings-corpus.text.tsv`
containing only normalized localized text values (no ID/key column), for sharing/debug.
When loading from `search.index.json`, the evaluator now resolves UID keys to real user-facing strings
from `Resources.resw` when available (auto-detected in repo layout). You can override the `.resw` path
with environment variable `SETTINGS_SEARCH_EVAL_RESW`.
Run evaluation by indexing and querying directly from the normalized corpus:
```powershell
.\arm64\Debug\SettingsSearchEvaluation.exe `
--normalized-corpus tools/SettingsSearchEvaluation/artifacts/normalized-settings-corpus.tsv `
--cases-json tools/SettingsSearchEvaluation/cases/settings-search-cases.sample.json `
--engine basic `
--top-k 5 `
--iterations 5 `
--warmup 1 `
--output-json tools/SettingsSearchEvaluation/artifacts/report.basic.normalized.json
```
### Startup args file
When launched via AUMID, command-line arguments may not be forwarded by shell activation.
If the evaluator starts with no CLI args, it will read one argument per line from:
- `%LOCALAPPDATA%\PowerToys.SettingsSearchEvaluation\launch.args.txt`
- `tools/SettingsSearchEvaluation/artifacts/launch.args.txt` (repo flow)
- override path with env var `SETTINGS_SEARCH_EVAL_ARGS_FILE`
Empty lines and `#` comments are ignored.
## Full package (recommended)
Build a dedicated evaluator MSIX (independent from PowerToys sparse package):
```powershell
pwsh .\tools\SettingsSearchEvaluation\BuildFullPackage.ps1 `
-Platform arm64 `
-Configuration Debug `
-Install
```
Output:
- `tools/SettingsSearchEvaluation/artifacts/full-package/SettingsSearchEvaluation.msix`
After install, launch with package identity:
```powershell
$pkg = Get-AppxPackage Microsoft.PowerToys.SettingsSearchEvaluation
Start-Process "shell:AppsFolder\$($pkg.PackageFamilyName)!SettingsSearchEvaluation"
```
### True semantic profile with packaged app
Write startup args (absolute paths recommended):
```powershell
$argsFile = Join-Path $env:LOCALAPPDATA 'PowerToys.SettingsSearchEvaluation\launch.args.txt'
New-Item -ItemType Directory -Force (Split-Path $argsFile) | Out-Null
@(
'--normalized-corpus'
'C:\data\normalized-settings-corpus.tsv'
'--cases-json'
'C:\data\settings-search-cases.sample.json'
'--engine'
'both'
'--top-k'
'5'
'--iterations'
'5'
'--warmup'
'1'
'--semantic-timeout-ms'
'60000'
'--output-json'
'C:\data\report.both.normalized.json'
) | Set-Content -LiteralPath $argsFile -Encoding UTF8
```
Then launch by AUMID (from previous section). The generated report will include semantic capability flags.
## Visualize results
Generate an HTML dashboard from one or more report JSON files:
```powershell
pwsh .\tools\SettingsSearchEvaluation\GenerateProfileDashboard.ps1 `
-InputReports @(
".\tools\SettingsSearchEvaluation\artifacts\report.basic.direct.json",
".\tools\SettingsSearchEvaluation\artifacts\report.semantic.aumid.json"
) `
-OutputHtml ".\tools\SettingsSearchEvaluation\artifacts\search-profile-dashboard.html"
```
## Case file format
```json
[
{
"query": "color picker",
"expectedIds": ["ColorPicker"],
"notes": "Module entry"
}
]
```
If `--cases-json` is not provided, fallback cases are auto-generated from the index headers.

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace SettingsSearchEvaluation;
internal sealed class RunnerOptions
{
public required string IndexJsonPath { get; init; }
public string? NormalizedCorpusPath { get; init; }
public string? ExportNormalizedPath { get; init; }
public bool ExportOnly { get; init; }
public string? CasesJsonPath { get; init; }
public required IReadOnlyList<SearchEngineKind> Engines { get; init; }
public int MaxResults { get; init; } = 10;
public int TopK { get; init; } = 5;
public int Iterations { get; init; } = 5;
public int WarmupIterations { get; init; } = 1;
public TimeSpan SemanticIndexTimeout { get; init; } = TimeSpan.FromSeconds(15);
public string? OutputJsonPath { get; init; }
public bool UseNormalizedCorpus => !string.IsNullOrWhiteSpace(NormalizedCorpusPath);
public string InputDataPath => UseNormalizedCorpus ? NormalizedCorpusPath! : IndexJsonPath;
}

View File

@@ -0,0 +1,11 @@
// 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 SettingsSearchEvaluation;
internal enum SearchEngineKind
{
Basic,
Semantic,
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Common.Search;
namespace SettingsSearchEvaluation;
internal enum EntryType
{
SettingsPage,
SettingsCard,
SettingsExpander,
}
internal readonly record struct SettingEntry(
EntryType Type,
string Header,
string PageTypeName,
string ElementName,
string ElementUid,
string ParentElementName = "",
string Description = "",
string Icon = "") : ISearchable
{
public string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public string SearchableText => Header ?? string.Empty;
public string? SecondarySearchableText => Description;
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\src\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\src\Common.SelfContained.props" />
<PropertyGroup Condition="'$(UseSparseIdentity)'==''">
<UseSparseIdentity>false</UseSparseIdentity>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>SettingsSearchEvaluation</RootNamespace>
<AssemblyName>SettingsSearchEvaluation</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
<OutputPath>..\..\$(Platform)\$(Configuration)\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DefaultItemExcludes>$(DefaultItemExcludes);artifacts\**\*;bin\**\*;obj\**\*</DefaultItemExcludes>
</PropertyGroup>
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\common\Common.Search\Common.Search.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PowerToys.SettingsSearchEvaluation.app" />
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
packageName="Microsoft.PowerToys.SettingsSearchEvaluation"
applicationId="SettingsSearchEvaluation"
publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" />
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<ws2:dpiAwareness xmlns:ws2="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</ws2:dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
[
{ "query": "advanced paste", "expectedIds": [ "AdvancedPaste" ], "notes": "exact_module" },
{ "query": "always on top", "expectedIds": [ "AlwaysOnTop" ], "notes": "exact_module" },
{ "query": "awake", "expectedIds": [ "Awake" ], "notes": "exact_module" },
{ "query": "cmd not found", "expectedIds": [ "CmdNotFound" ], "notes": "exact_module" },
{ "query": "color picker", "expectedIds": [ "ColorPicker" ], "notes": "exact_module" },
{ "query": "crop and lock", "expectedIds": [ "CropAndLock" ], "notes": "exact_module" },
{ "query": "environment variables", "expectedIds": [ "EnvironmentVariables" ], "notes": "exact_module" },
{ "query": "fancy zones", "expectedIds": [ "FancyZones" ], "notes": "exact_module" },
{ "query": "file explorer preview", "expectedIds": [ "FileExplorerPreview" ], "notes": "exact_module" },
{ "query": "file locksmith", "expectedIds": [ "FileLocksmith" ], "notes": "exact_module" },
{ "query": "general settings", "expectedIds": [ "General" ], "notes": "exact_module" },
{ "query": "hosts", "expectedIds": [ "Hosts" ], "notes": "exact_module" },
{ "query": "image resizer", "expectedIds": [ "ImageResizer" ], "notes": "exact_module" },
{ "query": "keyboard manager", "expectedIds": [ "KeyboardManager" ], "notes": "exact_module" },
{ "query": "light switch", "expectedIds": [ "LightSwitch" ], "notes": "exact_module" },
{ "query": "measure tool", "expectedIds": [ "MeasureTool" ], "notes": "exact_module" },
{ "query": "mouse utilities", "expectedIds": [ "MouseUtils" ], "notes": "exact_module" },
{ "query": "mouse without borders", "expectedIds": [ "MouseWithoutBorders" ], "notes": "exact_module" },
{ "query": "new plus", "expectedIds": [ "NewPlus" ], "notes": "exact_module" },
{ "query": "peek", "expectedIds": [ "Peek" ], "notes": "exact_module" },
{ "query": "power display", "expectedIds": [ "PowerDisplay" ], "notes": "exact_module" },
{ "query": "power launcher", "expectedIds": [ "PowerLauncher" ], "notes": "exact_module" },
{ "query": "power rename", "expectedIds": [ "PowerRename" ], "notes": "exact_module" },
{ "query": "quick accent", "expectedIds": [ "QuickAccent" ], "notes": "exact_module" },
{ "query": "registry preview", "expectedIds": [ "RegistryPreview" ], "notes": "exact_module" },
{ "query": "shortcut guide", "expectedIds": [ "ShortcutGuide" ], "notes": "exact_module" },
{ "query": "text extractor", "expectedIds": [ "TextExtractor" ], "notes": "exact_module" },
{ "query": "workspaces", "expectedIds": [ "Workspaces" ], "notes": "exact_module" },
{ "query": "zoom it", "expectedIds": [ "ZoomIt" ], "notes": "exact_module" },
{ "query": "pin window", "expectedIds": [ "AlwaysOnTop" ], "notes": "intent_alias" },
{ "query": "keep window above others", "expectedIds": [ "AlwaysOnTop" ], "notes": "intent_alias" },
{ "query": "prevent sleep", "expectedIds": [ "Awake" ], "notes": "intent_alias" },
{ "query": "keep pc awake", "expectedIds": [ "Awake" ], "notes": "intent_alias" },
{ "query": "command palette", "expectedIds": [ "CmdPal_Launch", "CmdPal_Settings" ], "notes": "intent_alias" },
{ "query": "pick color from screen", "expectedIds": [ "ColorPicker" ], "notes": "intent_alias" },
{ "query": "crop a window thumbnail", "expectedIds": [ "CropAndLock" ], "notes": "intent_alias" },
{ "query": "env vars", "expectedIds": [ "EnvironmentVariables" ], "notes": "abbreviation" },
{ "query": "window layout manager", "expectedIds": [ "FancyZones" ], "notes": "intent_alias" },
{ "query": "preview svg in explorer", "expectedIds": [ "FileExplorerPreview" ], "notes": "intent_alias" },
{ "query": "which process locks file", "expectedIds": [ "FileLocksmith" ], "notes": "intent_alias" },
{ "query": "edit hosts file", "expectedIds": [ "Hosts" ], "notes": "intent_alias" },
{ "query": "resize images in explorer", "expectedIds": [ "ImageResizer" ], "notes": "intent_alias" },
{ "query": "remap keys", "expectedIds": [ "KeyboardManager" ], "notes": "intent_alias" },
{ "query": "screen ruler", "expectedIds": [ "MeasureTool" ], "notes": "intent_alias" },
{ "query": "mouse jump", "expectedIds": [ "MouseUtils_Enable_MouseJump" ], "notes": "intent_alias" },
{ "query": "share mouse and keyboard across pcs", "expectedIds": [ "MouseWithoutBorders" ], "notes": "intent_alias" },
{ "query": "file templates", "expectedIds": [ "NewPlus" ], "notes": "intent_alias" },
{ "query": "quick file preview", "expectedIds": [ "Peek" ], "notes": "intent_alias" },
{ "query": "monitor brightness control", "expectedIds": [ "PowerDisplay" ], "notes": "intent_alias" },
{ "query": "powertoys run", "expectedIds": [ "PowerLauncher" ], "notes": "intent_alias" },
{ "query": "bulk rename files", "expectedIds": [ "PowerRename" ], "notes": "intent_alias" },
{ "query": "type accented letters", "expectedIds": [ "QuickAccent" ], "notes": "intent_alias" },
{ "query": "open .reg safely", "expectedIds": [ "RegistryPreview" ], "notes": "intent_alias" },
{ "query": "windows shortcuts overlay", "expectedIds": [ "ShortcutGuide" ], "notes": "intent_alias" },
{ "query": "extract text from screen", "expectedIds": [ "TextExtractor" ], "notes": "intent_alias" },
{ "query": "desktop layouts", "expectedIds": [ "Workspaces" ], "notes": "intent_alias" },
{ "query": "presentation zoom", "expectedIds": [ "ZoomIt" ], "notes": "intent_alias" },
{ "query": "fz editor", "expectedIds": [ "FancyZones_LaunchEditorButtonControl", "FancyZones" ], "notes": "abbreviation" },
{ "query": "fz", "expectedIds": [ "FancyZones" ], "notes": "abbreviation" },
{ "query": "pt run", "expectedIds": [ "PowerLauncher" ], "notes": "abbreviation" },
{ "query": "mwb", "expectedIds": [ "MouseWithoutBorders" ], "notes": "abbreviation" },
{ "query": "km remap", "expectedIds": [ "KeyboardManager" ], "notes": "abbreviation" },
{ "query": "qa accents", "expectedIds": [ "QuickAccent" ], "notes": "abbreviation" },
{ "query": "reg preview", "expectedIds": [ "RegistryPreview" ], "notes": "abbreviation" },
{ "query": "ocr tool", "expectedIds": [ "TextExtractor" ], "notes": "abbreviation" },
{ "query": "powerrename", "expectedIds": [ "PowerRename" ], "notes": "typo" },
{ "query": "fancyzone", "expectedIds": [ "FancyZones" ], "notes": "typo" },
{ "query": "keybaord manager", "expectedIds": [ "KeyboardManager" ], "notes": "typo" },
{ "query": "enviroment variables", "expectedIds": [ "EnvironmentVariables" ], "notes": "typo" },
{ "query": "mose jump", "expectedIds": [ "MouseUtils_Enable_MouseJump" ], "notes": "typo" },
{ "query": "colr picker", "expectedIds": [ "ColorPicker" ], "notes": "typo" },
{ "query": "workspces", "expectedIds": [ "Workspaces" ], "notes": "typo" },
{ "query": "regsitry preview", "expectedIds": [ "RegistryPreview" ], "notes": "typo" },
{ "query": "shorcut guide", "expectedIds": [ "ShortcutGuide" ], "notes": "typo" },
{ "query": "zoomit", "expectedIds": [ "ZoomIt" ], "notes": "typo" },
{ "query": "cmd pal launch", "expectedIds": [ "CmdPal_Launch" ], "notes": "setting" },
{ "query": "cmd pal settings", "expectedIds": [ "CmdPal_Settings" ], "notes": "setting" },
{ "query": "always on top shortcut", "expectedIds": [ "AlwaysOnTop_ActivationShortcut" ], "notes": "setting" },
{ "query": "awake mode", "expectedIds": [ "Awake_ModeSettingsCard" ], "notes": "setting" },
{ "query": "awake interval", "expectedIds": [ "Awake_IntervalSettingsCard" ], "notes": "setting" },
{ "query": "advanced paste ai", "expectedIds": [ "AdvancedPaste_EnableAISettingsCard" ], "notes": "setting" },
{ "query": "advanced paste clipboard history", "expectedIds": [ "AdvancedPaste_Clipboard_History_Enabled_SettingsCard" ], "notes": "setting" },
{ "query": "advanced paste close after focus", "expectedIds": [ "AdvancedPaste_CloseAfterLosingFocus" ], "notes": "setting" },
{ "query": "color picker formats", "expectedIds": [ "ColorPicker_ColorFormats" ], "notes": "setting" },
{ "query": "color picker activation action", "expectedIds": [ "ColorPicker_ActivationAction" ], "notes": "setting" },
{ "query": "crop and lock thumbnail shortcut", "expectedIds": [ "CropAndLock_ThumbnailActivation_Shortcut" ], "notes": "setting" },
{ "query": "env vars launch button", "expectedIds": [ "EnvironmentVariables_LaunchButtonControl" ], "notes": "setting" },
{ "query": "fancy zones editor button", "expectedIds": [ "FancyZones_LaunchEditorButtonControl" ], "notes": "setting" },
{ "query": "preview svg toggle", "expectedIds": [ "FileExplorerPreview_ToggleSwitch_Preview_SVG" ], "notes": "setting" },
{ "query": "preview pdf toggle", "expectedIds": [ "FileExplorerPreview_ToggleSwitch_Preview_PDF" ], "notes": "setting" },
{ "query": "hosts backup location", "expectedIds": [ "Hosts_Backup_Location" ], "notes": "setting" },
{ "query": "hosts launch as admin", "expectedIds": [ "Hosts_Toggle_LaunchAdministrator" ], "notes": "setting" },
{ "query": "image resizer presets", "expectedIds": [ "ImageResizer_Presets" ], "notes": "setting" },
{ "query": "image resizer encoding", "expectedIds": [ "ImageResizer_Encoding" ], "notes": "setting" },
{ "query": "remap keyboard button", "expectedIds": [ "KeyboardManager_RemapKeyboardButton" ], "notes": "setting" },
{ "query": "remap shortcuts button", "expectedIds": [ "KeyboardManager_RemapShortcutsButton" ], "notes": "setting" },
{ "query": "dark mode toggle shortcut", "expectedIds": [ "LightSwitch_ThemeToggle_Shortcut" ], "notes": "setting" },
{ "query": "measure tool shortcut", "expectedIds": [ "MeasureTool_ActivationShortcut" ], "notes": "setting" },
{ "query": "enable mouse jump", "expectedIds": [ "MouseUtils_Enable_MouseJump" ], "notes": "setting" },
{ "query": "mouse jump shortcut", "expectedIds": [ "MouseUtils_MouseJump_ActivationShortcut" ], "notes": "setting" },
{ "query": "mouse without borders share clipboard", "expectedIds": [ "MouseWithoutBorders_ShareClipboard" ], "notes": "setting" },
{ "query": "mouse without borders security key", "expectedIds": [ "MouseWithoutBorders_SecurityKey" ], "notes": "setting" },
{ "query": "new plus templates location", "expectedIds": [ "NewPlus_Templates_Location" ], "notes": "setting" },
{ "query": "power display launch button", "expectedIds": [ "PowerDisplay_LaunchButtonControl" ], "notes": "setting" },
{ "query": "power run max results", "expectedIds": [ "PowerLauncher_MaximumNumberOfResults" ], "notes": "setting" },
{ "query": "power run action keyword", "expectedIds": [ "PowerLauncher_ActionKeyword" ], "notes": "setting" },
{ "query": "power rename context menu", "expectedIds": [ "PowerRename_Toggle_ContextMenu" ], "notes": "setting" },
{ "query": "power rename autocomplete", "expectedIds": [ "PowerRename_Toggle_AutoComplete" ], "notes": "setting" },
{ "query": "quick accent language", "expectedIds": [ "QuickAccent_SelectedLanguage" ], "notes": "setting" },
{ "query": "quick accent shortcut", "expectedIds": [ "QuickAccent_Activation_Shortcut" ], "notes": "setting" },
{ "query": "registry preview enable", "expectedIds": [ "RegistryPreview_Enable_RegistryPreview" ], "notes": "setting" },
{ "query": "text extractor languages", "expectedIds": [ "TextExtractor_Languages" ], "notes": "setting" },
{ "query": "workspaces launch editor", "expectedIds": [ "Workspaces_LaunchEditorButtonControl" ], "notes": "setting" },
{ "query": "zoomit zoom shortcut", "expectedIds": [ "ZoomIt_Zoom_Shortcut" ], "notes": "setting" },
{ "query": "zoomit break timeout", "expectedIds": [ "ZoomIt_Break_Timeout" ], "notes": "setting" }
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
[
{
"query": "always on top",
"expectedIds": [ "AlwaysOnTop" ],
"notes": "Module entry"
},
{
"query": "awake mode",
"expectedIds": [ "Awake_ModeSettingsCard" ],
"notes": "Feature setting"
},
{
"query": "color picker",
"expectedIds": [ "ColorPicker" ],
"notes": "Module entry"
},
{
"query": "fancy zones",
"expectedIds": [ "FancyZones" ],
"notes": "Module entry"
},
{
"query": "launch cmd pal",
"expectedIds": [ "CmdPal_Launch" ],
"notes": "CmdPal launch setting"
},
{
"query": "image resizer",
"expectedIds": [ "ImageResizer" ],
"notes": "Module entry"
},
{
"query": "keyboard manager",
"expectedIds": [ "KeyboardManager" ],
"notes": "Module entry"
},
{
"query": "mouse jump",
"expectedIds": [ "MouseUtils_Enable_MouseJump" ],
"notes": "Module entry"
},
{
"query": "mouse without borders",
"expectedIds": [ "MouseWithoutBorders" ],
"notes": "Module setting"
},
{
"query": "peek",
"expectedIds": [ "Peek" ],
"notes": "Module entry"
},
{
"query": "power rename",
"expectedIds": [ "PowerRename" ],
"notes": "Module entry"
},
{
"query": "power toys run",
"expectedIds": [ "PowerLauncher" ],
"notes": "Module entry"
},
{
"query": "registry preview",
"expectedIds": [ "RegistryPreview" ],
"notes": "Module entry"
},
{
"query": "workspaces",
"expectedIds": [ "Workspaces_EnableToggleControl_HeaderText" ],
"notes": "Module setting"
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff