Compare commits

..

3 Commits

Author SHA1 Message Date
Mike Griese
60baf602d6 get rid of this property 2026-02-10 06:23:02 -06:00
Mike Griese
89c3229658 fine spellbot 2026-02-05 11:38:18 -06:00
Mike Griese
78378e1d0a CmdPal: Add Dock API
This doesn't actually add the dock. It just adds the API for it.

Extension authors can use this to create their own dock bands.

re: #45201
2026-02-05 10:26:53 -06:00
88 changed files with 957 additions and 20658 deletions

View File

@@ -206,6 +206,7 @@ Bilibili
BVID
capturevideosample
cmdow
Contoso
Controlz
cortana
devhints

View File

@@ -0,0 +1,61 @@
---
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

@@ -0,0 +1,68 @@
---
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.3405.78" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<!-- 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,8 +77,10 @@
<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="2.0.0-experimental4" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.0.130-experimental" />
<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.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,16 +1059,6 @@
<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

@@ -1,244 +0,0 @@
# 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

@@ -1,325 +0,0 @@
# 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

@@ -1,496 +0,0 @@
# 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,7 +29,6 @@
</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" />
@@ -81,7 +80,6 @@
<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>
@@ -107,15 +105,5 @@
</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,19 +320,6 @@ 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,16 +4,5 @@
<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

@@ -1,306 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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 ??= new MatchOption();
opt = opt ?? new MatchOption();
if (string.IsNullOrEmpty(stringToCompare))
{

View File

@@ -11,10 +11,6 @@ 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

@@ -1,73 +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.
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

@@ -1,27 +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.
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

@@ -1,12 +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.
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

@@ -1,36 +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.
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

@@ -1,134 +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.
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

@@ -1,51 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
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

@@ -1,31 +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.
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

@@ -1,53 +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.
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

@@ -1,87 +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.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

@@ -1,35 +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.
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

@@ -1,33 +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.
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

@@ -1,46 +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.
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

@@ -1,21 +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.
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

@@ -1,406 +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 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

@@ -1,455 +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 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

@@ -1,26 +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.
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

@@ -1,31 +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.
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

@@ -1,42 +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.
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

@@ -1,21 +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.
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,4 +614,166 @@ 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.3405.78" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<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="2.0.0-experimental3" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
<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

@@ -107,7 +107,7 @@ public partial class AliasManager : ObservableObject
}
// Look for the alias belonging to another command, and remove it
if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId)
if (newAlias is not null && kv.Value.Alias == newAlias.Alias)
{
toRemove.Add(kv.Value);

View File

@@ -1,7 +1,7 @@
---
author: Mike Griese
created on: 2024-07-19
last updated: 2025-08-08
last updated: 2026-02-05
issue id: n/a
---
@@ -75,6 +75,8 @@ functionality.
- [Advanced scenarios](#advanced-scenarios)
- [Status messages](#status-messages)
- [Rendering of ICommandItems in Lists and Menus](#rendering-of-icommanditems-in-lists-and-menus)
- [Addenda I: API additions (ICommandProvider2)](#addenda-i-api-additions-icommandprovider2)
- [Addenda IV: Dock bands](#addenda-iv-dock-bands)
- [Class diagram](#class-diagram)
- [Future considerations](#future-considerations)
- [Arbitrary parameters and arguments](#arbitrary-parameters-and-arguments)
@@ -2045,6 +2047,87 @@ Fortunately, we can put all of that (`GetApiExtensionStubs`,
developers won't have to do anything. The toolkit will just do the right thing
for them.
## Addenda IV: Dock bands
The "dock" is another way to surface commands to the user. This is a
toolbar-like window that can be docked to the side of the screen, or floated as
its own window. It enables another surface for extensions to display real-time
information and shortcuts to users.
Bands are powered by the same interfaces as DevPal itself. Extensions can provide
bands via the new `DockBand` property on `ICommandProvider3`.
```csharp
interface ICommandProvider3 requires ICommandProvider2
{
ICommandItem[] GetDockBands();
};
```
A **Dock Band** is one "strip of items" in the dock. Each band can have multiple
items. This allows an extension to create a strip of buttons that should all be
treated as a single unit. For example, a media player band will want probably
four items:
* one for the previous track
* one for play/pause
* one for next track
* and one to display the album art and track title
`GetDockBands` returns an array of `ICommandItem`s. Each `ICommandItem`
represents one band in the dock. These represent all of the bands that an
extension would allow the user to add to their dock.
All of the `ICommandItem`s returned from `GetDockBands` **must** have a
`Command` with a non-empty `Id` set. If the `Id` is null or empty, DevPal will
ignore that band.
Bands are not automatically added to the dock. Instead, the user must choose
which bands they want to add. This is done via the DevPal settings page.
Furthermore, bands are not displayed in the list of commands in DevPal itself.
This allows extension authors to create objects that are only intended for the
dock, without cluttering up the main DevPal UI, and vice versa.
DevPal will then create UI in the dock for each band the user has chosen to add.
What that looks like will depend on the `Command` in the `ICommandItem`:
* A `IInvokableCommand` will be rendered as a single button. Think "the
time/date" button on the taskbar, that opens the notification center.
* A `IListPage` will be rendered as a strip of buttons, one for each `IListItem`
in the list. Think "media controls" for a music player.
* A `IContentPage` will be rendered as a single button. Clicking that button
will open a flyout with that content rendered in it. Think "weather" or "news"
flyouts.
If the `Command` in the `IListItem`s of a band are pages, then clicking those
buttons will open DevPal to that page, as if it were a flyout from the dock.
The `.Title` property of the top-level `ICommandItem` representing the band will
be used as the name of the band in the settings. So a media player band might
want to set the `Title` to "Contoso Music Player", even if the individual
buttons in the band don't show that title.
Users may also "pin" a top-level command from DevPal into the dock. DevPal will
take care of creating a new band (owned by devpal) with that command in it. This
allows users to add quick shortcuts to their favorite commands in the dock.
Think: pinning an app, or pinning a particular GitHub query.
Bands are added via ID. An extension may choose to have a TopLevelCommand and a
DockBand with the same `Id`. In this case, if the user pins the TopLevelCommand
to the dock, DevPal will pin the band from `GetDockBands`, rather than creating
a simple pinned command. This allows extension authors to seamlessly have a
top-level command present a palette-specific experience, while also having a
dock-specific experience. In our ongoing media player example, the top-level
command might open DevPal to a full-featured music control page, while the dock
band has simpler buttons on it (without a title/subtitle).
Users may choose to have:
* the orientation of the dock: vertical or horizontal
* the size of the dock
* which bands are shown in the dock
* whether the "labels" (read: `Title` & `Subtitle`) of individual bands are
shown or hidden.
- Dock bands will still display the `Title` & `Subtitle` of each item in the
band as the tooltip on those items, even when the "labels" are hidden.
## Class diagram
This is a diagram attempting to show the relationships between the various types we've defined for the SDK. Some elements are omitted for clarity. (Notably, `IconData` and `IPropChanged`, which are used in many places.)

View File

@@ -6,7 +6,10 @@ using Windows.Foundation;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public abstract partial class CommandProvider : ICommandProvider, ICommandProvider2
public abstract partial class CommandProvider :
ICommandProvider,
ICommandProvider2,
ICommandProvider3
{
public virtual string Id { get; protected set; } = string.Empty;
@@ -48,6 +51,21 @@ public abstract partial class CommandProvider : ICommandProvider, ICommandProvid
}
}
/// <summary>
/// Get the dock bands provided by this command provider. Dock bands are
/// strips of items that appear on various UI surfaces in CmdPal, such as a
/// toolbar. Each ICommandItem returned from this method will be treated as
/// one atomic band by cmdpal.
///
/// If the command on an item here is a
/// IListPage, then cmdpal will render all of the items on that page as one
/// band. You can use this to create complex bands with multiple buttons.
/// </summary>
public virtual ICommandItem[]? GetDockBands()
{
return null;
}
/// <summary>
/// This is used to manually populate the WinRT type cache in CmdPal with
/// any interfaces that might not follow a straight linear path of requires.

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
/// <summary>
/// Helper class for creating a band out of a set of items. This allows you to
/// simply just instantiate a set of buttons as ListItems, then pass them in to
/// this class to create a band from those items. For example:
///
/// ```cs
/// var foo = new MyFooListItem();
/// var bar = new MyBarListItem();
/// var band = new WrappedDockItem([foo, bar], "com.me.myBand", "My cool desk band");
/// ```
/// </summary>
public partial class WrappedDockItem : CommandItem
{
public override string Title => _itemTitle;
public override ICommand? Command => _backingList;
private readonly string _itemTitle;
private readonly WrappedDockList _backingList;
public IListItem[] Items { get => _backingList.GetItems(); set => _backingList.SetItems(value); }
public WrappedDockItem(
ICommand command,
string displayTitle)
{
_backingList = new WrappedDockList(command);
_itemTitle = string.IsNullOrEmpty(displayTitle) ? command.Name : displayTitle;
Icon = command.Icon;
}
// This was too much of a foot gun - we'd internally create a ListItem that
// didn't bubble the prop change events back up. That was bad.
// public WrappedDockItem(
// ICommandItem item,
// string id,
// string displayTitle)
// {
// _backingList = new WrappedDockList(item, id);
// _itemTitle = string.IsNullOrEmpty(displayTitle) ? item.Title : displayTitle;
// _icon = item.Icon;
// }
/// <summary>
/// Initializes a new instance of the <see cref="WrappedDockItem"/> class.
/// Create a new dock band for a set of list items
/// </summary>
public WrappedDockItem(
IListItem[] items,
string id,
string displayTitle)
{
_backingList = new WrappedDockList(items, id, displayTitle);
_itemTitle = displayTitle;
}
}

View File

@@ -0,0 +1,86 @@
// 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 Microsoft.CommandPalette.Extensions.Toolkit;
/// <summary>
/// Helper class for a list page that just holds a set of items as a band.
/// The page itself doesn't do anything interesting.
/// </summary>
internal sealed partial class WrappedDockList : ListPage
{
private string _id;
public override string Id => _id;
private List<IListItem> _items;
internal WrappedDockList(ICommand command)
{
_items = new() { new ListItem(command) };
Name = command.Name;
_id = command.Id;
}
// Maybe revisit sometime.
// The hard problem is that the wrapping item will not
// listen for property changes on the inner item.
// public WrappedDockList(ICommandItem item, string id)
// {
// var command = item.Command;
// _items = new()
// {
// new ListItem(command)
// {
// Title = item.Title,
// Subtitle = item.Subtitle,
// Icon = item.Icon,
// MoreCommands = item.MoreCommands,
// },
// };
// Name = command.Name;
// _id = string.IsNullOrEmpty(id) ? command.Id : id;
// }
/// <summary>
/// Initializes a new instance of the <see cref="WrappedDockList"/> class.
/// Create a new list page for the set of items provided.
/// </summary>
internal WrappedDockList(IListItem[] items, string id, string name)
{
_items = new(items);
Name = name;
_id = id;
}
internal WrappedDockList(ICommand[] items, string id, string name)
{
_items = new();
foreach (var item in items)
{
_items.Add(new ListItem(item));
}
Name = name;
_id = id;
}
public override IListItem[] GetItems()
{
return _items.ToArray();
}
internal void SetItems(IListItem[]? newItems)
{
if (newItems == null)
{
_items = [];
RaiseItemsChanged(0);
return;
}
ListHelpers.InPlaceUpdateList(_items, newItems);
RaiseItemsChanged(_items.Count);
}
}

View File

@@ -405,6 +405,11 @@ namespace Microsoft.CommandPalette.Extensions
{
Object[] GetApiExtensionStubs();
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandProvider3 requires ICommandProvider2
{
ICommandItem[] GetDockBands();
};
}

View File

@@ -7,8 +7,6 @@
<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,7 +3,6 @@
#include <Sddl.h>
#include <sstream>
#include <aclapi.h>
#include <shobjidl.h>
#include "powertoy_module.h"
#include <common/interop/two_way_pipe_message_ipc.h>
@@ -65,74 +64,6 @@ 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;
@@ -591,34 +522,15 @@ 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,10 +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.
#nullable enable
using Common.Search;
namespace Settings.UI.Library
{
public enum EntryType
@@ -15,7 +11,7 @@ namespace Settings.UI.Library
SettingsExpander,
}
public struct SettingEntry : ISearchable
public struct SettingEntry
{
public EntryType Type { get; set; }
@@ -33,23 +29,16 @@ 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 ?? string.Empty;
Description = description ?? string.Empty;
Icon = icon ?? string.Empty;
ParentElementName = parentElementName;
Description = description;
Icon = icon;
}
// ISearchable implementation
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public readonly string SearchableText => Header ?? string.Empty;
public readonly string? SecondarySearchableText => Description;
}
}

View File

@@ -1,25 +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.
#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,7 +23,6 @@
<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

@@ -1,140 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using 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,10 +3,6 @@
<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>
@@ -18,15 +14,11 @@
<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" />
@@ -227,23 +219,4 @@
<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>
<!--
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>
</Project>

View File

@@ -0,0 +1,338 @@
// 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

@@ -1,495 +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.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:SettingSearchResult">
<DataTemplate x:DataType="models:SettingEntry">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="ModuleButton_Click"
Header="{x:Bind Entry.Header}"
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE8B7;}"
Header="{x:Bind Header}"
HeaderIcon="{x:Bind 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: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>
<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>
</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 SettingSearchResult tagResult)
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
{
NavigateToModule(tagResult.Entry);
NavigateToModule(tagEntry);
}
}
private void SettingButton_Click(object sender, RoutedEventArgs e)
{
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
{
NavigateToSetting(tagResult.Entry);
NavigateToSetting(tagEntry);
}
}
@@ -98,9 +98,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views
{
public string Query { get; set; }
public List<SettingSearchResult> Results { get; set; }
public List<SettingEntry> Results { get; set; }
public SearchResultsNavigationParams(string query, List<SettingSearchResult> results)
public SearchResultsNavigationParams(string query, List<SettingEntry> results)
{
Query = query;
Results = results;

View File

@@ -8,6 +8,7 @@ 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;
@@ -384,7 +385,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
{
_ = Task.Run(() => SettingsSearch.Default.BuildIndexAsync());
Task.Run(() =>
{
SearchIndexService.BuildIndex();
})
.ContinueWith(_ => { });
}
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
@@ -422,7 +427,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0);
}
private List<SettingSearchResult> _lastSearchResults = new();
private List<SettingEntry> _lastSearchResults = new();
private string _lastQueryText = string.Empty;
private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
@@ -465,10 +470,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
// Query the index on a background thread to avoid blocking UI
List<SettingSearchResult> results = null;
List<SettingEntry> results = null;
try
{
results = (await SettingsSearch.Default.SearchAsync(query, options: null, token)).ToList();
// If the token is already canceled before scheduling, the task won't start.
results = await Task.Run(() => SearchIndexService.Search(query, token), token);
}
catch (OperationCanceledException)
{
@@ -572,7 +578,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
// Centralized suggestion projection logic used by TextChanged & GotFocus restore.
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingSearchResult> results)
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results)
{
results ??= new();
if (results.Count == 0)
@@ -595,17 +601,16 @@ namespace Microsoft.PowerToys.Settings.UI.Views
};
}
var list = results.Take(5).Select(result =>
var list = results.Take(5).Select(e =>
{
var entry = result.Entry;
string subtitle = string.Empty;
if (entry.Type != EntryType.SettingsPage)
if (e.Type != EntryType.SettingsPage)
{
subtitle = SettingsSearch.Default.GetLocalizedPageName(entry.PageTypeName);
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
if (string.IsNullOrEmpty(subtitle))
{
subtitle = SettingsSearch.Default.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == entry.PageTypeName)
subtitle = SearchIndexService.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
.Select(x => x.Header)
.FirstOrDefault() ?? string.Empty;
}
@@ -613,15 +618,12 @@ namespace Microsoft.PowerToys.Settings.UI.Views
return new SuggestionItem
{
Header = entry.Header,
Icon = entry.Icon,
PageTypeName = entry.PageTypeName,
ElementName = entry.ElementName,
ParentElementName = entry.ParentElementName,
Header = e.Header,
Icon = e.Icon,
PageTypeName = e.PageTypeName,
ElementName = e.ElementName,
ParentElementName = e.ParentElementName,
Subtitle = subtitle,
Score = result.Score,
MatchKind = result.MatchKind,
MatchSpans = result.MatchSpans ?? Array.Empty<MatchSpan>(),
IsShowAll = false,
};
}).ToList();
@@ -653,7 +655,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 SettingsSearch.Default.SearchAsync(queryText, options: null)).ToList();
: await Task.Run(() => SearchIndexService.Search(queryText));
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<SettingSearchResult> _moduleResults = new();
private ObservableCollection<SettingEntry> _moduleResults = new();
private ObservableCollection<SettingsGroup> _groupedSettingsResults = new();
private bool _hasNoResults;
public ObservableCollection<SettingSearchResult> ModuleResults
public ObservableCollection<SettingEntry> ModuleResults
{
get => _moduleResults;
set
@@ -48,7 +48,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public void SetSearchResults(string query, List<SettingSearchResult> results)
public void SetSearchResults(string query, List<SettingEntry> 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.Entry.Type == EntryType.SettingsPage).ToList();
var settings = results.Where(r => r.Entry.Type == EntryType.SettingsCard).ToList();
var modules = results.Where(r => r.Type == EntryType.SettingsPage).ToList();
var settings = results.Where(r => r.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 => SettingsSearch.Default.GetLocalizedPageName(s.Entry.PageTypeName))
.GroupBy(s => SearchIndexService.GetLocalizedPageName(s.PageTypeName))
.Select(g => new SettingsGroup
{
GroupName = g.Key,
Settings = new ObservableCollection<SettingSearchResult>(g),
Settings = new ObservableCollection<SettingEntry>(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<SettingSearchResult> _settings;
private ObservableCollection<SettingEntry> _settings;
public string GroupName
{
@@ -113,7 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public ObservableCollection<SettingSearchResult> Settings
public ObservableCollection<SettingEntry> Settings
{
get => _settings;
set

View File

@@ -2,9 +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.Collections.Generic;
using Common.Search;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public sealed partial class SuggestionItem
@@ -21,12 +18,6 @@ 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,11 +2,6 @@
<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

@@ -1,211 +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.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

@@ -1,50 +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.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

@@ -1,129 +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.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

@@ -1,17 +0,0 @@
<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

@@ -1,266 +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.
[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

@@ -1,26 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,30 +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.
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

@@ -1,14 +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.
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

@@ -1,696 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,65 +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.
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

@@ -1,18 +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.
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

@@ -1,291 +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.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

@@ -1,418 +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.
[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

@@ -1,302 +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.
[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

@@ -1,22 +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.
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

@@ -1,330 +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.
[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

@@ -1,535 +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.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

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

View File

@@ -1,20 +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.
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

@@ -1,163 +0,0 @@
# 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

@@ -1,36 +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.
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

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

View File

@@ -1,31 +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 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

@@ -1,34 +0,0 @@
<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

@@ -1,20 +0,0 @@
<?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>

View File

@@ -1,121 +0,0 @@
[
{ "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" }
]

View File

@@ -1,72 +0,0 @@
[
{
"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"
}
]