wlocate: rewrited voidtools everything

This commit is contained in:
xungeng committed 2026-10-09 19:48:36 +08:00
commit bb1f1a3cc4
59 files changed
+5647

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
/build/
/out/
/test-output/
*.user
*.log
+13
View File
@@ -0,0 +1,13 @@
param([string]$Configuration = 'Release', [string]$Generator = 'Visual Studio 18 2026')
$ErrorActionPreference = 'Stop'
if (-not (Test-Path "$PSScriptRoot\third_party\pcre2\deps\sljit\sljit_src\sljitLir.c")) {
git -C "$PSScriptRoot\third_party\pcre2" submodule update --init --depth 1
if ($LASTEXITCODE) { throw 'Could not initialize the pinned SLJIT dependency.' }
}
cmake -S $PSScriptRoot -B "$PSScriptRoot\build" -G $Generator -A x64
if ($LASTEXITCODE) { throw 'CMake configuration failed.' }
cmake --build "$PSScriptRoot\build" --config $Configuration --parallel
if ($LASTEXITCODE) { throw 'Build failed.' }
ctest --test-dir "$PSScriptRoot\build" -C $Configuration --output-on-failure
if ($LASTEXITCODE) { throw 'Core regression tests failed.' }
Get-ChildItem "$PSScriptRoot\build\bin\$Configuration\*.exe"
+50
View File
@@ -0,0 +1,50 @@
cmake_minimum_required(VERSION 3.25)
project(WLocate VERSION 1.0.0 LANGUAGES C CXX RC)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
add_compile_definitions(UNICODE _UNICODE NOMINMAX WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
add_compile_options(/W4 /utf-8 $<$<COMPILE_LANGUAGE:CXX>:/permissive-> $<$<COMPILE_LANGUAGE:CXX>:/EHsc> $<$<CONFIG:Release>:/O2>)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2_8 ON CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2_16 OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2_32 OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2GREP OFF CACHE BOOL "" FORCE)
set(PCRE2_SUPPORT_JIT ON CACHE BOOL "" FORCE)
add_subdirectory(third_party/pcre2 EXCLUDE_FROM_ALL)
add_library(wlocate_core STATIC
src/common.cpp src/query.cpp src/regex.cpp src/index.cpp src/ntfs.cpp src/ntfs_parser.cpp src/store.cpp src/ipc.cpp)
target_include_directories(wlocate_core PUBLIC include)
target_link_libraries(wlocate_core PUBLIC advapi32 shell32 ole32 shlwapi bcrypt pcre2-8-static)
add_executable(wlocate WIN32 src/main.cpp src/service.cpp src/gui.cpp src/shell.cpp src/app.rc)
target_link_options(wlocate PRIVATE /MANIFEST:NO)
target_link_libraries(wlocate PRIVATE wlocate_core comctl32 comdlg32 oleaut32 uuid uxtheme)
target_link_libraries(wlocate PRIVATE windowscodecs)
add_executable(locate src/cli.cpp src/app.rc)
target_link_options(locate PRIVATE /MANIFEST:NO)
target_link_libraries(locate PRIVATE wlocate_core)
add_executable(wlocate_tests tests/tests.cpp src/app.rc)
target_link_options(wlocate_tests PRIVATE /MANIFEST:NO)
target_link_libraries(wlocate_tests PRIVATE wlocate_core)
enable_testing()
add_test(NAME core COMMAND wlocate_tests)
add_executable(wlocate_ui_tests WIN32 tests/ui_tests.cpp src/gui.cpp src/shell.cpp src/app.rc)
target_link_options(wlocate_ui_tests PRIVATE /MANIFEST:NO)
target_link_libraries(wlocate_ui_tests PRIVATE wlocate_core comctl32 comdlg32 oleaut32 uuid uxtheme windowscodecs)
add_executable(wlocate_gui_cache_tests tests/gui_cache_tests.cpp src/shell.cpp src/app.rc)
target_link_options(wlocate_gui_cache_tests PRIVATE /MANIFEST:NO)
target_link_libraries(wlocate_gui_cache_tests PRIVATE wlocate_core comctl32 comdlg32 oleaut32 uuid uxtheme windowscodecs)
add_test(NAME gui_cache COMMAND wlocate_gui_cache_tests)
add_executable(wlocate_icon_cache_tests tests/icon_cache_tests.cpp src/app.rc)
target_link_options(wlocate_icon_cache_tests PRIVATE /MANIFEST:NO)
target_link_libraries(wlocate_icon_cache_tests PRIVATE wlocate_core comctl32)
add_test(NAME icon_cache COMMAND wlocate_icon_cache_tests)
add_executable(wlocate_hotkey_tests tests/gui_hotkey_tests.cpp src/shell.cpp src/app.rc)
target_link_options(wlocate_hotkey_tests PRIVATE /MANIFEST:NO)
target_link_libraries(wlocate_hotkey_tests PRIVATE wlocate_core comctl32 comdlg32 oleaut32 uuid uxtheme windowscodecs)
add_test(NAME hotkey COMMAND wlocate_hotkey_tests)
foreach(gui_target wlocate wlocate_ui_tests wlocate_gui_cache_tests wlocate_hotkey_tests)
target_link_libraries(${gui_target} PRIVATE uiautomationcore)
endforeach()
set_target_properties(wlocate locate PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
+51
View File
@@ -0,0 +1,51 @@
param(
[string]$BuildDirectory = "$PSScriptRoot\build\bin\Release",
[string]$InstallDirectory = "$env:ProgramFiles\WLocate",
[string]$CliDirectory = 'D:\tools\bin',
[switch]$StageOnly
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath (Join-Path $BuildDirectory 'wlocate.exe')) -and (Test-Path -LiteralPath "$PSScriptRoot\wlocate.exe")) { $BuildDirectory = $PSScriptRoot }
Start-Transcript -Path "$PSScriptRoot\install-transcript.log" -Append | Out-Null
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this installer with psuac or from an elevated PowerShell.' }
$serviceExe = Join-Path $BuildDirectory 'wlocate.exe'
$cliExe = Join-Path $BuildDirectory 'locate.exe'
if (-not (Test-Path -LiteralPath $serviceExe) -or -not (Test-Path -LiteralPath $cliExe)) { throw 'Build both release executables before installing.' }
$existing = Get-Service -Name WLocateService -ErrorAction SilentlyContinue
if ($existing -and $existing.Status -ne 'Stopped') { Stop-Service -Name WLocateService; $existing.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(120)) }
New-Item -ItemType Directory -Path $InstallDirectory -Force | Out-Null
Copy-Item -LiteralPath $serviceExe -Destination (Join-Path $InstallDirectory 'wlocate.exe') -Force
Copy-Item -LiteralPath $cliExe -Destination (Join-Path $InstallDirectory 'locate.exe') -Force
foreach ($document in @('README.md', 'THIRD-PARTY-NOTICES.md', 'PERFORMANCE.md')) {
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot $document)) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $document) -Destination $InstallDirectory -Force }
}
if (Test-Path -LiteralPath "$PSScriptRoot\licenses") { Copy-Item -LiteralPath "$PSScriptRoot\licenses" -Destination $InstallDirectory -Recurse -Force }
$install = Start-Process -FilePath (Join-Path $InstallDirectory 'wlocate.exe') -ArgumentList '--install-service' -WindowStyle Hidden -PassThru -Wait
if ($install.ExitCode -ne 0) { throw "Service installation failed with exit code $($install.ExitCode); see ProgramData\WLocate\service.log" }
if (-not $StageOnly) {
New-Item -ItemType Directory -Path $CliDirectory -Force | Out-Null
$destination = Join-Path $CliDirectory 'locate.exe'
$backup = $null
if (Test-Path -LiteralPath $destination) { $backup = "$destination.backup-$(Get-Date -Format yyyyMMdd-HHmmss)"; Copy-Item -LiteralPath $destination -Destination $backup }
$rollback = $backup
$statePath = Join-Path $InstallDirectory 'install-state.json'
if (Test-Path -LiteralPath $statePath) {
$previous = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
if ($previous.locate -eq $destination -and $previous.previous_locate -and (Test-Path -LiteralPath $previous.previous_locate)) { $rollback = $previous.previous_locate }
}
Copy-Item -LiteralPath $cliExe -Destination $destination -Force
Copy-Item -LiteralPath $serviceExe -Destination (Join-Path $CliDirectory 'wlocate.exe') -Force
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut("$env:ProgramData\Microsoft\Windows\Start Menu\Programs\WLocate.lnk")
$shortcut.TargetPath = Join-Path $InstallDirectory 'wlocate.exe'
$shortcut.WorkingDirectory = $InstallDirectory
$shortcut.Description = 'WLocate 文件查找'
$shortcut.IconLocation = (Join-Path $InstallDirectory 'wlocate.exe') + ',0'
$shortcut.Save()
@{ installed_at = (Get-Date -Format o); previous_locate = $rollback; last_update_backup = $backup; locate = $destination; gui = (Join-Path $InstallDirectory 'wlocate.exe') } | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding utf8
}
Get-Service WLocateService | Format-Table Name, Status
Get-FileHash -LiteralPath (Join-Path $InstallDirectory 'wlocate.exe'), (Join-Path $InstallDirectory 'locate.exe') -Algorithm SHA256
Stop-Transcript | Out-Null
+91
View File
@@ -0,0 +1,91 @@
# 本机验证结果
测量日期:2026-10-07 至 2026-10-08。Windows x64,C: 和 D: 合计约 327 万条文件/目录记录。数字会随本机文件变化、系统负载和缓存状态波动。
## 相对路径片段查询优化(2026-10-08)
`foo\bar` 没有盘符,也没有通配符,因此此前的绝对路径通配符优化没有覆盖它。修复前依然逐项重建完整路径;同样 17 个结果,原生命令行约 3779 ms,旧包装器约 253 ms。现在根据片段的最后一个组件在名称索引中找候选项,验证前面的目录组件,再遍历匹配目录的子树。`foo\bar` 允许 `afoo\barista` 这样的原有子串匹配,也包含匹配目录下的后代;不会把查询错误地改成组件精确匹配。
四次运行排除首次后的三次中位数,索引已在内存,GUI 同等查询每次清除上次结果缓存。命令行总耗时包含进程启动、IPC 和接收完整 stdout;GUI 列只测服务,不含输入防抖、绘制和图标。脚本:`tests/relative_path_benchmark.py`;原始数据:`test-output/relative-path-before.json`、`relative-path-after.json`。
| 查询 | 原生修复前总耗时 | 旧包装器本轮总耗时 | 原生修复后总耗时 | GUI 同等查询服务耗时 | 相同结果数 |
|---|---:|---:|---:|---:|---:|
| `foo\bar` | 3779.2 ms | 249.0 ms | **17.3 ms** | 1.374 ms | 17 |
| `Windows\Logs` | 3437.1 ms | 218.9 ms | **18.9 ms** | 1.490 ms | 364 |
| `Windows\Logs\` | 3382.0 ms | 247.7 ms | **14.7 ms** | 1.340 ms | 363 |
| `Windows\System32\drivers\etc` | 3423.7 ms | 235.9 ms | **17.6 ms** | 0.602 ms | 7 |
四种查询的旧包装器、原生 CLI 和 GUI 同等查询均逐路径比较一致。这里仍比较本机旧包装器,不代表测出了 Everything 搜索引擎本身的耗时或内部实现。
该方案复用已有名称前缀索引、父目录关系和子项链表,不增加常驻索引或重复的完整路径缓存。只有单组件加结尾斜杠(如 `foo\`)需要做名称后缀预筛。支持每个 OR 分支带正向路径片段的查询、附加筛选条件、大小写、Unicode、目录改名/移动和实时新增名称;无法安全缩小范围的查询继续通用匹配。2386 项核心断言及现有 GUI/图标测试通过,其中相对路径优化用逐项完整路径匹配作独立候选筛选对照,并验证 30000 个无关名称不会触发全扫描。实际磁盘集成测试同时比较名称查询、绝对路径通配符和相对路径片段,全部通过。
## 固定目录前缀的路径通配符优化(22:51 更新)
用户报告 `C:\Windows\Logs\*.log` 明显慢于旧版。修复前,路径通配符没有使用目录范围,每次对全盘约 327 万条记录重建完整路径并匹配;GUI 还遍历了全局名称排序。新增按父文件引用号维护的子项链表,先解析固定目录前缀,再遍历对应子树;目录移动/改名、硬链接变化、快照恢复和压缩都会维护或重建此索引。
同一台电脑、内存索引已就绪,6 次运行排除首次后的 5 次中位数。原始结果见 `test-output/path-before.json`、`test-output/path-after.json`;复测脚本 `tests/path_benchmark.py`。
| 查询 `C:\Windows\Logs\*.log` | 修复前 | 修复后 |
|---|---:|---:|
| 旧 `locate.old.exe` 启动至退出 | 152.8 ms | 160.1 ms |
| 原生 `locate.exe` 启动至退出 | 2598.9 ms | 14.7 ms |
| 原生命令行查询的服务内耗时 | 2581.8 ms | 0.486 ms |
| GUI 同等查询:名称排序、全部计数、返回前 256 条,服务内耗时 | 3638.2 ms | 0.571 ms |
三种查询均返回相同的 62 个路径。GUI 同等查询通过 CLI 的 Everything 语法和名称排序选项提交,每次先用不同查询清除上次结果缓存;这里测量服务查询,不包含 GUI 的 100 ms 输入防抖、界面绘制或图标加载。命令行总耗时包含进程启动、IPC 和接收 stdout,不包含终端绘制。比较对象是本机旧包装器,不能由此推断所有查询都快于 Everything 引擎。
保留原有通配符跨子目录、大小写、OR/AND、排除条件、排序和分页语义。只有每个 OR 分支都有正向、带绝对目录前缀的路径通配符时才缩小范围;其他查询沿用通用路径。子项索引增加每行 4 字节及每个父目录一个哈希表项,不保存重复的完整路径。442 项核心断言、30 项 GUI 缓存检查、16 项图标缓存检查通过;实际磁盘集成测试同时验证名称查询与目录范围查询,包括 30 个硬链接、目录改名、文件移动、删除、Unicode 长路径和 500 文件批量创建。
以下为此前测量记录。
## 同结果的命令行比较
命令通过 Python `subprocess` 启动,接收 stdout,测量从启动到退出的总时间;不包含终端逐行绘制。每项运行 9 次,排除首次后报告 8 次中位数。原始数据:`test-output/benchmark.json`。
| 命令 | 原来的 locate.old.exe | 原生 locate.exe | 结果 |
|---|---:|---:|---|
| `python*.exe` | 68.86 ms | 9.10 ms | 同样的 66 个完整路径,集合完全一致 |
| 不存在的文件名通配符 | 68.28 ms | 8.65 ms | 都为 0 条 |
| `-n 10 "python*.exe"` | 不支持该参数,不比较 | 8.40 ms | 10 条 |
| `-n 10 "*.exe"` | 不支持该参数,不比较 | 7.61 ms | 10 条 |
这里比较的是本机旧包装器和新命令行的端到端时间,不能据此声称原生搜索引擎在所有查询上都比 Everything 引擎快。旧版 `*.exe` 输出为 9999 条;原生为 12286 条,这种不同结果规模未用于速度结论。旧版独有的 9 个 Office 更新临时路径,Win32 检查均返回 ERROR_FILE_NOT_FOUND;记录见 `test-output/exe-comparison.json`。
## 原生服务内查询时间
来自 `test-output/performance.json`。每个查询连续运行三次;“首次”表示该查询没有已完成结果缓存,磁盘索引已经在内存。
| 查询 | 服务内时间 | 说明 |
|---|---:|---|
| `-n 10 python*.exe` | 0.30–0.38 ms | 名称前缀索引,找到足够数量就停止 |
| `-bic python` | 26–28 ms | 名称子串计数,10302 条 |
| `-ic python` | 43–52 ms | 完整路径子串计数,84020 条 |
| `-ic -r '^python.*\.exe$'` | 281–309 ms | Unicode PCRE2 JIT 正则,全名称扫描 |
| 全索引按名称排序,第一页 | 首次 1282 ms;缓存约 0.2 ms | 约 320 万条匹配、返回 256 条 |
| 全索引按完整路径排序,第一页 | 首次 3972 ms;缓存约 0.2 ms | 同上 |
| 全索引按大小排序,第一页 | 首次 2325 ms;缓存约 0.4 ms | 同上 |
最明显的剩余性能差距是大范围查询的首次排序和通用正则扫描。查询缓存会在相关索引变化后失效;名称查询不会因为仅修改文件大小/时间而失效。一次采样的服务工作集约 642 MiB、私有内存约 751 MiB;包含名称、父子关系、硬链接、文件元数据和查询缓存,未达到 Everything 的内存效率。
## 实时性、恢复与正确性
- 86 项核心断言通过:UTF-8 进程代码页和窄字符文件 API、查询、Unicode、正则工作量限制、日期边界、硬链接、DOS 别名计数、路径排序、分页、取消、快照校验、压缩与部分卷可用。
- 实际磁盘测试通过:30 个长名称硬链接跨 MFT 扩展记录;目录改名、单链接删除、跨目录移动、所有链接元数据同步、超过 MAX_PATH 的 Unicode 路径。
- 常规文件更新在测试中约 0.12–0.35 秒可见;500 文件突发创建约 0.12 秒可见。大范围排序占用索引读锁时,更新发布可能推迟。
- 服务停止后创建文件,重启后约 2143 ms 可查询,验证快照恢复与 USN 补读。
- 临时 NTFS VHD 插入后测试文件约 131 ms 可查;拔出后约 1787 ms 从已发布卷中移除,其他卷保持可查询。轮询相位会影响这些数字;热插拔检测周期为 2 秒。
- 60 次并发查询、20 个客户端线程,结果一致;单次 20000 条 / 约 2.1 MB 结果传输通过。
- 100 ms 请求超时约 104 ms 返回,随后服务继续正常响应;畸形请求和普通用户重建请求被拒绝。
- 程序内 GUI 测试通过:搜索、OR 分组与类型筛选、虚拟列表翻页、单文件与跨目录多选的系统/第三方 Shell 菜单、真实中文文件重命名。渲染图在 `test-output/ui-final`,已检查布局。
GUI 验证使用程序内集成测试和实际窗口渲染;本次外部桌面自动化接口不可用,未把模拟鼠标逐项点击全部第三方菜单当作已完成的测试。网络盘和非 NTFS 卷不在支持或测试范围。
## 主要实现选择
- 原生 MFT + USN 服务维护索引,CLI/GUI 通过本机管道提交查询,只传回匹配结果。
- 名称去重、排序前缀查找、64 位三元组 Bloom 预筛、连续内存扫描;路径子串查询复用父目录匹配结果。
- 目录采用文件引用号及父行缓存,改名无需重写所有子路径;完整 NTFS 序列号避免文件记录重用串到旧文件。
- PCRE2 JIT、64 KiB 传输缓冲、服务端限量和虚拟列表分页减少不必要的计算与传输。
- 快照带 SHA-256 校验并原子替换;未完成的记录更新保留日志重放位置,避免服务重启后漏掉未解决的变化。
这些优化解决了之前 C# 适配器整批传送目录、客户端重组和筛选的主要开销;性能问题不应简单归因于 C# 这一语言本身。
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = 'Stop'
$destination = Join-Path $PSScriptRoot 'out\WLocate-1.0.0-win-x64'
New-Item -ItemType Directory -Path $destination -Force | Out-Null
foreach ($name in @('wlocate.exe', 'locate.exe')) { Copy-Item -LiteralPath "$PSScriptRoot\build\bin\Release\$name" -Destination $destination -Force }
foreach ($name in @('README.md', 'PERFORMANCE.md', 'THIRD-PARTY-NOTICES.md', 'Install.ps1')) { Copy-Item -LiteralPath "$PSScriptRoot\$name" -Destination $destination -Force }
Copy-Item -LiteralPath "$PSScriptRoot\licenses" -Destination $destination -Recurse -Force
$hashes = Get-FileHash -LiteralPath "$destination\wlocate.exe", "$destination\locate.exe" -Algorithm SHA256
$hashes | ForEach-Object { "$($_.Hash.ToLowerInvariant()) $([IO.Path]::GetFileName($_.Path))" } | Set-Content -LiteralPath "$destination\SHA256SUMS.txt" -Encoding ascii
$archive = "$destination.zip"
Compress-Archive -LiteralPath $destination -DestinationPath $archive -CompressionLevel Optimal -Force
Get-Item -LiteralPath $archive
$hashes
+114
View File
@@ -0,0 +1,114 @@
# WLocate 1.0
Windows x64 原生文件查找工具。`wlocate.exe` 提供 GUI 和后台服务,`locate.exe` 提供命令行;两者共享独立的 C++ 索引。日常搜索无需管理员权限,不依赖 Everything、SwiftList 或 .NET。
两个程序均嵌入 UTF-8 activeCodePage manifest,启动时核验进程代码页为 65001,要求 Windows 10 1903 或更新系统。编译器使用 `/utf-8`;窄字符串、索引、IPC 和重定向文本统一为 UTF-8,在 Windows 窗口、Shell 和 W API 边界转换 UTF-16。它不会更改系统区域设置或父终端代码页。
绿色放大镜图标覆盖 EXE、窗口、任务栏、托盘和开始菜单,包含 16–256 像素共九种尺寸。可编辑矢量稿在 `assets/wlocate.svg`,运行 `tools/Build-Icon.ps1` 可重新生成 ICO。
## 本机安装与使用
- GUI / 服务程序:`C:\Program Files\WLocate\wlocate.exe`。
- 命令行:`D:\tools\bin\locate.exe`;同目录的 `wlocate.exe` 可直接启动界面。
- 开始菜单:WLocate。后台服务:`WLocateService`,自动启动。
- 索引和日志:`C:\ProgramData\WLocate`。界面设置:`%LOCALAPPDATA%\WLocate\settings.ini`。
- 原来的 `locate.old.exe` 保留。此次替换前的 locate 备份路径记录于 `C:\Program Files\WLocate\install-state.json`。
```powershell
locate -n 10 "python*.exe"
locate -i -b python
locate -ic "*.dll"
locate -0 -n 100 "*.cpp"
locate -r '^python.*\.exe$'
locate --query 'ext:exe size:>1mb !path:Windows' --sort size --descending -n 20
locate -S
wlocate "python*.exe"
```
## 已实现的功能
| 部分 | 行为 |
|---|---|
| 建索引 | 自动发现带盘符的本地 NTFS 卷;直接读取 MFT、扩展记录及长文件名硬链接;索引名称、父目录、大小、时间、属性 |
| 实时更新 | USN 日志增量更新,约 250 ms 轮询;创建、删除、改名、移动、硬链接及元数据变化;目录改名不必重写所有后代路径 |
| 持久化 | 原子替换的 SHA-256 校验快照;重启后续读 USN;卷标识或日志缺口不匹配时重建;临时繁忙记录保留旧值并重试 |
| 磁盘变化 | 自动加入新 NTFS 盘、移除拔出的盘;一个卷建索引时仍可查询其他就绪卷 |
| 查询 | Unicode、大小写开关、通配符、PCRE2 JIT 正则、AND/OR/NOT 和分组、常用 Everything 过滤函数 |
| 界面 | 输入即时搜索、类型筛选、名称/完整路径/大小/修改时间/扩展名排序、虚拟列表分页、书签、CSV/文本导出、托盘、自定义全局快捷键、可选登录启动 |
| 文件操作 | 原生 Windows Shell 右键菜单和已安装的扩展;打开、所在文件夹、复制、剪切、删除、属性、重命名、拖出;支持跨目录多选 |
| 命令行 | 服务端限量、计数、NUL 分隔、存在性检查、超时与 Ctrl+C 取消、UTF-8 重定向输出 |
程序自身不提升普通 GUI 的权限。安装服务和重建全盘索引才需要管理员权限;Shell 文件操作遵循当前用户的 Windows 权限和系统确认行为。
## 界面语法
默认忽略大小写、匹配名称。空格为 AND,`|` 为 OR,`!` 为 NOT,`<...>` 分组,双引号包含空格或运算符。
```text
python*.exe
<python | node> ext:exe
"Program Files"
file: ext:cpp;h !path:build
folder: path:D:\work
size:>10mb dm:today
size:1kb..10mb dc:2026-01-01..2026-12-31
attrib:h
case:Python
regex:"^python(?:3)?\.exe$"
```
支持 `file:`、`folder:`、`ext:`、`path:`、`parent:`、`size:`、`dm:`/`datemodified:`、`dc:`/`datecreated:`、`da:`/`dateaccessed:`、`attrib:`/`attributes:`、`case:`、`nocase:`、`regex:`、`ww:`/`wholeword:`。属性字母:`h s r a d l`。日期支持 `YYYY-MM-DD`、`today`、`yesterday`、范围和比较运算;按本地日历边界解释。大小单位使用 1024 倍数。完整路径正则以 `/` 作为路径分隔符。
| 快捷键 | 功能 |
|---|---|
| Ctrl+L / Ctrl+F | 定位搜索框 |
| Ctrl+Alt+L(默认,可修改) | 界面已运行时显示窗口 |
| Enter / Ctrl+Enter / Alt+Enter | 打开 / 所在文件夹 / 属性 |
| Ctrl+C / Ctrl+X / Ctrl+Shift+C | 复制文件 / 剪切文件 / 复制完整路径 |
| F2 / Delete / Shift+Delete | 重命名 / 系统删除 / 系统永久删除 |
| Ctrl+A / F5 | 全选 / 刷新 |
| Ctrl+S / Ctrl+D | 导出全部匹配结果 / 收藏当前查询 |
全局唤出快捷键可在 **工具 → 快捷键设置** 或托盘右键菜单中修改。选择 Ctrl / Alt / Shift / Win 组合与按键,点击“确定”立即生效并保存,下次启动继续使用;也可禁用或恢复默认。新组合被占用、系统不允许注册或设置保存失败时,原快捷键保持可用,窗口内会提示原因。为避免拦截日常输入,普通按键需搭配 Ctrl、Alt 或 Win;功能键可单独使用。程序启动时遇到快捷键冲突会给出托盘提示,仍可通过托盘打开窗口修改。
导出先写临时文件,完成后替换目标;CSV 使用 UTF-8 BOM。界面每两秒刷新;有选中项或正在重命名时暂停自动刷新,以免选中对象移动。搜索框清空可显示全部索引。列宽、窗口大小、查询开关及最多 30 条书签会保存。
## 命令行兼容范围
`locate --help` 列出完整选项。支持 Linux locate 常用参数 `-n/-l`、`-i`、`-b`、`-w`、`-A/-a`、`-c`、`-0`、`-e`、`-E`、`-r`、`--regex`、`-S` 和 `--`。
这是 Windows 索引客户端,不读取 GNU/mlocate/plocate 数据库,未实现 `updatedb`、`--database` 或全部 GNU 扩展。默认区分大小写;普通文字按完整路径子串匹配,未带分隔符的通配符和正则按名称匹配,`-w` 强制匹配完整路径,`-b` 强制名称匹配。多个模式默认 OR,`-A` 改为 AND;`--query` 使用界面语法并默认忽略大小写。正则语法为 PCRE2。
无排序的 `-n` 在后端找到足够条目即停止;结果顺序不保证稳定。需要确定顺序时加 `--sort`。`-c -n 10` 的计数最多为 10。退出码:0 有结果,1 无结果,2 错误,130 被取消。索引尚未全部就绪时可能返回部分结果,同时在 stderr 提示并以 2 退出。
## 编译、安装、回退
使用 MSVC、Windows SDK、CMake 3.25+;本机验证环境为 VS 2026、MSVC 19.50、CMake 4.2.3。依赖源已固定在 `third_party/pcre2`,需要其 SLJIT 子模块。
```powershell
.\Build.ps1
# 以管理员身份运行;在这台电脑可以使用 psuac:
psuac -w powershell.exe -NoProfile -ExecutionPolicy Bypass -File D:\work\gitprojs\swiftlist-locate\wlocate\Install.ps1
```
源码构建输出在 `build\bin\Release`。发布包内可直接运行其 `Install.ps1`,或指定 `-BuildDirectory`;`-CliDirectory` 指定命令行目录,默认 `D:\tools\bin`。`-StageOnly` 仅安装服务端及 Program Files 内的程序,不替换 PATH 中的 locate。更新前先退出 WLocate 界面。
`wlocate --install-service` 在提升权限后会将服务程序安装到 Program Files;不会让 SYSTEM 服务从普通用户可写的工具目录运行。安装目录及数据目录仅允许管理员/SYSTEM 写入。
需要回退命令行时,将 `install-state.json` 中 `previous_locate` 对应的文件复制回 `D:\tools\bin\locate.exe`。原始 Everything 客户端 `D:\tools\bin\locate.old.exe` 也一直保留。需要删除服务时,以管理员运行 `"C:\Program Files\WLocate\wlocate.exe" --uninstall-service`。此操作保留索引、用户设置和二进制文件,便于重新安装。
## 验证与性能
`tests/tests.cpp` 覆盖查询语法、Unicode、范围、正则边界、硬链接、路径/分页排序、取消、元数据缓存、MFT 解析、校验快照、压缩及部分卷可用。
`tests/integration.py` 使用工作区内独立 UUID 目录测试实时更新、30 个硬链接跨 MFT 扩展记录、Unicode 长路径、500 文件突发创建、元数据和清理。`tests/protocol.py` 测试畸形请求、60 次并发查询、2 万条输出、超时及未提升权限的重建拒绝。`tests/restart.ps1` 和 `tests/hotplug.ps1` 需要管理员权限;后者只格式化自己新建的临时 VHD,不选择物理磁盘。`wlocate_ui_tests.exe` 创建自己的窗口和测试文件,验证筛选、虚拟列表、跨目录原生菜单及 Unicode 重命名,并保存渲染图。
性能数据与测量口径见 `PERFORMANCE.md`,原始记录在源码目录的 `test-output`。旧版包装器不支持 `-n`;本次 `*.exe` 对比中旧版仅输出 9999 条,而原生版找到 12286 条,因此不能把它的限量/大范围输出直接当作等价性能基准。
## 当前边界
本版交付了本地 NTFS 文件名搜索的完整常用流程,并未实现 Everything 的全部功能,也不保证所有查询都达到其性能。目前不支持无盘符卷、ReFS/FAT/exFAT/网络共享的文件夹索引、内容搜索、ETP/HTTP 服务、Everything 插件/IPC 兼容接口、高级预览及所有搜索函数;未知函数会明确报错。目录大小不递归统计,重解析点目标不会展开为额外的别名路径。仅测试 Windows x64。
第一次全盘扫描、数百万条结果的首次全量排序仍可能需要数秒;首次建索引更久。快照缓存后的启动和常用限量名称查询要快得多。索引中的文件名可被本机经过身份验证的用户查询,和文件内容读取权限是两回事;文件打开和更改仍由 Windows ACL 控制。暂时无法读取的记录会显示为统计中的 `pending` 并重试。
许可证和参考出处见 `THIRD-PARTY-NOTICES.md` 与 `licenses`。
+39
View File
@@ -0,0 +1,39 @@
# Third-party notices and references
WLocate is a native C++ implementation. It does not link to, launch, or require
Everything, SwiftList, the Everything service, or the .NET runtime.
The binary statically includes PCRE2 10.49, upstream commit
`6f9d7c1373262c541324a16a358785b33ef116cf`, and its pinned SLJIT dependency
`de0259c7aaf36aa40cba8014f3fad3edde9307f9`.
Their complete redistribution notices are in `licenses/PCRE2-LICENCE.md` and
`licenses/SLJIT-LICENSE.txt`. Sources: <https://github.com/PCRE2Project/pcre2>
and <https://github.com/zherczeg/sljit>.
Architectural references:
- SwiftList, MIT licensed: <https://github.com/SwiftList/SwiftList>, local
reference commit `ef39db8c2aac8507130b789d5448f5305c5dfd27`. Its notice is
included in `licenses/SwiftList-LICENSE.txt` as reference attribution.
- Everything search syntax: <https://www.voidtools.com/support/everything/searching/>.
- Everything service model: <https://www.voidtools.com/en-us/support/everything/everything_service/>.
- Everything IPC documentation: <https://www.voidtools.com/support/everything/sdk/ipc/>.
- Microsoft NTFS, USN, Windows services and Shell API documentation.
The user's installed Everything 1.4.1.1032 was inspected locally with IDA MCP.
The reference executable's SHA-256 is
`F191F756996A14A11E5445FA7103D302EFD510CF2FBF920E6C0C8ED51D512E36`.
Observed query dispatch routines at `0x1400af3b0` and `0x1400ae4b0` partition
file/folder work and combine results; the NTFS record-reading routine is at
`0x1400cfe80`. These observations informed architectural choices. WLocate's
source is independently implemented; the Everything executable, SDK binary
and decompiled code are not included in the release package. No affiliation
with or endorsement by Voidtools or SwiftList is implied.
WLocate's green magnifying-glass icon is original vector artwork inspired by
Everything's magnifying-glass silhouette. Editable SVG and the reproducible
multi-resolution ICO generator are included in the source tree.
The UTF-8 process manifest follows the user's `D:\work\dev3\utillib\utf8.manifest`
and Microsoft's guidance:
<https://learn.microsoft.com/en-us/windows/apps/design/globalizing/use-utf8-code-page>.
+144
View File
@@ -0,0 +1,144 @@
# WLocate implementation record
## Feature — configurable global window hotkey, 2026-10-08
- Added native “工具 → 快捷键设置” dialog and the same entry in the tray context menu. Users can choose Ctrl/Alt/Shift/Win modifiers and letters, digits, function/navigation/punctuation/numpad keys, disable the shortcut, or restore the Ctrl+Alt+L default. Changes apply on OK and persist immediately; Cancel/default preview do not alter active settings. Existing installations retain the old default until changed.
- `gui_hotkeys.hpp` owns registration and one `[Keyboard] ShowWindow` INI value for enabled/modifiers/key. A replacement is registered under an alternate ID before releasing the active registration; occupied/reserved combinations or a failed settings write leave the old binding and saved value intact. Disable unregisters, restart loads saved preferences, malformed settings fall back to the default. Startup registration failures are shown in the tray notification/tooltip and settings dialog. Tray tooltip/About show the configured shortcut instead of hard-coded text. WM_HOTKEY validates current ID and chord and brings an already-open modal dialog forward.
- Native dialog uses the shared UTF-8 resource build and Windows dialog font/DPI scaling. Bare typing keys require Ctrl/Alt/Win; function keys can stand alone. F12 is excluded per Windows' documented debugger reservation. References: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey and https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-hotkey .
- Added 32 checks using actual RegisterHotKey ownership and a separate temporary INI: default/custom/disabled reload, invalid configuration, switching/releasing keys, unchanged selection, conflicts, failed-save rollback, startup failure and retry, dialog default/cancel/OK/disabled controls, inline conflict message. Tests reserve unused Ctrl+Alt+Shift+F13–F24 combinations and send no keyboard input to the desktop. Default UI integration tests no longer try to register the user's global shortcut. Dialog/conflict renders inspected at `test-output/hotkey-ui/hotkey-settings.png` and `hotkey-conflict.png`.
- Release build and all four CTest targets pass. Program Files/PATH executables, installed README and release ZIP refreshed; verified hashes in `test-output/installation-hotkey.json`. User's chosen settings are preserved; GUI restarted hidden, retaining the configured/default global shortcut.
## Fix — relative literal path fragment planning, 2026-10-08
- User reported `locate.old.exe foo\bar` was much faster. Reproduced with identical 17 paths: old wrapper 252.5 ms, native CLI 3779.2 ms (3765.1 ms service), GUI-equivalent uncached query 4866.1 ms service. The previous absolute-glob bound could not apply to literal relative fragments, which still reconstructed every indexed full path.
- `Query::path_literals()` chooses a necessary positive literal path term in every OR branch. `Volume::find_literals()` uses the last component as a name-prefix candidate range (exact component for a trailing separator), verifies the preceding path suffix, and walks matching directory descendants with row deduplication. A lone `foo\` uses Bloom-assisted name-suffix filtering. Actual predicates still enforce case, whole word, OR/AND, negation and metadata. Root/drive fragments, Unicode, partial first/last components, hardlink rows, late unique names and overlapping seed subtrees retain previous semantics. General matching remains the fallback when a branch is unbounded. Existing absolute-glob planning stays ahead of this path.
- No persistent index or snapshot format changes: reuse sorted names, new-name delta chains and parent/child relationships. Temporary row visitation bytes are allocated only when candidate seeds exist. Directory/name generation invalidates cached results as before.
- Added exhaustive full-path oracle comparisons across relative/absolute literals, slash forms, trailing separators, case collisions, Unicode, whole-word/OR/metadata, multi-pattern CLI, ordering, count-only, limits and GUI pagination. Generated fragments cross arbitrary component boundaries. Mutation checks cover directory moves/renames, changed hardlinks, orphan/late parents, deletions, compaction and snapshot restore. Bounded-work checks use 30000 unrelated names. All three CTest targets pass: 2386 core checks, existing 30 GUI cache and 16 icon cache checks.
- Extended live integration to compare all three query paths (name, absolute glob subtree, relative literal subtree) after create/rename/move/delete, 30 hardlinks, metadata changes, long Unicode paths and 500-file bursts; all passed. Logs: `test-output/build-relative-path.log`, `ctest-relative-path.log`, `integration-relative-path.log`.
- Deployed service benchmark: `foo\bar` native median 17.2717 ms total / 1.193 ms service, old wrapper 248.9998 ms total; uncached GUI-equivalent 1.374 ms service. `Windows\Logs`: 18.8648 vs 218.8839 ms, 364 identical paths; trailing slash: 14.6512 vs 247.6539 ms, 363 paths; `Windows\System32\drivers\etc`: 17.5668 vs 235.9423 ms, 7 paths. All four complete result sets agree among old/native/GUI. Four runs with first excluded, warm resident index; no GUI rendering/debounce included. Data and reusable script: `test-output/relative-path-{before,after}.json`, `tests/relative_path_benchmark.py`.
- Installed Program Files/PATH binaries, updated installed performance notes and release ZIP, verified hashes in `test-output/installation-relative-path.json`. GUI restarted hidden on `foo\bar` (Ctrl+Alt+L restores it). Original `locate.old.exe` unchanged. Prior delivery hashes and timings below are historical.
## Fix — anchored path glob subtree planning, 2026-10-07 22:51
- Reproduced `C:\Windows\Logs\*.log`: previous native CLI median 2598.9 ms end-to-end (2581.8 ms service), versus legacy wrapper 152.8 ms, identical 62 paths. GUI-equivalent uncached name-sorted query took 3638.2 ms inside the service. Path glob terms were Unknown during name evaluation, so the engine reconstructed/matched paths across both complete volumes.
- Query planning now derives a conservative absolute directory prefix for every positive OR branch, folds/merges overlapping bounds, and falls back to general scanning if any branch is unbounded. `Volume` resolves those prefixes and walks child chains keyed by parent NTFS file reference. Case-colliding directory names are all considered; original predicates still enforce case, glob, metadata, AND/OR and negation. Only matching subtree rows are sorted for GUI name order. Existing wildcard semantics include nested descendants.
- Four-byte-per-row sibling links plus one hash head per parent are maintained on insertion, deletion, hardlink replacement and directory moves; directory renames retain descendant references. `finish()` rebuilds chains after snapshot restore/compaction. No disk-format change or full reindex is required.
- Expanded core tests from 86 to 442 checks: exhaustive-path oracle across glob/case/slash/Unicode/OR/negation/sorting variants, limits/cancellation, hardlinks and mutations, late parents, snapshot/compaction, cross-volume ordering and GUI pagination. A bounded-work regression includes 30000 unrelated names. All three CTest targets pass (442 core + 30 GUI cache + 16 icon cache). Live integration now compares both name and subtree queries and passed with 30 hardlinks, rename/move/delete, long Unicode paths and a 500-file create burst.
- After installing the service: exact-query native CLI median 14.6785 ms total, 0.486 ms service; legacy wrapper 160.0808 ms total. GUI-equivalent query with its completed-result cache cleared before each run: 0.571 ms service. All 62 paths match across old/native/GUI. Five measured runs after one warmup; index already resident. Scripts/data: `tests/path_benchmark.py`, `test-output/path-before.json`, `path-after.json`, `integration-path-subtree.log`, `ctest-path-subtree.log`. These figures exclude actual GUI input debounce/rendering and are not a general Everything-engine comparison.
- Program Files/PATH executables and release ZIP refreshed; deployment hashes recorded in `test-output/installation-path-subtree.json`. GUI restarted hidden on the user's path query to avoid taking keyboard focus; Ctrl+Alt+L restores it. Previous executable hashes below are historical.
## Fix — actual file icons and full-path cache, 2026-10-07
- User observed that all three `Figma.exe` results stayed generic even when left idle. Confirmed in the installed UI. Previous async implementation only requested generic extension icons (`file.exe` with SHGFI_USEFILEATTRIBUTES); it never requested actual executable resources, so waiting could not change them. This was an incomplete follow-up load, not a missing repaint notification.
- `AsyncIcons` now has two layers: generic type icons provide the immediate fallback; the same dedicated COM worker loads actual file icons using the complete indexed path without SHGFI_USEFILEATTRIBUTES. Completion posts to the existing GUI handler and repaints automatically. File/path lookups remain off the GUI and search threads.
- Added a 4096-entry LRU cache keyed by full path, separate from type entries. Pending requests are deduplicated. Modification/creation timestamps, size and attributes invalidate a file entry; revision numbers reject outdated completions. Failed loads retain the generic fallback and retry after five seconds instead of poisoning the cache permanently or retrying every paint.
- Added 16 icon-cache checks covering fallback-to-file replacement, identical basename/extension in different directories, reuse, metadata invalidation, stale completion rejection, failure throttling/recovery, bounded eviction, and a real embedded EXE icon. Existing 30 GUI checks and 86 core checks also pass. Full UI integration/typing replay passed with a 250 ms maximum heartbeat gap on the 200 ms timer.
- Added `wlocate_ui_tests.exe <output> --file-icons` for the user's exact query. All three Figma paths loaded actual icons (system image indices 50/51/52 in that test process) without further input. Render inspected: `test-output/ui-figma-icons/file-icons.png`; report: `test-output/ui-figma-icons/report.txt`. File-specific Figma logos are visible on every row.
- Rebuilt/installed Program Files and PATH copies and refreshed the release ZIP; hashes are in `test-output/installation-file-icons.json`. GUI restarted hidden on `figma.exe` with its tray/hotkey available to avoid taking keyboard focus. Earlier icon/installation notes below are historical.
## Fix — input stalls with broad/intermediate queries, 2026-10-07
- User reported a several-second freeze while typing `python*`, before entering the final `n`. Isolated timings on the pre-fix controller: `pytho` service query ~84 ms and result publication ~7 ms; empty query ~0.93 s and `p` ~0.61 s on the worker. Those timings alone did not reproduce the reported UI stall (`test-output/gui-input-profile-before.txt`).
- Captured two installed-GUI stalls with CDB: the UI thread was inside `UIAutomationCore` bulk traversal of the stock owner-data ListView proxy. Intermediate `p` has ~1.61 million results, empty search ~3.27 million; the proxy walks every row and subitem. This also affected computer-use observation/input and could leave the application unresponsive for minutes. Stack captures: `gui-input-stack-before.txt`, `gui-input-stack-new.txt`. Do not attribute the user's original transient exclusively to icon loading; the directly observed long stall was UI Automation traversal.
- Added `gui_accessibility.hpp`: a server-side UIA fragment provider exposes visible rows only, with ItemContainer/VirtualizedItem for offscreen discovery and realization, native selection, focus, scroll-to-item and invoke. Row identities expire on query changes; detached providers cannot access destroyed windows. Calls use COM STA threading, keeping controls/cache access on the GUI thread. Native header accessibility remains available. A large select-all can be iterated through ItemContainer's IsSelected property; GetSelection avoids constructing arrays above 4096 elements. Reference: https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-workingwithvirtualizeditems
- Removed synchronous Shell icon lookup from startup/painting. `gui_icons.hpp` resolves generic type icons on a separate COM thread, deduplicates/cache requests, and posts completion to the GUI. It uses type names rather than probing indexed file paths. The worker owns detached shared state, so even a stuck Shell call cannot block search or window shutdown. Shared system image lists now use LVS_SHAREIMAGELISTS. Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shgetfileinfow
- Text changes now immediately cancel/retire the old query; only launching the newest query is debounced. Late replies and missing-page requests cannot repaint results for previous text during the debounce interval.
- Expanded the existing cache regression target to 30 checks: controlled blocked icon provider, input/cancel/shutdown, visible-row accessibility over 3,000,000 rows, real UI Automation FindAll, offscreen realization/selection, and provider invalidation/lifetime. Real UIA traversal visited 16 visible rows in ~47 ms on five consecutive runs. Core tests (86 checks), CTest and UI integration pass. Typing replay `p -> py -> pyt -> pyth -> pytho -> python -> python*` had a maximum 204 ms heartbeat gap on a 200 ms timer. Reports: `test-output/gui-input-regression-final.txt`, `ui-input-uia-final/report.txt`.
- During development one test process exited with 0xC0000409; it did not recur under CDB, in five subsequent runs, CTest, or final GUI integration. The earlier same-process UIA test used Sleep polling and exposed costly cross-thread control calls; final implementation uses STA marshalling and the test pumps messages without polling delay.
- Updated Program Files/PATH binaries and the release ZIP. GUI restarted hidden on `python*` with its tray icon/hotkey available, because live desktop input was detected during verification. Current final hashes are recorded in `test-output/installation-input-fix.json` and the package SHA256SUMS; previous hashes below are historical.
## Fix — repeated GUI queries after dismissing a context menu, 2026-10-07
- Reproduced the user's exact `figma.exe` scenario in the installed GUI with computer-use after its native pipe recovered: right-click the last of three results, dismiss onto blank list space, then move/click in the blank area. The row retained keyboard focus, and the status bar updated continuously. GUI CPU use was about 2.59 CPU seconds over a 3-second sample.
- Root cause: `receive()` removed the pending request and cleared page zero, then called `ListView_SetItemCountEx` before publishing the new page. The real Windows owner-data ListView synchronously requested the focused row (`LVN_GETDISPINFO`), found no cached data and queued page zero again. Each completion repeated the same cycle. Publish the page before notifying the control. Cache hints now reject negative/reversed/out-of-range intervals and clamp prefetch to the actual result count.
- Added `tests/gui_cache_tests.cpp` and the `gui_cache` CTest target. It exercises the production controller with actual common controls and deterministic replies, without a service or worker. The focused-last-row regression failed on the old code (one unwanted query), then passed after the fix. All 9 checks pass, including 100 refresh/repaint cycles without extra queries, preserved focus, empty results, bounded/deduplicated last-page fetches, and stale completions. Logs: `test-output/gui-cache-before.txt`, `gui-cache-after.txt`.
- Release build and both CTest targets pass (86 core checks plus 9 GUI cache checks). Existing UI integration passes search/filter grouping/paging/native Shell menus/Unicode rename; report and renders are in `test-output/ui-cache-fix`.
- Installed the corrected executable in Program Files and `D:\tools\bin`, restarted the service, and repeated the original mouse sequence in the installed GUI. Continuous refresh is gone; the normal two-second refresh remains. GUI CPU use fell to about 0.17 CPU seconds over the same 3-second sample (`test-output/gui-cache-cpu.json`). The window remains open on `figma.exe` for the user. `locate -n 10 "python*.exe"` still succeeds.
- WLocate SHA256: `30B925FF2F85719F2F046FC718F58DD8455B7749A957B2DD0009AC3D6E071848`; CLI unchanged. Release package regenerated with matching binaries. The earlier executable hashes below are historical.
## Update — green icon and UTF-8 manifest, 2026-10-07 21:30
- User clarified the icon reference is Everything, not Evernote. Created original green magnifying-glass vector artwork with the same general orientation; no original icon pixels are used in the release. `assets/wlocate.svg` and `tools/Build-Icon.ps1` regenerate the nine-size (16–256 px) `assets/wlocate.ico`.
- Embedded the icon in both EXEs. Window, taskbar, tray, per-monitor DPI icon reload and Start-menu shortcut use it. UI screenshot verified at `test-output/ui-green-utf8/search.png`.
- Merged the `activeCodePage` UTF-8 setting from the user's `D:\work\dev3\utillib\utf8.manifest` into the existing shared manifest, preserving DPI, long paths, common controls and execution level. Both entry points enforce `GetACP() == 65001`; internal narrow strings/index/IPC remain UTF-8 with UTF-16 conversion at Windows W/Shell/UI boundaries. Compiler already uses `/utf-8`.
- Both actual EXE manifests extracted with mt.exe and checked. 86 core checks pass, including process ACP, implicit-code-page decoding and UTF-8 CreateFileA with Chinese/emoji. UI integration and live Unicode/long-path/hardlink integration pass.
- Reinstalled both Program Files and PATH copies, regenerated release ZIP, verified all deployed hashes match. wlocate SHA256: 44DBEE1555D0A1B7DD76C61C09DF6F104A5D1CFCFF2543A55AE8328C426BD1EE; locate: 0717B40DB2CEBFB598D4C9CE7DBBDF4079FE5C9877E1703595FE55EB6692E36E. Service Running; C:/D: ready, pending=0. GUI reopened for the user.
The older delivery hashes and test counts below are historical.
## Delivery status — 2026-10-07 21:10 (supersedes older notes below)
- Both native executables built, tested, packaged and installed. `WLocateService` is Running/Auto from `C:\Program Files\WLocate\wlocate.exe --service`; both executables also installed in `D:\tools\bin`. Default `locate` now resolves to the native program, version 1.0.0. C: and D: report ready, pending=0, ~3.20 million records.
- Release: `out/WLocate-1.0.0-win-x64.zip`, unpacked beside it. README, performance report, installer, complete PCRE2/SLJIT/reference notices and SHA256SUMS included. Source and build/test scripts remain here; no commit or remote publication performed.
- Original Everything wrapper `D:\tools\bin\locate.old.exe` hash remains C9AB737C1F30183F954CD899F6B6A03380A00ABD4DDE72E832C66BB872959FA3. Pre-native C# wrapper backup: `D:\tools\bin\locate.exe.backup-20261007-210549`. `C:\Program Files\WLocate\install-state.json` preserves that original rollback path across native upgrades.
- Final executable hashes: wlocate 6E083E51C1BFD59D5CED5417570A4D71651CAE9BE68E0DB6AAEB03D72773BE35; locate 33E1D35F42B916E00B570006C102F7CAE53BF26F9E96C6D184FD77E46BF93F87. Build, Program Files, PATH and release copies match (`test-output/installation.json`).
- 83 core checks pass; live integration (Unicode, 30 long hardlinks, rename/move/delete/metadata, >300-character paths, 500-file create burst), protocol (60 concurrent requests, 20k rows, malformed requests, timeout recovery, admin checks), restart and VHD hotplug tests pass. UI self-test passed native menus across folders, Unicode Shell rename, filters, paging; rendered images inspected at `test-output/ui-final`.
- Stable same-output benchmark: python*.exe 66 identical paths, legacy median 68.86 ms vs native 9.10 ms. Native -n10 python 8.40 ms; -n10 *.exe 7.61 ms. See PERFORMANCE.md for measured broad-query limitations (uncached all-name sort 1.28 s, all-path 3.97 s, regex ~0.28 s; cached first pages ~0.2 ms). Do not claim universal Everything engine parity.
- Final critical correctness fix: NTFS header hardlink count includes separate DOS/8.3 FILE_NAME attributes. Track filename_attribute_count including DOS aliases while emitting only long names. Previous mismatch triggered permanent Access Denied fallback retries on protected files; after fix pending=0. New unit fixture verifies this, and 30-link live tests still pass.
- Snapshot v4 uses SHA-256 and atomic replacement. Keeps an earlier USN checkpoint for unresolved record retries. Name-only generation avoids metadata-only invalidation. Parent-path interning reduces sort allocations. PCRE2 10.49 JIT is statically linked into service. Buffered 64 KiB IPC, timeout/cancel and final acknowledgement protect output completeness. Compaction releases unused interned names too.
- Dynamic local NTFS drive manager polls every 2 s; independent indexers poll USN at 250 ms. One unavailable volume does not block ready ones; CLI marks partial results with stderr and exit 2. Protected Program Files and ProgramData directories; service repair always installs to Program Files.
- Unsupported: non-NTFS/network/folder indexes, no-letter volumes, content search, ETP/HTTP, all Everything advanced functions, exact GNU database compatibility. README documents support and limits. All-match initial sorts and memory footprint remain below Everything's efficiency.
- IDA analysis completed; Everything IDB saved at `..\reference\Everything-1.4.1.1032.exe.i64`, both IDA instances closed. Computer-use native pipe was unavailable; GUI validation used the application's own test target, not external mouse automation. See notices for IDA observations and references.
- psuac recipe: `Start-Process D:\tools\bin\psuac.exe -ArgumentList '-w powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <script>' -WindowStyle Hidden -PassThru -Wait`. Do NOT use psuac -o for PowerShell: invalid stdin makes PS silently fail startup. Use transcripts. -o works for simple native programs.
Older chronological development notes follow.
## Objective
Build an independent native Windows file finder, referring to SwiftList's indexing/service design and Everything's observable behavior. Deliver `wlocate.exe` (GUI and Windows-service modes) and `locate.exe` (command line). Keep existing Everything and SwiftList operational during development. Use `psuac -w -o <log> ...` for authorized administrative deployment and diagnostics.
## Acceptance
- Index all mounted local NTFS volumes, including every hard-link name, using MFT metadata; persist snapshots and replay USN changes after restart.
- Apply create, rename, move, delete and hard-link changes without a full rescan; detect journal gaps and rebuild safely.
- Search in the service, return only final matches, and stop limited CLI queries promptly.
- Native GUI with incremental search, virtual result list, common filters/syntax, sorting, standard keyboard actions and real Windows Shell context menus.
- CLI supports the prior locate-style options, UTF-8 and NUL output, clear exit codes and errors.
- Compare result sets and latency to the installed Everything-based `locate.old.exe`, including hard links and live-update fixtures.
- Exercise GUI, service restart/cache recovery and command replacement; retain rollback copies.
- Document implemented functions, measured performance, and remaining differences from Everything honestly.
## Current state
- Native source root: this directory. Previous C# adapter remains in `../SwiftList/Locate` for reference and rollback.
- Reference Everything: version 1.4.1.1032, SHA-256 F191F756996A14A11E5445FA7103D302EFD510CF2FBF920E6C0C8ED51D512E36. A private local analysis copy is under `../reference`.
- Old adapter baseline for `python*.exe`: 91–109 ms, 62 results. C# enumeration adapter: 3439–3629 ms, 58 results; ~71.7 MB of unnecessary pipe traffic. Four missing paths are hard links. SwiftList's MFT scanner currently skips extension records, an indexing issue to investigate.
- MSVC 14.50, Windows SDK, CMake and psuac are available. The shell is not elevated.
- IDA MCP open_database failed with worker exit 106; investigating without modifying the reference binary.
## Implementation plan
1. Native query engine, index records and protocol; unit tests.
2. MFT scanner, persistent cache and USN monitor; live service tests.
3. CLI and benchmarks; tune hot paths and validate hard links.
4. GUI, Shell operations and preferences; visual and interaction verification.
5. Installation, recovery, regression benchmarks and final delivery.
No commits or remote publication are authorized or planned.
# Progress 2026-10-07 20:04
## Update 20:16 (latest)
- Valid baseline `python*.exe`: old median 71.03 ms vs native 8.12 ms, exactly the same 66 paths. Later indexes gained temporary Python environments. Legacy wrapper DOES NOT support `-n`; fixed benchmark.py to exclude that invalid comparison. Do not cite old-vs-new limited-query results.
- `tests/integration.py` now passes live Unicode+emoji create, 30 long hardlinks forcing MFT extensions, directory rename, link removal, move, metadata-size updates, NUL/count/limit, errors/no-match exit codes, recursive fixture cleanup. Changes observed ~0.13–0.35 s. Script safely limits cleanup to its own UUID fixture under test-output.
- Fixed live hardlinks: nonresident attribute lists can be dirty in NTFS cache while raw-volume reads are stale. On link-count mismatch, enumerate current links using OpenFileById/GetFinalPathNameByHandle/FindFirstFileNameW and resolve parent IDs. Also merge unnamed DATA size from extension records and FILE_STANDARD_INFO. Initial raw scan now reconciles all extended records before publishing, including DATA-only extensions.
- Query optimization in progress: allocated-order name scanning and 64-bit trigram Bloom presence masks reduced uncached `python` basename searches from ~190 ms to ~25 ms. No false-negative filter intended; bracket patterns bypass masks. Plain full-path `python` was 4.55 s, then 1.07 s with parent memoization. Latest change uses component match flags + row-indexed ancestry (not yet benchmarked).
- Latest index changes: Entry uses its old 4-byte padding for parent_row cache, directory rename mutates stable row identity, full_path uses bounded stack array and rejects cycles/orphans. Separate name_generation avoids invalidating name-query caches for metadata-only USN updates. Name sort merges already-sorted volume results rather than resorting all 3.2M rows (prior empty GUI name sort was 5.9 s; latest not yet measured).
- Snapshot format bumped WLOCIDX3/version 3 to rebuild stale metadata and parent-row caches. Latest build passed 47 checks and just installed StageOnly; full rescans may be running. Build command CMake as above, no pending build session.
- IDA Everything analysis found file-query dispatcher 0x1400af3b0 divides file index into chunks, chooses worker count based on indexed file count/limits and merges per-thread results. Worker 0x140089ef0 failed decompilation (large/complex). Folder counterpart 0x1400ae4b0. NTFS file-record reader 0x1400cfe80. Document these as analysis observations, not copied source.
- IDA xrefs API actual `db.xrefs.to_ea(addr)` with `from_ea`/`to_ea` fields (reference's `to`/`frm` example is stale). Strings/functions get_at/get_pseudocode work. Avoid giant unfiltered outputs from Everything help string or old locate `-h` (old treats -h as a search).
Native C++ implementation now builds both executables with MSVC 19.50 / SDK 10.0.28000.0, CMake VS 18 2026. Source in this directory (not subject to cloned SwiftList AGENTS). 47 core checks pass via `ctest --test-dir build -C Release --output-on-failure`. All core/UI/service sources implemented, but real integration validation and performance optimization still in progress. Do not treat this as final delivery yet.
- Implemented raw NTFS MFT + extension FILE_NAME hardlinks, metadata, USN poll, snapshots, name interning/index, native pipe protocol, CLI options, Win32 virtual ListView GUI, Shell IContextMenu/clipboard/drag/rename, tray/hotkey/bookmarks/export.
- Native service `WLocateService` installed and running from `C:\Program Files\WLocate\wlocate.exe --service`. Installation via `Install.ps1 -StageOnly`; existing D:\tools\bin\locate.exe remains the old C# adapter, original Everything wrapper remains locate.old.exe.
- IMPORTANT elevation recipe: `Start-Process D:\tools\bin\psuac.exe -ArgumentList '-w powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File D:\work\gitprojs\swiftlist-locate\wlocate\Install.ps1 -StageOnly' -WindowStyle Hidden -PassThru -Wait`. Avoid psuac `-o` for PowerShell: its invalid stdin HANDLE prevents PS startup; transcript is `install-transcript.log`. For simple native commands -o works. Direct invoking GUI psuac via & can return before completion; Start-Process -Wait is necessary.
- Actual first scan: C ~990104 records /818773 file links, D ~2145178 records /1963931 file links. Service working set ~628 MB, private ~708 MB, not optimized yet. Data `%ProgramData%\WLocate`.
- Corrected `FSCTL_GET_NTFS_FILE_RECORD` handling: these are already fixed-up in-memory records, must NOT apply raw sector USA fixup (tails may change after previous disk write). Raw MFT scan still validates fixup.
- Corrected pipe lifetime: DisconnectNamedPipe discarded unread bytes; now client acknowledges fully received reply before server disconnects. Current latest native binaries installed with that fix. Await real CLI check.
- IDA MCP repaired: plugin `.venv` lacked pyvenv.cfg and dependencies. Restored pyvenv.cfg to actual Python 3.14.3 home, installed `ida-nexus 0.13.3`, `ida-domain 0.5.1`, `idapro 0.0.11`, zeromcp/packaging via uv pip. **Do not `uv sync` while server runs** (attempted recreation failed on running Scripts and removed libs; libs now restored). Main server remains running. Future uv run can restore ida-mcp dist metadata if needed.
- IDA active Everything instance `04e114d6f38c`, binary `..\reference\Everything-1.4.1.1032.exe`; psuac instance `44a40ce58704`. API reference already read. Can query strings/functions/xrefs and decompile. Found extensive Everything NTFS USN debug strings; next inspect relevant routines for indexing/query ideas. No Everything code copied into native program.
- Computer-use skill read + guidance/api/confirmations. Initialized @oai/sky via node_repl, but list_windows consistently fails `Computer Use native pipe is unavailable ... os error 2`. Per skill retried, reset, reinitialized; still fails. No Windows UI input performed. Need GUI testing when available or program-level integration checks, explicitly report any residual visual-test limitation.
Remaining important work: real result comparisons/benchmarks (python*.exe incl all hardlinks), live filesystem create/rename/move/hardlink/delete/metadata tests, restart snapshot test, query correctness/security tests, optimize broad GUI and path queries, add grouping/regex handling and missing common features where feasible, UI functional/visual verification, secure/final installation and replace default locate with backup, README/support matrix and measurements. Known concerns: GUI row cache/selection on refresh, CLI cancellation pending request, name-only orphan counting, full_path depth/cycles, unbuffered per-row IPC overhead, name sort unnecessarily sorts pre-sorted volume hits, no dynamic drive handling yet, unsupported filesystem folder indexes, incomplete grouped syntax. Core query tests cover current supported syntax only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 64 64">
<title>WLocate green magnifying glass</title>
<defs><linearGradient id="green" x1="0" y1="2" x2="0" y2="48" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#68eb8d"/><stop offset="1" stop-color="#16a34a"/>
</linearGradient></defs>
<path d="M7 56 L25 38" fill="none" stroke="#14532d" stroke-width="13" stroke-linecap="round"/>
<path d="M7 55 L25 37" fill="none" stroke="#22c55e" stroke-width="8" stroke-linecap="round"/>
<circle cx="39" cy="25" r="19" fill="none" stroke="#14532d" stroke-width="10"/>
<circle cx="39" cy="25" r="19" fill="none" stroke="url(#green)" stroke-width="7"/>
<path d="M22.66 21.53 A16.7 16.7 0 0 1 42.47 8.66" fill="none" stroke="#c0fcd1" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 832 B

+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "ipc.hpp"
namespace wl {
inline constexpr wchar_t service_name[] = L"WLocateService";
int run_service();
int run_server();
void install_service();
void uninstall_service();
int run_gui(HINSTANCE instance, int show, std::wstring initial_query = {}, std::filesystem::path test_output = {});
}
+94
View File
@@ -0,0 +1,94 @@
#pragma once
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <shared_mutex>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <unordered_map>
#include <vector>
namespace wl {
// All narrow application strings, index names, IPC and redirected text are UTF-8.
// UTF-16 is used only when interfacing with Windows' W/Shell APIs and UI controls.
constexpr uint32_t no_row = UINT32_MAX;
constexpr uint32_t deleted_attribute = 0x80000000;
class Error : public std::runtime_error { public: using std::runtime_error::runtime_error; };
inline void require_utf8_process() {
if (GetACP() != CP_UTF8) throw Error("WLocate requires the embedded UTF-8 manifest and Windows 10 version 1903 or later");
}
struct Handle {
HANDLE value = INVALID_HANDLE_VALUE;
Handle() = default;
explicit Handle(HANDLE h) : value(h) {}
~Handle() { reset(); }
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
Handle(Handle&& h) noexcept : value(std::exchange(h.value, INVALID_HANDLE_VALUE)) {}
Handle& operator=(Handle&& h) noexcept { if (this != &h) { reset(); value = std::exchange(h.value, INVALID_HANDLE_VALUE); } return *this; }
explicit operator bool() const { return value != nullptr && value != INVALID_HANDLE_VALUE; }
void reset(HANDLE h = INVALID_HANDLE_VALUE) { if (*this) CloseHandle(value); value = h; }
};
std::string utf8(std::wstring_view value);
std::wstring wide(std::string_view value);
std::string fold(std::string_view value);
std::string win_error(DWORD code = GetLastError());
std::wstring module_path();
std::filesystem::path data_directory();
uint64_t filetime_now();
uint64_t filetime_value(FILETIME value);
bool is_admin();
void log(std::string_view text);
bool read_exact(HANDLE file, void* data, size_t size);
bool write_exact(HANDLE file, const void* data, size_t size);
std::string filename(std::string_view path);
std::string directory(std::string_view path);
std::string normalize_path(std::string value);
// Trigram presence filter. Collisions only cause extra checks, never false negatives.
inline uint64_t name_bloom(std::string_view value) {
uint64_t mask = 0;
for (size_t i = 2; i < value.size(); ++i) {
uint32_t gram = uint8_t(value[i - 2]) | (uint32_t(uint8_t(value[i - 1])) << 8) | (uint32_t(uint8_t(value[i])) << 16);
gram ^= gram >> 11; gram *= 0x9E3779B1u; mask |= 1ull << (gram >> 26);
}
return mask;
}
struct Result {
std::string path;
uint64_t size = 0, modified = 0, created = 0, accessed = 0;
uint32_t attributes = 0;
};
enum QueryFlag : uint32_t {
MatchCase = 1, MatchPath = 2, Regex = 4, WholeWord = 8,
Basename = 16, AllPatterns = 32, CountOnly = 64, Existing = 128,
NonExisting = 256, EverythingSyntax = 512
};
enum class Sort : uint32_t { Name, Path, Size, Modified, Created, Extension, None };
struct Request {
uint32_t operation = 0;
uint32_t flags = 0;
uint64_t limit = UINT64_MAX, offset = 0;
Sort sort = Sort::Name;
bool descending = false;
uint32_t timeout_ms = 120000;
std::vector<std::string> patterns;
};
struct Reply {
uint64_t total = 0, generation = 0, search_us = 0;
std::vector<Result> results;
std::string message;
};
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "query.hpp"
#include <deque>
namespace wl {
struct Entry {
uint64_t id = 0, parent = 0, size = 0, modified = 0, created = 0, accessed = 0;
uint32_t attributes = 0, name = 0, next_name = no_row, parent_row = no_row;
};
struct Name {
std::string text, lower;
uint32_t first = no_row;
};
struct RawEntry {
uint64_t id = 0, parent = 0, size = 0, modified = 0, created = 0, accessed = 0;
uint32_t attributes = 0;
std::string name;
};
class Volume {
public:
mutable std::shared_mutex mutex;
std::string root;
uint64_t serial = 0, root_id = 0, journal_id = 0, next_usn = 0, generation = 1, name_generation = 1;
uint64_t checkpoint_usn = 0;
uint64_t pending_updates = 0;
uint64_t files = 0, directories = 0;
bool ready = false;
std::string state = "indexing";
std::vector<Entry> entries;
std::deque<Name> names;
std::vector<uint32_t> sorted_names;
std::unordered_map<uint64_t, uint32_t> directory_rows;
explicit Volume(std::string source) : root(std::move(source)) {}
void add_initial(RawEntry record);
void finish();
void compact();
void replace_links(uint64_t id, const std::vector<RawEntry>& records);
std::string full_path(uint32_t row) const;
Result result(uint32_t row, bool with_path = true) const;
std::vector<uint32_t> find(const Query& query, uint64_t stop_after, const std::function<bool()>& cancelled, bool ordered = false) const;
void save(const std::filesystem::path& path) const;
static std::shared_ptr<Volume> load(const std::filesystem::path& path, std::string root, uint64_t serial);
private:
std::unordered_map<std::string_view, uint32_t> name_ids_;
std::vector<uint64_t> name_masks_;
std::unordered_map<uint64_t, std::vector<uint32_t>> added_ids_;
// Intrusive child chains use four bytes per row, with one head per parent.
// Keying heads by NTFS file reference preserves descendants on directory moves.
std::unordered_map<uint64_t, uint32_t> first_children_;
std::vector<uint32_t> next_sibling_;
size_t base_count_ = 0, sorted_name_count_ = 0;
uint32_t intern(std::string name);
void add(RawEntry record, bool delta);
std::vector<uint32_t> rows_for_id(uint64_t id) const;
uint32_t parent_row(uint32_t row) const;
void link_child(uint32_t row);
void unlink_child(uint32_t row);
std::vector<uint32_t> find_literals(const Query& query, const std::vector<std::string_view>& literals,
uint64_t stop_after, const std::function<bool()>& cancelled, bool ordered) const;
};
struct RowRef { uint32_t volume, row; };
class Engine {
public:
mutable std::shared_mutex mutex;
std::vector<std::shared_ptr<Volume>> volumes;
Reply search(const Request& request, const std::function<bool()>& cancelled = [] { return false; });
Reply status() const;
void set_volumes(std::vector<std::shared_ptr<Volume>> value);
private:
struct Cached {
Request request;
uint64_t generation = 0;
std::vector<std::shared_ptr<Volume>> volumes;
std::vector<RowRef> rows;
};
std::mutex cache_mutex_;
std::shared_ptr<Cached> cache_;
uint64_t topology_ = 1;
};
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "index.hpp"
namespace wl {
inline constexpr wchar_t pipe_name[] = L"\\\\.\\pipe\\WLocate.Search.v1";
Reply request_service(const Request& request, HANDLE cancel = nullptr);
void serve(Engine& engine, HANDLE stop_event, const std::function<void()>& rebuild);
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "index.hpp"
#include <winioctl.h>
namespace wl {
struct DataRun { uint64_t vcn = 0, clusters = 0; int64_t lcn = 0; };
struct RecordInfo {
uint64_t id = 0, base_id = 0, size = 0, modified = 0, created = 0, accessed = 0;
uint32_t attributes = 0;
uint16_t link_count = 0;
uint32_t filename_attribute_count = 0;
bool in_use = false, has_attribute_list = false;
std::vector<std::pair<uint64_t, std::string>> names;
std::vector<uint64_t> extension_ids;
std::vector<DataRun> data_runs;
};
bool fixup_record(std::span<uint8_t> record, uint32_t sector_size);
RecordInfo parse_record(std::span<const uint8_t> record, uint64_t record_number);
std::vector<DataRun> parse_runs(std::span<const uint8_t> data, uint64_t first_vcn);
class NtfsReader {
public:
explicit NtfsReader(wchar_t drive);
std::shared_ptr<Volume> scan(const std::function<bool()>& cancelled);
std::vector<RawEntry> read_links(uint64_t id);
bool poll(Volume& volume);
uint64_t serial() const { return serial_; }
USN_JOURNAL_DATA_V0 journal() const;
private:
Handle handle_;
std::string root_;
uint32_t sector_size_ = 512, cluster_size_ = 4096, record_size_ = 1024;
uint64_t serial_ = 0, mft_length_ = 0;
std::vector<DataRun> mft_runs_;
std::unordered_map<uint64_t, uint64_t> pending_updates_;
uint64_t pending_from_ = UINT64_MAX;
std::vector<uint8_t> read_at(uint64_t offset, size_t bytes) const;
std::vector<uint8_t> raw_record(uint64_t index) const;
std::vector<uint8_t> get_record(uint64_t id) const;
void expand_attribute_list(std::span<const uint8_t> record, RecordInfo& info) const;
};
std::vector<wchar_t> ntfs_drives();
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "common.hpp"
#include "regex.hpp"
namespace wl {
bool glob_match(std::string_view pattern, std::string_view value);
enum class Truth { No, Yes, Unknown };
enum class Field { Name, Path, Parent, Extension, File, Folder, Size, Modified, Created, Accessed, Attributes };
struct Term {
Field field = Field::Name;
std::string value, folded;
bool negate = false, match_case = false, glob = false, whole_word = false;
uint64_t minimum = 0, maximum = UINT64_MAX;
std::shared_ptr<Pattern> regex;
Truth evaluate(std::string_view name, std::string_view lower_name, const Result* result) const;
};
class Query {
public:
explicit Query(const Request& request);
Truth evaluate(std::string_view name, std::string_view lower, const Result* result = nullptr) const;
std::string prefix() const;
bool requires_path() const;
bool requires_metadata() const;
const Term* simple_path_literal() const;
// Folded, non-overlapping absolute directory prefixes covering every OR
// branch. Empty means the query still needs a general name/path scan.
const std::vector<std::string>& path_prefixes() const { return path_prefixes_; }
// One necessary literal path fragment per OR branch, or empty if any
// branch cannot be narrowed safely. Views remain owned by this query.
std::vector<std::string_view> path_literals() const;
bool may_match_name(uint64_t bloom) const {
for (auto required : masks_) if ((bloom & required) == required) return true;
return false;
}
private:
std::vector<std::vector<Term>> groups_;
std::vector<uint64_t> masks_;
std::vector<std::string> path_prefixes_;
};
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "common.hpp"
namespace wl {
class Pattern {
void* code_ = nullptr;
public:
Pattern(std::string_view expression, bool match_case);
~Pattern();
Pattern(const Pattern&) = delete;
Pattern& operator=(const Pattern&) = delete;
bool matches(std::string_view text) const;
};
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#define IDI_WLOCATE 101
#define IDD_HOTKEY 201
#define IDC_HOTKEY_ENABLED 210
#define IDC_HOTKEY_CTRL 211
#define IDC_HOTKEY_ALT 212
#define IDC_HOTKEY_SHIFT 213
#define IDC_HOTKEY_WIN 214
#define IDC_HOTKEY_KEY 215
#define IDC_HOTKEY_STATUS 216
#define IDC_HOTKEY_DEFAULT 217
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "common.hpp"
#include <shlobj.h>
#include <wrl/client.h>
namespace wl {
using Microsoft::WRL::ComPtr;
inline constexpr UINT shell_open_parent = 0x7001, shell_copy_path = 0x7002, shell_rename_selected = 0x7003;
LRESULT shell_menu_message(UINT message, WPARAM wparam, LPARAM lparam, bool& handled);
UINT shell_context(HWND owner, const std::vector<std::wstring>& paths, POINT point);
void shell_verb(HWND owner, const std::vector<std::wstring>& paths, const char* verb, bool shift = false);
void shell_open(HWND owner, const std::wstring& path, bool parent = false);
void clipboard_paths(HWND owner, const std::vector<std::wstring>& paths, bool cut, bool text_only);
void shell_drag(HWND owner, const std::vector<std::wstring>& paths);
void shell_rename(HWND owner, const std::wstring& path, const std::wstring& name);
std::vector<std::string> shell_menu_verbs(HWND owner, const std::vector<std::wstring>& paths);
}
+104
View File
@@ -0,0 +1,104 @@
PCRE2 Licence
=============
| SPDX-License-Identifier: | BSD-3-Clause WITH PCRE2-exception |
|---------|-------|
PCRE2 is a library of functions to support regular expressions whose syntax
and semantics are as close as possible to those of the Perl 5 language.
Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD"
licence, as specified below, with one exemption for certain binary
redistributions. The documentation for PCRE2, supplied in the "doc" directory,
is distributed under the same terms as the software itself. The data in the
testdata directory is not copyrighted and is in the public domain.
The basic library functions are written in C and are freestanding. Also
included in the distribution is a just-in-time compiler that can be used to
optimize pattern matching. This is an optional feature that can be omitted when
the library is built. The just-in-time compiler is separately licensed under the
"2-clause BSD" licence.
COPYRIGHT
---------
### The basic library functions
Written by: Philip Hazel
Email local part: Philip.Hazel
Email domain: gmail.com
Retired from University of Cambridge Computing Service,
Cambridge, England.
Copyright (c) 1997-2007 University of Cambridge
Copyright (c) 2007-2024 Philip Hazel
All rights reserved.
### PCRE2 Just-In-Time compilation support
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright (c) 2010-2024 Zoltan Herczeg
All rights reserved.
### Stack-less Just-In-Time compiler
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright (c) 2009-2024 Zoltan Herczeg
All rights reserved.
The code in the `deps/sljit` directory has its own LICENSE file.
### All other contributions
Many other contributors have participated in the authorship of PCRE2. As PCRE2
has never required a Contributor Licensing Agreement, or other copyright
assignment agreement, all contributions have copyright retained by each
original contributor or their employer.
THE "BSD" LICENCE
-----------------
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notices,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notices, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the University of Cambridge nor the names of any
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES
------------------------------------------
The second condition in the BSD licence (covering binary redistributions) does
not apply all the way down a chain of software. If binary package A includes
PCRE2, it must respect the condition, but if package B is software that
includes package A, the condition is not imposed on package B unless it uses
PCRE2 independently.
+25
View File
@@ -0,0 +1,25 @@
/*
* Stack-less Just-In-Time compiler
*
* Copyright Zoltan Herczeg (hzmester@freemail.hu). All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other materials
* provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND CONTRIBUTORS ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
* SHALL THE COPYRIGHT HOLDER(S) OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
* BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" processorArchitecture="amd64" name="WLocate" type="win32"/>
<description>WLocate File Search</description>
<dependency><dependentAssembly><assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/></dependentAssembly></dependency>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false"/></requestedPrivileges></security></trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings></application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/></application></compatibility>
</assembly>
+47
View File
@@ -0,0 +1,47 @@
#include <windows.h>
#include "resource.h"
#pragma code_page(65001)
IDI_WLOCATE ICON "../assets/wlocate.ico"
1 RT_MANIFEST "app.manifest"
IDD_HOTKEY DIALOGEX 0, 0, 324, 207
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "快捷键设置"
FONT 9, "Microsoft YaHei UI"
BEGIN
LTEXT "设置从任意程序唤出 WLocate 的快捷键。", -1, 12, 12, 300, 16
CONTROL "启用全局快捷键(&E)", IDC_HOTKEY_ENABLED, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, 12, 36, 200, 14
GROUPBOX "唤出主窗口", -1, 12, 59, 300, 76
CONTROL "Ctrl(&C)", IDC_HOTKEY_CTRL, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, 24, 78, 65, 14
CONTROL "Alt(&A)", IDC_HOTKEY_ALT, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, 94, 78, 60, 14
CONTROL "Shift(&S)", IDC_HOTKEY_SHIFT, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, 160, 78, 65, 14
CONTROL "Win(&W)", IDC_HOTKEY_WIN, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, 230, 78, 64, 14
LTEXT "按键(&K):", -1, 24, 108, 44, 12
COMBOBOX IDC_HOTKEY_KEY, 72, 104, 160, 180, CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "", IDC_HOTKEY_STATUS, 12, 144, 300, 28
PUSHBUTTON "恢复默认(&R)", IDC_HOTKEY_DEFAULT, 12, 181, 85, 16
DEFPUSHBUTTON "确定", IDOK, 204, 181, 50, 16
PUSHBUTTON "取消", IDCANCEL, 262, 181, 50, 16
END
1 VERSIONINFO
FILEVERSION 1,0,0,0
PRODUCTVERSION 1,0,0,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "FileDescription", "WLocate File Search"
VALUE "FileVersion", "1.0.0"
VALUE "ProductName", "WLocate"
VALUE "ProductVersion", "1.0.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
+131
View File
@@ -0,0 +1,131 @@
#include "ipc.hpp"
#include <cstdio>
namespace {
std::atomic<bool> interrupted = false;
std::atomic<HANDLE> cancel_event = nullptr;
BOOL WINAPI interrupt(DWORD code) { if (code == CTRL_C_EVENT || code == CTRL_BREAK_EVENT) { interrupted = true; if (auto event = cancel_event.load()) SetEvent(event); return TRUE; } return FALSE; }
void output(HANDLE handle, const std::string& text) {
DWORD mode;
if (GetConsoleMode(handle, &mode)) {
auto unicode = wl::wide(text);
for (size_t at = 0; at < unicode.size();) {
DWORD done = 0; auto length = static_cast<DWORD>(std::min<size_t>(16000, unicode.size() - at));
if (at + length < unicode.size() && unicode[at + length - 1] >= 0xD800 && unicode[at + length - 1] <= 0xDBFF) --length;
if (!WriteConsoleW(handle, unicode.data() + at, length, &done, nullptr) || !done) throw wl::Error("Cannot write console output"); at += done;
}
} else if (!wl::write_exact(handle, text.data(), text.size())) throw wl::Error("Cannot write output: " + wl::win_error());
}
uint64_t number(const std::wstring& value, std::wstring_view option) {
if (value.empty() || value.find_first_not_of(L"0123456789") != value.npos) throw wl::Error("Invalid value for " + wl::utf8(option));
size_t used; try { return std::stoull(value, &used); } catch (...) { throw wl::Error("Numeric option is out of range"); }
}
constexpr char help[] =
"Usage: locate [OPTIONS] PATTERN...\n"
"Search the native WLocate file index. Quote wildcard patterns in your shell.\n\n"
" -n, -l, --limit N Print at most N matches (limited in the service)\n"
" -i, --ignore-case Ignore case (default: case-sensitive)\n"
" -b, --basename Match only the filename\n"
" -w, --wholename Match the full path\n"
" -A, -a, --all Require every pattern (default: any pattern)\n"
" -c, --count Print the number of matches\n"
" -0, --null Separate paths with NUL instead of newline\n"
" -e, --existing Check that each result still exists\n"
" -E, --non-existing Return only missing paths\n"
" -r, --regexp PATTERN Search using Unicode PCRE2 regular expressions\n"
" --regex Treat all patterns as regular expressions\n"
" --query TEXT Everything-style query (name matching, ignores case)\n"
" --sort FIELD name, path, size, modified, created, extension\n"
" --descending Reverse the selected sort\n"
" --timeout MS Request timeout (100..600000, default 120000)\n"
" --timing Print service search time to stderr\n"
" -S, --statistics Show index status\n"
" --reindex Request a full rebuild (administrator required)\n"
" -h, --help Show this help\n"
" -V, --version Show the version\n\n"
"Without wildcards, patterns are full-path substrings. Bare wildcard patterns\n"
"match filenames; patterns containing a separator match full paths. Multiple\n"
"patterns use OR unless --all is set. Glob syntax: *, ?, [abc], [!abc].\n"
"Output is UTF-8 when redirected. Exit codes: 0 matches, 1 none, 2 error.\n"
"Examples: locate -n 10 \"python*.exe\"\n"
" locate --query \"ext:cpp;h size:>1kb !path:build\"\n";
}
int wmain(int argc, wchar_t** argv) {
bool quiet = false;
try {
wl::require_utf8_process();
wl::Request request; request.flags = wl::MatchCase; request.sort = wl::Sort::None;
bool nul = false, timing = false, options = true;
auto argument = [&](int& index, const std::wstring& inline_value, std::wstring_view option) {
if (!inline_value.empty()) return inline_value;
if (++index >= argc) throw wl::Error("Missing argument for " + wl::utf8(option)); return std::wstring(argv[index]);
};
for (int i = 1; i < argc; ++i) {
std::wstring arg = argv[i];
if (options && arg == L"--") { options = false; continue; }
if (!options || arg.empty() || arg[0] != L'-' || arg == L"-") { request.patterns.push_back(wl::utf8(arg)); continue; }
auto equal = arg.find(L'='); std::wstring value;
if (equal != arg.npos) { value = arg.substr(equal + 1); arg.resize(equal); }
if (arg == L"--help" || arg == L"-h") { output(GetStdHandle(STD_OUTPUT_HANDLE), help); return 0; }
if (arg == L"--version" || arg == L"-V") { output(GetStdHandle(STD_OUTPUT_HANDLE), "locate (WLocate) 1.0.0 native x64\n"); return 0; }
if (arg == L"--ignore-case" || arg == L"-i") request.flags &= ~wl::MatchCase;
else if (arg == L"--basename" || arg == L"-b") { request.flags |= wl::Basename; request.flags &= ~wl::MatchPath; }
else if (arg == L"--wholename" || arg == L"-w") { request.flags |= wl::MatchPath; request.flags &= ~wl::Basename; }
else if (arg == L"--all" || arg == L"-A" || arg == L"-a") request.flags |= wl::AllPatterns;
else if (arg == L"--count" || arg == L"-c") request.flags |= wl::CountOnly;
else if (arg == L"--null" || arg == L"-0") nul = true;
else if (arg == L"--existing" || arg == L"-e") { request.flags |= wl::Existing; request.flags &= ~wl::NonExisting; }
else if (arg == L"--non-existing" || arg == L"-E") { request.flags |= wl::NonExisting; request.flags &= ~wl::Existing; }
else if (arg == L"--statistics" || arg == L"-S") request.operation = 1;
else if (arg == L"--reindex") request.operation = 2;
else if (arg == L"--timing") timing = true;
else if (arg == L"--quiet" || arg == L"-q") quiet = true;
else if (arg == L"--regex") request.flags |= wl::Regex;
else if (arg == L"--regexp" || arg == L"-r") { request.flags |= wl::Regex; request.patterns.push_back(wl::utf8(argument(i, value, arg))); }
else if (arg == L"--query") { request.flags |= wl::EverythingSyntax; request.flags &= ~wl::MatchCase; request.patterns.push_back(wl::utf8(argument(i, value, arg))); }
else if (arg == L"--descending") request.descending = true;
else if (arg == L"--timeout") { auto n = number(argument(i, value, arg), arg); if (n < 100 || n > 600000) throw wl::Error("Timeout must be 100..600000 ms"); request.timeout_ms = static_cast<uint32_t>(n); }
else if (arg == L"--sort") {
auto field = argument(i, value, arg);
if (field == L"name") request.sort = wl::Sort::Name; else if (field == L"path") request.sort = wl::Sort::Path;
else if (field == L"size") request.sort = wl::Sort::Size; else if (field == L"modified") request.sort = wl::Sort::Modified;
else if (field == L"created") request.sort = wl::Sort::Created; else if (field == L"extension") request.sort = wl::Sort::Extension;
else throw wl::Error("Unknown sort field");
} else if (arg == L"--limit" || arg == L"-n" || arg == L"-l") request.limit = number(argument(i, value, arg), arg);
else if (arg.size() > 2 && arg[0] == L'-' && (arg[1] == L'n' || arg[1] == L'l')) request.limit = number(arg.substr(2), arg.substr(0, 2));
else if (arg.size() > 2 && arg[1] != L'-') {
for (size_t c = 1; c < arg.size(); ++c) {
switch (arg[c]) { case L'i': request.flags &= ~wl::MatchCase; break; case L'c': request.flags |= wl::CountOnly; break;
case L'b': request.flags |= wl::Basename; break; case L'A': case L'a': request.flags |= wl::AllPatterns; break; case L'0': nul = true; break;
default: throw wl::Error("Unsupported combined option: " + wl::utf8(arg)); }
}
} else throw wl::Error("Unknown option: " + wl::utf8(arg));
}
if (!request.operation && request.patterns.empty()) { output(GetStdHandle(STD_ERROR_HANDLE), help); return 2; }
SetConsoleCtrlHandler(interrupt, TRUE);
wl::Handle cancellation(CreateEventW(nullptr, TRUE, FALSE, nullptr)); cancel_event = cancellation.value;
auto reply = wl::request_service(request, cancellation.value);
cancel_event = nullptr;
if (interrupted) return 130;
if (request.operation) { output(GetStdHandle(STD_OUTPUT_HANDLE), reply.message); return 0; }
if (request.flags & wl::CountOnly) output(GetStdHandle(STD_OUTPUT_HANDLE), std::to_string(std::min(reply.total, request.limit)) + "\n");
else {
std::string buffer; buffer.reserve(65536);
for (auto& row : reply.results) {
if (interrupted) return 130;
buffer += row.path; buffer += nul ? '\0' : '\n';
if (buffer.size() >= 65536) { output(GetStdHandle(STD_OUTPUT_HANDLE), buffer); buffer.clear(); }
}
output(GetStdHandle(STD_OUTPUT_HANDLE), buffer);
}
if (timing) output(GetStdHandle(STD_ERROR_HANDLE), "search=" + std::to_string(reply.search_us / 1000.0) + " ms, matches=" + std::to_string(reply.total) + "\n");
if (!reply.message.empty()) {
if (!quiet) output(GetStdHandle(STD_ERROR_HANDLE), "locate: partial index: " + reply.message + "\n");
return 2;
}
return reply.total ? 0 : 1;
} catch (const std::exception& error) {
if (!quiet) { try { output(GetStdHandle(STD_ERROR_HANDLE), std::string("locate: ") + error.what() + "\n"); } catch (...) {} }
return interrupted ? 130 : 2;
}
}
+90
View File
@@ -0,0 +1,90 @@
#include "common.hpp"
#include <shlobj.h>
#include <fstream>
#include <utility>
namespace wl {
std::string utf8(std::wstring_view value) {
if (value.empty()) return {};
int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
if (size <= 0) throw Error("Cannot encode Unicode text");
std::string result(size, '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::wstring wide(std::string_view value) {
if (value.empty()) return {};
int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()), nullptr, 0);
if (size <= 0) throw Error("Invalid UTF-8 text");
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::string fold(std::string_view value) {
bool ascii = std::all_of(value.begin(), value.end(), [](unsigned char c) { return c < 128; });
if (ascii) {
std::string result(value);
for (char& c : result) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
return result;
}
auto text = wide(value);
int size = LCMapStringEx(LOCALE_NAME_INVARIANT, LCMAP_LOWERCASE, text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr, 0);
if (!size) return std::string(value);
std::wstring lower(size, L'\0');
LCMapStringEx(LOCALE_NAME_INVARIANT, LCMAP_LOWERCASE, text.data(), static_cast<int>(text.size()), lower.data(), size, nullptr, nullptr, 0);
return utf8(lower);
}
std::string win_error(DWORD code) {
wchar_t* text = nullptr;
FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr, code, 0, reinterpret_cast<wchar_t*>(&text), 0, nullptr);
std::string result = text ? utf8(text) : "Windows error " + std::to_string(code);
if (text) LocalFree(text);
while (!result.empty() && (result.back() == '\r' || result.back() == '\n')) result.pop_back();
return result;
}
std::wstring module_path() { std::wstring path(32768, L'\0'); path.resize(GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()))); return path; }
std::filesystem::path data_directory() {
PWSTR path = nullptr;
if (FAILED(SHGetKnownFolderPath(FOLDERID_ProgramData, 0, nullptr, &path))) throw Error("Cannot locate ProgramData");
std::filesystem::path result(path); CoTaskMemFree(path); return result / L"WLocate";
}
uint64_t filetime_value(FILETIME value) { return (uint64_t(value.dwHighDateTime) << 32) | value.dwLowDateTime; }
uint64_t filetime_now() { FILETIME value; GetSystemTimeAsFileTime(&value); return filetime_value(value); }
bool is_admin() {
SID_IDENTIFIER_AUTHORITY authority = SECURITY_NT_AUTHORITY;
PSID sid = nullptr; BOOL member = FALSE;
if (AllocateAndInitializeSid(&authority, 2, SECURITY_BUILTIN_DOMAIN_RID, DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &sid)) {
CheckTokenMembership(nullptr, sid, &member); FreeSid(sid);
}
return member != FALSE;
}
void log(std::string_view text) {
static std::mutex mutex;
std::lock_guard lock(mutex);
try {
auto root = data_directory(); std::filesystem::create_directories(root);
auto path = root / L"service.log";
if (std::filesystem::exists(path) && std::filesystem::file_size(path) > 8 * 1024 * 1024) {
std::error_code ec; std::filesystem::remove(root / L"service.previous.log", ec);
std::filesystem::rename(path, root / L"service.previous.log", ec);
}
SYSTEMTIME now; GetLocalTime(&now);
char stamp[64]; snprintf(stamp, sizeof(stamp), "%04u-%02u-%02u %02u:%02u:%02u ", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond);
std::ofstream file(path, std::ios::app | std::ios::binary); file << stamp << text << '\n';
} catch (...) { }
}
bool read_exact(HANDLE file, void* data, size_t size) {
auto p = static_cast<char*>(data);
while (size) { DWORD got = 0; if (!ReadFile(file, p, static_cast<DWORD>(std::min<size_t>(size, 1 << 20)), &got, nullptr) || !got) return false; p += got; size -= got; }
return true;
}
bool write_exact(HANDLE file, const void* data, size_t size) {
auto p = static_cast<const char*>(data);
while (size) { DWORD done = 0; if (!WriteFile(file, p, static_cast<DWORD>(std::min<size_t>(size, 1 << 20)), &done, nullptr) || !done) return false; p += done; size -= done; }
return true;
}
std::string filename(std::string_view path) { auto pos = path.find_last_of("\\/"); return std::string(pos == path.npos ? path : path.substr(pos + 1)); }
std::string directory(std::string_view path) { auto pos = path.find_last_of("\\/"); if (pos == path.npos) return {}; if (pos == 2 && path[1] == ':') ++pos; return std::string(path.substr(0, pos)); }
std::string normalize_path(std::string value) { std::replace(value.begin(), value.end(), '/', '\\'); return value; }
}
+612
View File
@@ -0,0 +1,612 @@
#include "app.hpp"
#include "shell.hpp"
#include "resource.h"
#include "gui_icons.hpp"
#include "gui_accessibility.hpp"
#include "gui_hotkeys.hpp"
#include <commctrl.h>
#include <commdlg.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <uxtheme.h>
#include <wincodec.h>
#include <deque>
#include <fstream>
#include <unordered_set>
namespace wl {
namespace {
constexpr UINT completed = WM_APP + 1, tray_message = WM_APP + 2, icons_completed = WM_APP + 3;
enum : UINT { id_export = 100, id_exit, id_open, id_parent, id_copy, id_cut, id_paths, id_delete, id_properties,
id_rename, id_selectall, id_refresh, id_case, id_path, id_word, id_regex, id_status, id_reindex, id_install,
id_help, id_about, id_tray, id_startup, id_bookmark, id_hotkey, id_bookmarks = 300 };
constexpr uint64_t page_size = 256;
std::wstring window_text(HWND window) { auto n = GetWindowTextLengthW(window); std::wstring text(n + 1, L'\0'); GetWindowTextW(window, text.data(), n + 1); text.resize(n); return text; }
std::filesystem::path settings_path() {
wchar_t* folder = nullptr; SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &folder);
std::filesystem::path path = folder ? folder : L"."; CoTaskMemFree(folder); path /= L"WLocate"; std::filesystem::create_directories(path); return path / L"settings.ini";
}
std::wstring readable_time(uint64_t ticks) {
if (!ticks) return {};
FILETIME utc{DWORD(ticks), DWORD(ticks >> 32)}, local{}; SYSTEMTIME time{};
FileTimeToLocalFileTime(&utc, &local); FileTimeToSystemTime(&local, &time);
wchar_t buffer[64]; swprintf_s(buffer, L"%04u-%02u-%02u %02u:%02u", time.wYear, time.wMonth, time.wDay, time.wHour, time.wMinute); return buffer;
}
std::string csv_field(std::string text) { size_t p = 0; while ((p = text.find('"', p)) != text.npos) { text.insert(p, 1, '"'); p += 2; } return '"' + text + '"'; }
void render_window(HWND window, const std::filesystem::path& path) {
RECT rect; GetWindowRect(window, &rect); auto width = rect.right - rect.left, height = rect.bottom - rect.top;
auto screen = GetDC(window), memory = CreateCompatibleDC(screen); auto bitmap = CreateCompatibleBitmap(screen, width, height);
auto old = SelectObject(memory, bitmap); BOOL painted = PrintWindow(window, memory, 2); SelectObject(memory, old); DeleteDC(memory); ReleaseDC(window, screen);
if (!painted) { DeleteObject(bitmap); throw Error("Window rendering failed"); }
ComPtr<IWICImagingFactory> factory; auto hr = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory));
ComPtr<IWICBitmap> image; if (SUCCEEDED(hr)) hr = factory->CreateBitmapFromHBITMAP(bitmap, nullptr, WICBitmapIgnoreAlpha, &image);
DeleteObject(bitmap); ComPtr<IWICStream> stream; if (SUCCEEDED(hr)) hr = factory->CreateStream(&stream);
if (SUCCEEDED(hr)) hr = stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE);
ComPtr<IWICBitmapEncoder> encoder; if (SUCCEEDED(hr)) hr = factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder);
if (SUCCEEDED(hr)) hr = encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache);
ComPtr<IWICBitmapFrameEncode> frame; if (SUCCEEDED(hr)) hr = encoder->CreateNewFrame(&frame, nullptr);
if (SUCCEEDED(hr)) hr = frame->Initialize(nullptr); if (SUCCEEDED(hr)) hr = frame->SetSize(width, height);
auto format = GUID_WICPixelFormat24bppBGR; if (SUCCEEDED(hr)) hr = frame->SetPixelFormat(&format);
if (SUCCEEDED(hr)) hr = frame->WriteSource(image.Get(), nullptr); if (SUCCEEDED(hr)) hr = frame->Commit(); if (SUCCEEDED(hr)) hr = encoder->Commit();
if (FAILED(hr)) throw Error("Could not save rendered window: " + win_error(static_cast<DWORD>(hr)));
}
struct Task { uint64_t token = 0, offset = 0; Request request; std::filesystem::path export_file; };
struct Completion { uint64_t token, offset; Reply reply; std::string error; std::filesystem::path export_file; };
class Window {
public:
HWND hwnd = nullptr, search = nullptr, filter = nullptr, list = nullptr, status = nullptr;
HFONT font = nullptr;
HICON icon_large = nullptr, icon_small = nullptr;
HINSTANCE instance;
std::filesystem::path ini = settings_path();
uint32_t flags = EverythingSyntax;
Sort sort = Sort::Name;
bool descending = false, busy = false, closing = false, close_to_tray = false, search_scheduled = false;
uint64_t token = 0, total = 0, generation = 0;
std::wstring initial_query;
std::filesystem::path test_output;
int test_stage = 0, test_exit = 0;
uint64_t test_initial_count = 0, test_deadline = 0, test_last_tick = 0, test_max_tick_gap = 0;
std::unordered_map<uint64_t, std::vector<Result>> pages;
std::unordered_set<uint64_t> pending;
AsyncIcons icons;
AccessibleList accessibility;
GlobalHotkey hotkey;
std::vector<std::wstring> bookmarks;
Handle work{CreateEventW(nullptr, FALSE, FALSE, nullptr)}, cancel{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
std::mutex tasks_mutex;
std::deque<Task> tasks;
std::jthread worker;
explicit Window(HINSTANCE app, std::wstring query, std::filesystem::path testing, AsyncIcons::Loader icon_loader = load_file_icon)
: instance(app), initial_query(std::move(query)), test_output(std::move(testing)), icons(std::move(icon_loader)) {
if (!test_output.empty()) ini = test_output / L"test-settings.ini";
}
~Window() { shutdown(); if (font) DeleteObject(font); if (icon_large) DestroyIcon(icon_large); if (icon_small) DestroyIcon(icon_small); }
int scaled(int pixels) const { return MulDiv(pixels, static_cast<int>(GetDpiForWindow(hwnd)), 96); }
void error(const std::exception& e) {
if (!test_output.empty()) { std::ofstream file(test_output / L"failure.txt"); file << e.what(); test_exit = 1; closing = true; PostMessageW(hwnd, WM_CLOSE, 0, 0); return; }
MessageBoxW(hwnd, wide(e.what()).c_str(), L"WLocate", MB_OK | MB_ICONERROR);
}
void shutdown() {
accessibility.detach();
icons.stop();
if (!worker.joinable()) return;
worker.request_stop(); SetEvent(cancel.value); SetEvent(work.value); worker.join();
MSG msg; while (PeekMessageW(&msg, hwnd, completed, completed, PM_REMOVE)) delete reinterpret_cast<Completion*>(msg.lParam);
}
Request request() {
Request r; r.flags = flags; r.sort = sort; r.descending = descending; r.limit = page_size; r.timeout_ms = 120000;
std::string query = utf8(window_text(search)); auto selection = SendMessageW(filter, CB_GETCURSEL, 0, 0);
const char* types[]{"", "file:", "folder:", "ext:exe;com;bat;cmd;ps1;msi", "ext:txt;md;pdf;doc;docx;xls;xlsx;ppt;pptx", "ext:jpg;jpeg;png;gif;webp;bmp;svg;ico", "ext:mp3;flac;wav;m4a;ogg", "ext:mp4;mkv;avi;mov;wmv", "ext:zip;7z;rar;tar;gz"};
r.patterns.push_back(std::move(query));
if (selection > 0 && selection < std::size(types)) r.patterns.push_back(types[selection]);
return r;
}
void queue(uint64_t offset, bool fresh = false, std::filesystem::path export_file = {}) {
if (search_scheduled) return;
auto r = request(); r.offset = offset;
if (fresh) { ++token; pending.clear(); busy = true; }
if (export_file.empty() && !pending.insert(offset).second) return;
if (!export_file.empty()) { r.limit = UINT64_MAX; r.offset = 0; }
{ std::lock_guard guard(tasks_mutex); if (fresh) { tasks.clear(); SetEvent(cancel.value); } tasks.push_back({token, offset, std::move(r), std::move(export_file)}); }
SetEvent(work.value);
}
void run_worker() {
worker = std::jthread([this](std::stop_token stop) {
while (!stop.stop_requested()) {
WaitForSingleObject(work.value, INFINITE);
while (!stop.stop_requested()) {
Task task;
{ std::lock_guard guard(tasks_mutex); if (tasks.empty()) break; task = std::move(tasks.front()); tasks.pop_front(); ResetEvent(cancel.value); }
auto result = std::make_unique<Completion>(); result->token = task.token; result->offset = task.offset; result->export_file = task.export_file;
try {
result->reply = request_service(task.request, cancel.value);
if (!task.export_file.empty()) {
GUID guid; CoCreateGuid(&guid); wchar_t suffix[40]; StringFromGUID2(guid, suffix, 40);
auto temporary = task.export_file; temporary += std::wstring(L".") + suffix + L".tmp";
try {
std::ofstream file(temporary, std::ios::binary | std::ios::trunc); if (!file) throw Error("Cannot create export file");
bool csv = fold(utf8(task.export_file.extension().wstring())) == ".csv";
if (csv) file << "\xEF\xBB\xBFName,Path,Size,Modified\r\n";
for (auto& row : result->reply.results) {
if (stop.stop_requested() || WaitForSingleObject(cancel.value, 0) == WAIT_OBJECT_0) throw Error("Export cancelled");
if (csv) file << csv_field(filename(row.path)) << ',' << csv_field(directory(row.path)) << ',' << row.size << ',' << csv_field(utf8(readable_time(row.modified))) << "\r\n";
else file << row.path << "\r\n";
}
file.flush(); if (!file) throw Error("Failed while writing export"); file.close();
if (!MoveFileExW(temporary.c_str(), task.export_file.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) throw Error("Cannot finish export: " + win_error());
} catch (...) {
std::error_code ignored; std::filesystem::remove(temporary, ignored); throw;
}
result->reply.results.clear();
}
} catch (const std::exception& e) { result->error = e.what(); }
if (stop.stop_requested()) break;
if (PostMessageW(hwnd, completed, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
}
}
});
}
void schedule_search() {
// Retire the old query as soon as the text changes, not when the debounce
// timer expires: a late reply must not repaint results for earlier text.
++token; pending.clear(); busy = true; search_scheduled = true;
{ std::lock_guard guard(tasks_mutex); tasks.clear(); SetEvent(cancel.value); }
SetTimer(hwnd, 1, 100, nullptr);
}
void start_search() {
search_scheduled = false;
accessibility.reset();
KillTimer(hwnd, 1); ListView_SetItemState(list, -1, 0, LVIS_SELECTED | LVIS_FOCUSED);
pages.clear(); total = 0; ListView_SetItemCountEx(list, 0, LVSICF_NOSCROLL); queue(0, true);
SendMessageW(status, SB_SETTEXTW, 0, reinterpret_cast<LPARAM>(L"正在搜索…"));
}
void receive(std::unique_ptr<Completion> result) {
if (result->token != token) return;
pending.erase(result->offset); busy = false;
if (!result->error.empty()) { SendMessageW(status, SB_SETTEXTW, 0, reinterpret_cast<LPARAM>(wide(result->error).c_str())); return; }
if (!result->export_file.empty()) { auto text = L"已导出:" + result->export_file.wstring(); SendMessageW(status, SB_SETTEXTW, 0, reinterpret_cast<LPARAM>(text.c_str())); return; }
auto& reply = result->reply;
if (!result->offset) {
if (generation != reply.generation || total != reply.total) accessibility.reset();
pages.clear(); total = reply.total; generation = reply.generation;
} else if (generation != reply.generation) { queue(0, true); return; }
pages[result->offset] = std::move(reply.results);
// LVM_SETITEMCOUNT can synchronously request the focused row through
// LVN_GETDISPINFO. Publish its cache first, or every completed refresh
// schedules another query while the cache is temporarily empty.
if (!result->offset)
ListView_SetItemCountEx(list, static_cast<int>(std::min<uint64_t>(total, INT_MAX)), LVSICF_NOINVALIDATEALL | LVSICF_NOSCROLL);
if (pages.size() > 32) { for (auto it = pages.begin(); it != pages.end(); ++it) if (it->first != result->offset && it->first != 0) { pages.erase(it); break; } }
auto text = std::to_wstring(total) + L" 个结果 " + std::to_wstring(reply.search_us / 1000.0).substr(0, 7) + L" ms";
if (!reply.message.empty()) text += L" " + wide(reply.message);
SendMessageW(status, SB_SETTEXTW, 0, reinterpret_cast<LPARAM>(text.c_str()));
InvalidateRect(list, nullptr, FALSE);
}
const Result* row(int index) {
if (index < 0 || static_cast<uint64_t>(index) >= total) return nullptr;
auto start = uint64_t(index) / page_size * page_size; auto found = pages.find(start);
if (found == pages.end()) { queue(start); return nullptr; }
auto at = uint64_t(index) - start; return at < found->second.size() ? &found->second[static_cast<size_t>(at)] : nullptr;
}
std::vector<std::wstring> selection() {
if (busy) throw Error("Search results are updating; please try again in a moment");
std::vector<std::wstring> paths;
for (int i = -1; (i = ListView_GetNextItem(list, i, LVNI_SELECTED)) >= 0;) {
auto value = row(i);
if (!value) {
auto start = uint64_t(i) / page_size * page_size; auto r = request(); r.offset = start;
auto reply = request_service(r);
if (reply.generation != generation) throw Error("The index changed while loading the selection; refresh and select again");
pages[start] = std::move(reply.results); value = row(i);
}
if (!value) throw Error("Selected rows could not be loaded"); paths.push_back(wide(value->path));
}
return paths;
}
void display(NMLVDISPINFOW* info) {
auto value = row(info->item.iItem); std::wstring text = value ? L"" : L"…";
if (value) {
switch (info->item.iSubItem) {
case 0: text = wide(filename(value->path)); break;
case 1: text = wide(directory(value->path)); break;
case 2: if (!(value->attributes & FILE_ATTRIBUTE_DIRECTORY)) { wchar_t bytes[64]; StrFormatByteSizeW(static_cast<LONGLONG>(value->size), bytes, 64); text = bytes; } break;
case 3: text = readable_time(value->modified); break;
case 4: text = value->attributes & FILE_ATTRIBUTE_DIRECTORY ? L"文件夹" : wide(std::filesystem::path(wide(value->path)).extension().wstring().empty() ? "文件" : utf8(std::filesystem::path(wide(value->path)).extension().wstring())); break;
}
if (info->item.mask & LVIF_IMAGE) {
info->item.iImage = icons.lookup(*value);
}
}
if ((info->item.mask & LVIF_TEXT) && info->item.pszText && info->item.cchTextMax) wcsncpy_s(info->item.pszText, info->item.cchTextMax, text.c_str(), _TRUNCATE);
}
void make_menu() {
HMENU bar = CreateMenu(), file = CreatePopupMenu(), edit = CreatePopupMenu(), find = CreatePopupMenu(), saved = CreatePopupMenu(), tools = CreatePopupMenu(), help = CreatePopupMenu();
auto add = [](HMENU menu, UINT id, const wchar_t* text) { AppendMenuW(menu, MF_STRING, id, text); };
add(file, id_open, L"打开(&O)\tEnter"); add(file, id_parent, L"打开所在文件夹\tCtrl+Enter"); add(file, id_export, L"导出搜索结果(&E)…\tCtrl+S"); add(file, id_exit, L"退出(&X)");
add(edit, id_copy, L"复制(&C)\tCtrl+C"); add(edit, id_cut, L"剪切(&T)\tCtrl+X"); add(edit, id_paths, L"复制完整路径\tCtrl+Shift+C");
add(edit, id_rename, L"重命名(&R)\tF2"); add(edit, id_delete, L"删除(&D)\tDelete"); add(edit, id_properties, L"属性(&P)\tAlt+Enter"); add(edit, id_selectall, L"全选(&A)\tCtrl+A");
add(find, id_case, L"区分大小写(&C)"); add(find, id_path, L"匹配完整路径(&P)"); add(find, id_word, L"全字匹配(&W)"); add(find, id_regex, L"正则表达式(&R)"); add(find, id_refresh, L"刷新(&F)\tF5");
add(saved, id_bookmark, L"收藏当前搜索(&A)\tCtrl+D");
for (size_t i = 0; i < bookmarks.size(); ++i) add(saved, id_bookmarks + static_cast<UINT>(i), bookmarks[i].c_str());
add(tools, id_status, L"索引状态(&S)…"); add(tools, id_reindex, L"重新建立索引(&R)…"); add(tools, id_install, L"安装或修复后台服务(&I)…");
add(tools, id_tray, L"关闭窗口时留在托盘"); add(tools, id_startup, L"登录时启动界面");
add(tools, id_hotkey, L"快捷键设置(&K)…");
add(help, id_help, L"搜索语法(&H)…"); add(help, id_about, L"关于 WLocate(&A)…");
AppendMenuW(bar, MF_POPUP, reinterpret_cast<UINT_PTR>(file), L"文件(&F)");
AppendMenuW(bar, MF_POPUP, reinterpret_cast<UINT_PTR>(edit), L"编辑(&E)"); AppendMenuW(bar, MF_POPUP, reinterpret_cast<UINT_PTR>(find), L"搜索(&S)");
AppendMenuW(bar, MF_POPUP, reinterpret_cast<UINT_PTR>(saved), L"书签(&B)"); AppendMenuW(bar, MF_POPUP, reinterpret_cast<UINT_PTR>(tools), L"工具(&T)"); AppendMenuW(bar, MF_POPUP, reinterpret_cast<UINT_PTR>(help), L"帮助(&H)");
auto old = GetMenu(hwnd); SetMenu(hwnd, bar); if (old) DestroyMenu(old);
CheckMenuItem(bar, id_case, MF_BYCOMMAND | (flags & MatchCase ? MF_CHECKED : MF_UNCHECKED)); CheckMenuItem(bar, id_path, MF_BYCOMMAND | (flags & MatchPath ? MF_CHECKED : MF_UNCHECKED));
CheckMenuItem(bar, id_word, MF_BYCOMMAND | (flags & WholeWord ? MF_CHECKED : MF_UNCHECKED)); CheckMenuItem(bar, id_regex, MF_BYCOMMAND | (flags & Regex ? MF_CHECKED : MF_UNCHECKED));
CheckMenuItem(bar, id_tray, MF_BYCOMMAND | (close_to_tray ? MF_CHECKED : MF_UNCHECKED)); DrawMenuBar(hwnd);
}
void layout() {
RECT client; GetClientRect(hwnd, &client); SendMessageW(status, WM_SIZE, 0, 0); RECT bar; GetWindowRect(status, &bar);
auto margin = scaled(8), height = scaled(28), combo = scaled(150);
MoveWindow(search, margin, margin, std::max(10L, client.right - 3 * margin - combo), height, TRUE);
MoveWindow(filter, client.right - margin - combo, margin, combo, scaled(300), TRUE);
MoveWindow(list, 0, 2 * margin + height, client.right, std::max(0L, client.bottom - (bar.bottom - bar.top) - 2 * margin - height), TRUE);
if (Header_GetItemCount(ListView_GetHeader(list)) == 5) {
int fixed = GetSystemMetricsForDpi(SM_CXVSCROLL, GetDpiForWindow(hwnd)) + scaled(6);
for (int column : {0, 2, 3, 4}) fixed += ListView_GetColumnWidth(list, column);
ListView_SetColumnWidth(list, 1, std::max(scaled(180), static_cast<int>(client.right) - fixed));
}
}
void update_font() {
auto previous = font;
font = CreateFontW(-scaled(12), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI");
for (auto window : {search, filter, list, status}) SendMessageW(window, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
if (previous) DeleteObject(previous);
}
void update_icons() {
auto dpi = GetDpiForWindow(hwnd);
auto load = [&](int x, int y) { return static_cast<HICON>(LoadImageW(instance, MAKEINTRESOURCEW(IDI_WLOCATE), IMAGE_ICON,
GetSystemMetricsForDpi(x, dpi), GetSystemMetricsForDpi(y, dpi), LR_DEFAULTCOLOR)); };
auto next_large = load(SM_CXICON, SM_CYICON), next_small = load(SM_CXSMICON, SM_CYSMICON);
if (!next_large || !next_small) { if (next_large) DestroyIcon(next_large); if (next_small) DestroyIcon(next_small); throw Error("Cannot load WLocate icon"); }
SendMessageW(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(next_large));
SendMessageW(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(next_small));
NOTIFYICONDATAW tray{sizeof(tray)}; tray.hWnd = hwnd; tray.uID = 1; tray.uFlags = NIF_ICON; tray.hIcon = next_small;
Shell_NotifyIconW(NIM_MODIFY, &tray);
if (icon_large) DestroyIcon(icon_large); if (icon_small) DestroyIcon(icon_small);
icon_large = next_large; icon_small = next_small;
}
void update_tray_tip() {
auto text = L"WLocate 文件查找(" + hotkey_name(hotkey.config()) + (hotkey.failure().empty() ? L")" : L",未生效)");
NOTIFYICONDATAW tray{sizeof(tray)}; tray.hWnd = hwnd; tray.uID = 1; tray.uFlags = NIF_TIP;
wcsncpy_s(tray.szTip, text.c_str(), _TRUNCATE); Shell_NotifyIconW(NIM_MODIFY, &tray);
}
void configure_hotkey() {
HotkeyDialog dialog{hotkey.config(), hotkey.failure(), [this](HotkeyConfig value) {
hotkey.change(value, [this](const HotkeyConfig& config) { return save_hotkey(ini, config); });
update_tray_tip();
}};
if (DialogBoxParamW(instance, MAKEINTRESOURCEW(IDD_HOTKEY), hwnd, HotkeyDialog::procedure, reinterpret_cast<LPARAM>(&dialog)) == -1)
throw Error("无法打开快捷键设置:" + win_error());
}
void create() {
flags = GetPrivateProfileIntW(L"Search", L"flags", EverythingSyntax, ini.c_str()) | EverythingSyntax;
close_to_tray = GetPrivateProfileIntW(L"Window", L"tray", 0, ini.c_str()) != 0;
for (unsigned i = 0; i < 30; ++i) { wchar_t value[4096]{}; auto key = std::to_wstring(i); GetPrivateProfileStringW(L"Bookmarks", key.c_str(), L"", value, 4096, ini.c_str()); if (*value) bookmarks.emplace_back(value); }
make_menu();
search = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", initial_query.c_str(), WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, 0, 0, 0, 0, hwnd, reinterpret_cast<HMENU>(1), instance, nullptr);
SendMessageW(search, EM_SETCUEBANNER, TRUE, reinterpret_cast<LPARAM>(L"搜索文件和文件夹,例如 python*.exe 或 ext:cpp size:>1kb"));
SendMessageW(search, EM_SETLIMITTEXT, 30000, 0);
filter = CreateWindowExW(0, L"COMBOBOX", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST, 0, 0, 0, 0, hwnd, reinterpret_cast<HMENU>(2), instance, nullptr);
for (const auto* name : {L"全部", L"文件", L"文件夹", L"可执行文件", L"文档", L"图片", L"音频", L"视频", L"压缩文件"}) SendMessageW(filter, CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(name));
SendMessageW(filter, CB_SETCURSEL, 0, 0);
list = CreateWindowExW(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT | LVS_OWNERDATA | LVS_SHOWSELALWAYS | LVS_EDITLABELS | LVS_SHAREIMAGELISTS, 0, 0, 0, 0, hwnd, reinterpret_cast<HMENU>(3), instance, nullptr);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_HEADERDRAGDROP | LVS_EX_LABELTIP);
SetWindowTheme(list, L"Explorer", nullptr); SetWindowTheme(search, L"Explorer", nullptr);
const wchar_t* labels[]{L"名称", L"路径", L"大小", L"修改时间", L"类型"}; int widths[]{220, 400, 95, 155, 80};
for (int i = 0; i < 5; ++i) {
auto key = std::to_wstring(i); auto width = std::clamp<int>(GetPrivateProfileIntW(L"Columns", key.c_str(), widths[i], ini.c_str()), 40, 2000);
LVCOLUMNW column{}; column.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_FMT; column.pszText = const_cast<wchar_t*>(labels[i]); column.cx = scaled(width); column.fmt = i == 2 ? LVCFMT_RIGHT : LVCFMT_LEFT; ListView_InsertColumn(list, i, &column);
}
icons.start(hwnd, icons_completed); icons.lookup(""); icons.lookup("<folder>");
attach_accessibility();
status = CreateWindowExW(0, STATUSCLASSNAMEW, L"正在连接索引服务…", WS_CHILD | WS_VISIBLE | SBARS_SIZEGRIP, 0, 0, 0, 0, hwnd, nullptr, instance, nullptr);
update_font(); update_icons();
layout(); run_worker(); start_search(); SetTimer(hwnd, 2, 2000, nullptr); SetFocus(search);
if (!test_output.empty()) { test_deadline = GetTickCount64() + 60000; SetTimer(hwnd, 3, 200, nullptr); }
hotkey.start(hwnd, test_output.empty() ? load_hotkey(ini) : HotkeyConfig{false});
NOTIFYICONDATAW tray{sizeof(tray)}; tray.hWnd = hwnd; tray.uID = 1; tray.uFlags = NIF_MESSAGE | NIF_ICON; tray.uCallbackMessage = tray_message; tray.hIcon = icon_small; Shell_NotifyIconW(NIM_ADD, &tray);
update_tray_tip();
if (!hotkey.failure().empty()) {
tray.uFlags = NIF_INFO; tray.dwInfoFlags = NIIF_WARNING;
wcscpy_s(tray.szInfoTitle, L"全局快捷键未生效");
wcscpy_s(tray.szInfo, L"设定的快捷键已被占用或被系统保留。请通过“工具 → 快捷键设置”修改,也可以点击托盘图标打开窗口。");
Shell_NotifyIconW(NIM_MODIFY, &tray);
}
}
void save() {
auto value = std::to_wstring(flags); WritePrivateProfileStringW(L"Search", L"flags", value.c_str(), ini.c_str());
WritePrivateProfileStringW(L"Window", L"tray", close_to_tray ? L"1" : L"0", ini.c_str());
RECT rect; GetWindowRect(hwnd, &rect);
if (!IsIconic(hwnd)) { value = std::to_wstring(rect.right - rect.left); WritePrivateProfileStringW(L"Window", L"width", value.c_str(), ini.c_str()); value = std::to_wstring(rect.bottom - rect.top); WritePrivateProfileStringW(L"Window", L"height", value.c_str(), ini.c_str()); }
for (size_t i = 0; i < bookmarks.size(); ++i) { auto key = std::to_wstring(i); WritePrivateProfileStringW(L"Bookmarks", key.c_str(), bookmarks[i].c_str(), ini.c_str()); }
for (int column = 0; column < 5; ++column) {
auto key = std::to_wstring(column); value = std::to_wstring(MulDiv(ListView_GetColumnWidth(list, column), 96, GetDpiForWindow(hwnd)));
WritePrivateProfileStringW(L"Columns", key.c_str(), value.c_str(), ini.c_str());
}
}
void command(UINT id);
void attach_accessibility() {
accessibility.attach(list, [this](int index) {
auto value = row(index); return value ? wide(filename(value->path)) + L" — " + wide(directory(value->path)) : L"正在加载…";
}, [this](int) { PostMessageW(hwnd, WM_COMMAND, id_open, 0); });
}
void test_tick();
LRESULT message(UINT message, WPARAM wparam, LPARAM lparam);
};
void Window::test_tick() {
if (GetTickCount64() > test_deadline) throw Error("UI integration test timed out");
if (initial_query == L"figma.exe") {
if (busy || !pending.empty() || !total || total > page_size) return;
bool custom = false;
for (int i = 0; i < static_cast<int>(total); ++i) {
auto value = row(i); if (!value) return;
icons.lookup(*value); if (!icons.has_file_icon(*value)) return;
custom |= icons.lookup(*value) != icons.lookup(".exe");
}
if (!custom) throw Error("Figma results still have only the generic EXE icon");
render_window(hwnd, test_output / L"file-icons.png");
std::ofstream report(test_output / L"report.txt"); report << total << " Figma results received their actual full-path icons without input.\n";
for (int i = 0; i < static_cast<int>(total); ++i) report << icons.lookup(*row(i)) << " " << row(i)->path << '\n';
closing = true; PostMessageW(hwnd, WM_CLOSE, 0, 0); return;
}
if (test_stage >= 4) {
auto now = GetTickCount64();
test_max_tick_gap = std::max(test_max_tick_gap, now - test_last_tick); test_last_tick = now;
if (test_stage < 10) {
const wchar_t* prefixes[]{L"py", L"pyt", L"pyth", L"pytho", L"python", L"python*"};
SetWindowTextW(search, prefixes[test_stage - 4]); ++test_stage; return;
}
}
if (busy || !pending.empty()) return;
auto check = [](bool ok, const char* what) { if (!ok) throw Error(what); };
if (test_stage == 0) {
check(GetACP() == CP_UTF8, "GUI process code page is not UTF-8");
check(icon_large && icon_small && reinterpret_cast<HICON>(SendMessageW(hwnd, WM_GETICON, ICON_SMALL, 0)) == icon_small, "GUI does not use the embedded WLocate icon");
if (!total) { start_search(); return; }
check(total > 0, "Initial GUI query returned no results"); check(ListView_GetItemCount(list) == static_cast<int>(total), "Virtual list count mismatch");
check(Header_GetItemCount(ListView_GetHeader(list)) == 5, "Missing result columns");
auto value = row(0); check(value && fold(filename(value->path)).starts_with("python"), "Unexpected first GUI result");
test_initial_count = total; render_window(hwnd, test_output / L"search.png");
SendMessageW(filter, CB_SETCURSEL, 3, 0); SetWindowTextW(search, L"python*.exe | *.txt"); ++test_stage; start_search();
} else if (test_stage == 1) {
check(total == test_initial_count, "GUI type filter did not scope across the OR expression");
SendMessageW(filter, CB_SETCURSEL, 0, 0); SetWindowTextW(search, L"*.exe"); ++test_stage; start_search();
} else if (test_stage == 2) {
check(total > 300, "Expected enough executable files to test virtual paging");
ListView_EnsureVisible(list, 300, FALSE); row(300); ++test_stage;
} else if (test_stage == 3) {
if (!row(300)) return;
render_window(hwnd, test_output / L"paging.png");
auto fixture = test_output / L"shell-fixture"; std::filesystem::create_directories(fixture / L"nested");
auto a = fixture / L"a.txt", b = fixture / L"nested" / L"b.txt";
{ std::ofstream file(a); file << "WLocate UI test"; } { std::ofstream file(b); file << "WLocate UI test"; }
auto single = shell_menu_verbs(hwnd, {a.wstring()}); auto multi = shell_menu_verbs(hwnd, {a.wstring(), b.wstring()});
for (auto needed : {"copy", "cut", "delete", "rename", "properties"}) check(std::find(single.begin(), single.end(), needed) != single.end(), "Single-item Shell menu is missing a core verb");
for (auto needed : {"copy", "cut", "delete", "properties"}) check(std::find(multi.begin(), multi.end(), needed) != multi.end(), "Cross-folder Shell selection is missing a core verb");
shell_rename(hwnd, a.wstring(), L"renamed-中文.txt");
check(std::filesystem::exists(fixture / L"renamed-中文.txt") && !std::filesystem::exists(a), "Shell rename did not update the file");
std::ofstream report(test_output / L"report.txt"); report << "GUI search, filter grouping, virtual paging, native Shell menus and Unicode Shell rename passed.\nInitial matches: " << test_initial_count << "\nSingle-file verbs:\n";
for (auto& verb : single) report << verb << '\n'; report << "Multi-file verbs:\n"; for (auto& verb : multi) report << verb << '\n';
++test_stage; test_last_tick = GetTickCount64();
SetWindowTextW(search, L"p");
} else if (test_stage == 10) {
check(window_text(search) == L"python*" && total > 0, "Typing replay did not finish the latest query");
check(row(0) && fold(filename(row(0)->path)).starts_with("python"), "Typing replay displayed an obsolete query");
check(test_max_tick_gap < 1000, "UI heartbeat stalled for at least a second while typing");
render_window(hwnd, test_output / L"typing.png");
std::ofstream report(test_output / L"report.txt", std::ios::app);
report << "Typing replay p -> py -> pyt -> pyth -> pytho -> python -> python* passed.\n"
<< "Maximum UI heartbeat gap (200 ms timer): " << test_max_tick_gap << " ms\n";
++test_stage; closing = true; PostMessageW(hwnd, WM_CLOSE, 0, 0);
}
}
void Window::command(UINT id) {
if (id >= id_bookmarks && id < id_bookmarks + bookmarks.size()) { SetWindowTextW(search, bookmarks[id - id_bookmarks].c_str()); start_search(); return; }
switch (id) {
case id_exit: closing = true; SendMessageW(hwnd, WM_CLOSE, 0, 0); return;
case id_refresh: start_search(); return;
case id_selectall: ListView_SetItemState(list, -1, LVIS_SELECTED, LVIS_SELECTED); return;
case id_case: flags ^= MatchCase; break;
case id_path: flags ^= MatchPath; break;
case id_word: flags ^= WholeWord; break;
case id_regex: flags ^= Regex; break;
case id_tray: close_to_tray = !close_to_tray; make_menu(); save(); return;
case id_hotkey: configure_hotkey(); return;
case id_bookmark: {
auto query = window_text(search); if (!query.empty() && std::find(bookmarks.begin(), bookmarks.end(), query) == bookmarks.end()) { if (bookmarks.size() == 30) bookmarks.erase(bookmarks.begin()); bookmarks.push_back(query); make_menu(); save(); } return;
}
case id_startup: {
HKEY key; if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr, 0, KEY_QUERY_VALUE | KEY_SET_VALUE, nullptr, &key, nullptr) != ERROR_SUCCESS) throw Error("Cannot open startup settings");
DWORD size = 0; bool enabled = RegQueryValueExW(key, L"WLocate", nullptr, nullptr, nullptr, &size) == ERROR_SUCCESS;
if (enabled) RegDeleteValueW(key, L"WLocate");
else { auto text = L"\"" + module_path() + L"\" --tray"; RegSetValueExW(key, L"WLocate", 0, REG_SZ, reinterpret_cast<const BYTE*>(text.c_str()), static_cast<DWORD>((text.size() + 1) * 2)); }
RegCloseKey(key); CheckMenuItem(GetMenu(hwnd), id_startup, MF_BYCOMMAND | (!enabled ? MF_CHECKED : MF_UNCHECKED)); return;
}
case id_status: {
Request r; r.operation = 1; r.timeout_ms = 3000; auto reply = request_service(r);
MessageBoxW(hwnd, wide(reply.message).c_str(), L"索引状态", MB_OK | MB_ICONINFORMATION); return;
}
case id_reindex: case id_install: {
SHELLEXECUTEINFOW info{sizeof(info)}; info.hwnd = hwnd; auto path = module_path(); info.lpFile = path.c_str(); info.lpVerb = L"runas"; info.lpParameters = id == id_install ? L"--install-service" : L"--reindex"; info.nShow = SW_HIDE;
if (!ShellExecuteExW(&info) && GetLastError() != ERROR_CANCELLED) throw Error(win_error()); return;
}
case id_export: {
wchar_t path[32768] = L"WLocate-results.csv"; OPENFILENAMEW dialog{sizeof(dialog)}; dialog.hwndOwner = hwnd;
dialog.lpstrFilter = L"CSV 文件 (*.csv)\0*.csv\0文本文件 (*.txt)\0*.txt\0\0"; dialog.lpstrFile = path; dialog.nMaxFile = 32768;
dialog.lpstrDefExt = L"csv"; dialog.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_EXPLORER;
if (GetSaveFileNameW(&dialog)) { queue(0, false, path); SendMessageW(status, SB_SETTEXTW, 0, reinterpret_cast<LPARAM>(L"正在导出全部搜索结果…")); } return;
}
case id_help:
MessageBoxW(hwnd, L"输入文字即时查找名称;空格表示 AND,| 表示 OR,! 排除。\n\n通配符:* ? [a-z]\n引号:\"Program Files\"\n类型:file: folder: ext:exe;dll\n路径:path:C:\\Windows parent:C:\\Temp\n大小:size:>1mb size:1kb..10mb\n时间:dm:today dc:2026-01-01..2026-12-31\n属性:attrib:h attrib:r\n其他:case:Python regex:^python.*\\.exe$\n\nCtrl+L 搜索框,Ctrl+Alt+L 显示窗口\nEnter 打开,Ctrl+Enter 所在文件夹,Alt+Enter 属性\nCtrl+C 复制文件,Ctrl+X 剪切,Ctrl+Shift+C 复制路径\nF2 重命名,Delete 删除,Shift+Delete 永久删除\nCtrl+S 导出全部结果,Ctrl+D 收藏当前搜索\n\n命令行示例:locate -n 10 \"python*.exe\"", L"搜索语法和快捷键", MB_OK | MB_ICONINFORMATION); return;
case id_about: {
auto text = L"WLocate 1.0.0 · 原生 x64 文件查找\n\n独立的 NTFS 索引与 USN 增量更新服务。\n界面使用 Windows Shell 右键菜单。\n不依赖 Everything 或 .NET。\n\n全局快捷键:" + hotkey_name(hotkey.config()) + L"\n可在“工具 → 快捷键设置”中修改。";
MessageBoxW(hwnd, text.c_str(), L"关于 WLocate", MB_OK | MB_ICONINFORMATION); return;
}
default: {
auto paths = selection(); if (paths.empty()) return;
switch (id) {
case id_open: for (auto& path : paths) shell_open(hwnd, path); break;
case id_parent: for (auto& path : paths) shell_open(hwnd, path, true); break;
case id_copy: clipboard_paths(hwnd, paths, false, false); break;
case id_cut: clipboard_paths(hwnd, paths, true, false); break;
case id_paths: clipboard_paths(hwnd, paths, false, true); break;
case id_delete: shell_verb(hwnd, paths, "delete", GetKeyState(VK_SHIFT) < 0); break;
case id_properties: shell_verb(hwnd, paths, "properties"); break;
case id_rename: SetFocus(list); ListView_EditLabel(list, ListView_GetNextItem(list, -1, LVNI_SELECTED)); break;
}
return;
}
}
make_menu(); start_search();
}
LRESULT Window::message(UINT message, WPARAM wparam, LPARAM lparam) {
bool handled; auto shell_result = shell_menu_message(message, wparam, lparam, handled); if (handled) return shell_result;
switch (message) {
case WM_CREATE: create(); return 0;
case WM_SIZE: if (status) layout(); return 0;
case WM_GETMINMAXINFO: reinterpret_cast<MINMAXINFO*>(lparam)->ptMinTrackSize = {scaled(620), scaled(300)}; return 0;
case WM_DPICHANGED: { update_font(); update_icons(); auto rect = reinterpret_cast<RECT*>(lparam); SetWindowPos(hwnd, nullptr, rect->left, rect->top, rect->right - rect->left, rect->bottom - rect->top, SWP_NOZORDER); layout(); return 0; }
case WM_SETFOCUS: SetFocus(search); return 0;
case WM_HOTKEY:
if (hotkey.handles(wparam) && HIWORD(lparam) == hotkey.config().key && LOWORD(lparam) == hotkey.config().modifiers) {
auto popup = GetLastActivePopup(hwnd);
if (popup != hwnd && IsWindowVisible(popup)) { SetForegroundWindow(popup); return 0; }
ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); SetFocus(search);
}
return 0;
case WM_COPYDATA: {
auto data = reinterpret_cast<COPYDATASTRUCT*>(lparam);
if (data->dwData == 1 && data->cbData >= 2 && data->cbData <= 60002 && data->cbData % 2 == 0) {
auto value = static_cast<const wchar_t*>(data->lpData);
if (value[data->cbData / 2 - 1] == 0) { SetWindowTextW(search, value); ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); start_search(); return TRUE; }
}
return FALSE;
}
case WM_COMMAND:
if (reinterpret_cast<HWND>(lparam) == search && HIWORD(wparam) == EN_CHANGE) { schedule_search(); return 0; }
if (reinterpret_cast<HWND>(lparam) == filter && HIWORD(wparam) == CBN_SELCHANGE) { start_search(); return 0; }
if (!lparam) { command(LOWORD(wparam)); return 0; } break;
case WM_TIMER:
if (wparam == 1) start_search();
else if (wparam == 3) test_tick();
else if (wparam == 2 && test_output.empty() && IsWindowVisible(hwnd) && !busy && pending.empty() && !ListView_GetSelectedCount(list) && !ListView_GetEditControl(list)) queue(0, true);
return 0;
case completed: receive(std::unique_ptr<Completion>(reinterpret_cast<Completion*>(lparam))); return 0;
case icons_completed:
if (icons.collect()) {
if (auto images = icons.image_list(); images && images != ListView_GetImageList(list, LVSIL_SMALL)) ListView_SetImageList(list, images, LVSIL_SMALL);
InvalidateRect(list, nullptr, FALSE);
}
return 0;
case WM_NOTIFY: {
auto notification = reinterpret_cast<NMHDR*>(lparam);
if (notification->hwndFrom != list) break;
switch (notification->code) {
case LVN_GETDISPINFOW: display(reinterpret_cast<NMLVDISPINFOW*>(lparam)); return 0;
case NM_DBLCLK: command(id_open); return 0;
case LVN_COLUMNCLICK: {
auto column = reinterpret_cast<NMLISTVIEW*>(lparam)->iSubItem; Sort values[]{Sort::Name, Sort::Path, Sort::Size, Sort::Modified, Sort::Extension};
if (sort == values[column]) descending = !descending; else { sort = values[column]; descending = false; }
auto header = ListView_GetHeader(list);
for (int i = 0; i < 5; ++i) { HDITEMW item{}; item.mask = HDI_FORMAT; Header_GetItem(header, i, &item); item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN); if (i == column) item.fmt |= descending ? HDF_SORTDOWN : HDF_SORTUP; Header_SetItem(header, i, &item); }
start_search(); return 0;
}
case LVN_BEGINLABELEDITW: return row(reinterpret_cast<NMLVDISPINFOW*>(lparam)->item.iItem) ? FALSE : TRUE;
case LVN_ENDLABELEDITW: {
auto info = reinterpret_cast<NMLVDISPINFOW*>(lparam); auto value = row(info->item.iItem);
if (value && info->item.pszText) { shell_rename(hwnd, wide(value->path), info->item.pszText); SetTimer(hwnd, 1, 350, nullptr); } return FALSE;
}
case LVN_BEGINDRAG: shell_drag(hwnd, selection()); return 0;
case LVN_ODCACHEHINT: {
auto hint = reinterpret_cast<NMLVCACHEHINT*>(lparam);
if (hint->iFrom < 0 || hint->iTo < hint->iFrom || static_cast<uint64_t>(hint->iFrom) >= total) return 0;
auto first = uint64_t(hint->iFrom) / page_size * page_size;
auto last = std::min<uint64_t>(hint->iTo, total - 1) / page_size * page_size;
for (auto offset = first; offset <= std::min(last, first + 2 * page_size); offset += page_size) if (!pages.contains(offset)) queue(offset);
return 0;
}
}
break;
}
case WM_CONTEXTMENU:
if (reinterpret_cast<HWND>(wparam) == list) {
auto paths = selection(); if (paths.empty()) return 0;
POINT point{static_cast<short>(LOWORD(lparam)), static_cast<short>(HIWORD(lparam))};
if (point.x == -1 && point.y == -1) { RECT rect; ListView_GetItemRect(list, ListView_GetNextItem(list, -1, LVNI_SELECTED), &rect, LVIR_BOUNDS); point = {rect.left + scaled(30), rect.bottom}; ClientToScreen(list, &point); }
auto selected = shell_context(hwnd, paths, point);
if (selected == shell_open_parent) for (auto& path : paths) shell_open(hwnd, path, true);
if (selected == shell_copy_path) clipboard_paths(hwnd, paths, false, true);
if (selected == shell_rename_selected) command(id_rename);
return 0;
}
break;
case tray_message:
if (lparam == WM_LBUTTONUP || lparam == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); SetFocus(search); }
if (lparam == WM_RBUTTONUP) {
auto menu = CreatePopupMenu(); AppendMenuW(menu, MF_STRING, 1, L"显示 WLocate"); AppendMenuW(menu, MF_STRING, 3, L"快捷键设置…"); AppendMenuW(menu, MF_STRING, 2, L"退出"); POINT point; GetCursorPos(&point); SetForegroundWindow(hwnd);
auto selected = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, point.x, point.y, 0, hwnd, nullptr); DestroyMenu(menu);
if (selected == 1) { ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); } if (selected == 2) command(id_exit);
if (selected == 3) command(id_hotkey);
}
return 0;
case WM_CLOSE:
if (close_to_tray && !closing) { save(); ShowWindow(hwnd, SW_HIDE); return 0; }
if (test_output.empty()) save(); shutdown(); DestroyWindow(hwnd); return 0;
case WM_DESTROY: {
hotkey.stop(); NOTIFYICONDATAW tray{sizeof(tray)}; tray.hWnd = hwnd; tray.uID = 1; Shell_NotifyIconW(NIM_DELETE, &tray); PostQuitMessage(test_exit); return 0;
}
}
return DefWindowProcW(hwnd, message, wparam, lparam);
}
LRESULT CALLBACK procedure(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
auto self = reinterpret_cast<Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
if (message == WM_NCCREATE) { self = static_cast<Window*>(reinterpret_cast<CREATESTRUCTW*>(lparam)->lpCreateParams); self->hwnd = hwnd; SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self)); }
if (!self) return DefWindowProcW(hwnd, message, wparam, lparam);
try { return self->message(message, wparam, lparam); } catch (const std::exception& error) { self->error(error); return 0; }
}
}
int run_gui(HINSTANCE instance, int show, std::wstring initial_query, std::filesystem::path test_output) {
if (auto existing = test_output.empty() ? FindWindowW(L"WLocate.Main.v1", nullptr) : nullptr) {
COPYDATASTRUCT data{1, static_cast<DWORD>((initial_query.size() + 1) * 2), initial_query.data()}; DWORD_PTR result;
SendMessageTimeoutW(existing, WM_COPYDATA, 0, reinterpret_cast<LPARAM>(&data), SMTO_ABORTIFHUNG, 3000, &result); return 0;
}
OleInitialize(nullptr); INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES | ICC_BAR_CLASSES}; InitCommonControlsEx(&controls);
WNDCLASSEXW cls{sizeof(cls)}; cls.lpfnWndProc = procedure; cls.hInstance = instance; cls.hCursor = LoadCursorW(nullptr, IDC_ARROW); cls.hIcon = LoadIconW(instance, MAKEINTRESOURCEW(IDI_WLOCATE)); cls.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1); cls.lpszClassName = L"WLocate.Main.v1"; RegisterClassExW(&cls);
Window window(instance, std::move(initial_query), std::move(test_output));
auto width = std::clamp(GetPrivateProfileIntW(L"Window", L"width", 1200, window.ini.c_str()), 620u, 4000u);
auto height = std::clamp(GetPrivateProfileIntW(L"Window", L"height", 740, window.ini.c_str()), 300u, 2400u);
auto hwnd = CreateWindowExW(WS_EX_CONTROLPARENT, cls.lpszClassName, L"WLocate", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, width, height, nullptr, nullptr, instance, &window);
if (!hwnd) throw Error("Create main window: " + win_error()); ShowWindow(hwnd, show); UpdateWindow(hwnd);
MSG message;
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
if (message.message == WM_KEYDOWN || message.message == WM_SYSKEYDOWN) {
bool ctrl = GetKeyState(VK_CONTROL) < 0, shift = GetKeyState(VK_SHIFT) < 0, alt = GetKeyState(VK_MENU) < 0; UINT command = 0;
bool editing = GetFocus() == window.search || GetFocus() == ListView_GetEditControl(window.list);
if (ctrl && (message.wParam == 'L' || message.wParam == 'F')) { SetFocus(window.search); SendMessageW(window.search, EM_SETSEL, 0, -1); continue; }
if (message.wParam == VK_F5) command = id_refresh;
else if (ctrl && message.wParam == 'S') command = id_export;
else if (ctrl && message.wParam == 'D') command = id_bookmark;
else if (message.wParam == VK_RETURN && GetFocus() == window.search) { SetFocus(window.list); if (window.total) { ListView_SetItemState(window.list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); } continue; }
else if (!editing) {
if (message.wParam == VK_RETURN) command = alt ? id_properties : ctrl ? id_parent : id_open;
else if (message.wParam == VK_DELETE) command = id_delete;
else if (message.wParam == VK_F2) command = id_rename;
else if (ctrl && message.wParam == 'C') command = shift ? id_paths : id_copy;
else if (ctrl && message.wParam == 'X') command = id_cut;
else if (ctrl && message.wParam == 'A') command = id_selectall;
}
if (command) { SendMessageW(hwnd, WM_COMMAND, command, 0); continue; }
if (message.wParam == VK_ESCAPE && !ListView_GetEditControl(window.list)) { SetFocus(window.search); SendMessageW(window.search, EM_SETSEL, 0, -1); continue; }
}
if (!IsDialogMessageW(hwnd, &message)) { TranslateMessage(&message); DispatchMessageW(&message); }
}
window.shutdown(); OleUninitialize(); return static_cast<int>(message.wParam);
}
}
+204
View File
@@ -0,0 +1,204 @@
#pragma once
#include "common.hpp"
#include <commctrl.h>
#include <UIAutomation.h>
#include <wrl/client.h>
namespace wl {
// The stock ListView UIA proxy enumerates every owner-data row, including all
// its cells, on the GUI thread. Expose a virtualized tree instead: visible rows
// are children; ItemContainer/VirtualizedItem provide access to other rows.
class AccessibleList {
struct State {
HWND list = nullptr;
uint32_t epoch = 1;
std::function<std::wstring(int)> name;
std::function<void(int)> invoke;
IRawElementProviderSimple* root = nullptr;
};
class Provider final : public IRawElementProviderSimple, public IRawElementProviderFragment,
public IRawElementProviderFragmentRoot, public IItemContainerProvider, public IVirtualizedItemProvider,
public ISelectionProvider, public ISelectionItemProvider, public IScrollItemProvider, public IInvokeProvider {
std::atomic<ULONG> references_ = 1;
std::shared_ptr<State> state_;
int index_;
uint32_t epoch_;
bool valid() const { return state_->list && (index_ < 0 || (epoch_ == state_->epoch && index_ < ListView_GetItemCount(state_->list))); }
int first() const { return std::max(0, ListView_GetTopIndex(state_->list)); }
int last() const { return static_cast<int>(std::min<int64_t>(ListView_GetItemCount(state_->list) - 1, int64_t(first()) + ListView_GetCountPerPage(state_->list))); }
bool visible() const { return index_ < 0 || (index_ >= first() && index_ <= last()); }
Provider* child(int index) { return new Provider(state_, index); }
HRESULT root(REFIID iid, void** output) { return valid() ? state_->root->QueryInterface(iid, output) : UIA_E_ELEMENTNOTAVAILABLE; }
public:
Provider(std::shared_ptr<State> state, int index) : state_(std::move(state)), index_(index), epoch_(state_->epoch) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** output) override {
if (!output) return E_POINTER; *output = nullptr;
if (iid == __uuidof(IUnknown) || iid == __uuidof(IRawElementProviderSimple)) *output = static_cast<IRawElementProviderSimple*>(this);
else if (iid == __uuidof(IRawElementProviderFragment)) *output = static_cast<IRawElementProviderFragment*>(this);
else if (index_ < 0 && iid == __uuidof(IRawElementProviderFragmentRoot)) *output = static_cast<IRawElementProviderFragmentRoot*>(this);
else if (index_ < 0 && iid == __uuidof(IItemContainerProvider)) *output = static_cast<IItemContainerProvider*>(this);
else if (index_ < 0 && iid == __uuidof(ISelectionProvider)) *output = static_cast<ISelectionProvider*>(this);
else if (index_ >= 0 && iid == __uuidof(IVirtualizedItemProvider)) *output = static_cast<IVirtualizedItemProvider*>(this);
else if (index_ >= 0 && iid == __uuidof(ISelectionItemProvider)) *output = static_cast<ISelectionItemProvider*>(this);
else if (index_ >= 0 && iid == __uuidof(IScrollItemProvider)) *output = static_cast<IScrollItemProvider*>(this);
else if (index_ >= 0 && iid == __uuidof(IInvokeProvider)) *output = static_cast<IInvokeProvider*>(this);
if (!*output) return E_NOINTERFACE; AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references_; }
ULONG STDMETHODCALLTYPE Release() override { auto count = --references_; if (!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE get_ProviderOptions(ProviderOptions* result) override {
// Controls and their cached rows belong to the GUI STA. Marshal
// provider calls there instead of cross-thread SendMessage per cell.
*result = static_cast<ProviderOptions>(ProviderOptions_ServerSideProvider | ProviderOptions_UseComThreading); return S_OK;
}
HRESULT STDMETHODCALLTYPE GetPatternProvider(PATTERNID id, IUnknown** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
if (index_ < 0) {
if (id == UIA_ItemContainerPatternId) *result = static_cast<IItemContainerProvider*>(this);
if (id == UIA_SelectionPatternId) *result = static_cast<ISelectionProvider*>(this);
} else {
if (id == UIA_VirtualizedItemPatternId) *result = static_cast<IVirtualizedItemProvider*>(this);
if (id == UIA_SelectionItemPatternId) *result = static_cast<ISelectionItemProvider*>(this);
if (id == UIA_ScrollItemPatternId) *result = static_cast<IScrollItemProvider*>(this);
if (id == UIA_InvokePatternId) *result = static_cast<IInvokeProvider*>(this);
}
if (*result) AddRef(); return S_OK;
}
HRESULT STDMETHODCALLTYPE GetPropertyValue(PROPERTYID id, VARIANT* value) override {
VariantInit(value); if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
try {
auto boolean = [&](bool v) { value->vt = VT_BOOL; value->boolVal = v ? VARIANT_TRUE : VARIANT_FALSE; };
auto string = [&](const std::wstring& v) { value->vt = VT_BSTR; value->bstrVal = SysAllocString(v.c_str()); return value->bstrVal ? S_OK : E_OUTOFMEMORY; };
switch (id) {
case UIA_ControlTypePropertyId: value->vt = VT_I4; value->lVal = index_ < 0 ? UIA_ListControlTypeId : UIA_ListItemControlTypeId; break;
case UIA_NamePropertyId:
if (index_ >= 0 && !visible()) return UIA_E_ELEMENTNOTAVAILABLE;
return string(index_ < 0 ? L"搜索结果" : state_->name(index_));
case UIA_AutomationIdPropertyId: return string(index_ < 0 ? L"Results" : L"row-" + std::to_wstring(index_));
case UIA_ItemStatusPropertyId: if (index_ < 0) return string(std::to_wstring(ListView_GetItemCount(state_->list)) + L" 个结果"); break;
case UIA_IsControlElementPropertyId: case UIA_IsContentElementPropertyId: case UIA_IsKeyboardFocusablePropertyId: boolean(true); break;
case UIA_IsEnabledPropertyId: boolean(IsWindowEnabled(state_->list)); break;
case UIA_HasKeyboardFocusPropertyId: boolean(::GetFocus() == state_->list && (index_ < 0 || ListView_GetNextItem(state_->list, -1, LVNI_FOCUSED) == index_)); break;
case UIA_IsOffscreenPropertyId: boolean(!IsWindowVisible(state_->list) || !visible()); break;
case UIA_FrameworkIdPropertyId: return string(L"Win32");
}
return S_OK;
} catch (...) { return E_FAIL; }
}
HRESULT STDMETHODCALLTYPE get_HostRawElementProvider(IRawElementProviderSimple** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
return index_ < 0 ? UiaHostProviderFromHwnd(state_->list, result) : S_OK;
}
HRESULT STDMETHODCALLTYPE Navigate(NavigateDirection direction, IRawElementProviderFragment** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
if (index_ < 0) {
if (ListView_GetItemCount(state_->list) && (direction == NavigateDirection_FirstChild || direction == NavigateDirection_LastChild))
*result = child(direction == NavigateDirection_FirstChild ? first() : last());
} else {
if (direction == NavigateDirection_Parent) return root(__uuidof(IRawElementProviderFragment), reinterpret_cast<void**>(result));
if (visible() && direction == NavigateDirection_NextSibling && index_ < last()) *result = child(index_ + 1);
if (visible() && direction == NavigateDirection_PreviousSibling && index_ > first()) *result = child(index_ - 1);
}
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetRuntimeId(SAFEARRAY** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; if (index_ < 0) return S_OK;
int id[]{UiaAppendRuntimeId, static_cast<int>(epoch_), index_};
*result = SafeArrayCreateVector(VT_I4, 0, 3); if (!*result) return E_OUTOFMEMORY;
for (LONG i = 0; i < 3; ++i) SafeArrayPutElement(*result, &i, &id[i]); return S_OK;
}
HRESULT STDMETHODCALLTYPE get_BoundingRectangle(UiaRect* result) override {
*result = {}; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
RECT rect{};
if (index_ < 0) GetWindowRect(state_->list, &rect);
else if (visible()) {
ListView_GetItemRect(state_->list, index_, &rect, LVIR_BOUNDS);
RECT client; GetClientRect(state_->list, &client); IntersectRect(&rect, &rect, &client);
MapWindowPoints(state_->list, nullptr, reinterpret_cast<POINT*>(&rect), 2);
}
*result = {double(rect.left), double(rect.top), double(rect.right - rect.left), double(rect.bottom - rect.top)}; return S_OK;
}
HRESULT STDMETHODCALLTYPE GetEmbeddedFragmentRoots(SAFEARRAY** result) override { *result = nullptr; return S_OK; }
HRESULT STDMETHODCALLTYPE SetFocus() override {
if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; ::SetFocus(state_->list);
if (index_ >= 0) { ListView_EnsureVisible(state_->list, index_, FALSE); ListView_SetItemState(state_->list, index_, LVIS_FOCUSED, LVIS_FOCUSED); } return S_OK;
}
HRESULT STDMETHODCALLTYPE get_FragmentRoot(IRawElementProviderFragmentRoot** result) override { *result = nullptr; return root(__uuidof(IRawElementProviderFragmentRoot), reinterpret_cast<void**>(result)); }
HRESULT STDMETHODCALLTYPE ElementProviderFromPoint(double x, double y, IRawElementProviderFragment** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
LVHITTESTINFO hit{}; hit.pt = {static_cast<LONG>(x), static_cast<LONG>(y)}; ScreenToClient(state_->list, &hit.pt);
auto index = ListView_HitTest(state_->list, &hit);
if (index >= 0) *result = child(index); else return root(__uuidof(IRawElementProviderFragment), reinterpret_cast<void**>(result)); return S_OK;
}
HRESULT STDMETHODCALLTYPE GetFocus(IRawElementProviderFragment** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
if (::GetFocus() == state_->list) { auto index = ListView_GetNextItem(state_->list, -1, LVNI_FOCUSED); if (index >= 0) *result = child(index); } return S_OK;
}
HRESULT STDMETHODCALLTYPE FindItemByProperty(IRawElementProviderSimple* after, PROPERTYID property, VARIANT value, IRawElementProviderSimple** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
int index = 0;
if (after) {
auto previous = dynamic_cast<Provider*>(after);
if (!previous || previous->state_ != state_ || !previous->valid() || previous->index_ < 0) return E_INVALIDARG;
index = previous->index_ + 1;
}
if (property == UIA_AutomationIdPropertyId) {
if (value.vt != VT_BSTR || !value.bstrVal || wcsncmp(value.bstrVal, L"row-", 4)) return E_INVALIDARG;
wchar_t* end = nullptr; auto parsed = wcstoll(value.bstrVal + 4, &end, 10);
if (*end || end == value.bstrVal + 4 || parsed < index || parsed > INT_MAX) return S_OK; index = static_cast<int>(parsed);
} else if (property == UIA_SelectionItemIsSelectedPropertyId && value.vt == VT_BOOL && value.boolVal == VARIANT_TRUE) {
index = ListView_GetNextItem(state_->list, index - 1, LVNI_SELECTED);
} else if (property != 0) return UIA_E_NOTSUPPORTED;
if (index >= 0 && index < ListView_GetItemCount(state_->list)) *result = child(index); return S_OK;
}
HRESULT STDMETHODCALLTYPE Realize() override { return ScrollIntoView(); }
HRESULT STDMETHODCALLTYPE ScrollIntoView() override { if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; if (index_ >= 0) ListView_EnsureVisible(state_->list, index_, FALSE); return S_OK; }
HRESULT STDMETHODCALLTYPE GetSelection(SAFEARRAY** result) override {
*result = nullptr; if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE;
auto count = ListView_GetSelectedCount(state_->list);
// A select-all over millions of rows is represented by the
// ItemContainer's IsSelected query, without a giant COM array.
if (count > 4096) return UIA_E_NOTSUPPORTED;
*result = SafeArrayCreateVector(VT_UNKNOWN, 0, count); if (!*result) return E_OUTOFMEMORY;
LONG at = 0;
for (int i = -1; (i = ListView_GetNextItem(state_->list, i, LVNI_SELECTED)) >= 0;) {
auto item = static_cast<IRawElementProviderSimple*>(child(i)); SafeArrayPutElement(*result, &at, item); item->Release(); ++at;
}
return S_OK;
}
HRESULT STDMETHODCALLTYPE get_CanSelectMultiple(BOOL* result) override { *result = TRUE; return S_OK; }
HRESULT STDMETHODCALLTYPE get_IsSelectionRequired(BOOL* result) override { *result = FALSE; return S_OK; }
HRESULT STDMETHODCALLTYPE Select() override {
if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; ListView_SetItemState(state_->list, -1, 0, LVIS_SELECTED); return AddToSelection();
}
HRESULT STDMETHODCALLTYPE AddToSelection() override { if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; ListView_SetItemState(state_->list, index_, LVIS_SELECTED, LVIS_SELECTED); return S_OK; }
HRESULT STDMETHODCALLTYPE RemoveFromSelection() override { if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; ListView_SetItemState(state_->list, index_, 0, LVIS_SELECTED); return S_OK; }
HRESULT STDMETHODCALLTYPE get_IsSelected(BOOL* result) override { if (!valid()) return UIA_E_ELEMENTNOTAVAILABLE; *result = (ListView_GetItemState(state_->list, index_, LVIS_SELECTED) != 0); return S_OK; }
HRESULT STDMETHODCALLTYPE get_SelectionContainer(IRawElementProviderSimple** result) override { *result = nullptr; return root(__uuidof(IRawElementProviderSimple), reinterpret_cast<void**>(result)); }
HRESULT STDMETHODCALLTYPE Invoke() override { auto hr = Select(); if (SUCCEEDED(hr)) state_->invoke(index_); return hr; }
};
std::shared_ptr<State> state_ = std::make_shared<State>();
Microsoft::WRL::ComPtr<IRawElementProviderSimple> provider_;
static LRESULT CALLBACK subclass(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR id, DWORD_PTR data) {
auto self = reinterpret_cast<AccessibleList*>(data);
if (message == WM_GETOBJECT && static_cast<LONG>(lparam) == UiaRootObjectId)
return UiaReturnRawElementProvider(hwnd, wparam, lparam, self->provider_.Get());
if (message == WM_NCDESTROY) { RemoveWindowSubclass(hwnd, subclass, id); self->detach(); }
return DefSubclassProc(hwnd, message, wparam, lparam);
}
public:
~AccessibleList() { detach(); }
void attach(HWND list, std::function<std::wstring(int)> name, std::function<void(int)> invoke) {
state_->list = list; state_->name = std::move(name); state_->invoke = std::move(invoke);
provider_.Attach(new Provider(state_, -1)); state_->root = provider_.Get();
SetWindowSubclass(list, subclass, 1, reinterpret_cast<DWORD_PTR>(this));
}
void detach() {
if (state_->list) RemoveWindowSubclass(state_->list, subclass, 1);
state_->list = nullptr; state_->name = {}; state_->invoke = {}; state_->root = nullptr;
if (provider_) { UiaDisconnectProvider(provider_.Get()); provider_.Reset(); }
}
void reset() { ++state_->epoch; }
IRawElementProviderSimple* provider() const { return provider_.Get(); }
};
}
+173
View File
@@ -0,0 +1,173 @@
#pragma once
#include "common.hpp"
#include "resource.h"
#include <commctrl.h>
namespace wl {
struct HotkeyConfig {
bool enabled = true;
UINT modifiers = MOD_CONTROL | MOD_ALT, key = 'L';
bool operator==(const HotkeyConfig&) const = default;
};
inline const std::vector<UINT>& hotkey_keys() {
static const auto keys = [] {
std::vector<UINT> result;
for (UINT key = 'A'; key <= 'Z'; ++key) result.push_back(key);
for (UINT key = '0'; key <= '9'; ++key) result.push_back(key);
for (UINT key = VK_F1; key <= VK_F24; ++key) if (key != VK_F12) result.push_back(key); // Reserved for the debugger by Windows.
for (UINT key : {VK_SPACE, VK_TAB, VK_RETURN, VK_ESCAPE, VK_INSERT, VK_DELETE, VK_HOME, VK_END,
VK_PRIOR, VK_NEXT, VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_PAUSE,
VK_OEM_1, VK_OEM_PLUS, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7}) result.push_back(key);
for (UINT key = VK_NUMPAD0; key <= VK_DIVIDE; ++key) if (key != VK_SEPARATOR) result.push_back(key);
return result;
}();
return keys;
}
inline std::wstring hotkey_key_name(UINT key) {
if ((key >= 'A' && key <= 'Z') || (key >= '0' && key <= '9')) return std::wstring(1, static_cast<wchar_t>(key));
if (key >= VK_F1 && key <= VK_F24) return L"F" + std::to_wstring(key - VK_F1 + 1);
if (key >= VK_NUMPAD0 && key <= VK_NUMPAD9) return L"数字键盘 " + std::to_wstring(key - VK_NUMPAD0);
switch (key) {
case VK_SPACE: return L"Space(空格)";
case VK_TAB: return L"Tab";
case VK_RETURN: return L"Enter";
case VK_ESCAPE: return L"Esc";
case VK_PAUSE: return L"Pause";
case VK_MULTIPLY: return L"数字键盘 *";
case VK_ADD: return L"数字键盘 +";
case VK_SUBTRACT: return L"数字键盘 -";
case VK_DECIMAL: return L"数字键盘 .";
case VK_DIVIDE: return L"数字键盘 /";
}
UINT scan = MapVirtualKeyW(key, MAPVK_VK_TO_VSC);
if (key == VK_INSERT || key == VK_DELETE || key == VK_HOME || key == VK_END || key == VK_PRIOR || key == VK_NEXT ||
key == VK_UP || key == VK_DOWN || key == VK_LEFT || key == VK_RIGHT || key == VK_DIVIDE) scan |= 0x100;
wchar_t text[64]{};
if (GetKeyNameTextW(static_cast<LONG>(scan << 16), text, 64)) return text;
return L"VK " + std::to_wstring(key);
}
inline std::wstring hotkey_name(const HotkeyConfig& value) {
if (!value.enabled) return L"已禁用";
std::wstring text;
if (value.modifiers & MOD_CONTROL) text += L"Ctrl+";
if (value.modifiers & MOD_ALT) text += L"Alt+";
if (value.modifiers & MOD_SHIFT) text += L"Shift+";
if (value.modifiers & MOD_WIN) text += L"Win+";
return text + hotkey_key_name(value.key);
}
inline bool valid_hotkey(const HotkeyConfig& value) {
constexpr UINT allowed = MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_WIN;
if ((value.modifiers & ~allowed) || std::find(hotkey_keys().begin(), hotkey_keys().end(), value.key) == hotkey_keys().end()) return false;
return !value.enabled || (value.modifiers & (MOD_CONTROL | MOD_ALT | MOD_WIN)) || (value.key >= VK_F1 && value.key <= VK_F24);
}
inline HotkeyConfig load_hotkey(const std::filesystem::path& ini) {
wchar_t text[64]{}; GetPrivateProfileStringW(L"Keyboard", L"ShowWindow", L"", text, 64, ini.c_str());
unsigned enabled = 0, modifiers = 0, key = 0; wchar_t trailing = 0;
if (swscanf_s(text, L"%u,%u,%u%c", &enabled, &modifiers, &key, &trailing, 1u) != 3 || enabled > 1) return {};
HotkeyConfig value{enabled != 0, modifiers, key}; return valid_hotkey(value) ? value : HotkeyConfig{};
}
inline bool save_hotkey(const std::filesystem::path& ini, const HotkeyConfig& value) {
// One key avoids partially saving the modifier/key/enabled combination.
auto text = std::to_wstring(value.enabled) + L"," + std::to_wstring(value.modifiers) + L"," + std::to_wstring(value.key);
return WritePrivateProfileStringW(L"Keyboard", L"ShowWindow", text.c_str(), ini.c_str()) != FALSE;
}
class GlobalHotkey {
HWND owner_ = nullptr;
int id_ = 0;
HotkeyConfig config_;
std::wstring failure_;
public:
using Save = std::function<bool(const HotkeyConfig&)>;
GlobalHotkey() = default;
GlobalHotkey(const GlobalHotkey&) = delete;
GlobalHotkey& operator=(const GlobalHotkey&) = delete;
~GlobalHotkey() { stop(); }
void stop() { if (id_) UnregisterHotKey(owner_, id_); id_ = 0; }
const HotkeyConfig& config() const { return config_; }
int id() const { return id_; }
const std::wstring& failure() const { return failure_; }
bool handles(WPARAM id) const { return id_ && id == static_cast<WPARAM>(id_); }
void start(HWND owner, HotkeyConfig value) {
stop(); owner_ = owner; config_ = value;
try { change(value); } catch (const Error& error) { failure_ = wide(error.what()); }
}
void change(HotkeyConfig next, const Save& save = {}) {
if (!valid_hotkey(next)) throw Error("请选择 Ctrl、Alt 或 Win 组合;也可以单独使用功能键。" );
int next_id = id_;
if (next.enabled && (!id_ || next != config_)) {
next_id = id_ == 1 ? 2 : 1;
if (!RegisterHotKey(owner_, next_id, next.modifiers | MOD_NOREPEAT, next.key))
throw Error("快捷键已被占用或被系统保留,请换一个组合。原设置保持不变。" );
} else if (!next.enabled) next_id = 0;
// Acquire the new combination before releasing the old one. A conflict
// or failed settings write must leave the working shortcut intact.
bool saved = false;
try { saved = !save || save(next); }
catch (...) { if (next_id && next_id != id_) UnregisterHotKey(owner_, next_id); throw; }
if (!saved) {
if (next_id && next_id != id_) UnregisterHotKey(owner_, next_id);
throw Error("无法保存快捷键设置。请检查设置文件是否可写,原设置保持不变。" );
}
if (id_ && id_ != next_id) UnregisterHotKey(owner_, id_);
id_ = next_id; config_ = next; failure_.clear();
}
};
struct HotkeyDialog {
HotkeyConfig initial;
std::wstring initial_failure;
std::function<void(HotkeyConfig)> apply;
bool modal = true;
static constexpr int modifier_ids[]{IDC_HOTKEY_CTRL, IDC_HOTKEY_ALT, IDC_HOTKEY_SHIFT, IDC_HOTKEY_WIN};
static constexpr UINT modifier_bits[]{MOD_CONTROL, MOD_ALT, MOD_SHIFT, MOD_WIN};
void enable_controls(HWND dialog) {
bool enabled = IsDlgButtonChecked(dialog, IDC_HOTKEY_ENABLED) == BST_CHECKED;
for (auto id : modifier_ids) EnableWindow(GetDlgItem(dialog, id), enabled);
EnableWindow(GetDlgItem(dialog, IDC_HOTKEY_KEY), enabled);
}
void set(HWND dialog, HotkeyConfig value) {
CheckDlgButton(dialog, IDC_HOTKEY_ENABLED, value.enabled ? BST_CHECKED : BST_UNCHECKED);
for (size_t i = 0; i < std::size(modifier_ids); ++i) CheckDlgButton(dialog, modifier_ids[i], value.modifiers & modifier_bits[i] ? BST_CHECKED : BST_UNCHECKED);
auto combo = GetDlgItem(dialog, IDC_HOTKEY_KEY);
for (LRESULT i = 0, n = SendMessageW(combo, CB_GETCOUNT, 0, 0); i < n; ++i)
if (SendMessageW(combo, CB_GETITEMDATA, i, 0) == value.key) { SendMessageW(combo, CB_SETCURSEL, i, 0); break; }
enable_controls(dialog);
}
HotkeyConfig get(HWND dialog) {
HotkeyConfig value; value.enabled = IsDlgButtonChecked(dialog, IDC_HOTKEY_ENABLED) == BST_CHECKED; value.modifiers = 0;
for (size_t i = 0; i < std::size(modifier_ids); ++i) if (IsDlgButtonChecked(dialog, modifier_ids[i]) == BST_CHECKED) value.modifiers |= modifier_bits[i];
auto combo = GetDlgItem(dialog, IDC_HOTKEY_KEY); auto index = SendMessageW(combo, CB_GETCURSEL, 0, 0);
value.key = static_cast<UINT>(SendMessageW(combo, CB_GETITEMDATA, index, 0)); return value;
}
void finish(HWND dialog, INT_PTR result) { if (modal) EndDialog(dialog, result); else DestroyWindow(dialog); }
static INT_PTR CALLBACK procedure(HWND dialog, UINT message, WPARAM wparam, LPARAM lparam) {
auto self = reinterpret_cast<HotkeyDialog*>(GetWindowLongPtrW(dialog, DWLP_USER));
if (message == WM_INITDIALOG) {
self = reinterpret_cast<HotkeyDialog*>(lparam); SetWindowLongPtrW(dialog, DWLP_USER, lparam);
auto combo = GetDlgItem(dialog, IDC_HOTKEY_KEY);
for (auto key : hotkey_keys()) {
auto name = hotkey_key_name(key); auto index = SendMessageW(combo, CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(name.c_str()));
SendMessageW(combo, CB_SETITEMDATA, index, key);
}
self->set(dialog, self->initial);
auto text = self->initial_failure.empty() ? L"当前设置:" + hotkey_name(self->initial) + L"。保存后立即生效。" : self->initial_failure;
SetDlgItemTextW(dialog, IDC_HOTKEY_STATUS, text.c_str()); return TRUE;
}
if (!self) return FALSE;
if (message == WM_CLOSE) { self->finish(dialog, IDCANCEL); return TRUE; }
if (message != WM_COMMAND) return FALSE;
switch (LOWORD(wparam)) {
case IDCANCEL: self->finish(dialog, IDCANCEL); return TRUE;
case IDOK:
try { self->apply(self->get(dialog)); self->finish(dialog, IDOK); }
catch (const std::exception& error) { SetDlgItemTextW(dialog, IDC_HOTKEY_STATUS, wide(error.what()).c_str()); }
return TRUE;
case IDC_HOTKEY_DEFAULT: self->set(dialog, {}); break;
case IDC_HOTKEY_ENABLED: self->enable_controls(dialog); break;
case IDC_HOTKEY_CTRL: case IDC_HOTKEY_ALT: case IDC_HOTKEY_SHIFT: case IDC_HOTKEY_WIN: break;
case IDC_HOTKEY_KEY: if (HIWORD(wparam) != CBN_SELCHANGE) return FALSE; break;
default: return FALSE;
}
auto text = L"待保存:" + hotkey_name(self->get(dialog)); SetDlgItemTextW(dialog, IDC_HOTKEY_STATUS, text.c_str()); return TRUE;
}
};
}
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include "common.hpp"
#include <commctrl.h>
#include <shellapi.h>
#include <objbase.h>
#include <condition_variable>
#include <deque>
#include <list>
namespace wl {
struct FileIcon { HIMAGELIST images = nullptr; int index = I_IMAGENONE; };
struct IconRequest { std::string path; uint32_t attributes = FILE_ATTRIBUTE_NORMAL; bool type_only = false; };
inline FileIcon load_file_icon(const IconRequest& request) {
auto name = wide(request.path);
SHFILEINFOW info{};
auto images = SHGetFileInfoW(name.c_str(), request.attributes, &info, sizeof(info),
SHGFI_SYSICONINDEX | SHGFI_SMALLICON | (request.type_only ? SHGFI_USEFILEATTRIBUTES : 0));
return {reinterpret_cast<HIMAGELIST>(images), images ? info.iIcon : I_IMAGENONE};
}
class AsyncIcons {
public:
using Loader = std::function<FileIcon(const IconRequest&)>;
using Clock = std::function<uint64_t()>;
private:
struct Stamp {
uint64_t modified = 0, created = 0, size = 0; uint32_t attributes = 0;
bool operator==(const Stamp&) const = default;
};
struct Job { std::string key; uint64_t revision; IconRequest request; };
struct Done { std::string key; uint64_t revision; FileIcon icon; };
struct Entry {
int index = I_IMAGENONE;
bool pending = false;
uint64_t revision = 0, retry_after = 0;
Stamp stamp;
std::list<std::string>::iterator used;
};
struct State {
std::mutex mutex;
std::condition_variable work;
std::deque<Job> jobs;
std::vector<Done> done;
HWND window = nullptr;
UINT message = 0;
bool stopped = false;
Loader loader;
explicit State(Loader load) : loader(std::move(load)) {}
};
std::shared_ptr<State> state_;
// These fields are accessed only by the GUI thread.
std::unordered_map<std::string, Entry> cache_;
std::list<std::string> recent_;
uint64_t revision_ = 0;
size_t capacity_;
Clock clock_;
HIMAGELIST images_ = nullptr;
static Stamp stamp(const Result& row) { return {row.modified, row.created, row.size, row.attributes}; }
static std::string file_key(const Result& row) { return "path:" + normalize_path(row.path); }
int lookup(const std::string& key, const IconRequest& request, const Stamp& version) {
auto found = cache_.find(key);
if (found == cache_.end()) {
recent_.push_front(key);
Entry entry; entry.stamp = version; entry.used = recent_.begin(); entry.revision = ++revision_;
found = cache_.emplace(key, std::move(entry)).first;
if (cache_.size() > capacity_) { cache_.erase(recent_.back()); recent_.pop_back(); }
} else recent_.splice(recent_.begin(), recent_, found->second.used);
auto& entry = found->second;
if (!(entry.stamp == version)) {
entry.stamp = version; entry.index = I_IMAGENONE; entry.pending = false;
entry.retry_after = 0; entry.revision = ++revision_;
}
if (entry.index != I_IMAGENONE || entry.pending || clock_() < entry.retry_after) return entry.index;
{
std::lock_guard lock(state_->mutex);
if (state_->stopped || !state_->window || state_->jobs.size() >= 256) return I_IMAGENONE;
entry.pending = true; entry.revision = ++revision_;
state_->jobs.push_back({key, entry.revision, request});
}
state_->work.notify_one(); return I_IMAGENONE;
}
public:
explicit AsyncIcons(Loader loader = load_file_icon, size_t capacity = 4096, Clock clock = GetTickCount64)
: state_(std::make_shared<State>(std::move(loader))), capacity_(std::max<size_t>(2, capacity)), clock_(std::move(clock)) {}
~AsyncIcons() { stop(); }
AsyncIcons(const AsyncIcons&) = delete;
AsyncIcons& operator=(const AsyncIcons&) = delete;
void start(HWND window, UINT message) {
auto state = state_;
{ std::lock_guard lock(state->mutex); state->window = window; state->message = message; }
// A Shell extension may block indefinitely. The worker owns only this
// shared state, never Window/controls, so closing never waits for Shell.
std::thread([state] {
auto com = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
for (;;) {
Job job;
{
std::unique_lock lock(state->mutex);
state->work.wait(lock, [&] { return state->stopped || !state->jobs.empty(); });
if (state->stopped) break;
job = std::move(state->jobs.front()); state->jobs.pop_front();
}
FileIcon icon;
try { if (SUCCEEDED(com)) icon = state->loader(job.request); } catch (...) {}
{
std::lock_guard lock(state->mutex);
if (state->stopped) break;
state->done.push_back({std::move(job.key), job.revision, icon});
PostMessageW(state->window, state->message, 0, 0);
}
}
if (SUCCEEDED(com)) CoUninitialize();
}).detach();
}
void stop() {
{ std::lock_guard lock(state_->mutex); state_->stopped = true; state_->window = nullptr; state_->jobs.clear(); state_->done.clear(); }
state_->work.notify_one();
}
int lookup(const std::string& key) {
bool folder = key == "<folder>";
return lookup("type:" + key, {folder ? "folder" : "file" + key, static_cast<uint32_t>(folder ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL), true}, {});
}
int lookup(const Result& row) {
auto type = (row.attributes & FILE_ATTRIBUTE_DIRECTORY) ? "<folder>" : fold(utf8(std::filesystem::path(wide(row.path)).extension().wstring()));
auto fallback = lookup(type);
auto actual = lookup(file_key(row), {row.path, row.attributes, false}, stamp(row));
return actual != I_IMAGENONE ? actual : fallback;
}
bool has_file_icon(const Result& row) const {
auto found = cache_.find(file_key(row));
return found != cache_.end() && found->second.stamp == stamp(row) && found->second.index != I_IMAGENONE;
}
bool collect() {
std::vector<Done> ready;
{ std::lock_guard lock(state_->mutex); ready.swap(state_->done); }
bool changed = false;
for (auto& done : ready) {
auto found = cache_.find(done.key);
if (found == cache_.end() || found->second.revision != done.revision) continue;
auto& entry = found->second;
entry.pending = false; entry.index = done.icon.index;
entry.retry_after = entry.index == I_IMAGENONE ? clock_() + 5000 : 0;
if (done.icon.images) images_ = done.icon.images;
changed = true;
}
return changed;
}
HIMAGELIST image_list() const { return images_; }
};
}
+519
View File
@@ -0,0 +1,519 @@
#include "index.hpp"
#include <sstream>
#include <array>
namespace wl {
void Volume::link_child(uint32_t row) {
const auto& entry = entries[row];
if (entry.id == root_id || (entry.attributes & deleted_attribute)) return;
auto [head, added] = first_children_.try_emplace(entry.parent, no_row);
next_sibling_[row] = head->second; head->second = row;
}
void Volume::unlink_child(uint32_t row) {
auto head = first_children_.find(entries[row].parent);
if (head == first_children_.end()) return;
auto* link = &head->second;
while (*link != no_row && *link != row) link = &next_sibling_[*link];
if (*link == row) *link = next_sibling_[row];
next_sibling_[row] = no_row;
if (head->second == no_row) first_children_.erase(head);
}
uint32_t Volume::intern(std::string name) {
if (auto found = name_ids_.find(name); found != name_ids_.end()) return found->second;
auto id = static_cast<uint32_t>(names.size());
names.push_back({ std::move(name), {}, no_row });
names.back().lower = fold(names.back().text);
name_masks_.push_back(name_bloom(names.back().lower));
name_ids_.emplace(names.back().text, id);
return id;
}
void Volume::add(RawEntry record, bool delta) {
auto name = intern(std::move(record.name));
auto row = static_cast<uint32_t>(entries.size());
entries.push_back({record.id, record.parent, record.size, record.modified, record.created, record.accessed,
record.attributes, name, names[name].first});
next_sibling_.push_back(no_row); link_child(row);
auto parent = directory_rows.find(record.parent); if (parent != directory_rows.end()) entries.back().parent_row = parent->second;
names[name].first = row;
if (record.attributes & FILE_ATTRIBUTE_DIRECTORY) { directory_rows[record.id] = row; ++directories; } else ++files;
if (delta) added_ids_[record.id].push_back(row);
}
void Volume::add_initial(RawEntry record) { add(std::move(record), false); }
void Volume::finish() {
std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) { return a.id < b.id; });
directory_rows.clear(); files = directories = 0;
first_children_.clear(); next_sibling_.assign(entries.size(), no_row);
for (auto& name : names) name.first = no_row;
for (uint32_t row = 0; row < entries.size(); ++row) {
auto& entry = entries[row];
entry.next_name = names[entry.name].first; names[entry.name].first = row;
if (entry.attributes & deleted_attribute) continue;
link_child(row);
if (entry.attributes & FILE_ATTRIBUTE_DIRECTORY) { directory_rows[entry.id] = row; ++directories; } else ++files;
}
for (auto& entry : entries) { auto parent = directory_rows.find(entry.parent); entry.parent_row = parent == directory_rows.end() ? no_row : parent->second; }
sorted_names.resize(names.size());
for (uint32_t i = 0; i < names.size(); ++i) sorted_names[i] = i;
std::sort(sorted_names.begin(), sorted_names.end(), [&](uint32_t a, uint32_t b) { return names[a].lower < names[b].lower; });
base_count_ = entries.size(); sorted_name_count_ = names.size(); added_ids_.clear();
ready = true; state = "ready"; ++generation; ++name_generation;
}
void Volume::compact() {
std::erase_if(entries, [](const Entry& value) { return value.attributes & deleted_attribute; });
// Discard unused interned strings as well as rows, so sustained file churn
// does not grow the name table indefinitely.
name_ids_.clear(); name_masks_.clear();
auto previous = std::move(names); names.clear();
for (auto& entry : entries) entry.name = intern(previous[entry.name].text);
finish();
}
std::vector<uint32_t> Volume::rows_for_id(uint64_t id) const {
std::vector<uint32_t> rows;
auto end = entries.begin() + base_count_;
auto first = std::lower_bound(entries.begin(), end, id, [](const Entry& e, uint64_t v) { return e.id < v; });
while (first != end && first->id == id) { rows.push_back(static_cast<uint32_t>(first - entries.begin())); ++first; }
if (auto found = added_ids_.find(id); found != added_ids_.end()) rows.insert(rows.end(), found->second.begin(), found->second.end());
return rows;
}
void Volume::replace_links(uint64_t id, const std::vector<RawEntry>& records) {
auto previous = rows_for_id(id);
std::erase_if(previous, [&](uint32_t row) { return (entries[row].attributes & deleted_attribute) != 0; });
bool unchanged_names = previous.size() == records.size();
std::vector<std::pair<uint32_t, const RawEntry*>> matched;
if (unchanged_names) for (auto& record : records) {
auto found = std::find_if(previous.begin(), previous.end(), [&](uint32_t row) {
const auto& old = entries[row];
return old.parent == record.parent && names[old.name].text == record.name && (old.attributes & FILE_ATTRIBUTE_DIRECTORY) == (record.attributes & FILE_ATTRIBUTE_DIRECTORY);
});
if (found == previous.end()) { unchanged_names = false; break; }
matched.emplace_back(*found, &record);
}
if (unchanged_names) {
bool changed = false;
for (auto [row, record] : matched) {
auto& old = entries[row];
changed |= old.size != record->size || old.modified != record->modified || old.created != record->created || old.accessed != record->accessed || old.attributes != record->attributes;
old.size = record->size; old.modified = record->modified; old.created = record->created; old.accessed = record->accessed; old.attributes = record->attributes;
}
if (changed) ++generation;
return;
}
// Preserve directory row identity on rename/move, so every descendant's
// parent-row cache stays valid without rewriting the whole subtree.
if (previous.size() == 1 && records.size() == 1 && (entries[previous[0]].attributes & FILE_ATTRIBUTE_DIRECTORY) && (records[0].attributes & FILE_ATTRIBUTE_DIRECTORY)) {
auto row = previous[0]; auto& old = entries[row]; const auto& record = records[0];
uint32_t* link = &names[old.name].first;
while (*link != no_row && *link != row) link = &entries[*link].next_name;
if (*link == row) *link = old.next_name;
if (old.parent != record.parent) unlink_child(row);
old.name = intern(record.name); old.next_name = names[old.name].first; names[old.name].first = row;
bool moved = old.parent != record.parent;
old.parent = record.parent; auto parent = directory_rows.find(record.parent); old.parent_row = parent == directory_rows.end() ? no_row : parent->second;
if (moved) link_child(row);
old.size = record.size; old.modified = record.modified; old.created = record.created; old.accessed = record.accessed; old.attributes = record.attributes;
++generation; ++name_generation; return;
}
for (auto row : previous) {
auto& entry = entries[row];
if (entry.attributes & deleted_attribute) continue;
if (entry.attributes & FILE_ATTRIBUTE_DIRECTORY) --directories; else --files;
unlink_child(row);
entry.attributes |= deleted_attribute;
}
directory_rows.erase(id); added_ids_.erase(id);
for (auto& record : records) add(record, true);
++generation; ++name_generation;
}
uint32_t Volume::parent_row(uint32_t row) const {
const auto& entry = entries[row]; auto parent = entry.parent_row;
if (parent < entries.size() && entries[parent].id == entry.parent && !(entries[parent].attributes & deleted_attribute)) return parent;
auto found = directory_rows.find(entry.parent); return found == directory_rows.end() ? no_row : found->second;
}
std::string Volume::full_path(uint32_t row) const {
std::array<std::string_view, 512> parts; size_t count = 0;
auto current = row;
while (current < entries.size()) {
const auto& entry = entries[current];
if (entry.attributes & deleted_attribute) return {};
if (entry.id == root_id) break;
if (count == parts.size()) return {};
parts[count++] = names[entry.name].text;
auto parent = parent_row(current); if (parent == no_row || parent == current) return {};
current = parent;
}
if (current >= entries.size()) return {};
std::string path = root;
while (count) { if (!path.ends_with('\\')) path += '\\'; path += parts[--count]; }
return path;
}
Result Volume::result(uint32_t row, bool with_path) const {
const auto& e = entries[row];
return { with_path ? full_path(row) : std::string{}, e.size, e.modified, e.created, e.accessed, e.attributes };
}
std::vector<uint32_t> Volume::find_literals(const Query& query, const std::vector<std::string_view>& literals,
uint64_t stop_after, const std::function<bool()>& cancelled, bool ordered) const {
uint32_t checked = 0;
auto check_cancelled = [&] {
if (!(checked++ & 1023) && cancelled()) throw Error("Search cancelled or timed out");
};
auto children = [&](uint64_t id) {
auto found = first_children_.find(id);
return found == first_children_.end() ? no_row : found->second;
};
std::vector<uint32_t> seeds;
bool entire_volume = false;
const auto folded_root = fold(root);
for (auto literal : literals) {
check_cancelled();
if (folded_root.find(literal) != folded_root.npos) { entire_volume = true; break; }
bool trailing = literal.ends_with('\\');
size_t end = literal.size() - size_t(trailing);
auto slash = literal.rfind('\\', end - 1);
auto begin = slash == literal.npos ? 0 : slash + 1;
auto anchor = literal.substr(begin, end - begin);
bool suffix = trailing && begin == 0;
auto visit = [&](uint32_t uid) {
check_cancelled();
const auto& name = names[uid];
if (suffix ? !name.lower.ends_with(anchor) : trailing ? name.lower != anchor : !name.lower.starts_with(anchor)) return;
for (auto row = name.first; row != no_row; row = entries[row].next_name) {
check_cancelled();
const auto& entry = entries[row];
if ((entry.attributes & deleted_attribute) || entry.id == root_id || (trailing && !(entry.attributes & FILE_ATTRIBUTE_DIRECTORY))) continue;
auto path = full_path(row); if (path.empty()) continue;
path = fold(path);
if (trailing) path += '\\';
else {
// The last fragment starts at a filename boundary. Check
// the preceding components against this occurrence only.
auto at = path.find_last_of('\\');
if (at == path.npos) continue;
path.resize(at + 1 + anchor.size());
}
if (path.ends_with(literal)) seeds.push_back(row);
}
};
if (suffix) {
const auto mask = name_bloom(anchor);
for (uint32_t uid = 0; uid < names.size(); ++uid) {
check_cancelled();
if ((name_masks_[uid] & mask) == mask) visit(uid);
}
} else {
auto first = std::lower_bound(sorted_names.begin(), sorted_names.end(), anchor,
[&](uint32_t uid, std::string_view value) { return names[uid].lower < value; });
for (auto it = first; it != sorted_names.end() && names[*it].lower.starts_with(anchor); ++it) visit(*it);
// New names do not enter the lexical permutation until compaction.
for (size_t uid = sorted_name_count_; uid < names.size(); ++uid) visit(static_cast<uint32_t>(uid));
}
}
if (entire_volume) {
seeds.clear();
if (auto found = directory_rows.find(root_id); found != directory_rows.end()) seeds.push_back(found->second);
}
std::vector<uint32_t> hits;
if (seeds.empty()) return hits;
// Overlapping OR branches and repeated occurrences can select nested
// roots. Visit each row once, preserving distinct NTFS hardlink names.
std::vector<uint8_t> visited(entries.size());
while (!seeds.empty()) {
auto row = seeds.back(); seeds.pop_back(); check_cancelled();
if (visited[row]) continue;
visited[row] = 1;
const auto& entry = entries[row];
if (entry.attributes & deleted_attribute) continue;
auto value = result(row); if (value.path.empty()) continue;
if (entry.attributes & FILE_ATTRIBUTE_DIRECTORY) {
for (auto child = children(entry.id); child != no_row; child = next_sibling_[child]) {
check_cancelled();
if (!visited[child]) seeds.push_back(child);
}
}
if (entry.id == root_id) continue;
const auto& name = names[entry.name];
if (!query.may_match_name(name_masks_[entry.name]) || query.evaluate(name.text, name.lower, &value) != Truth::Yes) continue;
hits.push_back(row);
if (hits.size() >= stop_after && !ordered) return hits;
}
if (ordered) {
std::stable_sort(hits.begin(), hits.end(), [&](uint32_t a, uint32_t b) {
check_cancelled();
return names[entries[a].name].lower < names[entries[b].name].lower;
});
if (hits.size() > stop_after) hits.resize(static_cast<size_t>(stop_after));
}
return hits;
}
std::vector<uint32_t> Volume::find(const Query& query, uint64_t stop_after, const std::function<bool()>& cancelled, bool ordered) const {
std::vector<uint32_t> hits;
if (!stop_after) return hits;
if (!query.path_prefixes().empty()) {
uint32_t checked = 0;
auto check_cancelled = [&] {
if (!(checked++ & 1023) && cancelled()) throw Error("Search cancelled or timed out");
};
auto children = [&](uint64_t id) {
auto found = first_children_.find(id);
return found == first_children_.end() ? no_row : found->second;
};
auto folded_root = fold(root);
if (!folded_root.ends_with('\\')) folded_root += '\\';
std::vector<uint64_t> roots;
for (const auto& prefix : query.path_prefixes()) {
check_cancelled();
if (!prefix.starts_with(folded_root)) continue;
std::vector<uint64_t> parents{root_id};
size_t begin = folded_root.size();
while (begin < prefix.size() && !parents.empty()) {
auto end = prefix.find('\\', begin);
auto component = std::string_view(prefix).substr(begin, end - begin);
std::vector<uint64_t> next;
for (auto id : parents) for (auto row = children(id); row != no_row; row = next_sibling_[row]) {
check_cancelled();
const auto& entry = entries[row];
if ((entry.attributes & FILE_ATTRIBUTE_DIRECTORY) && !(entry.attributes & deleted_attribute) && names[entry.name].lower == component)
next.push_back(entry.id);
}
parents = std::move(next); begin = end + 1;
}
roots.insert(roots.end(), parents.begin(), parents.end());
}
// Resolve and walk the bounded subtrees only. Keep the original query
// as the final predicate: wildcards still cross separators, and case,
// metadata, OR/AND and negation retain their existing semantics.
std::vector<uint32_t> pending;
for (auto id : roots) if (auto row = children(id); row != no_row) pending.push_back(row);
const bool needs_path = query.requires_path();
while (!pending.empty()) {
auto row = pending.back(); pending.pop_back();
for (; row != no_row; row = next_sibling_[row]) {
check_cancelled();
const auto& entry = entries[row];
if ((entry.attributes & deleted_attribute) || entry.id == root_id) continue;
auto value = result(row, needs_path);
if (needs_path && value.path.empty()) continue;
if (entry.attributes & FILE_ATTRIBUTE_DIRECTORY)
if (auto child = children(entry.id); child != no_row) pending.push_back(child);
const auto& name = names[entry.name];
if (query.may_match_name(name_masks_[entry.name]) && query.evaluate(name.text, name.lower, &value) == Truth::Yes) {
hits.push_back(row);
if (hits.size() >= stop_after && !ordered) return hits;
}
}
}
if (ordered) {
std::stable_sort(hits.begin(), hits.end(), [&](uint32_t a, uint32_t b) {
check_cancelled();
return names[entries[a].name].lower < names[entries[b].name].lower;
});
if (hits.size() > stop_after) hits.resize(static_cast<size_t>(stop_after));
}
return hits;
}
if (auto literals = query.path_literals(); !literals.empty()) return find_literals(query, literals, stop_after, cancelled, ordered);
auto prefix = query.prefix();
auto first = prefix.empty() ? sorted_names.begin() : std::lower_bound(sorted_names.begin(), sorted_names.end(), prefix,
[&](uint32_t uid, const std::string& value) { return names[uid].lower < value; });
uint32_t checked = 0;
const auto* simple_path = query.simple_path_literal();
std::string_view path_needle = simple_path ? (simple_path->match_case ? simple_path->value : simple_path->folded) : std::string_view{};
std::vector<uint8_t> name_matches;
if (simple_path) {
name_matches.resize(names.size()); auto bloom = name_bloom(simple_path->folded);
for (uint32_t uid = 0; uid < names.size(); ++uid) if ((name_masks_[uid] & bloom) == bloom)
name_matches[uid] = (simple_path->match_case ? names[uid].text : names[uid].lower).find(path_needle) != std::string::npos;
}
// Memoize reachability and ancestor matches in compact row-indexed bytes.
// Plain path substrings without separators match an individual component;
// no full path needs to be allocated for the millions of nonmatching files.
std::vector<uint8_t> ancestors(entries.size());
bool root_match = simple_path && (simple_path->match_case ? root : fold(root)).find(path_needle) != std::string::npos;
auto ancestry = [&](uint32_t row) {
std::array<uint32_t, 512> trail; size_t count = 0; uint8_t flags = 1;
while (row < entries.size()) {
if (ancestors[row]) { flags = ancestors[row]; break; }
if (entries[row].attributes & deleted_attribute) break;
if (entries[row].id == root_id) { flags = root_match ? 7 : 3; break; }
if (count == trail.size()) break;
trail[count++] = row; row = parent_row(row);
}
while (count) { auto item = trail[--count]; if (simple_path && (flags & 2) && name_matches[entries[item].name]) flags |= 4; ancestors[item] = flags; }
return flags;
};
if (simple_path && !ordered) {
for (uint32_t row = 0; row < entries.size(); ++row) {
if (!(row & 16383) && cancelled()) throw Error("Search cancelled or timed out");
const auto& entry = entries[row]; if ((entry.attributes & deleted_attribute) || entry.id == root_id) continue;
auto flags = ancestry(parent_row(row));
if ((flags & 2) && ((flags & 4) || name_matches[entry.name])) { hits.push_back(row); if (hits.size() >= stop_after) break; }
}
return hits;
}
auto visit = [&](uint32_t uid) {
if (!(checked++ & 1023) && cancelled()) throw Error("Search cancelled or timed out");
if (!query.may_match_name(name_masks_[uid])) return false;
const auto& name = names[uid];
auto name_match = query.evaluate(name.text, name.lower);
if (name_match == Truth::No) return false;
for (auto row = name.first; row != no_row; row = entries[row].next_name) {
if (!(checked++ & 4095) && cancelled()) throw Error("Search cancelled or timed out");
const auto& entry = entries[row];
if ((entry.attributes & deleted_attribute) || entry.id == root_id) continue;
auto flags = ancestry(parent_row(row)); if (!(flags & 2)) continue;
if (simple_path) {
if (!(flags & 4) && !name_matches[entry.name]) continue;
} else if (name_match == Truth::Unknown) {
auto value = result(row, query.requires_path());
if (query.requires_path() && value.path.empty()) continue;
if (query.evaluate(name.text, name.lower, &value) != Truth::Yes) continue;
}
hits.push_back(row);
if (hits.size() >= stop_after) return true;
}
return false;
};
if (prefix.empty() && !ordered) {
// Scan names in allocation order: traversing the lexical permutation causes
// a cache miss for almost every unique name on a multi-million-file index.
for (uint32_t uid = 0; uid < names.size(); ++uid) if (visit(uid)) return hits;
} else {
for (auto i = first; i != sorted_names.end(); ++i) {
if (!prefix.empty() && !names[*i].lower.starts_with(prefix)) break;
if (visit(*i)) return hits;
}
auto delta_start = hits.size();
for (auto uid = sorted_name_count_; uid < names.size(); ++uid) if (visit(static_cast<uint32_t>(uid))) return hits;
if (ordered && hits.size() > delta_start) {
auto less = [&](uint32_t a, uint32_t b) { return names[entries[a].name].lower < names[entries[b].name].lower; };
std::stable_sort(hits.begin() + delta_start, hits.end(), less);
std::inplace_merge(hits.begin(), hits.begin() + delta_start, hits.end(), less);
}
}
return hits;
}
void Engine::set_volumes(std::vector<std::shared_ptr<Volume>> value) { std::unique_lock lock(mutex); volumes = std::move(value); ++topology_; }
Reply Engine::status() const {
std::shared_lock lock(mutex); Reply reply; std::ostringstream text;
for (auto& volume : volumes) {
std::shared_lock volume_lock(volume->mutex);
text << volume->root << '\t' << volume->state << "\tfiles=" << volume->files << "\tdirectories=" << volume->directories
<< "\tpending=" << volume->pending_updates << "\n";
reply.total += volume->files + volume->directories;
}
reply.message = text.str(); return reply;
}
Reply Engine::search(const Request& request, const std::function<bool()>& cancelled) {
auto start = std::chrono::steady_clock::now();
Query query(request); Reply reply;
std::shared_lock lock(mutex);
std::vector<std::shared_lock<std::shared_mutex>> locks;
uint64_t generation = topology_;
bool metadata = query.requires_metadata() || request.sort == Sort::Size || request.sort == Sort::Modified || request.sort == Sort::Created;
bool any_ready = false;
for (auto& volume : volumes) {
locks.emplace_back(volume->mutex); generation += metadata ? volume->generation : volume->name_generation;
any_ready |= volume->ready;
if (!volume->ready) reply.message += volume->root + " " + volume->state + "; ";
}
if (!any_ready) throw Error(reply.message.empty() ? "No indexed NTFS volumes are available" : reply.message);
if (cancelled()) throw Error("Search cancelled or timed out");
auto limit = request.limit;
bool early = request.sort == Sort::None && !(request.flags & EverythingSyntax) && !(request.flags & (Existing | NonExisting));
std::shared_ptr<Cached> cache;
auto same = [&](const Request& a) { return a.flags == request.flags && a.patterns == request.patterns && a.sort == request.sort && a.descending == request.descending; };
if (!early) { std::lock_guard guard(cache_mutex_); if (cache_ && cache_->generation == generation && same(cache_->request)) cache = cache_; }
if (!cache) {
cache = std::make_shared<Cached>(); cache->generation = generation; cache->request = request; cache->volumes = volumes;
std::vector<size_t> boundaries;
for (uint32_t v = 0; v < volumes.size(); ++v) {
auto remaining = early ? limit - std::min<uint64_t>(limit, cache->rows.size()) : UINT64_MAX;
boundaries.push_back(cache->rows.size());
if (!volumes[v]->ready) continue;
auto rows = volumes[v]->find(query, remaining, cancelled, request.sort == Sort::Name);
for (auto row : rows) cache->rows.push_back({v, row});
if (early && cache->rows.size() >= limit) break;
}
auto compare = [&](RowRef a, RowRef b) {
const auto& va = *volumes[a.volume]; const auto& vb = *volumes[b.volume];
const auto& ea = va.entries[a.row]; const auto& eb = vb.entries[b.row];
switch (request.sort) {
case Sort::Size: return ea.size < eb.size;
case Sort::Modified: return ea.modified < eb.modified;
case Sort::Created: return ea.created < eb.created;
case Sort::Extension: {
auto& an = va.names[ea.name].lower; auto& bn = vb.names[eb.name].lower;
auto ap = an.find_last_of('.'), bp = bn.find_last_of('.');
return (ap == an.npos ? std::string_view{} : std::string_view(an).substr(ap)) < (bp == bn.npos ? std::string_view{} : std::string_view(bn).substr(bp));
}
default: return va.names[ea.name].lower < vb.names[eb.name].lower;
}
};
if (request.sort == Sort::Name) {
for (size_t v = 1; v < boundaries.size(); ++v) std::inplace_merge(cache->rows.begin(), cache->rows.begin() + boundaries[v],
cache->rows.begin() + (v + 1 < boundaries.size() ? boundaries[v + 1] : cache->rows.size()), compare);
} else if (request.sort == Sort::Path) {
// Store each parent path once, instead of allocating and case-folding
// complete paths during every comparison (millions of allocations).
std::vector<std::unordered_map<uint64_t, std::string>> parents(volumes.size());
struct PathKey { RowRef ref; std::string_view parent, name; };
std::vector<PathKey> keys; keys.reserve(cache->rows.size());
for (auto ref : cache->rows) {
if (!(keys.size() & 4095) && cancelled()) throw Error("Search cancelled or timed out");
const auto& volume = *volumes[ref.volume]; const auto& entry = volume.entries[ref.row];
auto [it, added] = parents[ref.volume].try_emplace(entry.parent);
if (added) { auto path = volume.full_path(ref.row); it->second = fold(path.substr(0, path.find_last_of('\\') + 1)); }
keys.push_back({ref, it->second, volume.names[entry.name].lower});
}
uint64_t comparisons = 0;
std::stable_sort(keys.begin(), keys.end(), [&](const PathKey& a, const PathKey& b) {
if (!(comparisons++ & 65535) && cancelled()) throw Error("Search cancelled or timed out");
std::string_view left[]{a.parent, a.name}, right[]{b.parent, b.name};
size_t l = 0, r = 0;
while (l < 2 && r < 2) {
auto length = std::min(left[l].size(), right[r].size());
auto order = left[l].substr(0, length).compare(right[r].substr(0, length));
if (order) return order < 0;
left[l].remove_prefix(length); right[r].remove_prefix(length);
if (left[l].empty()) ++l; if (right[r].empty()) ++r;
}
return l == 2 && r != 2;
});
for (size_t i = 0; i < keys.size(); ++i) cache->rows[i] = keys[i].ref;
} else if (request.sort != Sort::None) {
uint64_t comparisons = 0;
std::stable_sort(cache->rows.begin(), cache->rows.end(), [&](RowRef a, RowRef b) {
if (!(comparisons++ & 65535) && cancelled()) throw Error("Search cancelled or timed out");
return compare(a, b);
});
}
if (request.descending) std::reverse(cache->rows.begin(), cache->rows.end());
if (!early && !(request.flags & (Existing | NonExisting))) { std::lock_guard guard(cache_mutex_); cache_ = cache; }
}
uint64_t accepted = 0;
if (!(request.flags & (Existing | NonExisting))) {
accepted = cache->rows.size();
auto end = request.offset + std::min(limit, accepted - std::min(accepted, request.offset));
if (!(request.flags & CountOnly)) for (auto position = request.offset; position < end; ++position) {
if (!(position & 4095) && cancelled()) throw Error("Search cancelled or timed out");
auto ref = cache->rows[static_cast<size_t>(position)];
reply.results.push_back(volumes[ref.volume]->result(ref.row));
}
} else for (auto ref : cache->rows) {
if (!(accepted & 4095) && cancelled()) throw Error("Search cancelled or timed out");
Result value;
if (request.flags & (Existing | NonExisting)) {
value = volumes[ref.volume]->result(ref.row);
bool exists = !value.path.empty() && GetFileAttributesW(wide(value.path).c_str()) != INVALID_FILE_ATTRIBUTES;
if ((request.flags & Existing) ? !exists : exists) continue;
}
auto position = accepted++;
if (position < request.offset || position - request.offset >= limit || (request.flags & CountOnly)) continue;
if (value.path.empty()) value = volumes[ref.volume]->result(ref.row);
if (!value.path.empty()) reply.results.push_back(std::move(value));
}
reply.total = (request.flags & EverythingSyntax) ? accepted : std::min<uint64_t>(accepted, request.limit);
reply.generation = generation;
reply.search_us = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - start).count();
return reply;
}
}
+177
View File
@@ -0,0 +1,177 @@
#include "ipc.hpp"
#include <sddl.h>
#include <cstring>
#include <array>
namespace wl {
namespace {
constexpr uint32_t signature = 0x31434C57;
struct RequestHeader {
uint32_t magic = signature, bytes = 0, operation = 0, flags = 0;
uint64_t limit = 0, offset = 0;
uint32_t sort = 0, descending = 0, timeout = 0, reserved = 0;
};
struct ReplyHeader {
uint32_t magic = signature, error = 0;
uint64_t total = 0, generation = 0, search_us = 0, count = 0;
};
struct RowHeader { uint64_t size, modified, created, accessed; uint32_t attributes, path_bytes; };
class Connection {
Handle event_{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
std::array<uint8_t, 65536> input_, output_;
size_t input_at_ = 0, input_size_ = 0, output_size_ = 0;
DWORD io(void* data, DWORD amount, bool output) {
if (!event_) throw Error(win_error());
DWORD done = 0; OVERLAPPED op{}; op.hEvent = event_.value; ResetEvent(event_.value);
BOOL ok = output ? WriteFile(pipe, data, amount, &done, &op) : ReadFile(pipe, data, amount, &done, &op);
if (!ok) {
auto error = GetLastError(); if (error != ERROR_IO_PENDING) throw Error("IPC: " + win_error(error));
auto now = GetTickCount64();
auto timeout = now >= deadline ? 0 : static_cast<DWORD>(std::min<uint64_t>(deadline - now, UINT32_MAX - 1));
HANDLE events[]{event_.value, cancel};
auto wait = WaitForMultipleObjects(cancel ? 2 : 1, events, FALSE, timeout);
if (wait != WAIT_OBJECT_0) {
CancelIoEx(pipe, &op); GetOverlappedResult(pipe, &op, &done, TRUE);
throw Error(wait == WAIT_OBJECT_0 + 1 ? "Search cancelled" : "WLocate request timed out");
}
if (!GetOverlappedResult(pipe, &op, &done, FALSE)) throw Error("IPC: " + win_error());
}
if (!done) throw Error("WLocate connection closed"); return done;
}
public:
HANDLE pipe;
uint64_t deadline;
HANDLE cancel = nullptr;
Connection(HANDLE handle, uint32_t timeout, HANDLE cancellation = nullptr) : pipe(handle), deadline(GetTickCount64() + timeout), cancel(cancellation) {}
void flush() {
for (size_t sent = 0; sent < output_size_;) sent += io(output_.data() + sent, static_cast<DWORD>(output_size_ - sent), true);
output_size_ = 0;
}
void transfer(void* data, size_t bytes, bool output) {
if (!output) flush();
while (bytes) {
size_t done;
if (output) {
done = std::min(bytes, output_.size() - output_size_);
memcpy(output_.data() + output_size_, data, done); output_size_ += done;
if (output_size_ == output_.size()) flush();
} else {
if (input_at_ == input_size_) { input_size_ = io(input_.data(), static_cast<DWORD>(input_.size()), false); input_at_ = 0; }
done = std::min(bytes, input_size_ - input_at_); memcpy(data, input_.data() + input_at_, done); input_at_ += done;
}
data = static_cast<uint8_t*>(data) + done; bytes -= done;
}
}
template<class T> T read() { T value{}; transfer(&value, sizeof(value), false); return value; }
template<class T> void write(const T& value) { transfer(const_cast<T*>(&value), sizeof(value), true); }
std::string string(uint32_t bytes) {
if (bytes > 1024 * 1024) throw Error("IPC string exceeds limit");
std::string value(bytes, '\0'); transfer(value.data(), value.size(), false); return value;
}
void string(const std::string& value) { write(static_cast<uint32_t>(value.size())); transfer(const_cast<char*>(value.data()), value.size(), true); }
};
void handle_client(HANDLE pipe, Engine& engine, HANDLE stop, const std::function<void()>& rebuild) {
Connection connection(pipe, 3000, stop);
try {
auto header = connection.read<RequestHeader>();
if (header.magic != signature || header.bytes > 65536 || header.sort > uint32_t(Sort::None) || header.flags & ~1023u || header.reserved) throw Error("Invalid WLocate protocol request");
Request request; request.operation = header.operation; request.flags = header.flags; request.limit = header.limit;
request.offset = header.offset; request.sort = Sort(header.sort); request.descending = header.descending != 0;
request.timeout_ms = std::clamp(header.timeout, 100u, 600000u);
auto data = connection.string(header.bytes);
for (size_t at = 0; at < data.size();) {
if (data.size() - at < 4) throw Error("Truncated query pattern");
uint32_t length; memcpy(&length, data.data() + at, 4); at += 4;
if (length > data.size() - at || request.patterns.size() >= 256) throw Error("Invalid query pattern length");
request.patterns.emplace_back(data.substr(at, length)); at += length;
}
connection.deadline = GetTickCount64() + request.timeout_ms;
auto cancelled = [&] { return GetTickCount64() >= connection.deadline || WaitForSingleObject(stop, 0) == WAIT_OBJECT_0 || !PeekNamedPipe(pipe, nullptr, 0, nullptr, nullptr, nullptr); };
Reply reply;
if (request.operation == 0) reply = engine.search(request, cancelled);
else if (request.operation == 1) reply = engine.status();
else if (request.operation == 2) {
if (!ImpersonateNamedPipeClient(pipe)) throw Error("Cannot verify caller privileges");
bool admin = is_admin(); RevertToSelf();
if (!admin) throw Error("Rebuilding the system index requires an elevated terminal");
rebuild(); reply.message = "Index rebuild scheduled";
} else throw Error("Unknown operation");
connection.write(ReplyHeader{signature, 0, reply.total, reply.generation, reply.search_us, reply.results.size()});
connection.string(reply.message);
for (auto& row : reply.results) {
connection.write(RowHeader{row.size, row.modified, row.created, row.accessed, row.attributes, static_cast<uint32_t>(row.path.size())});
connection.transfer(row.path.data(), row.path.size(), true);
}
// DisconnectNamedPipe discards unread buffered bytes. Wait until the client
// confirms the complete reply instead of flushing synchronously without a timeout.
connection.deadline = GetTickCount64() + 3000;
if (connection.read<uint32_t>() != signature) throw Error("Invalid reply acknowledgement");
} catch (const std::exception& error) {
try { connection.deadline = GetTickCount64() + 1000; connection.write(ReplyHeader{signature, 1}); connection.string(std::string(error.what())); connection.read<uint32_t>(); } catch (...) {}
}
}
}
Reply request_service(const Request& request, HANDLE cancel) {
auto deadline = GetTickCount64() + std::min(request.timeout_ms, 10000u);
Handle pipe;
for (;;) {
pipe.reset(CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION, nullptr));
if (pipe) break;
auto error = GetLastError();
if (error != ERROR_PIPE_BUSY || GetTickCount64() >= deadline) throw Error("WLocate service is unavailable: " + win_error(error) + "\nRun wlocate --install-service from an elevated terminal.");
if (cancel && WaitForSingleObject(cancel, 0) == WAIT_OBJECT_0) throw Error("Search cancelled");
WaitNamedPipeW(pipe_name, 100);
}
Connection connection(pipe.value, request.timeout_ms, cancel);
std::string patterns;
for (auto& value : request.patterns) { auto length = static_cast<uint32_t>(value.size()); patterns.append(reinterpret_cast<const char*>(&length), 4); patterns += value; }
if (patterns.size() > 65536) throw Error("Search patterns exceed 64 KiB");
connection.write(RequestHeader{signature, static_cast<uint32_t>(patterns.size()), request.operation, request.flags, request.limit, request.offset,
uint32_t(request.sort), request.descending ? 1u : 0u, request.timeout_ms, 0});
connection.transfer(patterns.data(), patterns.size(), true);
auto header = connection.read<ReplyHeader>(); if (header.magic != signature || header.count > 50000000) throw Error("Invalid WLocate service response");
Reply reply; reply.message = connection.string(connection.read<uint32_t>());
if (header.error) { connection.write(signature); connection.flush(); throw Error(reply.message); }
reply.total = header.total; reply.generation = header.generation; reply.search_us = header.search_us;
reply.results.reserve(static_cast<size_t>(header.count));
for (uint64_t i = 0; i < header.count; ++i) {
auto row = connection.read<RowHeader>();
reply.results.push_back({connection.string(row.path_bytes), row.size, row.modified, row.created, row.accessed, row.attributes});
}
connection.write(signature);
connection.flush();
return reply;
}
void serve(Engine& engine, HANDLE stop_event, const std::function<void()>& rebuild) {
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)", SDDL_REVISION_1, &descriptor, nullptr)) throw Error(win_error());
SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor, FALSE};
std::vector<Handle> pipes;
// Keep every instance open for the server lifetime so another process cannot seize the name.
for (unsigned i = 0; i < 8; ++i) {
Handle pipe(CreateNamedPipeW(pipe_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | (i ? 0 : FILE_FLAG_FIRST_PIPE_INSTANCE),
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 8, 256 * 1024, 65536, 0, &attributes));
if (!pipe) { auto error = win_error(); LocalFree(descriptor); throw Error("Create search pipe: " + error); }
pipes.push_back(std::move(pipe));
}
LocalFree(descriptor);
std::vector<std::jthread> workers;
for (auto& pipe : pipes) workers.emplace_back([&, handle = pipe.value] {
Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr));
while (WaitForSingleObject(stop_event, 0) != WAIT_OBJECT_0) {
OVERLAPPED op{}; op.hEvent = event.value; ResetEvent(event.value);
auto connected = ConnectNamedPipe(handle, &op); auto error = connected ? ERROR_SUCCESS : GetLastError();
if (error == ERROR_IO_PENDING) {
HANDLE events[]{stop_event, event.value};
if (WaitForMultipleObjects(2, events, FALSE, INFINITE) != WAIT_OBJECT_0 + 1) {
CancelIoEx(handle, &op); DWORD done; GetOverlappedResult(handle, &op, &done, TRUE); break;
}
} else if (error != ERROR_PIPE_CONNECTED && error != ERROR_SUCCESS) { log("Connect pipe: " + win_error(error)); break; }
handle_client(handle, engine, stop_event, rebuild);
DisconnectNamedPipe(handle);
}
});
WaitForSingleObject(stop_event, INFINITE);
for (auto& pipe : pipes) CancelIoEx(pipe.value, nullptr);
}
}
+26
View File
@@ -0,0 +1,26 @@
#include "app.hpp"
#include <shellapi.h>
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, wchar_t*, int show) {
int argc = 0; auto argv = CommandLineToArgvW(GetCommandLineW(), &argc);
bool command = argc > 1 && std::wstring_view(argv[1]).starts_with(L"--");
try {
wl::require_utf8_process();
std::wstring option = argc > 1 ? argv[1] : L"";
if (option == L"--service") { LocalFree(argv); argv = nullptr; return wl::run_service(); }
if (option == L"--server") { LocalFree(argv); argv = nullptr; return wl::run_server(); }
if (option == L"--tray") { LocalFree(argv); argv = nullptr; return wl::run_gui(instance, SW_HIDE); }
if (option == L"--install-service") wl::install_service();
else if (option == L"--uninstall-service") wl::uninstall_service();
else if (option == L"--reindex") { wl::Request request; request.operation = 2; wl::request_service(request); }
else if (option == L"--version") { auto text = std::string("WLocate 1.0.0 native x64\r\n"); wl::write_exact(GetStdHandle(STD_OUTPUT_HANDLE), text.data(), text.size()); }
else { std::wstring query; for (int i = 1; i < argc; ++i) { if (i > 1) query += L' '; query += argv[i]; } LocalFree(argv); argv = nullptr; return wl::run_gui(instance, show, query); }
LocalFree(argv); return 0;
} catch (const std::exception& error) {
LocalFree(argv); wl::log(error.what());
std::string message = std::string("WLocate: ") + error.what() + "\r\n";
if (command) wl::write_exact(GetStdHandle(STD_ERROR_HANDLE), message.data(), message.size());
else MessageBoxW(nullptr, wl::wide(error.what()).c_str(), L"WLocate", MB_OK | MB_ICONERROR);
return 2;
}
}
+296
View File
@@ -0,0 +1,296 @@
#include "ntfs.hpp"
#include <cstring>
#include <unordered_set>
namespace wl {
namespace {
constexpr uint64_t frn_mask = 0xFFFFFFFFFFFFull;
class RetryRecord : public Error { public: using Error::Error; };
template<class T> T get(std::span<const uint8_t> bytes, size_t at) {
if (at > bytes.size() || sizeof(T) > bytes.size() - at) throw Error("Invalid NTFS field bounds");
T value; memcpy(&value, bytes.data() + at, sizeof(value)); return value;
}
RawEntry entry(const RecordInfo& base, const std::pair<uint64_t, std::string>& name) {
return {base.id, name.first, base.size, base.modified, base.created, base.accessed, base.attributes, name.second};
}
}
std::vector<wchar_t> ntfs_drives() {
std::vector<wchar_t> drives;
auto mask = GetLogicalDrives();
for (unsigned i = 0; i < 26; ++i) if (mask & (1u << i)) {
wchar_t root[] = L"A:\\"; root[0] += static_cast<wchar_t>(i);
if (GetDriveTypeW(root) != DRIVE_FIXED && GetDriveTypeW(root) != DRIVE_REMOVABLE) continue;
wchar_t fs[64]{};
if (GetVolumeInformationW(root, nullptr, 0, nullptr, nullptr, nullptr, fs, 64) && !_wcsicmp(fs, L"NTFS")) drives.push_back(root[0]);
}
return drives;
}
std::vector<uint8_t> NtfsReader::read_at(uint64_t offset, size_t bytes) const {
LARGE_INTEGER position; position.QuadPart = static_cast<LONGLONG>(offset);
if (!SetFilePointerEx(handle_.value, position, nullptr, FILE_BEGIN)) throw Error("Seek volume: " + win_error());
std::vector<uint8_t> data(bytes);
if (!read_exact(handle_.value, data.data(), data.size())) throw Error("Read volume: " + win_error());
return data;
}
NtfsReader::NtfsReader(wchar_t drive) {
std::wstring path = L"\\\\.\\A:"; path[4] = drive; root_ = utf8(std::wstring(1, drive) + L":\\");
handle_.reset(CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, 0, nullptr));
if (!handle_) throw Error("Open " + root_ + ": " + win_error());
NTFS_VOLUME_DATA_BUFFER info{}; DWORD returned = 0;
if (!DeviceIoControl(handle_.value, FSCTL_GET_NTFS_VOLUME_DATA, nullptr, 0, &info, sizeof(info), &returned, nullptr)) throw Error("NTFS volume: " + win_error());
sector_size_ = info.BytesPerSector; cluster_size_ = info.BytesPerCluster; record_size_ = info.BytesPerFileRecordSegment;
serial_ = static_cast<uint64_t>(info.VolumeSerialNumber.QuadPart); mft_length_ = static_cast<uint64_t>(info.MftValidDataLength.QuadPart);
if (!sector_size_ || !cluster_size_ || record_size_ < 512 || record_size_ > 65536) throw Error("Unsupported NTFS geometry");
auto record = read_at(static_cast<uint64_t>(info.MftStartLcn.QuadPart) * cluster_size_, record_size_);
if (!fixup_record(record, sector_size_)) throw Error("Invalid MFT record zero");
auto mft = parse_record(record, 0); mft_runs_ = mft.data_runs;
expand_attribute_list(record, mft);
for (auto id : mft.extension_ids) if (id != mft.id) {
auto extra = get_record(id); if (extra.empty()) throw Error("Cannot read MFT extension");
auto part = parse_record(extra, id & frn_mask);
if (part.base_id != mft.id) throw Error("MFT extension belongs to another record");
mft_runs_.insert(mft_runs_.end(), part.data_runs.begin(), part.data_runs.end());
}
std::sort(mft_runs_.begin(), mft_runs_.end(), [](auto a, auto b) { return a.vcn < b.vcn; });
mft_runs_.erase(std::unique(mft_runs_.begin(), mft_runs_.end(), [](auto a, auto b) { return a.vcn == b.vcn; }), mft_runs_.end());
if (mft_runs_.empty()) throw Error("MFT has no data extents");
USN_JOURNAL_DATA_V0 journal_info{};
if (!DeviceIoControl(handle_.value, FSCTL_QUERY_USN_JOURNAL, nullptr, 0, &journal_info, sizeof(journal_info), &returned, nullptr)) {
auto error = GetLastError();
if (error != ERROR_JOURNAL_NOT_ACTIVE) throw Error("Query USN journal: " + win_error(error));
CREATE_USN_JOURNAL_DATA create{64ull * 1024 * 1024, 8ull * 1024 * 1024};
if (!DeviceIoControl(handle_.value, FSCTL_CREATE_USN_JOURNAL, &create, sizeof(create), nullptr, 0, &returned, nullptr)) throw Error("Create USN journal: " + win_error());
}
}
USN_JOURNAL_DATA_V0 NtfsReader::journal() const {
USN_JOURNAL_DATA_V0 value{}; DWORD returned;
if (!DeviceIoControl(handle_.value, FSCTL_QUERY_USN_JOURNAL, nullptr, 0, &value, sizeof(value), &returned, nullptr)) throw Error("Query journal: " + win_error());
return value;
}
std::vector<uint8_t> NtfsReader::raw_record(uint64_t index) const {
auto offset = (index & frn_mask) * record_size_;
for (auto run : mft_runs_) {
auto start = run.vcn * cluster_size_, end = start + run.clusters * cluster_size_;
if (offset >= start && offset + record_size_ <= end && run.lcn >= 0) {
auto bytes = read_at(uint64_t(run.lcn) * cluster_size_ + offset - start, record_size_);
if (!fixup_record(bytes, sector_size_)) return {};
return bytes;
}
}
return {};
}
std::vector<uint8_t> NtfsReader::get_record(uint64_t id) const {
NTFS_FILE_RECORD_INPUT_BUFFER input{}; input.FileReferenceNumber.QuadPart = static_cast<LONGLONG>(id & frn_mask);
std::vector<uint8_t> bytes(sizeof(NTFS_FILE_RECORD_OUTPUT_BUFFER) + record_size_); DWORD returned;
if (!DeviceIoControl(handle_.value, FSCTL_GET_NTFS_FILE_RECORD, &input, sizeof(input), bytes.data(), static_cast<DWORD>(bytes.size()), &returned, nullptr)) {
auto error = GetLastError();
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_INVALID_PARAMETER || error == ERROR_HANDLE_EOF) return {};
throw Error("Read file record: " + win_error(error));
}
if (returned < offsetof(NTFS_FILE_RECORD_OUTPUT_BUFFER, FileRecordBuffer)) throw Error("Truncated file record header");
auto* output = reinterpret_cast<NTFS_FILE_RECORD_OUTPUT_BUFFER*>(bytes.data());
if ((uint64_t(output->FileReferenceNumber.QuadPart) & frn_mask) != (id & frn_mask)) return {};
if (output->FileRecordLength > record_size_ || offsetof(NTFS_FILE_RECORD_OUTPUT_BUFFER, FileRecordBuffer) + output->FileRecordLength > returned) throw Error("Truncated file record");
std::vector<uint8_t> record(output->FileRecordBuffer, output->FileRecordBuffer + output->FileRecordLength);
// NTFS returns its current in-memory record here. Sector fixups belong only to
// raw disk reads; the in-memory sector tails can have changed since the last write.
if (record.size() < 48 || memcmp(record.data(), "FILE", 4)) throw Error("Invalid kernel file record");
auto parsed = parse_record(record, id & frn_mask);
if (!parsed.in_use || ((id >> 48) && parsed.id != id)) return {};
return record;
}
void NtfsReader::expand_attribute_list(std::span<const uint8_t> record, RecordInfo& info) const {
for (size_t at = get<uint16_t>(record, 0x14); at + 24 <= record.size();) {
auto type = get<uint32_t>(record, at); if (type == UINT32_MAX) break;
auto length = get<uint32_t>(record, at + 4); if (length < 24 || length > record.size() - at) throw Error("Invalid attribute list header");
auto attr = record.subspan(at, length);
if (type == 0x20 && attr[8]) {
auto size = get<uint64_t>(attr, 48); auto first = get<uint64_t>(attr, 16);
if (first || size > 16 * 1024 * 1024) throw Error("Unsupported attribute-list extent");
std::vector<uint8_t> list(static_cast<size_t>(size));
auto runs = parse_runs(attr.subspan(get<uint16_t>(attr, 32)), first);
for (auto run : runs) {
auto offset = run.vcn * cluster_size_;
if (offset >= size || run.lcn < 0) continue;
auto bytes = std::min<uint64_t>(run.clusters * cluster_size_, size - offset);
auto data = read_at(uint64_t(run.lcn) * cluster_size_, static_cast<size_t>((bytes + sector_size_ - 1) / sector_size_ * sector_size_));
memcpy(list.data() + offset, data.data(), static_cast<size_t>(bytes));
}
for (size_t p = 0; p + 26 <= list.size();) {
auto item = get<uint32_t>(list, p); if (!item || item == UINT32_MAX) break;
auto len = get<uint16_t>(list, p + 4); if (len < 26 || len > list.size() - p) throw Error("Invalid external attribute list");
if (item == 0x30 || item == 0x80) info.extension_ids.push_back(get<uint64_t>(list, p + 16));
p += len;
}
}
at += length;
}
std::sort(info.extension_ids.begin(), info.extension_ids.end());
info.extension_ids.erase(std::unique(info.extension_ids.begin(), info.extension_ids.end()), info.extension_ids.end());
}
std::vector<RawEntry> NtfsReader::read_links(uint64_t id) {
try {
if ((id & frn_mask) < 16 && (id & frn_mask) != 5) return {};
auto record = get_record(id); if (record.empty()) return {};
auto base = parse_record(record, id & frn_mask);
if (base.base_id) {
id = base.base_id; record = get_record(id); if (record.empty()) return {};
base = parse_record(record, id & frn_mask);
if (base.base_id) throw Error("Invalid NTFS base-record chain");
}
expand_attribute_list(record, base);
auto names = base.names;
auto filename_attributes = base.filename_attribute_count;
for (auto ref : base.extension_ids) if (ref != base.id) {
auto bytes = get_record(ref); if (bytes.empty()) continue;
auto extra = parse_record(bytes, ref & frn_mask);
if (extra.base_id == base.id) { names.insert(names.end(), extra.names.begin(), extra.names.end()); base.size = std::max(base.size, extra.size); filename_attributes += extra.filename_attribute_count; }
}
std::sort(names.begin(), names.end()); names.erase(std::unique(names.begin(), names.end()), names.end());
// The MFT header also counts separate DOS (8.3) FILE_NAME attributes.
// Those aliases must count toward completeness even though search output
// intentionally contains long names only.
if (!(base.attributes & FILE_ATTRIBUTE_DIRECTORY) && base.link_count > filename_attributes) {
// A nonresident $ATTRIBUTE_LIST may still be dirty in NTFS's cache when
// its USN notification arrives. Raw volume reads cannot see those bytes.
// Ask NTFS for the complete current link set instead of publishing a partial set.
FILE_ID_DESCRIPTOR descriptor{}; descriptor.dwSize = sizeof(descriptor); descriptor.Type = FileIdType;
descriptor.FileId.QuadPart = static_cast<LONGLONG>(base.id);
Handle file(OpenFileById(handle_.value, &descriptor, FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, FILE_FLAG_BACKUP_SEMANTICS));
if (!file) throw RetryRecord("Hardlink record is temporarily unavailable: " + win_error());
FILE_STANDARD_INFO standard{};
if (GetFileInformationByHandleEx(file.value, FileStandardInfo, &standard, sizeof(standard))) base.size = static_cast<uint64_t>(standard.EndOfFile.QuadPart);
std::wstring path(32768, L'\0');
auto chars = GetFinalPathNameByHandleW(file.value, path.data(), static_cast<DWORD>(path.size()), FILE_NAME_OPENED | VOLUME_NAME_DOS);
if (!chars || chars >= path.size()) throw RetryRecord("Cannot resolve updated hardlink path: " + win_error()); path.resize(chars);
std::vector<wchar_t> buffer(32768); DWORD length = static_cast<DWORD>(buffer.size());
HANDLE search = FindFirstFileNameW(path.c_str(), 0, &length, buffer.data());
if (search == INVALID_HANDLE_VALUE && GetLastError() == ERROR_MORE_DATA) { buffer.resize(length); search = FindFirstFileNameW(path.c_str(), 0, &length, buffer.data()); }
if (search == INVALID_HANDLE_VALUE) throw RetryRecord("Cannot enumerate updated hardlinks: " + win_error());
std::vector<std::pair<uint64_t, std::string>> current_names;
try {
for (;;) {
std::wstring link = L"\\\\?\\" + wide(root_.substr(0, 2)) + buffer.data();
auto slash = link.find_last_of(L'\\'); auto parent_path = link.substr(0, slash); if (parent_path.size() == 6) parent_path += L'\\';
Handle parent(CreateFileW(parent_path.c_str(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr));
BY_HANDLE_FILE_INFORMATION information{};
if (!parent || !GetFileInformationByHandle(parent.value, &information)) throw RetryRecord("Cannot resolve hardlink parent: " + win_error());
current_names.emplace_back((uint64_t(information.nFileIndexHigh) << 32) | information.nFileIndexLow, utf8(link.substr(slash + 1)));
length = static_cast<DWORD>(buffer.size());
if (!FindNextFileNameW(search, &length, buffer.data())) {
auto error = GetLastError();
if (error == ERROR_MORE_DATA) { buffer.resize(length); if (FindNextFileNameW(search, &length, buffer.data())) continue; error = GetLastError(); }
if (error != ERROR_HANDLE_EOF) throw RetryRecord("Hardlink enumeration: " + win_error(error));
break;
}
}
FindClose(search);
} catch (...) { FindClose(search); throw; }
names = std::move(current_names);
}
std::vector<RawEntry> values;
for (auto& name : names) values.push_back(entry(base, name));
return values;
} catch (const Error& error) {
// A file can change between the kernel record read and raw attribute-list
// read. Keep its previous entries and retry; a single busy/corrupt record
// must not take the entire volume's working index offline.
throw RetryRecord(error.what());
}
}
std::shared_ptr<Volume> NtfsReader::scan(const std::function<bool()>& cancelled) {
auto volume = std::make_shared<Volume>(root_); volume->serial = serial_;
auto before = journal(); volume->journal_id = before.UsnJournalID; volume->next_usn = before.NextUsn;
auto root_record = get_record(5); if (root_record.empty()) throw Error("Cannot read volume root");
volume->root_id = parse_record(root_record, 5).id;
volume->entries.reserve(static_cast<size_t>(mft_length_ / record_size_));
std::unordered_map<uint64_t, RecordInfo> bases;
std::unordered_set<uint64_t> extended;
uint64_t scanned = 0, invalid = 0;
for (auto run : mft_runs_) {
if (run.lcn < 0) continue;
auto logical = run.vcn * cluster_size_;
auto length = std::min(run.clusters * cluster_size_, mft_length_ > logical ? mft_length_ - logical : 0);
for (uint64_t offset = 0; offset < length;) {
if (cancelled()) throw Error("Indexing cancelled");
auto bytes = static_cast<size_t>(std::min<uint64_t>(8 * 1024 * 1024, length - offset));
auto data = read_at(uint64_t(run.lcn) * cluster_size_ + offset, bytes);
for (size_t at = 0; at + record_size_ <= data.size(); at += record_size_) {
auto number = (logical + offset + at) / record_size_;
auto record = std::span(data).subspan(at, record_size_);
if (!fixup_record(record, sector_size_)) { if (!memcmp(record.data(), "FILE", 4)) ++invalid; continue; }
auto info = parse_record(record, number); if (!info.in_use) continue;
auto owner = (info.base_id ? info.base_id : info.id) & frn_mask; if (owner < 16 && owner != 5) continue;
if (info.base_id) extended.insert(info.base_id); else if (info.has_attribute_list) extended.insert(info.id);
if (info.names.empty()) continue;
if (info.base_id) {
auto found = bases.find(info.base_id);
if (found == bases.end()) {
auto base = get_record(info.base_id); if (base.empty()) continue;
found = bases.emplace(info.base_id, parse_record(base, info.base_id & frn_mask)).first;
}
for (auto& name : info.names) volume->add_initial(entry(found->second, name));
} else for (auto& name : info.names) volume->add_initial(entry(info, name));
++scanned;
}
offset += bytes;
}
}
if (invalid) throw Error("MFT scan encountered " + std::to_string(invalid) + " invalid FILE records; retry required");
volume->finish();
// Also resolve DATA attributes moved to extension records. Reconcile the
// current complete record sets through NTFS before publishing the raw scan.
for (auto id : extended) {
if (cancelled()) throw Error("Indexing cancelled");
try { volume->replace_links(id, read_links(id)); }
catch (const RetryRecord&) { pending_updates_[id] = GetTickCount64() + 500; pending_from_ = std::min(pending_from_, volume->next_usn); }
}
std::erase_if(volume->entries, [](const Entry& value) { return value.attributes & deleted_attribute; });
volume->finish();
while (poll(*volume)) if (cancelled()) throw Error("Indexing cancelled");
log(root_ + " scan complete: " + std::to_string(scanned) + " records, " + std::to_string(volume->files) + " file links");
return volume;
}
bool NtfsReader::poll(Volume& volume) {
auto current = journal();
if (current.UsnJournalID != volume.journal_id || volume.next_usn < static_cast<uint64_t>(current.FirstUsn) || volume.next_usn > static_cast<uint64_t>(current.NextUsn)) throw Error("USN journal gap; index rebuild required");
if (volume.next_usn == static_cast<uint64_t>(current.NextUsn) && pending_updates_.empty()) return false;
auto batch_start = volume.next_usn;
READ_USN_JOURNAL_DATA_V0 request{}; request.StartUsn = static_cast<USN>(volume.next_usn); request.ReasonMask = UINT32_MAX; request.UsnJournalID = volume.journal_id;
std::vector<uint8_t> bytes(1024 * 1024); DWORD returned = 0;
if (!DeviceIoControl(handle_.value, FSCTL_READ_USN_JOURNAL, &request, sizeof(request), bytes.data(), static_cast<DWORD>(bytes.size()), &returned, nullptr)) throw Error("Read USN journal: " + win_error());
if (returned < sizeof(USN)) throw Error("Truncated USN journal reply");
std::unordered_set<uint64_t> changed;
for (size_t at = sizeof(USN); at + 8 <= returned;) {
auto length = get<uint32_t>(bytes, at); auto version = get<uint16_t>(bytes, at + 4);
if (length < 8 || length > returned - at) throw Error("Invalid USN record length");
if (version != 2 || length < offsetof(USN_RECORD_V2, FileName)) throw Error("Unsupported USN record version");
changed.insert(get<uint64_t>(bytes, at + 8)); at += length;
}
std::vector<std::pair<uint64_t, std::vector<RawEntry>>> updates; updates.reserve(changed.size());
auto now = GetTickCount64();
for (auto [id, retry] : pending_updates_) if (retry <= now) changed.insert(id);
for (auto id : changed) {
try {
auto links = read_links(id); auto base = links.empty() ? id : links.front().id;
updates.emplace_back(base, std::move(links)); pending_updates_.erase(id);
}
catch (const RetryRecord& error) {
if (!pending_updates_.contains(id) && pending_updates_.size() < 16) log(root_ + " retrying record " + std::to_string(id) + ": " + error.what());
pending_updates_[id] = now + 500; pending_from_ = std::min(pending_from_, batch_start);
}
}
{
std::unique_lock lock(volume.mutex);
for (auto& [id, links] : updates) volume.replace_links(id, links);
volume.next_usn = get<uint64_t>(bytes, 0);
if (pending_updates_.empty()) pending_from_ = UINT64_MAX;
volume.checkpoint_usn = std::min(volume.next_usn, pending_from_);
volume.pending_updates = pending_updates_.size();
}
return volume.next_usn < static_cast<uint64_t>(current.NextUsn);
}
}
+101
View File
@@ -0,0 +1,101 @@
#include "ntfs.hpp"
#include <cstring>
namespace wl {
namespace {
template<class T> T load(std::span<const uint8_t> data, size_t at) {
if (at > data.size() || sizeof(T) > data.size() - at) throw Error("NTFS record field is outside its bounds");
T value; memcpy(&value, data.data() + at, sizeof(value)); return value;
}
void attribute_list(std::span<const uint8_t> value, std::vector<uint64_t>& refs) {
for (size_t at = 0; at + 26 <= value.size();) {
auto type = load<uint32_t>(value, at); if (type == UINT32_MAX || !type) break;
auto length = load<uint16_t>(value, at + 4); if (length < 26 || length > value.size() - at) throw Error("Invalid NTFS attribute list");
if (type == 0x30 || type == 0x80) refs.push_back(load<uint64_t>(value, at + 16));
at += length;
}
}
}
bool fixup_record(std::span<uint8_t> record, uint32_t sector_size) {
if (record.size() < 48 || memcmp(record.data(), "FILE", 4) || sector_size < 2) return false;
auto offset = load<uint16_t>(record, 4), count = load<uint16_t>(record, 6);
if (count < 2 || size_t(offset) + size_t(count) * 2 > record.size() || (uint64_t(count) - 1) * sector_size > record.size()) return false;
auto sequence = load<uint16_t>(record, offset);
for (uint32_t i = 1; i < count; ++i) {
auto end = size_t(i) * sector_size - 2;
auto original = load<uint16_t>(record, size_t(offset) + i * 2);
auto current = load<uint16_t>(record, end);
// FSCTL_GET_NTFS_FILE_RECORD may already have applied the update-sequence fixups.
if (current != sequence && current != original) return false;
memcpy(record.data() + end, &original, 2);
}
return true;
}
std::vector<DataRun> parse_runs(std::span<const uint8_t> data, uint64_t first_vcn) {
std::vector<DataRun> runs; size_t at = 0; int64_t lcn = 0;
while (at < data.size() && data[at]) {
auto header = data[at++]; unsigned length_bytes = header & 15, offset_bytes = header >> 4;
if (!length_bytes || length_bytes > 8 || offset_bytes > 8 || at + length_bytes + offset_bytes > data.size()) throw Error("Invalid NTFS data run");
uint64_t clusters = 0, raw = 0;
for (unsigned i = 0; i < length_bytes; ++i) clusters |= uint64_t(data[at++]) << (8 * i);
for (unsigned i = 0; i < offset_bytes; ++i) raw |= uint64_t(data[at++]) << (8 * i);
if (!clusters || clusters > INT64_MAX || first_vcn > UINT64_MAX - clusters) throw Error("Invalid NTFS extent length");
if (offset_bytes && offset_bytes < 8 && (raw & (1ull << (offset_bytes * 8 - 1)))) raw |= UINT64_MAX << (offset_bytes * 8);
if (offset_bytes) {
auto delta = static_cast<int64_t>(raw);
if ((delta > 0 && lcn > INT64_MAX - delta) || (delta < 0 && lcn < INT64_MIN - delta)) throw Error("NTFS extent overflow");
lcn += delta; if (lcn < 0) throw Error("Invalid NTFS extent offset");
}
runs.push_back({first_vcn, clusters, offset_bytes ? lcn : -1}); first_vcn += clusters;
}
return runs;
}
RecordInfo parse_record(std::span<const uint8_t> record, uint64_t record_number) {
RecordInfo info;
if (record.size() < 48 || memcmp(record.data(), "FILE", 4)) return info;
info.id = (uint64_t(load<uint16_t>(record, 0x10)) << 48) | (record_number & 0xFFFFFFFFFFFFull);
info.base_id = load<uint64_t>(record, 0x20);
info.link_count = load<uint16_t>(record, 0x12);
auto flags = load<uint16_t>(record, 0x16); info.in_use = (flags & 1) != 0;
if (!info.in_use) return info;
if (flags & 2) info.attributes |= FILE_ATTRIBUTE_DIRECTORY;
auto used = load<uint32_t>(record, 0x18);
if (used < 48 || used > record.size()) throw Error("Invalid NTFS record size");
record = record.first(used);
for (size_t at = load<uint16_t>(record, 0x14); at + 8 <= record.size();) {
auto type = load<uint32_t>(record, at); if (type == UINT32_MAX) break;
auto length = load<uint32_t>(record, at + 4);
if (length < 24 || length > record.size() - at) throw Error("Invalid NTFS attribute length");
auto attr = record.subspan(at, length);
bool resident = !attr[8]; auto name_length = attr[9];
std::span<const uint8_t> value;
if (resident) {
auto offset = load<uint16_t>(attr, 20); auto size = load<uint32_t>(attr, 16);
if (offset > attr.size() || size > attr.size() - offset) throw Error("Invalid NTFS resident value");
value = attr.subspan(offset, size);
}
if (type == 0x10 && resident && value.size() >= 36) {
info.created = load<uint64_t>(value, 0); info.modified = load<uint64_t>(value, 8); info.accessed = load<uint64_t>(value, 24);
info.attributes |= load<uint32_t>(value, 32);
} else if (type == 0x30 && resident && value.size() >= 66) {
auto chars = value[64], name_space = value[65];
if (size_t(chars) * 2 > value.size() - 66) throw Error("Invalid NTFS filename length");
++info.filename_attribute_count;
std::wstring name(chars, L'\0'); memcpy(name.data(), value.data() + 66, size_t(chars) * 2);
auto item = std::pair{load<uint64_t>(value, 0), utf8(name)};
if (name_space != 2) info.names.push_back(std::move(item));
if (load<uint32_t>(value, 56) & FILE_ATTRIBUTE_DIRECTORY) info.attributes |= FILE_ATTRIBUTE_DIRECTORY;
} else if (type == 0x80 && !name_length) {
if (resident) info.size = value.size();
else if (attr.size() >= 64) {
auto vcn = load<uint64_t>(attr, 16); auto offset = load<uint16_t>(attr, 32);
if (offset >= attr.size()) throw Error("Invalid NTFS run-list offset");
auto runs = parse_runs(attr.subspan(offset), vcn); info.data_runs.insert(info.data_runs.end(), runs.begin(), runs.end());
if (!vcn) info.size = load<uint64_t>(attr, 48);
}
} else if (type == 0x20) { info.has_attribute_list = true; if (resident) attribute_list(value, info.extension_ids); }
at += length;
}
return info;
}
}
+383
View File
@@ -0,0 +1,383 @@
#include "query.hpp"
#include <charconv>
#include <cctype>
#include <sstream>
namespace wl {
namespace {
uint32_t codepoint(std::string_view text, size_t& at) {
if (at >= text.size()) return 0;
auto c = static_cast<unsigned char>(text[at++]);
if (c < 128) return c;
unsigned n = c < 0xE0 ? 1 : c < 0xF0 ? 2 : 3;
uint32_t value = c & ((1u << (6 - n)) - 1);
while (n-- && at < text.size()) value = (value << 6) | (static_cast<unsigned char>(text[at++]) & 63);
return value;
}
bool character_match(std::string_view pattern, size_t& p, uint32_t c) {
if (pattern[p] == '?') { ++p; return true; }
if (pattern[p] != '[') return codepoint(pattern, p) == c;
size_t begin = p++, end = pattern.find(']', p + (p < pattern.size() && pattern[p] == ']' ? 1 : 0));
if (end == pattern.npos) { p = begin + 1; return c == '['; }
bool negate = p < end && (pattern[p] == '!' || pattern[p] == '^');
if (negate) ++p;
bool matched = false;
while (p < end) {
auto first = codepoint(pattern, p), last = first;
if (p + 1 < end && pattern[p] == '-') { ++p; last = codepoint(pattern, p); }
matched |= c >= first && c <= last;
}
p = end + 1;
return matched != negate;
}
bool has_glob(std::string_view value) { return value.find_first_of("*?[") != value.npos; }
bool word(unsigned char c) { return c >= 128 || std::isalnum(c) || c == '_'; }
bool text_match(const Term& term, std::string_view original, std::string_view lower) {
if (term.regex) return term.regex->matches(original);
auto target = term.match_case ? original : lower;
std::string_view needle = term.match_case ? term.value : term.folded;
if (term.glob) return glob_match(needle, target);
auto pos = target.find(needle);
if (!term.whole_word) return pos != target.npos;
while (pos != target.npos) {
if ((pos == 0 || !word(static_cast<unsigned char>(target[pos - 1]))) &&
(pos + needle.size() == target.size() || !word(static_cast<unsigned char>(target[pos + needle.size()])))) return true;
pos = target.find(needle, pos + 1);
}
return false;
}
uint64_t size_value(std::string text) {
size_t end = 0;
double number;
try { number = std::stod(text, &end); } catch (...) { throw Error("Invalid size: " + text); }
auto unit = fold(text.substr(end));
uint64_t multiplier = 1;
if (unit == "k" || unit == "kb" || unit == "kib") multiplier = 1024;
else if (unit == "m" || unit == "mb" || unit == "mib") multiplier = 1024ull * 1024;
else if (unit == "g" || unit == "gb" || unit == "gib") multiplier = 1024ull * 1024 * 1024;
else if (unit == "t" || unit == "tb" || unit == "tib") multiplier = 1024ull * 1024 * 1024 * 1024;
else if (!unit.empty() && unit != "b") throw Error("Unknown size unit: " + unit);
if (!std::isfinite(number) || number < 0 || number * multiplier >= double(UINT64_MAX)) throw Error("Size out of range");
return static_cast<uint64_t>(number * multiplier);
}
uint64_t date_value(std::string text, bool end_of_day = false) {
SYSTEMTIME local{}; GetLocalTime(&local); local.wHour = local.wMinute = local.wSecond = local.wMilliseconds = 0;
auto lower = fold(text);
if (lower != "today" && lower != "yesterday") {
int year = 0, month = 0, day = 0;
bool format = text.size() == 10 && text[4] == '-' && text[7] == '-';
for (size_t i = 0; i < text.size(); ++i) if (i != 4 && i != 7 && (text[i] < '0' || text[i] > '9')) format = false;
if (!format || sscanf_s(text.c_str(), "%d-%d-%d", &year, &month, &day) != 3 || year < 1601 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31)
throw Error("Date must be YYYY-MM-DD, today, or yesterday");
local.wYear = static_cast<WORD>(year); local.wMonth = static_cast<WORD>(month); local.wDay = static_cast<WORD>(day);
}
// Advance the calendar day before timezone conversion so 23/25-hour DST
// days have the correct boundaries too.
FILETIME calendar{}; SYSTEMTIME verified{};
if (!SystemTimeToFileTime(&local, &calendar) || !FileTimeToSystemTime(&calendar, &verified) ||
verified.wYear != local.wYear || verified.wMonth != local.wMonth || verified.wDay != local.wDay) throw Error("Invalid date: " + text);
auto calendar_value = filetime_value(calendar);
if (lower == "yesterday") calendar_value -= 864000000000ull;
if (end_of_day) calendar_value += 864000000000ull;
calendar = FILETIME{DWORD(calendar_value), DWORD(calendar_value >> 32)};
if (!FileTimeToSystemTime(&calendar, &local)) throw Error("Date is out of range");
SYSTEMTIME utc{}; FILETIME time{};
if (!TzSpecificLocalTimeToSystemTime(nullptr, &local, &utc) || !SystemTimeToFileTime(&utc, &time)) throw Error("Invalid date: " + text);
auto value = filetime_value(time);
return end_of_day ? value - 1 : value;
}
void range(Term& term, std::string value, bool date) {
auto number = [&](std::string part) { return date ? date_value(std::move(part)) : size_value(std::move(part)); };
auto upper = [&](std::string part) { return date ? date_value(std::move(part), true) : size_value(std::move(part)); };
auto split = value.find("..");
if (split != value.npos) {
term.minimum = number(value.substr(0, split)); term.maximum = upper(value.substr(split + 2));
} else if (value.starts_with(">=")) term.minimum = number(value.substr(2));
else if (value.starts_with("<=")) term.maximum = upper(value.substr(2));
else if (value.starts_with('>')) { auto n = upper(value.substr(1)); if (n == UINT64_MAX) throw Error("Range overflow"); term.minimum = n + 1; }
else if (value.starts_with('<')) { auto n = number(value.substr(1)); if (!n) throw Error("Range is empty"); term.maximum = n - 1; }
else { term.minimum = number(value); term.maximum = upper(value); }
if (term.minimum > term.maximum) throw Error("Range is empty");
}
enum class TokenKind { Word, Open, Close, Or, Not };
struct Token { TokenKind kind; std::string text; };
std::vector<Token> tokenize(std::string_view value) {
std::vector<Token> result;
std::string token; bool quoted = false, started = false; unsigned depth = 0;
auto flush = [&] { if (started) { result.push_back({TokenKind::Word, std::move(token)}); token.clear(); started = false; } };
for (size_t i = 0; i < value.size(); ++i) {
char c = value[i];
if (c == '"') { quoted = !quoted; started = true; continue; }
bool comparator = false;
if (!token.empty() && token.back() == ':') {
auto key = fold(token); key.pop_back(); auto colon = key.find_last_of(':'); if (colon != key.npos) key.erase(0, colon + 1);
comparator = key == "size" || key == "dm" || key == "dc" || key == "da" || key.starts_with("date");
}
if (!quoted && std::isspace(static_cast<unsigned char>(c))) flush();
else if (!quoted && c == '|') { flush(); result.push_back({TokenKind::Or, {}}); }
else if (!quoted && c == '!' && !started) result.push_back({TokenKind::Not, {}});
else if (!quoted && c == '<' && !comparator) { flush(); result.push_back({TokenKind::Open, {}}); ++depth; }
else if (!quoted && c == '>' && !comparator && (depth || !started)) { flush(); result.push_back({TokenKind::Close, {}}); if (depth) --depth; }
else { token += c; started = true; }
}
if (quoted) throw Error("Unclosed quote");
flush();
return result;
}
Term make_term(std::string value, uint32_t flags, bool syntax) {
Term term; term.match_case = (flags & MatchCase) != 0; term.whole_word = (flags & WholeWord) != 0;
bool use_regex = (flags & Regex) != 0;
bool explicit_field = false;
if (syntax) {
for (;;) {
auto colon = value.find(':');
if (colon == value.npos || colon == 1) break;
auto key = fold(value.substr(0, colon));
if (key == "case") term.match_case = true;
else if (key == "nocase") term.match_case = false;
else if (key == "regex") use_regex = true;
else if (key == "wholeword" || key == "ww") term.whole_word = true;
else if (key == "path") { term.field = Field::Path; explicit_field = true; }
else if (key == "parent") { term.field = Field::Parent; explicit_field = true; }
else if (key == "file" || key == "files") { term.field = Field::File; explicit_field = true; }
else if (key == "folder" || key == "folders") { term.field = Field::Folder; explicit_field = true; }
else if (key == "ext") { term.field = Field::Extension; explicit_field = true; }
else if (key == "size") { term.field = Field::Size; explicit_field = true; }
else if (key == "dm" || key == "datemodified" || key == "date-modified") { term.field = Field::Modified; explicit_field = true; }
else if (key == "dc" || key == "datecreated" || key == "date-created") { term.field = Field::Created; explicit_field = true; }
else if (key == "da" || key == "dateaccessed") { term.field = Field::Accessed; explicit_field = true; }
else if (key == "attrib" || key == "attributes") { term.field = Field::Attributes; explicit_field = true; }
else throw Error("Unsupported search function: " + key);
value.erase(0, colon + 1);
if (explicit_field || key == "regex") break;
}
}
if (!explicit_field) {
bool path = (flags & MatchPath) != 0;
if (!(flags & Basename)) {
path |= !syntax && !has_glob(value) && !use_regex;
path |= value.find_first_of("/\\") != value.npos && !use_regex;
}
term.field = path ? Field::Path : Field::Name;
}
if (term.field == Field::Size) range(term, value, false);
if (term.field == Field::Modified || term.field == Field::Created || term.field == Field::Accessed) range(term, value, true);
if (!use_regex) value = normalize_path(std::move(value));
term.value = std::move(value); term.folded = fold(term.value);
term.glob = !use_regex && has_glob(term.value);
if (use_regex) {
term.regex = std::make_shared<Pattern>(term.value, term.match_case);
}
return term;
}
using Expression = std::vector<std::vector<Term>>;
Expression conjunction(const Expression& left, const Expression& right) {
if (left.size() * right.size() > 256) throw Error("Search grouping is too complex");
Expression result;
for (auto& a : left) for (auto& b : right) {
if (a.size() + b.size() > 128) throw Error("Too many search terms");
auto group = a; group.insert(group.end(), b.begin(), b.end()); result.push_back(std::move(group));
}
return result;
}
Expression negate(Expression input) {
Expression output{{}};
for (auto& group : input) {
Expression alternatives;
for (auto term : group) { term.negate = !term.negate; alternatives.push_back({std::move(term)}); }
output = conjunction(output, alternatives);
}
return output;
}
class ExpressionParser {
std::vector<Token> tokens_;
size_t position_ = 0;
uint32_t flags_;
unsigned depth_ = 0;
Expression atom() {
if (position_ >= tokens_.size()) throw Error("Missing search expression");
bool negative = false;
while (position_ < tokens_.size() && tokens_[position_].kind == TokenKind::Not) { negative = !negative; ++position_; }
if (position_ == tokens_.size()) throw Error("Missing expression after !");
Expression result;
if (tokens_[position_].kind == TokenKind::Open) {
if (++depth_ > 32) throw Error("Search groups are nested too deeply"); ++position_; result = disjunction();
if (position_ == tokens_.size() || tokens_[position_].kind != TokenKind::Close) throw Error("Unclosed search group"); ++position_; --depth_;
} else {
if (tokens_[position_].kind != TokenKind::Word) throw Error("Missing search term");
result = {{make_term(tokens_[position_++].text, flags_, true)}};
}
return negative ? negate(std::move(result)) : result;
}
Expression and_terms() {
auto result = atom();
while (position_ < tokens_.size() && tokens_[position_].kind != TokenKind::Or && tokens_[position_].kind != TokenKind::Close) result = conjunction(result, atom());
return result;
}
Expression disjunction() {
auto result = and_terms();
while (position_ < tokens_.size() && tokens_[position_].kind == TokenKind::Or) {
++position_; auto other = and_terms(); result.insert(result.end(), other.begin(), other.end());
if (result.size() > 256) throw Error("Too many OR branches");
}
return result;
}
public:
ExpressionParser(std::string text, uint32_t flags) : tokens_(tokenize(text)), flags_(flags) { if (tokens_.size() > 2048) throw Error("Too many search tokens"); }
Expression parse() {
if (tokens_.empty()) return {{}};
auto result = disjunction(); if (position_ != tokens_.size()) throw Error("Unexpected closing group"); return result;
}
};
}
bool glob_match(std::string_view pattern, std::string_view value) {
size_t p = 0, v = 0, star = pattern.npos, retry = 0;
while (v < value.size()) {
if (p < pattern.size() && pattern[p] == '*') { star = ++p; retry = v; continue; }
size_t next_v = v, next_p = p; auto c = codepoint(value, next_v);
if (p < pattern.size() && character_match(pattern, next_p, c)) { p = next_p; v = next_v; continue; }
if (star == pattern.npos) return false;
p = star; codepoint(value, retry); v = retry;
}
while (p < pattern.size() && pattern[p] == '*') ++p;
return p == pattern.size();
}
Truth Term::evaluate(std::string_view name, std::string_view lower_name, const Result* result) const {
bool match = false;
if (field == Field::Name) match = text_match(*this, name, lower_name);
else if (field == Field::Extension) {
auto dot = lower_name.find_last_of('.'); auto ext = dot == lower_name.npos ? std::string_view{} : lower_name.substr(dot + 1);
size_t at = 0;
do { auto end = folded.find(';', at); if (end == folded.npos) end = folded.size(); auto part = std::string_view(folded).substr(at, end - at); if (part.starts_with('.')) part.remove_prefix(1); match |= glob_match(part, ext); at = end + 1; } while (at <= folded.size());
} else if (!result) return Truth::Unknown;
else if (field == Field::File || field == Field::Folder) {
match = ((result->attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) == (field == Field::Folder);
if (match && !value.empty()) match = text_match(*this, name, lower_name);
} else if (field == Field::Size || field == Field::Modified || field == Field::Created || field == Field::Accessed) {
uint64_t actual = field == Field::Size ? result->size : field == Field::Modified ? result->modified : field == Field::Created ? result->created : result->accessed;
match = actual >= minimum && actual <= maximum;
if (field == Field::Size && (result->attributes & FILE_ATTRIBUTE_DIRECTORY)) match = false;
} else if (field == Field::Attributes) {
match = true;
for (char c : folded) {
uint32_t bit = c == 'h' ? FILE_ATTRIBUTE_HIDDEN : c == 's' ? FILE_ATTRIBUTE_SYSTEM : c == 'r' ? FILE_ATTRIBUTE_READONLY : c == 'a' ? FILE_ATTRIBUTE_ARCHIVE : c == 'd' ? FILE_ATTRIBUTE_DIRECTORY : c == 'l' ? FILE_ATTRIBUTE_REPARSE_POINT : 0;
match &= bit && (result->attributes & bit);
}
} else {
auto path = field == Field::Parent ? directory(result->path) : result->path;
if (regex) std::replace(path.begin(), path.end(), '\\', '/');
match = text_match(*this, path, match_case ? path : fold(path));
}
return match != negate ? Truth::Yes : Truth::No;
}
Query::Query(const Request& request) {
if (request.patterns.size() > 256) throw Error("Too many patterns");
if (request.flags & EverythingSyntax) {
groups_ = {{}};
for (size_t i = 0; i < request.patterns.size(); ++i) {
auto expression = (request.flags & Regex) && !i ? Expression{{make_term(request.patterns[i], request.flags, false)}}
: ExpressionParser(request.patterns[i], request.flags & ~Regex).parse();
groups_ = conjunction(groups_, expression);
}
} else if (request.flags & AllPatterns) {
groups_.emplace_back(); for (auto& pattern : request.patterns) groups_.back().push_back(make_term(pattern, request.flags, false));
} else for (auto& pattern : request.patterns) groups_.push_back({ make_term(pattern, request.flags, false) });
if (groups_.empty()) groups_.emplace_back();
for (auto& group : groups_) {
uint64_t required = 0;
for (auto& term : group) {
if (term.negate || term.regex || (term.field != Field::Name && term.field != Field::File && term.field != Field::Folder)) continue;
if (!term.glob) { required |= name_bloom(term.folded); continue; }
if (term.folded.find('[') != term.folded.npos) continue;
size_t start = 0;
for (size_t i = 0; i < term.folded.size(); ++i) if (term.folded[i] == '*' || term.folded[i] == '?' || term.folded[i] == '[') {
required |= name_bloom(std::string_view(term.folded).substr(start, i - start));
if (term.folded[i] == '[') { auto end = term.folded.find(']', i + 1); if (end != term.folded.npos) i = end; }
start = i + 1;
}
required |= name_bloom(std::string_view(term.folded).substr(start));
}
masks_.push_back(required);
}
for (const auto& group : groups_) {
std::string prefix;
for (const auto& term : group) {
if (term.field != Field::Path || term.negate || term.regex || !term.glob) continue;
const auto& value = term.folded;
if (value.size() < 3 || value[0] < 'a' || value[0] > 'z' || value[1] != ':' || value[2] != '\\') continue;
auto literal = value.substr(0, value.find_first_of("*?["));
auto separator = literal.find_last_of('\\');
if (separator != literal.npos && separator + 1 > prefix.size()) prefix = literal.substr(0, separator + 1);
}
// A positive bound is required in EVERY disjunct. Negative terms and
// unanchored substring/regex matches cannot safely narrow a subtree.
if (prefix.empty()) { path_prefixes_.clear(); return; }
path_prefixes_.push_back(std::move(prefix));
}
std::sort(path_prefixes_.begin(), path_prefixes_.end());
size_t kept = 0;
for (size_t i = 0; i < path_prefixes_.size(); ++i) {
if (kept && path_prefixes_[i].starts_with(path_prefixes_[kept - 1])) continue;
if (kept != i) path_prefixes_[kept] = std::move(path_prefixes_[i]);
++kept;
}
path_prefixes_.resize(kept);
}
Truth Query::evaluate(std::string_view name, std::string_view lower, const Result* result) const {
bool unknown = false;
for (const auto& group : groups_) {
Truth state = Truth::Yes;
for (const auto& term : group) { auto value = term.evaluate(name, lower, result); if (value == Truth::No) { state = Truth::No; break; } if (value == Truth::Unknown) state = value; }
if (state == Truth::Yes) return state;
unknown |= state == Truth::Unknown;
}
return unknown ? Truth::Unknown : Truth::No;
}
std::string Query::prefix() const {
std::string prefix;
if (groups_.size() != 1) return prefix;
for (auto& term : groups_[0]) if (term.field == Field::Name && term.glob && !term.negate) {
auto candidate = term.folded.substr(0, term.folded.find_first_of("*?["));
if (candidate.size() > prefix.size()) prefix = std::move(candidate);
}
return prefix;
}
bool Query::requires_path() const {
for (auto& group : groups_) for (auto& term : group) if (term.field == Field::Path || term.field == Field::Parent) return true;
return false;
}
std::vector<std::string_view> Query::path_literals() const {
std::vector<std::string_view> literals;
for (const auto& group : groups_) {
std::string_view best;
size_t best_score = 0;
for (const auto& term : group) {
if (term.field != Field::Path || term.negate || term.glob || term.regex) continue;
std::string_view text = term.folded;
if (text.find('\\') == text.npos) continue;
bool trailing = text.ends_with('\\');
auto end = text.size() - size_t(trailing);
if (!end) continue;
auto slash = text.rfind('\\', end - 1);
auto begin = slash == text.npos ? 0 : slash + 1;
if (begin == end) continue;
// Prefer a longer indexed final component. A single component
// followed by a slash needs a suffix scan rather than a prefix.
size_t score = (end - begin) * 2 + size_t(begin != 0);
if (best.empty() || score > best_score) { best = text; best_score = score; }
}
if (best.empty()) return {};
if (std::find(literals.begin(), literals.end(), best) == literals.end()) literals.push_back(best);
}
return literals;
}
bool Query::requires_metadata() const {
for (auto& group : groups_) for (auto& term : group) if (term.field >= Field::Size) return true;
return false;
}
const Term* Query::simple_path_literal() const {
if (groups_.size() != 1 || groups_[0].size() != 1) return nullptr;
const auto& term = groups_[0][0];
return term.field == Field::Path && !term.negate && !term.glob && !term.regex && !term.whole_word && term.value.find_first_of("/\\") == term.value.npos ? &term : nullptr;
}
}
+40
View File
@@ -0,0 +1,40 @@
#include "regex.hpp"
#define PCRE2_CODE_UNIT_WIDTH 8
#include <pcre2.h>
namespace wl {
namespace {
struct MatchState {
pcre2_match_data* data = pcre2_match_data_create(1, nullptr);
pcre2_match_context* context = pcre2_match_context_create(nullptr);
pcre2_jit_stack* stack = pcre2_jit_stack_create(32768, 256 * 1024, nullptr);
MatchState() {
if (!data || !context) {
pcre2_match_data_free(data); pcre2_match_context_free(context); pcre2_jit_stack_free(stack);
throw Error("Cannot allocate regex match state");
}
pcre2_set_match_limit(context, 100000); pcre2_set_depth_limit(context, 1000); pcre2_set_heap_limit(context, 8192);
if (stack) pcre2_jit_stack_assign(context, nullptr, stack);
}
~MatchState() { pcre2_match_data_free(data); pcre2_match_context_free(context); pcre2_jit_stack_free(stack); }
};
std::string message(int error) {
PCRE2_UCHAR buffer[256]{}; pcre2_get_error_message(error, buffer, sizeof(buffer)); return reinterpret_cast<char*>(buffer);
}
}
Pattern::Pattern(std::string_view expression, bool match_case) {
int error = 0; PCRE2_SIZE offset = 0;
code_ = pcre2_compile(reinterpret_cast<PCRE2_SPTR>(expression.data()), expression.size(), PCRE2_UTF | PCRE2_UCP | (match_case ? 0 : PCRE2_CASELESS), &error, &offset, nullptr);
if (!code_) throw Error("Invalid regular expression at byte " + std::to_string(offset) + ": " + message(error));
// Unsupported JIT patterns remain usable through PCRE2's bounded interpreter.
pcre2_jit_compile(static_cast<pcre2_code*>(code_), PCRE2_JIT_COMPLETE);
}
Pattern::~Pattern() { pcre2_code_free(static_cast<pcre2_code*>(code_)); }
bool Pattern::matches(std::string_view text) const {
thread_local MatchState state;
auto rc = pcre2_match(static_cast<pcre2_code*>(code_), reinterpret_cast<PCRE2_SPTR>(text.data()), text.size(), 0, 0, state.data, state.context);
if (rc == PCRE2_ERROR_NOMATCH) return false;
if (rc < 0) throw Error("Regular expression: " + message(rc));
return true;
}
}
+209
View File
@@ -0,0 +1,209 @@
#include "app.hpp"
#include "ntfs.hpp"
#include <iostream>
#include <map>
#include <shlobj.h>
#include <aclapi.h>
#include <sddl.h>
namespace wl {
namespace {
SERVICE_STATUS_HANDLE status_handle = nullptr;
SERVICE_STATUS service_status{};
HANDLE stop_handle = nullptr;
void report(DWORD state, DWORD error = NO_ERROR) {
service_status.dwServiceType = SERVICE_WIN32_OWN_PROCESS; service_status.dwCurrentState = state;
service_status.dwControlsAccepted = state == SERVICE_RUNNING ? SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN : 0;
service_status.dwWin32ExitCode = error; service_status.dwWaitHint = state == SERVICE_STOP_PENDING ? 120000 : 0;
if (status_handle) SetServiceStatus(status_handle, &service_status);
}
DWORD WINAPI control(DWORD code, DWORD, void*, void*) {
if (code == SERVICE_CONTROL_STOP || code == SERVICE_CONTROL_SHUTDOWN) { report(SERVICE_STOP_PENDING); SetEvent(stop_handle); }
return NO_ERROR;
}
BOOL WINAPI console_control(DWORD code) {
if (code == CTRL_C_EVENT || code == CTRL_BREAK_EVENT || code == CTRL_CLOSE_EVENT) { if (stop_handle) SetEvent(stop_handle); return TRUE; }
return FALSE;
}
void background(HANDLE stop) {
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOOPENFILEERRORBOX);
HANDLE token_handle = nullptr;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &token_handle)) {
Handle token(token_handle); TOKEN_PRIVILEGES privilege{}; privilege.PrivilegeCount = 1;
if (LookupPrivilegeValueW(nullptr, SE_BACKUP_NAME, &privilege.Privileges[0].Luid)) {
privilege.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(token.value, FALSE, &privilege, 0, nullptr, nullptr);
}
}
Engine engine;
std::map<wchar_t, std::shared_ptr<Volume>> current;
std::mutex publication;
std::atomic<uint64_t> rebuild_epoch = 0;
auto publish = [&] {
std::vector<std::shared_ptr<Volume>> volumes;
for (const auto& [drive, volume] : current) volumes.push_back(volume);
engine.set_volumes(std::move(volumes));
};
auto index_volume = [&](wchar_t drive, std::stop_token token) {
auto root = utf8(std::wstring(1, drive) + L":\\");
auto cancelled = [&] { return token.stop_requested() || WaitForSingleObject(stop, 0) == WAIT_OBJECT_0; };
uint64_t epoch = rebuild_epoch.load(); bool force = false;
while (!cancelled()) {
try {
NtfsReader reader(drive);
auto snapshot = data_directory() / (std::wstring(1, drive) + L".widx");
std::shared_ptr<Volume> volume;
if (!force) {
try {
volume = Volume::load(snapshot, root, reader.serial());
if (volume) { while (reader.poll(*volume)) if (cancelled()) return; log(volume->root + " restored from snapshot"); }
} catch (const std::exception& error) { log(std::string("Snapshot rebuild: ") + error.what()); volume.reset(); }
}
if (!volume) volume = reader.scan(cancelled);
{
std::lock_guard guard(publication);
if (cancelled()) return;
current[drive] = volume; publish();
}
if (cancelled()) return;
volume->save(snapshot);
auto saved = GetTickCount64();
while (!cancelled() && epoch == rebuild_epoch.load()) {
while (reader.poll(*volume)) if (cancelled() || epoch != rebuild_epoch.load()) break;
if (GetTickCount64() - saved > 120000) {
// Compact accumulated journal tombstones before the next durable snapshot.
{
std::unique_lock lock(volume->mutex);
auto live = volume->files + volume->directories;
if (volume->entries.size() > live + std::max<uint64_t>(10000, live / 20)) {
volume->compact();
}
}
volume->save(snapshot); saved = GetTickCount64();
}
WaitForSingleObject(stop, 250);
}
if (cancelled()) { volume->save(snapshot); return; }
epoch = rebuild_epoch.load(); force = true;
log(volume->root + " requested rebuild");
} catch (const std::exception& error) {
if (cancelled()) return;
auto message = std::string(error.what()); log(root + " index error: " + message);
{
std::lock_guard guard(publication); if (cancelled()) return;
auto failed = std::make_shared<Volume>(root);
failed->state = "rebuilding: " + message; current[drive] = failed; publish();
}
force = true;
for (unsigned retry = 0; retry < 20 && !cancelled(); ++retry) WaitForSingleObject(stop, 250);
}
}
};
std::jthread monitor([&](std::stop_token token) {
std::map<wchar_t, std::jthread> indexers;
while (!token.stop_requested() && WaitForSingleObject(stop, 0) != WAIT_OBJECT_0) {
const auto drives = ntfs_drives();
for (auto it = indexers.begin(); it != indexers.end();) {
if (std::find(drives.begin(), drives.end(), it->first) != drives.end()) { ++it; continue; }
it->second.request_stop();
{ std::lock_guard guard(publication); current.erase(it->first); publish(); }
log(std::string(1, char(it->first)) + ": volume removed");
it = indexers.erase(it);
}
for (auto drive : drives) if (!indexers.contains(drive)) {
{ std::lock_guard guard(publication); current[drive] = std::make_shared<Volume>(utf8(std::wstring(1, drive) + L":\\")); publish(); }
indexers.emplace(drive, std::jthread([&, drive](std::stop_token child) { index_volume(drive, child); }));
}
WaitForSingleObject(stop, 2000);
}
for (auto& [drive, thread] : indexers) thread.request_stop();
});
try { serve(engine, stop, [&] { ++rebuild_epoch; }); }
catch (...) { SetEvent(stop); throw; }
}
void WINAPI service_main(DWORD, wchar_t**) {
Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); stop_handle = event.value;
status_handle = RegisterServiceCtrlHandlerExW(service_name, control, nullptr);
if (!status_handle) return;
report(SERVICE_RUNNING);
try { background(event.value); report(SERVICE_STOPPED); }
catch (const std::exception& error) { log(error.what()); report(SERVICE_STOPPED, ERROR_SERVICE_SPECIFIC_ERROR); }
stop_handle = nullptr;
}
struct ScHandle {
SC_HANDLE value;
~ScHandle() { if (value) CloseServiceHandle(value); }
};
void protect_directory(const std::filesystem::path& directory) {
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)", SDDL_REVISION_1, &descriptor, nullptr)) throw Error(win_error());
BOOL present, defaulted; PACL acl = nullptr; GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted);
auto error = SetNamedSecurityInfoW(const_cast<LPWSTR>(directory.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, acl, nullptr);
LocalFree(descriptor); if (error != ERROR_SUCCESS) throw Error("Protect WLocate directory: " + win_error(error));
}
void wait_stopped(SC_HANDLE service) {
SERVICE_STATUS_PROCESS status{}; DWORD needed;
auto deadline = GetTickCount64() + 120000;
while (QueryServiceStatusEx(service, SC_STATUS_PROCESS_INFO, reinterpret_cast<BYTE*>(&status), sizeof(status), &needed)) {
if (status.dwCurrentState == SERVICE_STOPPED) return;
if (GetTickCount64() > deadline) throw Error("Timed out waiting for WLocate service to stop");
Sleep(100);
}
throw Error("Query service: " + win_error());
}
}
int run_service() {
SERVICE_TABLE_ENTRYW table[]{{const_cast<wchar_t*>(service_name), service_main}, {nullptr, nullptr}};
if (!StartServiceCtrlDispatcherW(table)) throw Error("Service dispatcher: " + win_error());
return 0;
}
int run_server() {
if (!is_admin()) throw Error("The indexing server requires administrator privileges");
Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); stop_handle = event.value;
SetConsoleCtrlHandler(console_control, TRUE);
background(event.value); stop_handle = nullptr; return 0;
}
void install_service() {
if (!is_admin()) throw Error("Service installation requires administrator privileges");
std::filesystem::create_directories(data_directory());
protect_directory(data_directory());
ScHandle manager{OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE)};
if (!manager.value) throw Error(win_error());
wchar_t* program_files = nullptr;
if (FAILED(SHGetKnownFolderPath(FOLDERID_ProgramFiles, 0, nullptr, &program_files))) throw Error("Cannot locate Program Files");
auto installed = std::filesystem::path(program_files) / L"WLocate"; CoTaskMemFree(program_files);
std::filesystem::create_directories(installed);
protect_directory(installed);
auto target = installed / L"wlocate.exe";
auto command = L"\"" + target.wstring() + L"\" --service";
ScHandle service{OpenServiceW(manager.value, service_name, SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_STOP | SERVICE_QUERY_STATUS)};
if (service.value) {
SERVICE_STATUS status{}; ControlService(service.value, SERVICE_CONTROL_STOP, &status); wait_stopped(service.value);
}
if (_wcsicmp(module_path().c_str(), target.c_str())) {
std::filesystem::copy_file(module_path(), target, std::filesystem::copy_options::overwrite_existing);
auto cli = std::filesystem::path(module_path()).parent_path() / L"locate.exe";
if (std::filesystem::exists(cli)) std::filesystem::copy_file(cli, installed / L"locate.exe", std::filesystem::copy_options::overwrite_existing);
}
if (service.value) {
if (!ChangeServiceConfigW(service.value, SERVICE_NO_CHANGE, SERVICE_AUTO_START, SERVICE_NO_CHANGE, command.c_str(), nullptr, nullptr, nullptr, nullptr, nullptr, L"WLocate File Index")) throw Error(win_error());
} else {
service.value = CreateServiceW(manager.value, service_name, L"WLocate File Index", SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_QUERY_STATUS,
SERVICE_WIN32_OWN_PROCESS, SERVICE_AUTO_START, SERVICE_ERROR_NORMAL, command.c_str(), nullptr, nullptr, nullptr, nullptr, nullptr);
if (!service.value) throw Error("Create service: " + win_error());
}
SERVICE_DESCRIPTIONW description{const_cast<wchar_t*>(L"Indexes local NTFS file names and tracks changes for WLocate and locate. No file contents are indexed.")};
ChangeServiceConfig2W(service.value, SERVICE_CONFIG_DESCRIPTION, &description);
SC_ACTION actions[]{{SC_ACTION_RESTART, 5000}, {SC_ACTION_RESTART, 15000}, {SC_ACTION_RESTART, 60000}};
SERVICE_FAILURE_ACTIONSW failures{86400, nullptr, nullptr, 3, actions}; ChangeServiceConfig2W(service.value, SERVICE_CONFIG_FAILURE_ACTIONS, &failures);
if (!StartServiceW(service.value, 0, nullptr) && GetLastError() != ERROR_SERVICE_ALREADY_RUNNING) throw Error("Start service: " + win_error());
log("Service installed at " + utf8(module_path()));
}
void uninstall_service() {
if (!is_admin()) throw Error("Service removal requires administrator privileges");
ScHandle manager{OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT)}; if (!manager.value) throw Error(win_error());
ScHandle service{OpenServiceW(manager.value, service_name, DELETE | SERVICE_STOP | SERVICE_QUERY_STATUS)}; if (!service.value) throw Error(win_error());
SERVICE_STATUS status{}; ControlService(service.value, SERVICE_CONTROL_STOP, &status); wait_stopped(service.value);
if (!DeleteService(service.value)) throw Error(win_error());
}
}
+134
View File
@@ -0,0 +1,134 @@
#include "shell.hpp"
#include <shellapi.h>
#include <shlwapi.h>
namespace wl {
namespace {
ComPtr<IContextMenu2> active2;
ComPtr<IContextMenu3> active3;
void check(HRESULT result, const char* action) { if (FAILED(result)) throw Error(std::string(action) + ": " + win_error(static_cast<DWORD>(result))); }
struct IdLists {
std::vector<PIDLIST_ABSOLUTE> values;
explicit IdLists(const std::vector<std::wstring>& paths) {
for (auto& path : paths) {
PIDLIST_ABSOLUTE id = nullptr; auto hr = SHParseDisplayName(path.c_str(), nullptr, &id, 0, nullptr);
if (FAILED(hr)) { for (auto item : values) CoTaskMemFree(item); throw Error("File is no longer available: " + utf8(path)); }
values.push_back(id);
}
}
~IdLists() { for (auto id : values) CoTaskMemFree(id); }
};
ComPtr<IShellItemArray> items(const IdLists& ids) {
ComPtr<IShellItemArray> array;
check(SHCreateShellItemArrayFromIDLists(static_cast<UINT>(ids.values.size()), const_cast<PCIDLIST_ABSOLUTE*>(ids.values.data()), &array), "Create shell selection");
return array;
}
ComPtr<IContextMenu> context(HWND owner, const std::vector<std::wstring>& paths) {
if (paths.empty()) throw Error("No files selected");
IdLists ids(paths); auto array = items(ids); ComPtr<IContextMenu> menu;
auto hr = array->BindToHandler(nullptr, BHID_SFUIObject, IID_PPV_ARGS(&menu));
if (FAILED(hr)) {
ComPtr<IShellFolder> desktop; check(SHGetDesktopFolder(&desktop), "Get desktop folder");
DEFCONTEXTMENU definition{}; definition.hwnd = owner; definition.psf = desktop.Get();
std::vector<PCUITEMID_CHILD> children(ids.values.begin(), ids.values.end());
definition.cidl = static_cast<UINT>(children.size()); definition.apidl = children.data();
check(SHCreateDefaultContextMenu(&definition, IID_PPV_ARGS(&menu)), "Create shell context menu");
}
return menu;
}
void invoke(HWND owner, IContextMenu* menu, const char* verb, bool shift) {
CMINVOKECOMMANDINFOEX info{}; info.cbSize = sizeof(info); info.hwnd = owner;
info.fMask = CMIC_MASK_UNICODE | CMIC_MASK_ASYNCOK | (shift ? CMIC_MASK_SHIFT_DOWN : 0);
info.lpVerb = verb; std::wstring unicode;
if (HIWORD(verb)) { unicode = wide(verb); info.lpVerbW = unicode.c_str(); } else info.lpVerbW = reinterpret_cast<LPCWSTR>(verb);
info.nShow = SW_SHOWNORMAL;
check(menu->InvokeCommand(reinterpret_cast<CMINVOKECOMMANDINFO*>(&info)), "Shell action");
}
class DropSource final : public IDropSource {
LONG references_ = 1;
public:
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** value) override {
if (iid == IID_IUnknown || iid == IID_IDropSource) { *value = static_cast<IDropSource*>(this); AddRef(); return S_OK; } *value = nullptr; return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&references_); }
ULONG STDMETHODCALLTYPE Release() override { auto n = InterlockedDecrement(&references_); if (!n) delete this; return n; }
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escape, DWORD keys) override { return escape ? DRAGDROP_S_CANCEL : !(keys & MK_LBUTTON) ? DRAGDROP_S_DROP : S_OK; }
HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD) override { return DRAGDROP_S_USEDEFAULTCURSORS; }
};
}
LRESULT shell_menu_message(UINT message, WPARAM wparam, LPARAM lparam, bool& handled) {
handled = false;
if (message != WM_INITMENUPOPUP && message != WM_DRAWITEM && message != WM_MEASUREITEM && message != WM_MENUCHAR) return 0;
LRESULT result = 0;
if (active3 && SUCCEEDED(active3->HandleMenuMsg2(message, wparam, lparam, &result))) handled = true;
else if (active2 && SUCCEEDED(active2->HandleMenuMsg(message, wparam, lparam))) handled = true;
return result;
}
UINT shell_context(HWND owner, const std::vector<std::wstring>& paths, POINT point) {
auto context_menu = context(owner, paths); HMENU popup = CreatePopupMenu();
bool shift = GetKeyState(VK_SHIFT) < 0;
auto hr = context_menu->QueryContextMenu(popup, 0, 1, 0x6FFF, CMF_NORMAL | CMF_CANRENAME | (shift ? CMF_EXTENDEDVERBS : 0));
if (FAILED(hr)) { DestroyMenu(popup); check(hr, "Build shell menu"); }
AppendMenuW(popup, MF_SEPARATOR, 0, nullptr);
AppendMenuW(popup, MF_STRING, shell_open_parent, L"打开所在文件夹(&F)\tCtrl+Enter");
AppendMenuW(popup, MF_STRING, shell_copy_path, L"复制完整路径(&P)\tCtrl+Shift+C");
context_menu.As(&active2); context_menu.As(&active3);
UINT command = TrackPopupMenuEx(popup, TPM_RETURNCMD | TPM_RIGHTBUTTON, point.x, point.y, owner, nullptr);
active3.Reset(); active2.Reset(); DestroyMenu(popup);
if (command > 0 && command < 0x7000) {
wchar_t verb[128]{};
context_menu->GetCommandString(command - 1, GCS_VERBW, nullptr, reinterpret_cast<LPSTR>(verb), 128);
if (!_wcsicmp(verb, L"rename")) return shell_rename_selected;
invoke(owner, context_menu.Get(), MAKEINTRESOURCEA(command - 1), shift);
}
return command;
}
void shell_verb(HWND owner, const std::vector<std::wstring>& paths, const char* verb, bool shift) {
auto menu = context(owner, paths); auto popup = CreatePopupMenu();
auto hr = menu->QueryContextMenu(popup, 0, 1, 0x6FFF, CMF_NORMAL | CMF_CANRENAME); DestroyMenu(popup);
check(hr, "Build shell actions"); invoke(owner, menu.Get(), verb, shift);
}
void shell_open(HWND owner, const std::wstring& path, bool parent) {
if (parent) {
IdLists ids({path}); check(SHOpenFolderAndSelectItems(ids.values.front(), 0, nullptr, 0), "Open containing folder");
} else {
SHELLEXECUTEINFOW info{sizeof(info)}; info.hwnd = owner; info.lpFile = path.c_str(); info.nShow = SW_SHOWNORMAL; info.fMask = SEE_MASK_FLAG_NO_UI;
if (!ShellExecuteExW(&info)) throw Error("Open file: " + win_error());
}
}
void clipboard_paths(HWND owner, const std::vector<std::wstring>& paths, bool cut, bool text_only) {
if (paths.empty()) return;
if (!text_only) { shell_verb(owner, paths, cut ? "cut" : "copy"); return; }
std::wstring text;
for (auto& path : paths) { if (!text.empty()) text += L"\r\n"; text += path; }
HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, (text.size() + 1) * sizeof(wchar_t)); if (!memory) throw Error("Cannot allocate clipboard data");
auto* data = GlobalLock(memory); memcpy(data, text.c_str(), (text.size() + 1) * sizeof(wchar_t)); GlobalUnlock(memory);
if (!OpenClipboard(owner)) { GlobalFree(memory); throw Error("Clipboard is busy"); }
EmptyClipboard(); if (!SetClipboardData(CF_UNICODETEXT, memory)) GlobalFree(memory); CloseClipboard();
}
void shell_drag(HWND, const std::vector<std::wstring>& paths) {
if (paths.empty()) return;
IdLists ids(paths); auto array = items(ids); ComPtr<IDataObject> data;
check(array->BindToHandler(nullptr, BHID_DataObject, IID_PPV_ARGS(&data)), "Create drag data");
ComPtr<IDropSource> source; source.Attach(new DropSource()); DWORD effect;
DoDragDrop(data.Get(), source.Get(), DROPEFFECT_COPY | DROPEFFECT_MOVE | DROPEFFECT_LINK, &effect);
}
void shell_rename(HWND owner, const std::wstring& path, const std::wstring& name) {
if (name.empty() || name.find_first_of(L"\\/:*?\"<>|") != name.npos || name == L"." || name == L"..") throw Error("Invalid file name");
ComPtr<IFileOperation> operation; check(CoCreateInstance(CLSID_FileOperation, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&operation)), "Create rename operation");
operation->SetOwnerWindow(owner); operation->SetOperationFlags(FOF_ALLOWUNDO | FOFX_ADDUNDORECORD);
ComPtr<IShellItem> item; check(SHCreateItemFromParsingName(path.c_str(), nullptr, IID_PPV_ARGS(&item)), "Open rename item");
check(operation->RenameItem(item.Get(), name.c_str(), nullptr), "Rename file"); check(operation->PerformOperations(), "Rename file");
}
std::vector<std::string> shell_menu_verbs(HWND owner, const std::vector<std::wstring>& paths) {
auto menu = context(owner, paths); auto popup = CreatePopupMenu();
auto hr = menu->QueryContextMenu(popup, 0, 1, 0x6FFF, CMF_NORMAL | CMF_CANRENAME);
if (FAILED(hr)) { DestroyMenu(popup); check(hr, "Build shell menu"); }
std::vector<std::string> verbs;
for (UINT id = 0; id < static_cast<UINT>(HRESULT_CODE(hr)); ++id) {
wchar_t verb[256]{};
if (SUCCEEDED(menu->GetCommandString(id, GCS_VERBW, nullptr, reinterpret_cast<LPSTR>(verb), 256)) && *verb) verbs.push_back(utf8(verb));
}
DestroyMenu(popup); return verbs;
}
}
+90
View File
@@ -0,0 +1,90 @@
#include "index.hpp"
#include <fstream>
#include <bcrypt.h>
#include <array>
namespace wl {
namespace {
struct Header {
char magic[8] = {'W','L','O','C','I','D','X','4'};
uint32_t version = 4, entry_size = sizeof(Entry);
uint64_t serial = 0, root_id = 0, journal_id = 0, next_usn = 0, name_count = 0, entry_count = 0;
std::array<uint8_t, 32> digest{};
};
class Hash {
BCRYPT_ALG_HANDLE algorithm_ = nullptr;
BCRYPT_HASH_HANDLE hash_ = nullptr;
public:
Hash() {
if (BCryptOpenAlgorithmProvider(&algorithm_, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) throw Error("Cannot open snapshot checksum provider");
if (BCryptCreateHash(algorithm_, &hash_, nullptr, 0, nullptr, 0, 0) < 0) { BCryptCloseAlgorithmProvider(algorithm_, 0); throw Error("Cannot create snapshot checksum"); }
}
~Hash() { BCryptDestroyHash(hash_); BCryptCloseAlgorithmProvider(algorithm_, 0); }
void add(const void* data, size_t bytes) {
auto at = static_cast<const uint8_t*>(data);
while (bytes) {
auto count = static_cast<ULONG>(std::min<size_t>(bytes, 1024 * 1024));
if (BCryptHashData(hash_, const_cast<PUCHAR>(at), count, 0) < 0) throw Error("Cannot calculate snapshot checksum");
at += count; bytes -= count;
}
}
void name(std::string_view text) { auto length = static_cast<uint32_t>(text.size()); add(&length, sizeof(length)); add(text.data(), text.size()); }
std::array<uint8_t, 32> finish() {
std::array<uint8_t, 32> digest{};
if (BCryptFinishHash(hash_, digest.data(), static_cast<ULONG>(digest.size()), 0) < 0) throw Error("Cannot finish snapshot checksum");
return digest;
}
};
template<class T> void put(std::ostream& out, const T& value) { out.write(reinterpret_cast<const char*>(&value), sizeof(value)); }
template<class T> T get(std::istream& in) { T value{}; in.read(reinterpret_cast<char*>(&value), sizeof(value)); if (!in) throw Error("Index snapshot is truncated"); return value; }
void put_string(std::ostream& out, std::string_view value) { put(out, static_cast<uint32_t>(value.size())); out.write(value.data(), value.size()); }
std::string get_string(std::istream& in) { auto size = get<uint32_t>(in); if (size > 65536) throw Error("Invalid index name length"); std::string value(size, '\0'); in.read(value.data(), size); if (!in) throw Error("Index snapshot is truncated"); return value; }
}
void Volume::save(const std::filesystem::path& path) const {
std::shared_lock lock(mutex);
if (!ready) return;
std::filesystem::create_directories(path.parent_path());
auto temporary = path; temporary += L".tmp";
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
if (!out) throw Error("Cannot write index snapshot");
Header header; header.serial = serial; header.root_id = root_id; header.journal_id = journal_id; header.next_usn = checkpoint_usn ? checkpoint_usn : next_usn;
header.name_count = names.size(); header.entry_count = entries.size();
Hash hash; hash.add(&header, sizeof(header));
put(out, header);
for (const auto& name : names) { put_string(out, name.text); hash.name(name.text); }
out.write(reinterpret_cast<const char*>(entries.data()), entries.size() * sizeof(Entry));
hash.add(entries.data(), entries.size() * sizeof(Entry)); header.digest = hash.finish();
out.seekp(0); put(out, header);
out.flush(); if (!out) throw Error("Cannot finish index snapshot"); out.close();
Handle file(CreateFileW(temporary.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
if (!file || !FlushFileBuffers(file.value)) throw Error("Cannot flush index snapshot: " + win_error());
file.reset();
if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) throw Error("Cannot replace index snapshot: " + win_error());
}
std::shared_ptr<Volume> Volume::load(const std::filesystem::path& path, std::string root, uint64_t expected_serial) {
std::ifstream in(path, std::ios::binary);
if (!in) return {};
auto header = get<Header>(in);
if (memcmp(header.magic, "WLOCIDX4", 8) || header.version != 4 || header.entry_size != sizeof(Entry) || header.serial != expected_serial) return {};
const auto digest = header.digest; header.digest.fill(0); Hash hash; hash.add(&header, sizeof(header));
if (header.name_count > 20000000 || header.entry_count > 50000000) throw Error("Index snapshot exceeds size limits");
auto bytes = std::filesystem::file_size(path);
if (header.entry_count * sizeof(Entry) > bytes) throw Error("Invalid index snapshot record count");
auto volume = std::make_shared<Volume>(std::move(root));
volume->serial = header.serial; volume->root_id = header.root_id; volume->journal_id = header.journal_id; volume->next_usn = header.next_usn;
volume->checkpoint_usn = header.next_usn;
for (uint64_t i = 0; i < header.name_count; ++i) {
auto text = get_string(in);
hash.name(text);
if (volume->intern(std::move(text)) != i) throw Error("Duplicate name in index snapshot");
}
volume->entries.resize(static_cast<size_t>(header.entry_count));
in.read(reinterpret_cast<char*>(volume->entries.data()), volume->entries.size() * sizeof(Entry));
if (!in) throw Error("Index snapshot is truncated");
hash.add(volume->entries.data(), volume->entries.size() * sizeof(Entry));
if (hash.finish() != digest || in.peek() != std::char_traits<char>::eof()) throw Error("Index snapshot checksum mismatch; rebuilding required");
for (auto& entry : volume->entries) if (entry.name >= volume->names.size()) throw Error("Invalid name reference in index snapshot");
volume->finish();
return volume;
}
}
+41
View File
@@ -0,0 +1,41 @@
"""Measure process-to-exit latency; capture output so console rendering is excluded."""
import json
import statistics
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
NATIVE = ROOT / "build/bin/Release/locate.exe"
OLD = Path(r"D:\tools\bin\locate.old.exe")
cases = {
"python_all": ["python*.exe"],
"python_10": ["-n", "10", "python*.exe"],
"no_matches": ["wlocate-no-such-file-a62f*.invalid"],
"exe_10": ["-n", "10", "*.exe"],
}
report = {"cases": {}}
for name, args in cases.items():
times = {"old": [], "native": []}
outputs = {}
for iteration in range(9):
for label, exe in (("old", OLD), ("native", NATIVE)):
if label == "old" and "10" in args:
continue # The legacy wrapper does not implement -n; comparing it would be invalid.
start = time.perf_counter_ns()
result = subprocess.run([str(exe), *args], capture_output=True, timeout=30)
elapsed = (time.perf_counter_ns() - start) / 1e6
if iteration:
times[label].append(elapsed)
outputs[label] = result.stdout.decode("utf-8", errors="replace").splitlines()
if result.returncode not in (0, 1):
raise RuntimeError((label, name, result.stderr.decode(errors="replace")))
summary = {label: {"median_ms": statistics.median(data), "min_ms": min(data), "max_ms": max(data), "runs_ms": data, "rows": len(outputs[label])} for label, data in times.items() if data}
if name == "python_all":
summary["only_old"] = sorted(set(outputs["old"]) - set(outputs["native"]))
summary["only_native"] = sorted(set(outputs["native"]) - set(outputs["old"]))
report["cases"][name] = summary
print(name, {label: (round(value["median_ms"], 2), value["rows"]) for label, value in summary.items() if isinstance(value, dict)})
(ROOT / "test-output").mkdir(exist_ok=True)
(ROOT / "test-output/benchmark.json").write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print("Python result differences:", report["cases"]["python_all"].get("only_old"), report["cases"]["python_all"].get("only_native"))
+222
View File
@@ -0,0 +1,222 @@
// Exercise the private GUI controller against real Windows common controls,
// with deterministic replies and no index service or worker thread.
#include "../src/gui.cpp"
#include <iostream>
namespace {
struct IconGate {
wl::Handle started{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
wl::Handle release{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
std::atomic<unsigned> calls = 0;
std::atomic<DWORD> thread = 0;
};
LRESULT CALLBACK host_proc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
auto window = reinterpret_cast<wl::Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
if (message == WM_NCCREATE) {
window = static_cast<wl::Window*>(reinterpret_cast<CREATESTRUCTW*>(lparam)->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
window->hwnd = hwnd;
}
if (window && (message == WM_NOTIFY || message == WM_COMMAND || message == wl::icons_completed)) return window->message(message, wparam, lparam);
return DefWindowProcW(hwnd, message, wparam, lparam);
}
std::unique_ptr<wl::Completion> reply(wl::Window& window, uint64_t count = 3, uint64_t offset = 0) {
auto result = std::make_unique<wl::Completion>();
result->token = window.token; result->offset = offset;
result->reply.total = count; result->reply.generation = 42;
for (auto i = offset; i < std::min(count, offset + wl::page_size); ++i) {
wl::Result row; row.path = "C:\\fixture\\" + std::to_string(i) + "\\Figma.exe";
result->reply.results.push_back(std::move(row));
}
return result;
}
}
int main(int argc, char** argv) {
try {
wl::require_utf8_process(); OleInitialize(nullptr);
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES | ICC_BAR_CLASSES}; InitCommonControlsEx(&controls);
auto instance = GetModuleHandleW(nullptr);
bool profile = argc > 1 && std::string_view(argv[1]) == "--profile-input";
auto gate = std::make_shared<IconGate>();
wl::AsyncIcons::Loader slow_icon = [gate](const wl::IconRequest&) {
++gate->calls; gate->thread = GetCurrentThreadId(); SetEvent(gate->started.value);
WaitForSingleObject(gate->release.value, 10000);
return wl::FileIcon{nullptr, 7};
};
wl::Window window(instance, L"figma.exe", {}, profile ? wl::load_file_icon : slow_icon);
WNDCLASSW cls{}; cls.lpfnWndProc = host_proc; cls.hInstance = instance; cls.lpszClassName = L"WLocate.CacheTest"; RegisterClassW(&cls);
auto hwnd = CreateWindowW(cls.lpszClassName, L"WLocate cache regression", WS_OVERLAPPEDWINDOW, 0, 0, 800, 500, nullptr, nullptr, instance, &window);
window.search = CreateWindowW(L"EDIT", L"figma.exe", WS_CHILD, 0, 0, 0, 0, hwnd, reinterpret_cast<HMENU>(1), instance, nullptr);
window.filter = CreateWindowW(L"COMBOBOX", L"", WS_CHILD | CBS_DROPDOWNLIST, 0, 0, 0, 0, hwnd, reinterpret_cast<HMENU>(2), instance, nullptr);
window.list = CreateWindowW(WC_LISTVIEWW, L"", WS_CHILD | WS_VISIBLE | LVS_REPORT | LVS_OWNERDATA | LVS_SHOWSELALWAYS | LVS_SHAREIMAGELISTS,
0, 0, 780, 400, hwnd, reinterpret_cast<HMENU>(3), instance, nullptr);
ListView_SetExtendedListViewStyle(window.list, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP);
SetWindowTheme(window.list, L"Explorer", nullptr);
LVCOLUMNW column{}; column.mask = LVCF_WIDTH; column.cx = 500; ListView_InsertColumn(window.list, 0, &column);
window.status = CreateWindowW(STATUSCLASSNAMEW, L"", WS_CHILD, 0, 0, 0, 0, hwnd, nullptr, instance, nullptr);
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
if (profile) {
window.icons.start(hwnd, wl::icons_completed);
auto timed = [](auto action) {
auto start = std::chrono::steady_clock::now(); action();
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
};
for (auto query : {L"", L"p", L"py", L"pyt", L"pyth", L"pytho", L"python", L"python*"}) {
SetWindowTextW(window.search, query);
auto clear_ms = timed([&] { window.start_search(); });
window.tasks.clear();
auto result = std::make_unique<wl::Completion>(); result->token = window.token;
auto search_ms = timed([&] { result->reply = wl::request_service(window.request()); });
auto count = result->reply.total;
auto publish_ms = timed([&] { window.receive(std::move(result)); UpdateWindow(window.list); });
std::cout << "query=" << wl::utf8(query) << " count=" << count << " clear_ms=" << clear_ms << " search_ms=" << search_ms << " publish_ms=" << publish_ms << std::endl;
for (int i = 0; i < std::min<uint64_t>(count, 30); ++i) {
NMLVDISPINFOW info{}; info.item.iItem = i; info.item.mask = LVIF_IMAGE;
auto ms = timed([&] { window.display(&info); });
if (ms > 10) std::cout << " icon_ms=" << ms << " path=" << window.row(i)->path << std::endl;
}
}
DestroyWindow(hwnd); window.hwnd = nullptr; OleUninitialize(); return 0;
}
window.receive(reply(window)); UpdateWindow(window.list);
// Dismissing the Shell menu onto blank list space clears selection but
// leaves the last row focused. The next automatic refresh used to loop.
SetFocus(window.list);
ListView_SetItemState(window.list, 2, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
ListView_SetItemState(window.list, -1, 0, LVIS_SELECTED);
window.tasks.clear(); window.pending.clear();
unsigned failures = 0, checks = 0;
auto check = [&](bool ok, const char* what) {
++checks; std::cout << (ok ? "PASS " : "FAIL ") << what << '\n'; if (!ok) ++failures;
};
window.queue(0, true); window.tasks.clear();
window.receive(reply(window)); UpdateWindow(window.list);
check(window.tasks.empty() && window.pending.empty(), "focused last row refresh queues no duplicate query");
check(window.row(2) && window.row(2)->path == "C:\\fixture\\2\\Figma.exe", "last row remains available after refresh");
check(ListView_GetNextItem(window.list, -1, LVNI_FOCUSED) == 2, "refresh preserves focused row");
std::cout << "Unexpected queued queries: " << window.tasks.size() << '\n';
window.tasks.clear(); window.pending.clear();
for (unsigned i = 0; i < 100; ++i) {
window.receive(reply(window)); UpdateWindow(window.list);
}
check(window.tasks.empty() && window.pending.empty(), "100 refresh/repaint cycles do not trigger another query");
auto hint = [&](int from, int to) {
NMLVCACHEHINT notification{}; notification.hdr = {window.list, 3, LVN_ODCACHEHINT};
notification.iFrom = from; notification.iTo = to;
SendMessageW(hwnd, WM_NOTIFY, 3, reinterpret_cast<LPARAM>(&notification));
};
hint(0, 2); hint(-1, -1); hint(256, 512); hint(2, 1);
check(window.tasks.empty(), "cached, negative, reversed and out-of-range cache hints queue no query");
window.receive(reply(window, 0));
hint(0, 0); hint(-1, -1);
check(window.tasks.empty() && !window.row(0) && ListView_GetItemCount(window.list) == 0, "empty results ignore cache hints and clear rows");
window.receive(reply(window, 300)); window.tasks.clear(); window.pending.clear();
hint(256, 1000); hint(256, 299);
check(window.tasks.size() == 1 && window.tasks.front().offset == 256, "last-page hints are bounded by total and deduplicated");
window.tasks.clear(); window.receive(reply(window, 300, 256));
check(window.pending.empty() && window.row(299) && !window.row(300), "partial last page loads with correct bounds");
auto stale = reply(window, 3); stale->token = window.token - 1;
window.receive(std::move(stale));
check(window.total == 300 && window.row(299), "stale query completion cannot replace current results");
// Hold the Shell provider behind a gate. Painting and real EDIT changes
// must keep working even while it cannot return an icon.
window.icons.start(hwnd, wl::icons_completed);
window.pages[0][0].path = "C:\\fixture\\python.py";
auto icon_row = window.pages[0][0];
NMLVDISPINFOW info{}; info.item.mask = LVIF_IMAGE; info.item.iItem = 0;
auto began = GetTickCount64(); window.display(&info);
check(GetTickCount64() - began < 100 && info.item.iImage == I_IMAGENONE, "painting returns a placeholder without waiting for Shell");
check(WaitForSingleObject(gate->started.value, 1000) == WAIT_OBJECT_0 && gate->thread != GetCurrentThreadId(), "Shell provider runs on a separate thread");
for (unsigned i = 0; i < 100; ++i) window.display(&info);
check(gate->calls == 1, "repaints do not enqueue duplicate icon loads");
window.queue(0, true); auto old_reply = reply(window, 3); ResetEvent(window.cancel.value);
began = GetTickCount64();
for (auto query : {L"p", L"py", L"pyt", L"pyth", L"pytho", L"python", L"python*"}) SetWindowTextW(window.search, query);
check(GetTickCount64() - began < 100 && wl::window_text(window.search) == L"python*", "typing all prefixes remains responsive while the icon provider is blocked");
check(window.search_scheduled && window.tasks.empty() && window.pending.empty() && WaitForSingleObject(window.cancel.value, 0) == WAIT_OBJECT_0, "text changes immediately cancel and retire the previous query");
window.receive(std::move(old_reply)); window.queue(256);
check(window.total == 300 && window.tasks.empty(), "old replies and page requests cannot refresh results during debounce");
window.start_search();
check(window.tasks.size() == 1 && window.tasks.front().request.patterns[0] == "python*", "debounce starts only the latest query");
SetEvent(gate->release.value);
auto deadline = GetTickCount64() + 1000;
while (GetTickCount64() < deadline && !window.icons.has_file_icon(icon_row)) { window.icons.collect(); Sleep(1); }
check(window.icons.lookup(icon_row) == 7 && window.icons.has_file_icon(icon_row), "asynchronous file icon completion is cached on the UI thread");
ResetEvent(gate->release.value); ResetEvent(gate->started.value);
window.icons.lookup(".pyc");
check(WaitForSingleObject(gate->started.value, 1000) == WAIT_OBJECT_0, "second icon provider call entered");
began = GetTickCount64(); window.icons.stop();
check(GetTickCount64() - began < 100, "closing does not wait for a blocked Shell provider");
SetEvent(gate->release.value);
window.tasks.clear(); window.pending.clear(); window.busy = false;
window.receive(reply(window, 3000000)); window.attach_accessibility();
wl::ComPtr<IRawElementProviderFragment> root;
window.accessibility.provider()->QueryInterface(IID_PPV_ARGS(&root));
wl::ComPtr<IRawElementProviderFragment> current;
root->Navigate(NavigateDirection_FirstChild, &current);
unsigned accessible_rows = 0; began = GetTickCount64();
while (current && accessible_rows < 100) {
++accessible_rows; wl::ComPtr<IRawElementProviderFragment> next;
current->Navigate(NavigateDirection_NextSibling, &next); current = std::move(next);
}
check(accessible_rows > 0 && accessible_rows <= static_cast<unsigned>(ListView_GetCountPerPage(window.list) + 1), "three million results expose only visible rows to accessibility traversal");
check(GetTickCount64() - began < 100, "large-result accessibility traversal stays bounded");
// Test through the real UI Automation client as well as the provider
// interface; the GUI thread must keep pumping COM/window messages.
struct AutomationResult { std::atomic<bool> done = false; std::atomic<int> stage = 0; HRESULT hr = E_FAIL; int count = 0; uint64_t ms = 0; };
auto automation_result = std::make_shared<AutomationResult>();
std::thread([target = window.list, automation_result] {
auto com = CoInitializeEx(nullptr, COINIT_MULTITHREADED); auto start = GetTickCount64();
automation_result->stage = 1;
wl::ComPtr<IUIAutomation> automation; wl::ComPtr<IUIAutomationElement> element;
wl::ComPtr<IUIAutomationCondition> condition; wl::ComPtr<IUIAutomationElementArray> rows;
auto hr = CoCreateInstance(CLSID_CUIAutomation, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&automation));
automation_result->stage = 2;
if (SUCCEEDED(hr)) hr = automation->ElementFromHandle(target, &element);
automation_result->stage = 3;
VARIANT type{}; type.vt = VT_I4; type.lVal = UIA_ListItemControlTypeId;
if (SUCCEEDED(hr)) hr = automation->CreatePropertyCondition(UIA_ControlTypePropertyId, type, &condition);
automation_result->stage = 4;
if (SUCCEEDED(hr)) hr = element->FindAll(TreeScope_Descendants, condition.Get(), &rows);
automation_result->stage = 5;
if (SUCCEEDED(hr)) hr = rows->get_Length(&automation_result->count);
automation_result->hr = hr; automation_result->ms = GetTickCount64() - start;
rows.Reset(); condition.Reset(); element.Reset(); automation.Reset();
if (SUCCEEDED(com)) CoUninitialize(); automation_result->done = true;
}).detach();
auto automation_deadline = GetTickCount64() + 10000;
while (!automation_result->done && GetTickCount64() < automation_deadline) {
MSG message;
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&message); DispatchMessageW(&message); }
MsgWaitForMultipleObjectsEx(0, nullptr, 10, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
}
check(automation_result->done && SUCCEEDED(automation_result->hr) && automation_result->count > 0 && automation_result->count <= 50,
"real UI Automation FindAll visits only visible results out of three million");
check(automation_result->done && automation_result->ms < 1000, "real UI Automation traversal completes in under a second");
if (automation_result->done) std::cout << "UI Automation visible rows: " << automation_result->count << ", elapsed: " << automation_result->ms << " ms, HRESULT: " << std::hex << automation_result->hr << std::dec << '\n';
else std::cout << "UI Automation timed out at stage " << automation_result->stage << '\n';
wl::ComPtr<IItemContainerProvider> container;
window.accessibility.provider()->QueryInterface(IID_PPV_ARGS(&container));
VARIANT id{}; id.vt = VT_BSTR; id.bstrVal = SysAllocString(L"row-2999999");
wl::ComPtr<IRawElementProviderSimple> virtual_row;
auto hr = container->FindItemByProperty(nullptr, UIA_AutomationIdPropertyId, id, &virtual_row); VariantClear(&id);
check(SUCCEEDED(hr) && virtual_row, "offscreen results remain discoverable by ItemContainer");
VARIANT name{};
check(virtual_row && virtual_row->GetPropertyValue(UIA_NamePropertyId, &name) == UIA_E_ELEMENTNOTAVAILABLE, "unrealized row exposes a virtual placeholder"); VariantClear(&name);
wl::ComPtr<IVirtualizedItemProvider> realize; virtual_row.As(&realize);
check(realize && SUCCEEDED(realize->Realize()) && ListView_GetTopIndex(window.list) > 2999900, "VirtualizedItem realizes distant rows without enumerating the whole index");
wl::ComPtr<ISelectionItemProvider> selection; virtual_row.As(&selection); BOOL selected = FALSE;
check(selection && SUCCEEDED(selection->Select()) && SUCCEEDED(selection->get_IsSelected(&selected)) && selected && ListView_GetSelectedCount(window.list) == 1, "virtualized result supports native selection");
wl::ComPtr<IRawElementProviderSimple> next;
VARIANT ignored{};
check(SUCCEEDED(container->FindItemByProperty(virtual_row.Get(), 0, ignored, &next)) && !next, "ItemContainer iteration ends after the last result");
window.accessibility.reset();
check(virtual_row->GetPropertyValue(UIA_NamePropertyId, &name) == UIA_E_ELEMENTNOTAVAILABLE, "old accessibility rows become unavailable when the query changes");
window.accessibility.detach();
check(root->Navigate(NavigateDirection_FirstChild, &current) == UIA_E_ELEMENTNOTAVAILABLE, "providers retained by clients are safe after window detachment");
DestroyWindow(hwnd); window.hwnd = nullptr; OleUninitialize();
std::cout << checks << " checks, " << failures << " failures\n";
return failures ? 1 : 0;
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
}
+90
View File
@@ -0,0 +1,90 @@
// Test our settings dialog and real RegisterHotKey ownership without sending
// keyboard input to the desktop or modifying the user's settings.
#include "../src/gui.cpp"
#include <iostream>
int main(int argc, char** argv) {
unsigned checks = 0;
try {
wl::require_utf8_process(); OleInitialize(nullptr);
auto output = argc > 1 ? std::filesystem::path(wl::wide(argv[1])) : std::filesystem::temp_directory_path() / (L"wlocate-hotkey-" + std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(output); auto ini = output / L"settings.ini";
auto require = [&](bool value, const char* message) { ++checks; if (!value) throw wl::Error(message); };
auto rejects = [&](auto action, const char* message) { bool failed = false; try { action(); } catch (const wl::Error&) { failed = true; } require(failed, message); };
auto instance = GetModuleHandleW(nullptr);
auto owner = CreateWindowExW(0, L"STATIC", L"WLocate hotkey tests", WS_OVERLAPPEDWINDOW, 50, 50, 700, 400, nullptr, nullptr, instance, nullptr);
auto blocker = CreateWindowExW(0, L"STATIC", L"WLocate hotkey conflict", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, instance, nullptr);
require(owner && blocker, "test windows created");
std::vector<wl::HotkeyConfig> available;
for (UINT key = VK_F24; key >= VK_F13 && available.size() < 3; --key) {
wl::HotkeyConfig config{true, MOD_CONTROL | MOD_ALT | MOD_SHIFT, key};
if (RegisterHotKey(blocker, 100, config.modifiers | MOD_NOREPEAT, key)) { UnregisterHotKey(blocker, 100); available.push_back(config); }
}
require(available.size() == 3, "three unused test key combinations available");
auto a = available[0], b = available[1], c = available[2];
WritePrivateProfileStringW(L"Keyboard", L"ShowWindow", nullptr, ini.c_str());
require(wl::load_hotkey(ini) == wl::HotkeyConfig{}, "old settings default to Ctrl+Alt+L");
require(wl::save_hotkey(ini, a) && wl::load_hotkey(ini) == a, "custom shortcut persists and reloads");
WritePrivateProfileStringW(L"Keyboard", L"ShowWindow", L"1,65535,0garbage", ini.c_str());
require(wl::load_hotkey(ini) == wl::HotkeyConfig{}, "invalid saved shortcut falls back safely");
require(!wl::valid_hotkey({true, 0, 'L'}) && wl::valid_hotkey({true, 0, VK_F9}), "bare typing keys rejected, function keys accepted");
require(wl::valid_hotkey({true, MOD_WIN, VK_SPACE}) && !wl::valid_hotkey({true, MOD_CONTROL, VK_F12}), "Win combinations supported and F12 excluded");
wl::GlobalHotkey hotkey; hotkey.start(owner, a);
require(hotkey.id() && hotkey.failure().empty(), "initial shortcut registered");
auto occupied = [&](wl::HotkeyConfig value) {
bool registered = RegisterHotKey(blocker, 101, value.modifiers | MOD_NOREPEAT, value.key) != FALSE;
if (registered) UnregisterHotKey(blocker, 101);
return !registered;
};
require(occupied(a), "Windows confirms ownership of initial shortcut");
auto save = [&](const wl::HotkeyConfig& value) { return wl::save_hotkey(ini, value); };
hotkey.change(b, save);
require(!occupied(a) && occupied(b) && wl::load_hotkey(ini) == b, "change releases old shortcut and immediately reserves saved new shortcut");
auto registered_id = hotkey.id(); hotkey.change(b, save);
require(hotkey.id() == registered_id && occupied(b), "unchanged settings do not duplicate registration");
require(RegisterHotKey(blocker, 102, c.modifiers | MOD_NOREPEAT, c.key), "competing shortcut registered");
rejects([&] { hotkey.change(c, save); }, "occupied shortcut rejected");
require(hotkey.config() == b && occupied(b) && wl::load_hotkey(ini) == b, "conflict retains current shortcut and saved settings");
UnregisterHotKey(blocker, 102);
rejects([&] { hotkey.change(c, [](const auto&) { return false; }); }, "failed settings write reported");
require(occupied(b) && !occupied(c) && hotkey.config() == b, "failed save rolls back candidate registration");
auto disabled = b; disabled.enabled = false; hotkey.change(disabled, save);
require(!hotkey.id() && !occupied(b) && wl::load_hotkey(ini) == disabled, "disable releases shortcut and persists");
hotkey.start(owner, wl::load_hotkey(ini)); require(!hotkey.id(), "disabled shortcut stays disabled after restart");
hotkey.change(b, save); hotkey.stop(); hotkey.start(owner, wl::load_hotkey(ini));
require(hotkey.id() && occupied(b), "custom shortcut restored on restart");
hotkey.stop(); RegisterHotKey(blocker, 102, b.modifiers | MOD_NOREPEAT, b.key); hotkey.start(owner, b);
require(!hotkey.id() && !hotkey.failure().empty() && hotkey.config() == b, "startup conflict is visible and retains configured combination");
UnregisterHotKey(blocker, 102); hotkey.change(b, save);
require(hotkey.id() && hotkey.failure().empty(), "same setting can retry after startup conflict clears");
unsigned applies = 0;
wl::HotkeyDialog model{b, {}, [&](wl::HotkeyConfig value) { hotkey.change(value, save); ++applies; }, false};
auto create = [&] {
auto dialog = CreateDialogParamW(instance, MAKEINTRESOURCEW(IDD_HOTKEY), owner, wl::HotkeyDialog::procedure, reinterpret_cast<LPARAM>(&model));
require(dialog != nullptr, "settings dialog resource loads"); return dialog;
};
auto dialog = create();
require(model.get(dialog) == b, "dialog shows currently configured shortcut");
SendMessageW(dialog, WM_COMMAND, IDC_HOTKEY_DEFAULT, 0);
require(model.get(dialog) == wl::HotkeyConfig{} && hotkey.config() == b, "restore default edits only the pending selection");
ShowWindow(dialog, SW_SHOWNOACTIVATE); UpdateWindow(dialog); wl::render_window(dialog, output / L"hotkey-settings.png");
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
require(!IsWindow(dialog) && applies == 0 && wl::load_hotkey(ini) == b, "cancel leaves active shortcut and disk settings unchanged");
dialog = create(); model.set(dialog, c);
require(RegisterHotKey(blocker, 102, c.modifiers | MOD_NOREPEAT, c.key), "dialog conflict fixture acquired");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(IsWindow(dialog) && hotkey.config() == b && wl::window_text(GetDlgItem(dialog, IDC_HOTKEY_STATUS)).find(L"占用") != std::wstring::npos,
"conflict keeps dialog open with actionable error");
ShowWindow(dialog, SW_SHOWNOACTIVATE); UpdateWindow(dialog); wl::render_window(dialog, output / L"hotkey-conflict.png");
UnregisterHotKey(blocker, 102); SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(!IsWindow(dialog) && hotkey.config() == c && wl::load_hotkey(ini) == c && applies == 1, "dialog OK saves and applies chosen shortcut");
model.initial = c; dialog = create();
CheckDlgButton(dialog, IDC_HOTKEY_ENABLED, BST_UNCHECKED); SendMessageW(dialog, WM_COMMAND, IDC_HOTKEY_ENABLED, 0);
require(!IsWindowEnabled(GetDlgItem(dialog, IDC_HOTKEY_KEY)) && !IsWindowEnabled(GetDlgItem(dialog, IDC_HOTKEY_WIN)), "disable checkbox disables key controls");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(!hotkey.id() && !wl::load_hotkey(ini).enabled, "dialog can disable global shortcut");
hotkey.stop(); DestroyWindow(blocker); DestroyWindow(owner); OleUninitialize();
std::cout << checks << " hotkey checks passed; renders: " << wl::utf8(output.wstring()) << '\n'; return 0;
} catch (const std::exception& error) { std::cerr << "After " << checks << " checks: " << error.what() << '\n'; return 1; }
}
+52
View File
@@ -0,0 +1,52 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
Start-Transcript -Path "$root\test-output\hotplug.log" -Force | Out-Null
$id = [guid]::NewGuid().ToString('N')
$image = [IO.Path]::GetFullPath((Join-Path "$root\test-output" "wlhotplug-$id.vhd"))
$testRoot = [IO.Path]::GetFullPath((Join-Path $root 'test-output')) + '\'
if (-not $image.StartsWith($testRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'VHD fixture escaped test-output.' }
$letter = @('Z','Y','X','W','V','U','T' | Where-Object { -not (Test-Path "${_}:\") -and -not (Test-Path "$env:ProgramData\WLocate\$_.widx") })[0]
if (-not $letter) { throw 'No unused test drive letter is available.' }
$diskScript = Join-Path "$root\test-output" "diskpart-$id.txt"
$exe = Join-Path $root 'build\bin\Release\locate.exe'
$name = "wlhotplug_$id.txt"
try {
# Every disk operation is scoped to this newly created virtual image. No
# physical disk is selected by number and no existing partition is touched.
@("create vdisk file=`"$image`" maximum=64 type=expandable", "select vdisk file=`"$image`"", 'attach vdisk', 'create partition primary', 'format fs=ntfs quick label=WLocateTest', "assign letter=$letter") | Set-Content -LiteralPath $diskScript -Encoding ascii
& diskpart.exe /s $diskScript | Out-File "$root\test-output\diskpart-create.log"
$virtual = Get-DiskImage -ImagePath $image | Get-Disk
$mounted = Get-Partition -DriveLetter $letter | Get-Disk
if ($mounted.Number -ne $virtual.Number -or $virtual.BusType -ne 'File Backed Virtual') { throw 'Fixture drive did not map to the expected virtual disk.' }
[IO.File]::WriteAllText("${letter}:\$name", 'WLocate hotplug fixture')
$timer = [Diagnostics.Stopwatch]::StartNew()
do {
$result = & $exe $name 2>$null
if ($result -eq "${letter}:\$name") { break }
if ($timer.Elapsed.TotalSeconds -gt 30) { throw 'Attached NTFS volume was not indexed.' }
Start-Sleep -Milliseconds 100
} while ($true)
$attachMs = $timer.Elapsed.TotalMilliseconds
Dismount-DiskImage -ImagePath $image | Out-Null
$timer.Restart()
do {
$status = & $exe -S
if (-not ($status -match "^${letter}:")) { break }
if ($timer.Elapsed.TotalSeconds -gt 15) { throw 'Detached volume remained published.' }
Start-Sleep -Milliseconds 100
} while ($true)
$report = @{ volume = $letter; attach_to_search_ms = $attachMs; detach_to_removed_ms = $timer.Elapsed.TotalMilliseconds; other_volumes_searchable = [bool](& $exe -n 1 'python*.exe') }
$report | ConvertTo-Json | Set-Content "$root\test-output\hotplug.json" -Encoding utf8
$report
} catch {
$_ | Out-String | Write-Output
throw
} finally {
if (Test-Path -LiteralPath $image) {
$state = Get-DiskImage -ImagePath $image -ErrorAction SilentlyContinue
if ($state.Attached) { Dismount-DiskImage -ImagePath $image | Out-Null }
Remove-Item -LiteralPath $image
}
if (Test-Path -LiteralPath $diskScript) { Remove-Item -LiteralPath $diskScript }
Stop-Transcript | Out-Null
}
+77
View File
@@ -0,0 +1,77 @@
#include "../src/gui_icons.hpp"
#include <iostream>
int main() {
wl::require_utf8_process(); CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto hwnd = CreateWindowW(L"STATIC", L"Icon cache test", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, GetModuleHandleW(nullptr), nullptr);
unsigned checks = 0, failures = 0;
auto check = [&](bool ok, const char* what) { ++checks; if (!ok) ++failures; std::cout << (ok ? "PASS " : "FAIL ") << what << '\n'; };
auto until = [](auto condition) {
auto deadline = GetTickCount64() + 5000;
for (;;) { if (condition()) return true; if (GetTickCount64() >= deadline) return false; Sleep(1); }
};
struct Provider {
std::mutex mutex;
std::unordered_map<std::string, unsigned> calls;
wl::Handle entered{CreateEventW(nullptr, TRUE, FALSE, nullptr)}, release{CreateEventW(nullptr, TRUE, FALSE, nullptr)};
std::atomic<bool> block = false, fail = false;
unsigned count(const std::string& key) { std::lock_guard lock(mutex); return calls[key]; }
};
auto provider = std::make_shared<Provider>();
uint64_t now = 0;
wl::AsyncIcons cache([provider](const wl::IconRequest& request) {
unsigned call;
{ std::lock_guard lock(provider->mutex); call = ++provider->calls[request.path]; }
if (request.type_only) return wl::FileIcon{nullptr, 10};
if (provider->block.exchange(false)) { SetEvent(provider->entered.value); WaitForSingleObject(provider->release.value, 5000); }
if (provider->fail) return wl::FileIcon{};
int base = request.path.find("one") != std::string::npos ? 100 : 200;
return wl::FileIcon{nullptr, base + static_cast<int>(call)};
}, 4, [&] { return now; });
cache.start(hwnd, WM_APP + 4);
cache.lookup(".exe");
check(until([&] { cache.collect(); return cache.lookup(".exe") == 10; }), "generic type icon becomes available first");
wl::Result one; one.path = "C:\\one\\Same.exe"; one.modified = 1; one.attributes = FILE_ATTRIBUTE_NORMAL;
wl::Result two = one; two.path = "C:\\two\\Same.exe";
provider->block = true;
check(cache.lookup(one) == 10, "uncached file immediately returns its type fallback");
check(WaitForSingleObject(provider->entered.value, 1000) == WAIT_OBJECT_0, "real-file icon request was queued");
for (unsigned i = 0; i < 100; ++i) cache.lookup(one);
check(provider->count(one.path) == 1, "repaints deduplicate a pending full-path lookup");
SetEvent(provider->release.value);
check(until([&] { cache.collect(); return cache.has_file_icon(one); }) && cache.lookup(one) == 101, "actual file icon replaces the fallback");
cache.lookup(two);
check(until([&] { cache.collect(); return cache.has_file_icon(two); }) && cache.lookup(two) == 201, "same basename and extension in another directory get an independent icon");
for (unsigned i = 0; i < 100; ++i) { cache.lookup(one); cache.lookup(two); }
check(provider->count(one.path) == 1 && provider->count(two.path) == 1, "repeated searches reuse the full-path cache");
++one.modified; cache.lookup(one);
check(until([&] { cache.collect(); return cache.has_file_icon(one); }) && cache.lookup(one) == 102, "changed file metadata refreshes its icon");
ResetEvent(provider->entered.value); ResetEvent(provider->release.value); provider->block = true;
++one.modified; cache.lookup(one);
check(WaitForSingleObject(provider->entered.value, 1000) == WAIT_OBJECT_0, "old file revision is loading");
++one.modified; cache.lookup(one); SetEvent(provider->release.value);
check(until([&] { cache.collect(); return cache.has_file_icon(one); }) && cache.lookup(one) == 104, "late completion from an older revision cannot overwrite the newer icon");
provider->fail = true; ++two.modified; cache.lookup(two);
until([&] { return provider->count(two.path) == 2; });
check(until([&] { return cache.collect(); }) && cache.lookup(two) == 10, "failed lookup preserves the type fallback");
for (unsigned i = 0; i < 100; ++i) cache.lookup(two);
check(provider->count(two.path) == 2, "failed lookups are throttled instead of retried on every repaint");
provider->fail = false; now = 5001; cache.lookup(two);
check(until([&] { cache.collect(); return cache.has_file_icon(two); }) && cache.lookup(two) == 203, "failed lookups can recover after the retry interval");
auto three = one; three.path = "C:\\three\\Same.exe";
auto four = one; four.path = "C:\\four\\Same.exe";
cache.lookup(three); until([&] { cache.collect(); return cache.has_file_icon(three); });
cache.lookup(four); until([&] { cache.collect(); return cache.has_file_icon(four); });
cache.lookup(one);
check(until([&] { cache.collect(); return cache.has_file_icon(one); }) && provider->count(one.path) == 5, "bounded LRU cache reloads evicted paths");
cache.stop();
wl::AsyncIcons real; real.start(hwnd, WM_APP + 5);
wl::Result executable; executable.path = wl::utf8(wl::module_path()); executable.attributes = FILE_ATTRIBUTE_NORMAL;
real.lookup(executable);
check(until([&] { real.collect(); return real.has_file_icon(executable); }), "real Shell lookup loads this executable's embedded icon asynchronously");
auto expected = wl::load_file_icon({executable.path, executable.attributes, false});
check(real.lookup(executable) == expected.index && expected.index != real.lookup(".exe"), "cached executable icon matches its full-path Shell icon and differs from generic EXE");
real.stop(); DestroyWindow(hwnd); CoUninitialize();
std::cout << checks << " checks, " << failures << " failures\n"; return failures ? 1 : 0;
}
+103
View File
@@ -0,0 +1,103 @@
"""Exercise live USN updates and CLI behavior using a dedicated disposable fixture."""
import json
import os
import shutil
import subprocess
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "build/bin/Release/locate.exe"
PREFIX = "wltest_" + uuid.uuid4().hex[:12]
BASE = ROOT / "test-output" / PREFIX
checks = []
def run(*args, success=True):
result = subprocess.run([str(EXE), *args], capture_output=True, timeout=30)
if success and result.returncode not in (0, 1):
raise AssertionError(result.stderr.decode("utf-8", errors="replace"))
return result
def paths(pattern):
return set(run(pattern).stdout.decode("utf-8").splitlines())
def eventual(label, expected, pattern=PREFIX + "*.txt"):
start = time.perf_counter()
while True:
actual = paths(pattern)
subtree = paths(str(BASE) + "\\*" + pattern)
relative_query = '"' + BASE.name + '\\" file: ext:' + pattern.rsplit('.', 1)[1]
relative = set(run("--query", relative_query).stdout.decode("utf-8").splitlines())
if actual == subtree == relative == set(map(str, expected)):
checks.append({"name": label, "latency_ms": (time.perf_counter() - start) * 1000, "rows": len(actual)})
print(label, round(checks[-1]["latency_ms"], 1), "ms", flush=True)
return
if time.perf_counter() - start > 10:
raise AssertionError((label, "missing", set(map(str, expected)) - actual, "extra", actual - set(map(str, expected)), "subtree", subtree, "relative", relative))
time.sleep(0.1)
BASE.mkdir(parents=True)
try:
child = BASE / "folder" / "nested"
child.mkdir(parents=True)
original = child / (PREFIX + "_中文😀.txt")
original.write_bytes(b"a" * 9000)
eventual("create Unicode file and parent directories", [original])
links = []
for i in range(30):
link = child / (PREFIX + f"_hardlink_{i:02d}_long-enough-to-require-MFT-extension.txt")
os.link(original, link)
links.append(link)
eventual("30 hardlinks spanning MFT extension records", [original, *links])
renamed = BASE / "renamed"
(BASE / "folder").rename(renamed)
original = renamed / "nested" / original.name
links = [renamed / "nested" / item.name for item in links]
eventual("directory rename moves every descendant path", [original, *links])
assert paths(str(child) + r"\*.txt") == set(), "old directory prefix survived rename"
assert paths(str(renamed) + r"\*.txt") == set(map(str, [original, *links])), "renamed subtree lost hardlinks"
links[0].unlink()
links = links[1:]
eventual("remove one hardlink preserves siblings", [original, *links])
moved = BASE / (PREFIX + "_moved.txt")
original.rename(moved)
eventual("move base file preserves other hardlinks", [moved, *links])
result = run("-c", PREFIX + "*.txt")
assert result.stdout.strip() == b"30"
result = run("-n", "3", "-0", PREFIX + "*.txt")
assert result.stdout.count(b"\0") == 3 and b"\n" not in result.stdout
assert run("--query", f"{PREFIX} ext:txt size:>8kb", "-c").stdout.strip() == b"30"
moved.write_bytes(b"b" * 17000)
deadline = time.monotonic() + 10
while run("--query", f"{PREFIX} ext:txt size:>16kb", "-c").stdout.strip() != b"30":
assert time.monotonic() < deadline, "metadata updates did not reach every hardlink"
time.sleep(0.1)
checks.append({"name": "size updates for all hardlinks and CLI count/NUL/limit", "passed": True})
deep = BASE
for i in range(5):
deep /= "long_directory_" + str(i) + "_" + "深" * 35
os.makedirs("\\\\?\\" + str(deep))
long_file = deep / (PREFIX + "_long_path.dat")
with open("\\\\?\\" + str(long_file), "wb") as file:
file.write(b"long path")
assert len(str(long_file)) > 300
eventual("Unicode path longer than MAX_PATH", [long_file], PREFIX + "*.dat")
bulk = BASE / "bulk"
bulk.mkdir()
for i in range(500):
(bulk / f"{PREFIX}_burst_{i:04d}.dat").write_bytes(b"burst")
expected_bulk = [long_file, *(bulk / f"{PREFIX}_burst_{i:04d}.dat" for i in range(500))]
eventual("500-file USN create burst", expected_bulk, PREFIX + "*.dat")
assert run("--regex", "[", success=False).returncode == 2
assert run(PREFIX + "no-such-file").returncode == 1
assert run("--limit", "-1", "foo", success=False).returncode == 2
checks.append({"name": "error and no-match exit codes", "passed": True})
finally:
# This fixture path is a direct child under the workspace test-output directory.
assert BASE.parent.resolve() == (ROOT / "test-output").resolve() and BASE.name.startswith("wltest_")
shutil.rmtree("\\\\?\\" + str(BASE))
eventual("delete fixture removes every indexed entry", [])
eventual("delete burst and long-path entries", [], PREFIX + "*.dat")
(ROOT / "test-output/integration.json").write_text(json.dumps(checks, indent=2), encoding="utf-8")
print("All live integration checks passed")
+48
View File
@@ -0,0 +1,48 @@
"""Exact user-reported path glob, with uncached GUI and CLI timings."""
import json
import re
import statistics
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
NATIVE = ROOT / "build/bin/Release/locate.exe"
OLD = Path(r"D:\tools\bin\locate.old.exe")
PATTERN = r"C:\Windows\Logs\*.log"
report = {"pattern": PATTERN, "cases": {}}
outputs = {}
for label, exe, args in (
("old", OLD, [PATTERN]),
("native_cli", NATIVE, [PATTERN, "--timing"]),
("native_gui_uncached", NATIVE, ["--query", PATTERN, "--sort", "name", "-n", "256", "--timing"]),
):
runs = []
for i in range(6):
if label == "native_gui_uncached":
cleared = subprocess.run([str(NATIVE), "--query", "wlocate-cache-reset-no-match-xyzyx"], capture_output=True, timeout=60)
assert cleared.returncode in (0, 1), cleared.stderr
start = time.perf_counter_ns()
result = subprocess.run([str(exe), *args], capture_output=True, timeout=60)
elapsed = (time.perf_counter_ns() - start) / 1e6
assert result.returncode in (0, 1), result.stderr
outputs[label] = result.stdout.decode("utf-8", errors="strict").splitlines()
match = re.search(rb"search=([0-9.]+) ms, matches=(\d+)", result.stderr)
item = {"process_ms": elapsed, "rows": len(outputs[label])}
if match:
item.update(service_ms=float(match[1]), matches=int(match[2]))
if i:
runs.append(item)
print(label, i, item, flush=True)
report["cases"][label] = {"runs": runs, "median_process_ms": statistics.median(r["process_ms"] for r in runs)}
if "service_ms" in runs[0]:
report["cases"][label]["median_service_ms"] = statistics.median(r["service_ms"] for r in runs)
report["only_old"] = sorted(set(outputs["old"]) - set(outputs["native_cli"]))
report["only_native"] = sorted(set(outputs["native_cli"]) - set(outputs["old"]))
report["gui_matches_cli"] = set(outputs["native_gui_uncached"]) == set(outputs["native_cli"])
assert report["gui_matches_cli"], "GUI-equivalent and CLI result sets differ"
report["outputs"] = outputs
target = ROOT / "test-output" / (sys.argv[1] if len(sys.argv) > 1 else "path-benchmark.json")
target.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print("Saved", target)
+39
View File
@@ -0,0 +1,39 @@
"""Separate service search time from process/IPC/output latency, on a warm index."""
import json
import re
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "build/bin/Release/locate.exe"
cases = {
"prefix_limit_10": ["-n", "10", "python*.exe"],
"basename_substring_count": ["-bic", "python"],
"fullpath_substring_count": ["-ic", "python"],
"regex_count": ["-ic", "-r", r"^python.*\.exe$"],
"gui_all_name_first_page": ["--query", "*", "--sort", "name", "-n", "256"],
"gui_all_path_first_page": ["--query", "*", "--sort", "path", "-n", "256"],
"gui_all_size_first_page": ["--query", "*", "--sort", "size", "-n", "256"],
}
report = {}
deadline = time.monotonic() + 60
while True:
status = subprocess.run([str(EXE), "-S"], capture_output=True, timeout=5)
if status.returncode == 0 and status.stdout and all(b"\tready\t" in line for line in status.stdout.splitlines()):
break
assert time.monotonic() < deadline, "Index did not become ready"
time.sleep(0.2)
for label, args in cases.items():
results = []
for _ in range(3):
start = time.perf_counter_ns()
result = subprocess.run([str(EXE), *args, "--timing"], capture_output=True, timeout=30)
elapsed = (time.perf_counter_ns() - start) / 1e6
assert result.returncode == 0, result.stderr
match = re.search(rb"search=([0-9.]+) ms, matches=(\d+)", result.stderr)
assert match, result.stderr
results.append({"process_ms": elapsed, "service_ms": float(match[1]), "matches": int(match[2])})
report[label] = results
print(label, results, flush=True)
(ROOT / "test-output/performance.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
+69
View File
@@ -0,0 +1,69 @@
"""Live local protocol, concurrency, deadlines and bulk-output regression tests."""
import concurrent.futures
import json
import struct
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "build/bin/Release/locate.exe"
MAGIC = 0x31434C57
checks = []
def run(*args):
return subprocess.run([str(EXE), *args], capture_output=True, timeout=30)
def exact(pipe, size):
data = b""
while len(data) < size:
chunk = pipe.read(size - len(data))
assert chunk, "truncated reply"
data += chunk
return data
def malformed(**changes):
fields = dict(magic=MAGIC, size=0, operation=0, flags=0, limit=10,
offset=0, sort=0, descending=0, timeout=1000, reserved=0)
fields.update(changes)
with open(r"\\.\pipe\WLocate.Search.v1", "r+b", buffering=0) as pipe:
pipe.write(struct.pack("<IIIIQQIIII", *fields.values()))
reply = struct.unpack("<IIQQQQ", exact(pipe, 40))
message = exact(pipe, struct.unpack("<I", exact(pipe, 4))[0]).decode()
assert reply[0] == MAGIC and reply[1] == 1 and message
pipe.write(struct.pack("<I", MAGIC))
return message
for fields in ({"magic": 0}, {"size": 65537}, {"flags": 0xFFFFFFFF},
{"sort": 99}, {"reserved": 1}, {"operation": 99}):
checks.append({"malformed": fields, "error": malformed(**fields)})
baseline = run("python*.exe")
assert baseline.returncode == 0 and baseline.stdout
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
jobs = list(pool.map(lambda _: run("python*.exe"), range(60)))
assert all(job.returncode == 0 and set(job.stdout.splitlines()) == set(baseline.stdout.splitlines()) for job in jobs)
checks.append({"concurrent_queries": 60, "workers": 20, "passed": True})
large = run("-i", "-n", "20000", "-0", "*.txt")
assert large.returncode == 0 and large.stdout.count(b"\0") == 20000
assert all(path for path in large.stdout[:-1].decode("utf-8").split("\0"))
checks.append({"bulk_rows": 20000, "bytes": len(large.stdout), "passed": True})
start = time.monotonic()
limited = run("--timeout", "100", "--sort", "path", "-n", "10", "*")
elapsed = time.monotonic() - start
assert limited.returncode == 2 and elapsed < 3 and (b"timed out" in limited.stderr or b"cancelled" in limited.stderr)
assert run("python*.exe").returncode == 0
checks.append({"deadline_ms": elapsed * 1000, "service_recovers": True})
permission = run("--reindex")
assert permission.returncode == 2 and b"elevated" in permission.stderr
checks.append({"unelevated_rebuild_denied": True})
(ROOT / "test-output/protocol.json").write_text(json.dumps(checks, indent=2), encoding="utf-8")
print("Protocol, 60 concurrent queries, 20,000-row output, deadline and permission checks passed")
+52
View File
@@ -0,0 +1,52 @@
"""Compare relative path fragments, keeping warm-index searches uncached."""
import json
import re
import statistics
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
NATIVE = ROOT / "build/bin/Release/locate.exe"
OLD = Path(r"D:\tools\bin\locate.old.exe")
patterns = [r"foo\bar", r"Windows\Logs", "Windows\\Logs\\", r"Windows\System32\drivers\etc"]
report = {}
target = ROOT / "test-output" / (sys.argv[1] if len(sys.argv) > 1 else "relative-path-benchmark.json")
for pattern in patterns:
case = {"runs": {}, "outputs": {}}
for label, exe, args in (
("old", OLD, [pattern]),
("native_cli", NATIVE, [pattern, "--timing"]),
("native_gui_uncached", NATIVE, ["--query", pattern, "--sort", "name", "--timing"]),
):
runs = []
for iteration in range(4):
if label == "native_gui_uncached":
reset = subprocess.run([str(NATIVE), "--query", "wl-relative-benchmark-cache-reset-zyx"], capture_output=True, timeout=60)
assert reset.returncode in (0, 1), reset.stderr
start = time.perf_counter_ns()
result = subprocess.run([str(exe), *args], capture_output=True, timeout=60)
elapsed = (time.perf_counter_ns() - start) / 1e6
assert result.returncode in (0, 1), result.stderr
rows = result.stdout.decode("utf-8").splitlines()
item = {"process_ms": elapsed, "rows": len(rows)}
timing = re.search(rb"search=([0-9.]+) ms, matches=(\d+)", result.stderr)
if timing:
item.update(service_ms=float(timing[1]), matches=int(timing[2]))
if iteration:
runs.append(item)
case["outputs"][label] = rows
summary = {"samples": runs, "median_process_ms": statistics.median(r["process_ms"] for r in runs)}
if "service_ms" in runs[0]:
summary["median_service_ms"] = statistics.median(r["service_ms"] for r in runs)
case["runs"][label] = summary
print(pattern, label, {k: v for k, v in summary.items() if k != "samples"}, "rows", len(rows), flush=True)
old, native, gui = (set(case["outputs"][key]) for key in ("old", "native_cli", "native_gui_uncached"))
case["only_old"] = sorted(old - native)
case["only_native"] = sorted(native - old)
case["gui_only"] = sorted(gui - native)
case["cli_only"] = sorted(native - gui)
report[pattern] = case
target.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print("Saved", target)
+27
View File
@@ -0,0 +1,27 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
Start-Transcript -Path "$root\test-output\restart.log" -Force | Out-Null
$prefix = 'wlrestart_' + [guid]::NewGuid().ToString('N')
$fixture = Join-Path "$root\test-output" "$prefix.txt"
$exe = Join-Path $root 'build\bin\Release\locate.exe'
$service = Get-Service WLocateService
try {
Stop-Service WLocateService
$service.WaitForStatus('Stopped', [timespan]::FromSeconds(120))
[IO.File]::WriteAllText($fixture, 'Created while the index service was stopped.')
$timer = [Diagnostics.Stopwatch]::StartNew()
Start-Service WLocateService
do {
$result = & $exe "$prefix*.txt" 2>$null
if ($result -eq $fixture) { break }
if ($timer.Elapsed.TotalSeconds -gt 30) { throw 'Offline USN update was not recovered within 30 seconds.' }
Start-Sleep -Milliseconds 100
} while ($true)
$report = @{ offline_create_replayed = $true; restart_to_result_ms = $timer.Elapsed.TotalMilliseconds }
$report | ConvertTo-Json | Set-Content "$root\test-output\restart.json" -Encoding utf8
$report
} finally {
if ((Get-Service WLocateService).Status -ne 'Running') { Start-Service WLocateService }
if (Test-Path -LiteralPath $fixture) { Remove-Item -LiteralPath $fixture }
Stop-Transcript | Out-Null
}
+352
View File
@@ -0,0 +1,352 @@
#include "ntfs.hpp"
#include <iostream>
#include <cstring>
#include <fstream>
namespace {
unsigned checks = 0;
void require(bool value, const char* message) { ++checks; if (!value) throw wl::Error(message); }
template<class F> void rejects(F fn, const char* message) { bool failed = false; try { fn(); } catch (...) { failed = true; } require(failed, message); }
template<class T> void put(std::vector<uint8_t>& bytes, size_t at, T value) { memcpy(bytes.data() + at, &value, sizeof(value)); }
void encoding_tests() {
require(GetACP() == CP_UTF8, "embedded manifest selects UTF-8 process ACP");
const std::string text = "中文-é-😀";
wchar_t converted[64]{};
auto length = MultiByteToWideChar(CP_ACP, MB_ERR_INVALID_CHARS, text.data(), static_cast<int>(text.size()), converted, 64);
require(length > 0 && std::wstring_view(converted, length) == L"中文-é-😀", "default Win32 code page decodes UTF-8");
auto file = std::filesystem::temp_directory_path() / (L"wlocate-utf8-" + std::to_wstring(GetCurrentProcessId()) + L"-中文-é-😀.tmp");
auto path = wl::utf8(file.wstring());
wl::Handle created(CreateFileA(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_NEW, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr));
require(created && std::filesystem::exists(file), "UTF-8 narrow filename reaches the same file as a UTF-16 Windows path");
}
std::vector<uint8_t> record_fixture(bool extension) {
std::vector<uint8_t> bytes(1024); memcpy(bytes.data(), "FILE", 4);
put<uint16_t>(bytes, 4, 48); put<uint16_t>(bytes, 6, 3); put<uint16_t>(bytes, 0x10, 7);
put<uint16_t>(bytes, 0x14, 56); put<uint16_t>(bytes, 0x16, 1); put<uint64_t>(bytes, 0x20, extension ? (3ull << 48) | 70 : 0);
put<uint16_t>(bytes, 48, 0x1234); put<uint16_t>(bytes, 50, 0x5678); put<uint16_t>(bytes, 52, 0x90AB);
put<uint16_t>(bytes, 510, 0x1234); put<uint16_t>(bytes, 1022, 0x1234);
const std::wstring name = L"python3.14.exe"; auto attr_length = (24 + 66 + name.size() * 2 + 7) & ~size_t(7);
put<uint32_t>(bytes, 56, 0x30); put<uint32_t>(bytes, 60, static_cast<uint32_t>(attr_length));
put<uint32_t>(bytes, 72, static_cast<uint32_t>(66 + name.size() * 2)); put<uint16_t>(bytes, 76, 24);
put<uint64_t>(bytes, 80, (1ull << 48) | 5); bytes[80 + 64] = static_cast<uint8_t>(name.size()); bytes[80 + 65] = 1;
memcpy(bytes.data() + 80 + 66, name.data(), name.size() * 2);
put<uint32_t>(bytes, 56 + attr_length, UINT32_MAX); put<uint32_t>(bytes, 0x18, static_cast<uint32_t>(60 + attr_length));
return bytes;
}
std::shared_ptr<wl::Volume> fixture() {
auto volume = std::make_shared<wl::Volume>("X:\\"); volume->root_id = 5; volume->serial = 123;
volume->add_initial({5, 5, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "."});
volume->add_initial({6, 5, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "Python"});
volume->add_initial({7, 6, 10000, 100, 50, 90, FILE_ATTRIBUTE_ARCHIVE, "python.exe"});
volume->add_initial({7, 6, 10000, 100, 50, 90, FILE_ATTRIBUTE_ARCHIVE, "python3.exe"});
volume->add_initial({8, 6, 500, 200, 50, 90, FILE_ATTRIBUTE_HIDDEN, "README.txt"});
volume->add_initial({9, 5, 700, 300, 50, 90, 0, "中文😀.txt"});
volume->add_initial({10, 5, 100, 400, 50, 90, 0, "PYTHON.EXE"}); volume->finish(); return volume;
}
wl::Reply search(wl::Engine& engine, std::vector<std::string> patterns, uint32_t flags = 0, uint64_t limit = UINT64_MAX) {
wl::Request request; request.patterns = std::move(patterns); request.flags = flags; request.limit = limit; request.sort = wl::Sort::None;
return engine.search(request);
}
void query_tests() {
require(wl::glob_match("python*.exe", "python3.14.exe"), "glob prefix and suffix");
require(!wl::glob_match("python*.exe", "python.exe.bak"), "glob is anchored");
require(wl::glob_match("[!a-z]?", "9😀"), "Unicode glob and negated range");
require(!wl::glob_match("[a-c]", "z"), "range rejection");
require(wl::glob_match("a*b*c", "axbybc"), "glob backtracking");
require(wl::glob_match("a[", "a["), "unclosed bracket is literal");
require(wl::fold("ÄBC中文") == "äbc中文", "Unicode case folding");
wl::Engine engine; auto volume = fixture(); engine.set_volumes({volume});
require(search(engine, {"python*.exe"}).total == 3, "case insensitive glob and hard links");
require(search(engine, {"python*.exe"}, wl::MatchCase).total == 2, "case sensitive glob");
require(search(engine, {"python*.exe"}, 0, 1).results.size() == 1, "server limit");
require(search(engine, {"*"}, 0, 0).total == 0, "zero limit");
require(search(engine, {"Python"}).total == 5, "plain substring searches full paths");
require(search(engine, {"Python"}, wl::Basename).total == 4, "basename literal");
require(search(engine, {"*.exe", "*.txt"}).total == 5, "multiple patterns OR");
require(search(engine, {"python*", "*.exe"}, wl::AllPatterns).total == 3, "multiple patterns AND");
require(search(engine, {"*中文?.txt"}).total == 1, "non BMP wildcard");
require(search(engine, {"python"}, wl::EverythingSyntax).total == 4, "Everything name substring");
require(search(engine, {"ext:exe !case:PYTHON"}, wl::EverythingSyntax).total == 2, "extension and negative case term");
require(search(engine, {"file: ext:txt;exe size:>1kb"}, wl::EverythingSyntax).total == 2, "metadata filters");
require(search(engine, {"folder:"}, wl::EverythingSyntax).total == 1, "folder filter excludes root");
require(search(engine, {"attrib:h"}, wl::EverythingSyntax).total == 1, "attribute filter");
require(search(engine, {"parent:X:\\Python ext:txt"}, wl::EverythingSyntax).total == 1, "parent match");
require(search(engine, {"README | 中文"}, wl::EverythingSyntax).total == 2, "Everything OR");
require(search(engine, {"<README | python> ext:txt"}, wl::EverythingSyntax).total == 1, "grouped OR then AND");
require(search(engine, {"!<README | python> file:"}, wl::EverythingSyntax).total == 1, "negated group");
require(search(engine, {"<README | <python file:>> ext:exe"}, wl::EverythingSyntax).total == 3, "nested groups");
require(search(engine, {"python | README", "ext:txt"}, wl::EverythingSyntax).total == 1, "independent filter applies to every OR branch");
require(search(engine, {"^python(?:3)?\\.exe$", "ext:exe"}, wl::EverythingSyntax | wl::Regex).total == 3, "global GUI regex and independent filter");
require(search(engine, {"regex:\"^python(?:3)?\\.exe$\""}, wl::EverythingSyntax).total == 3, "regex modifier preserves colons and quoted operators");
rejects([&] { search(engine, {"<python"}, wl::EverythingSyntax); }, "unclosed group rejected");
require(search(engine, {"^python[0-9]*\\.exe$"}, wl::Regex).total == 3, "regex matches names");
rejects([&] { search(engine, {"["}, wl::Regex); }, "invalid regex");
require(search(engine, {"(?<=中文)\\p{So}\\.txt$"}, wl::Regex).total == 1, "PCRE2 Unicode property and lookbehind");
require(search(engine, {"^(python)(?:3)?\\.exe$"}, wl::Regex).total == 3, "PCRE2 captures do not change match success");
wl::Pattern unicode_case("äbc", false); require(unicode_case.matches("ÄBC"), "PCRE2 Unicode caseless match");
wl::Pattern bounded("(*NO_START_OPT)(*NO_AUTO_POSSESS)^(a+)+$", true);
rejects([&] { bounded.matches(std::string(300, 'a') + "!"); }, "catastrophic regex stops at bounded work");
rejects([&] { search(engine, {"size:foobar"}, wl::EverythingSyntax); }, "invalid metadata filter");
rejects([&] { search(engine, {"dm:2026-02-30"}, wl::EverythingSyntax); }, "invalid calendar date rejected");
rejects([&] { search(engine, {"dm:2026-01-01junk"}, wl::EverythingSyntax); }, "date trailing characters rejected");
SYSTEMTIME today{}; GetLocalTime(&today); today.wHour = 12; today.wMinute = today.wSecond = today.wMilliseconds = 0;
SYSTEMTIME utc{}; FILETIME ft{}; TzSpecificLocalTimeToSystemTime(nullptr, &today, &utc); SystemTimeToFileTime(&utc, &ft);
wl::Result dated; dated.modified = wl::filetime_value(ft);
wl::Request day; day.flags = wl::EverythingSyntax; day.patterns = {"dm:>today"};
require(wl::Query(day).evaluate("f", "f", &dated) == wl::Truth::No, "date greater-than excludes entire named day");
day.patterns = {"dm:<=today"}; require(wl::Query(day).evaluate("f", "f", &dated) == wl::Truth::Yes, "date less-or-equal includes entire named day");
rejects([&] { search(engine, {"\"unterminated"}, wl::EverythingSyntax); }, "invalid quotes");
auto before = volume->generation;
volume->replace_links(6, {{6, 5, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "Renamed"}});
auto moved = search(engine, {"python3.exe"}); require(moved.results[0].path == "X:\\Renamed\\python3.exe", "directory rename updates descendant paths");
require(volume->generation > before, "update invalidates cache generation");
volume->replace_links(7, {}); require(search(engine, {"python*.exe"}, wl::MatchCase).total == 0, "deletion removes every hardlink");
volume->replace_links(11, {{11, 5, 400, 0, 0, 0, 0, "new.exe"}}); require(search(engine, {"new*.exe"}).total == 1, "new unique name participates in prefix query");
wl::Request sorted; sorted.flags = wl::EverythingSyntax; sorted.patterns = {"ext:txt;exe"}; sorted.sort = wl::Sort::Size; sorted.limit = 1;
auto page = engine.search(sorted); require(page.total == 4 && page.results.size() == 1 && wl::filename(page.results[0].path) == "PYTHON.EXE", "sorted query reports full count and one row");
sorted.offset = 1; auto next = engine.search(sorted); require(wl::filename(next.results[0].path) == "new.exe", "cached pagination");
sorted.offset = UINT64_MAX; require(engine.search(sorted).results.empty(), "out-of-range pagination avoids overflow"); sorted.offset = 1;
rejects([&] { engine.search(sorted, [] { return true; }); }, "cancelled cached search");
volume->add_initial({20, 99999, 1, 0, 0, 0, 0, "orphan.txt"}); volume->finish();
require(search(engine, {"orphan*"}).total == 0, "orphan rows do not inflate counts");
auto names_version = volume->name_generation;
volume->replace_links(11, {{11, 5, 800, 10, 0, 0, 0, "new.exe"}});
require(names_version == volume->name_generation, "metadata-only updates preserve name-query cache identity");
auto unavailable = std::make_shared<wl::Volume>("Y:\\"); engine.set_volumes({volume, unavailable});
auto partial = search(engine, {"new.exe"}); require(partial.total == 1 && !partial.message.empty(), "one indexing volume does not block ready volumes");
engine.set_volumes({volume});
volume->add_initial({21, 5, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "Renamed-abc"});
volume->add_initial({22, 21, 1, 0, 0, 0, 0, "new.exe"}); volume->finish();
wl::Request path_sort; path_sort.patterns = {"*"}; path_sort.sort = wl::Sort::Path;
auto path_rows = engine.search(path_sort).results; auto expected = path_rows;
std::stable_sort(expected.begin(), expected.end(), [](const auto& a, const auto& b) { return wl::fold(a.path) < wl::fold(b.path); });
require(std::equal(path_rows.begin(), path_rows.end(), expected.begin(), [](const auto& a, const auto& b) { return a.path == b.path; }), "parent-interned path keys preserve full-path lexical ordering");
auto names_before = volume->names.size(); volume->compact();
require(volume->names.size() < names_before, "compaction releases unused interned names");
require(search(engine, {"new.exe"}, wl::Basename).total == 2, "compaction preserves active paths and hardlinks");
const std::vector<std::string> samples{"abc.exe", "中文😀.txt", "!HOW_TO.md", "]abcfoo", "file123.cpp", "ÄBCDEF.txt"};
const std::vector<std::string> patterns{"*abc*", "?bc*", "*.exe", "[a-z]*", "[]abc]foo", "*中文?*", "*abc?ef*", "[!x]*txt", "*HOW_TO*"};
for (auto& pattern : patterns) for (auto& sample : samples) {
wl::Request r; r.flags = wl::Basename; r.patterns = {pattern}; wl::Query q(r);
auto lower = wl::fold(sample); if (q.evaluate(sample, lower) == wl::Truth::Yes) require(q.may_match_name(wl::name_bloom(lower)), "Bloom filter must not reject a matching glob");
}
}
void path_subtree_tests() {
auto v = std::make_shared<wl::Volume>("X:\\"); v->root_id = 5; v->serial = 123;
auto add = [&](uint64_t id, uint64_t parent, std::string name, bool dir = false) {
v->add_initial({id, parent, 100, 200, 50, 90, uint32_t(dir ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_ARCHIVE), std::move(name)});
};
add(5, 5, ".", true); add(6, 5, "Windows", true); add(7, 6, "Logs", true);
add(8, 7, "a.log"); add(9, 7, "b.LOG"); add(10, 7, "nested", true);
add(11, 10, "中文😀.log"); add(12, 7, "other.txt"); add(13, 6, "Logs-old", true); add(14, 13, "outside.log");
add(15, 5, "Other", true); add(8, 15, "hardlink.log");
add(16, 5, "windows", true); add(17, 16, "logs", true); add(18, 17, "case.log");
add(19, 999, "orphan.log"); add(20, 7, "[literal.log"); add(21, 7, "empty", true); add(22, 7, "extra.log");
v->finish(); wl::Engine engine; engine.set_volumes({v});
// Compare the optimizer against a deliberately exhaustive full-path oracle.
// This catches pruning mistakes without duplicating the subtree algorithm.
auto compare = [&](const wl::Request& request) {
wl::Query query(request); std::vector<std::string> expected, actual;
for (uint32_t row = 0; row < v->entries.size(); ++row) {
const auto& e = v->entries[row]; if (e.id == v->root_id || (e.attributes & wl::deleted_attribute)) continue;
auto value = v->result(row); const auto& name = v->names[e.name];
if (!value.path.empty() && query.evaluate(name.text, name.lower, &value) == wl::Truth::Yes) expected.push_back(value.path);
}
auto reply = engine.search(request);
for (const auto& result : reply.results) actual.push_back(result.path);
require(reply.total == expected.size(), "path plan preserves exhaustive count");
if (request.sort == wl::Sort::Name)
require(std::is_sorted(actual.begin(), actual.end(), [](const auto& a, const auto& b) { return wl::fold(wl::filename(a)) < wl::fold(wl::filename(b)); }), "subtree name ordering");
std::sort(expected.begin(), expected.end()); std::sort(actual.begin(), actual.end());
require(expected == actual, "path plan preserves exhaustive result set without duplicates");
};
const std::vector<std::string> patterns{
"X:\\Windows\\Logs\\*.log", "x:\\windows\\logs\\*.log", "X:/Windows/Logs/*.log",
"X:\\Windows\\Logs\\nested\\*.log", "X:\\Windows\\Logs\\empty\\*", "Y:\\Windows\\Logs\\*.log",
"X:\\missing\\*.log", "X:\\Windows\\Logs*\\*.log", "X:\\Windows\\Logs\\?*.log",
"X:\\Windows\\Logs\\[ab].log", "X:\\Windows\\Logs\\[literal*", "X:\\*", "*.log",
"*Windows\\Logs\\*.log", "X:\\Windows\\Logs\\*.log | X:\\Windows\\*.log",
"X:\\Windows\\Logs\\*.log | X:\\Other\\*.log", "X:\\Windows\\Logs\\*.log | outside.log",
"<X:\\Windows\\Logs\\* | X:\\Other\\*> ext:log !b.LOG",
"X:\\Windows\\Logs\\*.log file: size:>50", "!X:\\Windows\\Logs\\*.log",
"path:X:\\Windows\\Logs\\*.log", "parent:X:\\Windows\\Logs*", "regex:.*log$"
};
for (auto flags : {uint32_t(wl::EverythingSyntax), uint32_t(wl::EverythingSyntax | wl::MatchCase)}) {
for (const auto& pattern : patterns) for (auto sort : {wl::Sort::None, wl::Sort::Name, wl::Sort::Path}) {
wl::Request request; request.flags = flags; request.patterns = {pattern}; request.sort = sort; compare(request);
}
}
wl::Request r; r.patterns = {"X:\\Windows\\Logs\\*.log", "X:\\Other\\*.log"}; r.sort = wl::Sort::None;
compare(r); r.flags = wl::AllPatterns; compare(r);
r.patterns = {"X:\\Windows\\Logs\\*.log"}; r.flags = wl::MatchCase; compare(r);
auto query = wl::Query(r);
require(v->find(query, 1, [] { return false; }).size() == 1, "bounded subtree CLI limit");
require(v->find(query, 0, [] { return false; }).empty(), "bounded subtree zero limit");
rejects([&] { v->find(query, UINT64_MAX, [] { return true; }); }, "bounded subtree cancellation");
// Real-time mutations must update both old and new parent chains, including
// hardlinks, directory moves, deleted parents and late-arriving parents.
v->replace_links(10, {{10, 15, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "moved"}}); compare(r);
r.patterns = {"X:\\Other\\*.log"}; compare(r);
v->replace_links(7, {{7, 6, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "Renamed"}});
r.patterns = {"X:\\Windows\\Logs\\*.log"}; compare(r);
r.patterns = {"X:\\Windows\\Renamed\\*.log"}; compare(r);
v->replace_links(8, {{8, 7, 100, 200, 50, 90, 0, "renamed.log"}, {8, 15, 100, 200, 50, 90, 0, "hardlink.log"}}); compare(r);
v->replace_links(8, {{8, 15, 100, 200, 50, 90, 0, "hardlink.log"}}); compare(r);
v->replace_links(7, {}); compare(r);
v->replace_links(7, {{7, 6, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "Restored"}});
r.patterns = {"X:\\Windows\\Restored\\*.log"}; compare(r);
v->replace_links(999, {{999, 6, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "late"}});
r.patterns = {"X:\\Windows\\late\\*.log"}; compare(r);
v->compact(); compare(r);
auto path = std::filesystem::temp_directory_path() / (L"wlocate-path-test-" + std::to_wstring(GetCurrentProcessId()) + L".widx");
v->save(path); v = wl::Volume::load(path, "X:\\", 123); engine.set_volumes({v}); compare(r); std::filesystem::remove(path);
for (uint64_t i = 0; i < 30000; ++i) add(1000 + i, 15, "unrelated" + std::to_string(i) + ".log");
v->finish();
r.patterns = {"X:\\Windows\\Restored\\*.log"}; r.sort = wl::Sort::Name;
unsigned polls = 0;
auto small = engine.search(r, [&] { return ++polls > 8; });
require(!small.results.empty(), "small subtree completes bounded work despite 30000 unrelated names");
auto second_volume = fixture(); second_volume->root = "Y:\\"; engine.set_volumes({v, second_volume});
r.patterns = {"X:\\Windows\\Restored\\*.log", "Y:\\Python\\*.exe"};
auto multi = engine.search(r);
require(multi.total == small.total + 2 && std::is_sorted(multi.results.begin(), multi.results.end(), [](const auto& a, const auto& b) {
return wl::fold(wl::filename(a.path)) < wl::fold(wl::filename(b.path));
}), "bounded OR across volumes merges name ordering");
r.flags = wl::EverythingSyntax | wl::MatchCase; r.patterns = {"X:\\Windows\\Restored\\*.log"}; r.limit = 1;
auto first = engine.search(r); r.offset = 1; auto second = engine.search(r);
require(first.total == small.total && second.total == small.total && first.results.size() == 1 && second.results.size() == 1 && first.results[0].path != second.results[0].path,
"GUI subtree pagination preserves full count and cached row offsets");
}
void relative_path_tests() {
auto v = std::make_shared<wl::Volume>("X:\\"); v->root_id = 5; v->serial = 345;
auto add = [&](uint64_t id, uint64_t parent, const std::string& name, bool dir = false) {
v->add_initial({id, parent, 123, 456, 50, 90, uint32_t(dir ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_ARCHIVE), name});
};
add(5, 5, ".", true); add(6, 5, "foo", true); add(7, 6, "bar", true); add(8, 7, "child.txt");
add(9, 6, "barista.txt"); add(10, 5, "afoo", true); add(11, 10, "bargain", true); add(12, 11, "other.txt");
add(13, 5, "foox", true); add(14, 13, "bar-no.txt"); add(15, 6, "nested", true); add(16, 15, "bar-no.txt");
add(17, 7, "foo", true); add(18, 17, "bar", true); add(19, 18, "deep.txt"); // overlapping seeds
add(20, 5, "FOO", true); add(21, 20, "BAR", true); add(22, 21, "case.txt");
add(23, 5, "Other", true); add(8, 23, "hardlink.txt"); add(8, 7, "second-link.txt");
add(24, 999, "bar-orphan.txt"); add(25, 5, "中文😀", true); add(26, 25, "ÄBC", true); add(27, 26, "emoji😀.txt");
add(28, 5, "space dir", true); add(29, 28, "bar with space", true); add(30, 29, "file.txt");
v->finish(); wl::Engine engine; engine.set_volumes({v});
auto compare = [&](const wl::Request& r) {
wl::Query q(r); std::vector<std::string> expected, actual;
for (uint32_t row = 0; row < v->entries.size(); ++row) {
const auto& e = v->entries[row];
if (e.id == v->root_id || (e.attributes & wl::deleted_attribute)) continue;
auto value = v->result(row); const auto& name = v->names[e.name];
if (!value.path.empty() && q.evaluate(name.text, name.lower, &value) == wl::Truth::Yes) expected.push_back(value.path);
}
auto reply = engine.search(r);
require(reply.total == expected.size(), "relative path plan count matches exhaustive full-path oracle");
for (const auto& value : reply.results) actual.push_back(value.path);
if (r.flags & wl::CountOnly) { require(actual.empty(), "relative path count-only returns no rows"); return; }
if (r.sort == wl::Sort::Name) require(std::is_sorted(actual.begin(), actual.end(), [](const auto& a, const auto& b) {
return wl::fold(wl::filename(a)) < wl::fold(wl::filename(b));
}), "relative path results remain name sorted");
std::sort(expected.begin(), expected.end()); std::sort(actual.begin(), actual.end());
if (actual != expected) throw wl::Error("Relative path result mismatch: " + r.patterns[0]);
require(actual == expected, "relative path plan neither misses nor duplicates paths");
};
const std::vector<std::string> patterns{
"foo\\bar", "FOO\\BAR", "oo\\ba", "foo/bar", "foo\\", "oo\\", "foo\\bar\\", "foo\\bar\\ch",
"foo\\bar\\foo\\ba", "foo\\nested\\ba", "foo\\bar\\nonexistent", "foo\\missing", "missing\\bar",
"\\bar", "\\foo\\", "\\foo\\bar", "X:\\foo", "x:\\foo", "X:\\", ":\\", "\\", "\\\\", "foo\\\\bar",
"中文😀\\ä", "中文😀/ÄBC/emoji😀", "文😀\\ÄBC\\", "X:\\中文😀\\Ä", "space dir\\bar", "space dir\\bar with space\\"
};
for (auto flags : {uint32_t(0), uint32_t(wl::MatchCase), uint32_t(wl::EverythingSyntax), uint32_t(wl::EverythingSyntax | wl::MatchCase)}) {
for (const auto& pattern : patterns) for (auto sort : {wl::Sort::None, wl::Sort::Name, wl::Sort::Path}) {
if ((flags & wl::EverythingSyntax) && pattern.starts_with(':')) continue; // Bare ':' is a syntax operator in this mode.
wl::Request r; r.flags = flags; r.sort = sort; r.patterns = {flags & wl::EverythingSyntax ? "\"" + pattern + "\"" : pattern}; compare(r);
}
}
for (const std::string pattern : {
"foo\\bar ext:txt !second-link", "<foo\\bar | 中文😀\\Ä> file:", "foo\\bar | oo\\bar", "foo\\bar | foo\\bar\\foo\\bar",
"foo\\bar | hardlink", "!foo\\bar", "ww:foo\\bar", "ww:foo\\", "case:foo\\bar", "path:foo\\bar size:>100",
"foo\\bar parent:foo", "foo\\bar !path:nested", "foo\\bar | X:\\Other\\*", "foo\\bar\\foo foo\\bar", "parent:foo\\bar"
}) {
wl::Request r; r.flags = wl::EverythingSyntax; r.patterns = {pattern}; r.sort = wl::Sort::Name; compare(r);
}
// Draw fragments through arbitrary component boundaries, independent of
// how the query planner chooses an anchor.
std::vector<std::string> sample_paths;
for (uint32_t row = 0; row < v->entries.size(); ++row) {
auto path = v->full_path(row);
if (std::all_of(path.begin(), path.end(), [](unsigned char c) { return c < 128; })) sample_paths.push_back(std::move(path));
}
for (const auto& path : sample_paths) for (size_t start = 0; start < path.size(); start += 2) {
for (size_t length = 2; length <= path.size() - start; length += 3) {
auto fragment = path.substr(start, length); if (fragment.find('\\') == fragment.npos) continue;
wl::Request r; r.flags = wl::MatchCase; r.sort = wl::Sort::None; r.patterns = {fragment}; compare(r);
}
}
wl::Request r; r.patterns = {"foo\\bar", "中文😀\\Ä"}; r.sort = wl::Sort::Name; compare(r);
r.flags = wl::AllPatterns; compare(r); r.flags = wl::CountOnly; compare(r);
r.flags = wl::EverythingSyntax; r.patterns = {"foo\\bar"};
auto total = engine.search(r).total;
r.limit = 1; auto first = engine.search(r); r.offset = 1; auto second = engine.search(r);
require(first.total == total && second.total == total && first.results.size() == 1 && second.results.size() == 1 && first.results[0].path != second.results[0].path,
"relative path cached GUI pagination preserves full count");
r.offset = 0; r.flags = wl::MatchCase; r.sort = wl::Sort::None;
require(engine.search(r).results.size() == 1, "relative path CLI early limit");
r.limit = 0; require(engine.search(r).results.empty(), "relative path zero limit"); r.limit = UINT64_MAX;
rejects([&] { v->find(wl::Query(r), UINT64_MAX, [] { return true; }); }, "relative path cancellation");
// Keep querying the same text while name generations and candidate chains change.
auto verify_mutation = [&] {
for (const std::string pattern : {"foo\\bar", "afoo\\bar", "renamed\\bar", "foo\\", "中文😀\\Ä"}) { r.patterns = {pattern}; compare(r); }
};
v->replace_links(7, {{7, 10, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "bar-moved"}}); verify_mutation();
v->replace_links(10, {{10, 5, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "renamed"}}); verify_mutation();
v->replace_links(8, {{8, 6, 123, 456, 50, 90, 0, "bar-new-link.txt"}, {8, 23, 123, 456, 50, 90, 0, "hardlink.txt"}}); verify_mutation();
v->replace_links(999, {{999, 5, 0, 0, 0, 0, FILE_ATTRIBUTE_DIRECTORY, "foo"}}); verify_mutation();
v->replace_links(999, {}); verify_mutation();
v->compact(); verify_mutation();
auto snapshot = std::filesystem::temp_directory_path() / (L"wlocate-relative-test-" + std::to_wstring(GetCurrentProcessId()) + L".widx");
v->save(snapshot); v = wl::Volume::load(snapshot, "X:\\", 345); engine.set_volumes({v}); verify_mutation(); std::filesystem::remove(snapshot);
for (uint64_t i = 0; i < 30000; ++i) add(1000 + i, 23, "unrelated" + std::to_string(i) + ".txt"); v->finish();
for (const std::string pattern : {"foo\\bar", "foo\\no-such-name", "renamed\\bar\\"}) {
r.patterns = {pattern}; r.sort = wl::Sort::Name; unsigned polls = 0;
engine.search(r, [&] { return ++polls > 8; }); require(true, "relative path avoids scanning 30000 unrelated names");
}
}
void parser_tests() {
auto bytes = record_fixture(true); require(wl::fixup_record(bytes, 512), "MFT sequence fixup");
require(bytes[510] == 0x78 && bytes[511] == 0x56, "sector tail restored");
auto record = wl::parse_record(bytes, 100);
require(record.id == ((7ull << 48) | 100), "FRN includes sequence");
require(record.base_id == ((3ull << 48) | 70), "extension retains base FRN");
require(record.names.size() == 1 && record.names[0].second == "python3.14.exe", "extension filename is preserved");
uint32_t attr_size; memcpy(&attr_size, bytes.data() + 60, sizeof(attr_size));
auto alias = std::vector<uint8_t>(bytes.begin() + 56, bytes.begin() + 56 + attr_size); alias[24 + 65] = 2;
memcpy(bytes.data() + 56 + attr_size, alias.data(), alias.size());
put<uint32_t>(bytes, 56 + 2 * attr_size, UINT32_MAX); put<uint32_t>(bytes, 0x18, 60 + 2 * attr_size); put<uint16_t>(bytes, 0x12, 2);
auto with_dos = wl::parse_record(bytes, 100);
require(with_dos.link_count == 2 && with_dos.filename_attribute_count == 2 && with_dos.names.size() == 1, "DOS aliases count toward MFT completeness but are not search results");
bytes = record_fixture(false); bytes[510] = 0; require(!wl::fixup_record(bytes, 512), "torn record rejected");
bytes = record_fixture(false); wl::fixup_record(bytes, 512); put<uint32_t>(bytes, 60, UINT32_MAX);
rejects([&] { wl::parse_record(bytes, 1); }, "invalid attribute rejected");
std::vector<uint8_t> data{0x11, 3, 100, 0x11, 2, 0xF6, 0x01, 1, 0}; auto runs = wl::parse_runs(data, 0);
require(runs.size() == 3 && runs[0].lcn == 100 && runs[1].lcn == 90 && runs[2].lcn == -1 && runs[2].vcn == 5, "signed and sparse runs");
data = {0x91, 1, 2}; rejects([&] { wl::parse_runs(data, 0); }, "invalid run rejected");
}
void snapshot_tests() {
auto path = std::filesystem::temp_directory_path() / (L"wlocate-test-" + std::to_wstring(GetCurrentProcessId()) + L".widx");
auto volume = fixture(); volume->journal_id = 456; volume->next_usn = 789; volume->save(path);
auto restored = wl::Volume::load(path, "X:\\", 123); require(restored && restored->files == volume->files && restored->next_usn == 789, "snapshot restores counters and journal");
wl::Engine engine; engine.set_volumes({restored}); require(search(engine, {"python*.exe"}).total == 3, "snapshot restores hardlinks and name lookup");
require(!wl::Volume::load(path, "X:\\", 124), "wrong volume serial rejected");
{ std::fstream file(path, std::ios::in | std::ios::out | std::ios::binary); file.seekg(-24, std::ios::end); char byte; file.get(byte); byte ^= 1; file.seekp(-24, std::ios::end); file.put(byte); }
rejects([&] { wl::Volume::load(path, "X:\\", 123); }, "snapshot detects silent data corruption");
volume->checkpoint_usn = 700; volume->save(path);
restored = wl::Volume::load(path, "X:\\", 123); require(restored->next_usn == 700, "snapshot replays unresolved journal updates after restart");
{ std::ofstream file(path, std::ios::trunc | std::ios::binary); file << "bad"; }
rejects([&] { wl::Volume::load(path, "X:\\", 123); }, "truncated snapshot rejected");
std::filesystem::remove(path);
}
}
int main() {
try { encoding_tests(); query_tests(); path_subtree_tests(); relative_path_tests(); parser_tests(); snapshot_tests(); std::cout << checks << " checks passed\n"; return 0; }
catch (const std::exception& error) { std::cerr << "After " << checks << " checks: " << error.what() << '\n'; return 1; }
}
+12
View File
@@ -0,0 +1,12 @@
#include "app.hpp"
#include <fstream>
#include <shellapi.h>
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, wchar_t*, int) {
int count = 0; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
auto output = count > 1 ? std::filesystem::path(args[1]) : std::filesystem::temp_directory_path() / L"WLocate-ui-test";
bool icons = count > 2 && std::wstring_view(args[2]) == L"--file-icons";
LocalFree(args); std::filesystem::create_directories(output);
try { return wl::run_gui(instance, SW_SHOWNOACTIVATE, icons ? L"figma.exe" : L"python*.exe", output); }
catch (const std::exception& error) { std::ofstream file(output / L"failure.txt"); file << error.what(); return 1; }
}
Vendored Submodule
+1
Submodule third_party/pcre2 added at 6f9d7c1373.
+70
View File
@@ -0,0 +1,70 @@
# Reproducible vector artwork and multi-resolution Windows icon. No image libraries
# beyond Windows' System.Drawing are needed; the original Everything image is not used.
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$assets = Join-Path (Split-Path $PSScriptRoot -Parent) 'assets'
New-Item -ItemType Directory -Path $assets -Force | Out-Null
$svg = @'
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 64 64">
<title>WLocate green magnifying glass</title>
<defs><linearGradient id="green" x1="0" y1="2" x2="0" y2="48" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#68eb8d"/><stop offset="1" stop-color="#16a34a"/>
</linearGradient></defs>
<path d="M7 56 L25 38" fill="none" stroke="#14532d" stroke-width="13" stroke-linecap="round"/>
<path d="M7 55 L25 37" fill="none" stroke="#22c55e" stroke-width="8" stroke-linecap="round"/>
<circle cx="39" cy="25" r="19" fill="none" stroke="#14532d" stroke-width="10"/>
<circle cx="39" cy="25" r="19" fill="none" stroke="url(#green)" stroke-width="7"/>
<path d="M22.66 21.53 A16.7 16.7 0 0 1 42.47 8.66" fill="none" stroke="#c0fcd1" stroke-width="1.6" stroke-linecap="round"/>
</svg>
'@
[IO.File]::WriteAllText((Join-Path $assets 'wlocate.svg'), $svg, [Text.UTF8Encoding]::new($false))
$frames = [Collections.Generic.List[byte[]]]::new()
$sizes = @(16,20,24,32,40,48,64,128,256)
foreach ($size in $sizes) {
$high = [Drawing.Bitmap]::new($size * 4, $size * 4, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$canvas = [Drawing.Graphics]::FromImage($high)
$canvas.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::AntiAlias
$canvas.Clear([Drawing.Color]::Transparent)
$canvas.ScaleTransform($size / 16.0, $size / 16.0)
$outline = [Drawing.Pen]::new([Drawing.ColorTranslator]::FromHtml('#14532d'), 13.0)
$outline.StartCap = $outline.EndCap = [Drawing.Drawing2D.LineCap]::Round
$canvas.DrawLine($outline, 7.0, 56.0, 25.0, 38.0)
$handle = [Drawing.Pen]::new([Drawing.ColorTranslator]::FromHtml('#22c55e'), 8.0)
$handle.StartCap = $handle.EndCap = [Drawing.Drawing2D.LineCap]::Round
$canvas.DrawLine($handle, 7.0, 55.0, 25.0, 37.0)
$outline.Width = 10.0
$canvas.DrawEllipse($outline, 20.0, 6.0, 38.0, 38.0)
$gradient = [Drawing.Drawing2D.LinearGradientBrush]::new([Drawing.PointF]::new(0,2), [Drawing.PointF]::new(0,48), [Drawing.ColorTranslator]::FromHtml('#68eb8d'), [Drawing.ColorTranslator]::FromHtml('#16a34a'))
$ring = [Drawing.Pen]::new($gradient, 7.0)
$canvas.DrawEllipse($ring, 20.0, 6.0, 38.0, 38.0)
$highlight = [Drawing.Pen]::new([Drawing.ColorTranslator]::FromHtml('#c0fcd1'), 1.6)
$highlight.StartCap = $highlight.EndCap = [Drawing.Drawing2D.LineCap]::Round
$canvas.DrawArc($highlight, 22.3, 8.3, 33.4, 33.4, 192.0, 90.0)
$canvas.Dispose(); $outline.Dispose(); $handle.Dispose(); $ring.Dispose(); $gradient.Dispose(); $highlight.Dispose()
$bitmap = [Drawing.Bitmap]::new($size, $size, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$canvas = [Drawing.Graphics]::FromImage($bitmap)
$canvas.CompositingMode = [Drawing.Drawing2D.CompositingMode]::SourceCopy
$canvas.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$canvas.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$canvas.DrawImage($high, [Drawing.Rectangle]::new(0,0,$size,$size), 0,0,$high.Width,$high.Height, [Drawing.GraphicsUnit]::Pixel)
$stream = [IO.MemoryStream]::new()
$bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png)
$frames.Add($stream.ToArray())
if ($size -in @(16,32,256)) { $bitmap.Save((Join-Path $assets "wlocate-$size.png"), [Drawing.Imaging.ImageFormat]::Png) }
$stream.Dispose(); $canvas.Dispose(); $bitmap.Dispose(); $high.Dispose()
}
$file = [IO.File]::Create((Join-Path $assets 'wlocate.ico'))
$writer = [IO.BinaryWriter]::new($file)
try {
$writer.Write([uint16]0); $writer.Write([uint16]1); $writer.Write([uint16]$sizes.Count)
$offset = 6 + 16 * $sizes.Count
for ($i = 0; $i -lt $sizes.Count; ++$i) {
$dimension = if ($sizes[$i] -eq 256) { 0 } else { $sizes[$i] }
$writer.Write([byte]$dimension); $writer.Write([byte]$dimension)
$writer.Write([byte]0); $writer.Write([byte]0); $writer.Write([uint16]1); $writer.Write([uint16]32)
$writer.Write([uint32]$frames[$i].Length); $writer.Write([uint32]$offset)
$offset += $frames[$i].Length
}
foreach ($frame in $frames) { $writer.Write($frame) }
} finally { $writer.Dispose(); $file.Dispose() }
Get-Item -LiteralPath (Join-Path $assets 'wlocate.ico')