From dfe79034b7f77e1b6ddef44045ded78e56798040 Mon Sep 17 00:00:00 2001 From: Matt Greathouse Date: Mon, 3 Aug 2026 13:55:57 -0400 Subject: [PATCH 1/3] Add Windows app resolution, desktop wake, and local code signing --- CMakeLists.txt | 6 + README.md | 7 + scripts/build-windows.ps1 | 93 ++++- src/cli/LuaPrelude.cpp | 35 +- src/platform/windows/PlatformWindows.cpp | 366 ++++++++++++++++++-- src/platform/windows/WindowsAppResolver.cpp | 104 ++++++ src/platform/windows/WindowsAppResolver.h | 27 ++ tests/CoreTests.cpp | 60 ++++ 8 files changed, 675 insertions(+), 23 deletions(-) create mode 100644 src/platform/windows/WindowsAppResolver.cpp create mode 100644 src/platform/windows/WindowsAppResolver.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 0056287..980d745 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -266,6 +266,7 @@ if(APPLE) elseif(WIN32) target_sources(computer_cpp_core PRIVATE src/platform/windows/PlatformWindows.cpp + src/platform/windows/WindowsAppResolver.cpp src/platform/windows/ScreenRecordingWindows.cpp src/platform/windows/GobiiCredentialStoreWindows.cpp src/platform/windows/GobiiStartupRegistrationWindows.cpp @@ -285,6 +286,7 @@ elseif(WIN32) shell32 user32 uuid + wtsapi32 ws2_32 ) elseif(UNIX) @@ -589,6 +591,10 @@ if(BUILD_TESTING) src/platform/linux ) + if(WIN32) + target_include_directories(computer_cpp_tests PRIVATE src/platform/windows) + endif() + target_link_libraries(computer_cpp_tests PRIVATE computer_cpp_core diff --git a/README.md b/README.md index 6000fca..868d7fa 100644 --- a/README.md +++ b/README.md @@ -608,6 +608,13 @@ developer environment before invoking CMake. It expects the Visual Studio Desktop development with C++ workload and either `VCPKG_ROOT` or a vcpkg checkout at `build/tools/vcpkg`. +After building, the script signs runtime executables and DLLs when a usable +`CN=Gobii AI computer.cpp` code-signing certificate is available in the current +user's certificate store. This keeps local rebuilds runnable on Windows systems +where Smart App Control is enforcing. If that policy is enabled but the signing +identity is missing or expired, the build stops with an actionable error rather +than launching a tray app whose CLI helper Windows will block. + The macOS build script also creates a reusable local signing identity before the first signed build if you do not already have an Apple Development or Developer ID Application certificate. You can create or refresh that identity diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index ce9b3eb..de24d5d 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -3,7 +3,11 @@ param( [ValidateSet("ComputerCpp", "computer.cpp", "all")] [string]$Target = "ComputerCpp", - [string]$BuildDir = "build/windows-gui" + [string]$BuildDir = "build/windows-gui", + + [string]$CodeSigningSubject = "CN=Gobii AI computer.cpp", + + [switch]$NoCodeSign ) $ErrorActionPreference = "Stop" @@ -58,6 +62,91 @@ function Find-VcpkgToolchain { throw "vcpkg was not found. Set VCPKG_ROOT or install it at build/tools/vcpkg." } +function Test-SmartAppControlEnforced { + try { + $policy = Get-ItemProperty ` + "HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy" ` + -ErrorAction Stop + return $policy.VerifiedAndReputablePolicyState -eq 1 + } catch { + return $false + } +} + +function Find-CodeSigningCertificate { + $now = Get-Date + return Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert ` + -ErrorAction SilentlyContinue | + Where-Object { + $_.Subject -eq $CodeSigningSubject -and + $_.HasPrivateKey -and + $_.NotBefore -le $now -and + $_.NotAfter -gt $now + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 +} + +function Find-SignTool { + $fromPath = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($fromPath) { + return $fromPath.Source + } + + $kitsBin = Join-Path ${env:ProgramFiles(x86)} "Windows Kits/10/bin" + if (Test-Path $kitsBin) { + $candidate = Get-ChildItem $kitsBin -Recurse -Filter signtool.exe ` + -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\x64\\signtool\.exe$" } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($candidate) { + return $candidate.FullName + } + } + + throw "signtool.exe was not found. Install a Windows SDK with the Visual Studio C++ workload." +} + +function Invoke-LocalCodeSigning { + if ($NoCodeSign) { + return + } + + $certificate = Find-CodeSigningCertificate + if (-not $certificate) { + if (Test-SmartAppControlEnforced) { + throw "Smart App Control is enforcing, but no usable '$CodeSigningSubject' certificate exists in Cert:\CurrentUser\My. Install the development signing identity or pass -NoCodeSign only on a machine where unsigned development binaries are allowed." + } + Write-Verbose "Skipping local code signing because '$CodeSigningSubject' was not found." + return + } + + $signTool = Find-SignTool + $artifacts = Get-ChildItem $BuildDir -File | + Where-Object { $_.Extension -in ".exe", ".dll" } + foreach ($artifact in $artifacts) { + $signature = Get-AuthenticodeSignature $artifact.FullName + if ($signature.Status -eq "Valid") { + continue + } + + & $signTool sign ` + /fd SHA256 ` + /sha1 $certificate.Thumbprint ` + /s My ` + $artifact.FullName + if ($LASTEXITCODE -ne 0) { + throw "Code signing failed for $($artifact.FullName) with exit code $LASTEXITCODE." + } + + $signature = Get-AuthenticodeSignature $artifact.FullName + if ($signature.Status -ne "Valid") { + throw "The signature on $($artifact.FullName) did not validate: $($signature.StatusMessage)" + } + } +} + Enter-ComputerCppDeveloperShell $cmakeCache = Join-Path $BuildDir "CMakeCache.txt" @@ -79,3 +168,5 @@ if (-not (Test-Path $cmakeCache)) { if ($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE." } + +Invoke-LocalCodeSigning diff --git a/src/cli/LuaPrelude.cpp b/src/cli/LuaPrelude.cpp index d98cb27..0ed7b45 100644 --- a/src/cli/LuaPrelude.cpp +++ b/src/cli/LuaPrelude.cpp @@ -2166,6 +2166,39 @@ function ac.desktop.focus_app(app, opts) if type(name) ~= "string" or name == "" then return { ok = true, data = { focused = false } } end + local allow_error = option_value(opts, "allowError", "allow_error", false) == true + if not context.dry_run and option_value(opts, "wakeDesktop", "wake_desktop", true) ~= false then + local inspected = ac.request("desktop_session_state", {}, { allow_error = true }) + local session = inspected.data and inspected.data.session or {} + local wake = nil + if inspected.ok == true and session.detectionSupported == true then + wake = ac.request("desktop_wake", { force = true }, { allow_error = true }) + elseif inspected.ok ~= true then + wake = inspected + end + if wake and (wake.ok ~= true or not wake.data or wake.data.ready ~= true) then + local code = wake.code or "desktop_session_unavailable" + local message = wake.error or "desktop did not become ready after wake" + if not allow_error then + error("computer.cpp step focus-wake failed: " .. tostring(message), 2) + end + return { + ok = true, + data = { + results = {{ + ok = false, + id = "focus-wake", + code = code, + error = message, + }}, + requested = 1, + executed = 1, + failed = 1, + stoppedOnError = true, + }, + } + end + end return ac.batch({ { id = "focus-launch", method = "app_launch", params = { query = name } }, { @@ -2178,7 +2211,7 @@ function ac.desktop.focus_app(app, opts) }, }, }, { - allow_error = option_value(opts, "allowError", "allow_error", false) == true, + allow_error = allow_error, allow_start = option_value(opts, "allowStart", "allow_start", nil), }) end diff --git a/src/platform/windows/PlatformWindows.cpp b/src/platform/windows/PlatformWindows.cpp index 3d2139c..fd948d4 100644 --- a/src/platform/windows/PlatformWindows.cpp +++ b/src/platform/windows/PlatformWindows.cpp @@ -5,12 +5,19 @@ #include "computer_cpp/StringUtils.h" #include "computer_cpp/WindowsUtil.h" +#include "WindowsAppResolver.h" + #define NOMINMAX #include #include +#include +#include #include #include +#include +#include #include +#include #include #include @@ -19,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -507,20 +515,206 @@ void AppendElementLines(IUIAutomation* automation, IUIAutomationElement* element PermissionStatus CheckPermissions(bool) { return {true, true}; } DesktopSessionState GetDesktopSessionState() { DesktopSessionState state; - state.available = true; - state.onConsole = true; - state.loginDone = true; + state.detectionSupported = true; + + DWORD processSessionId = 0; + const bool hasProcessSession = + ProcessIdToSessionId(GetCurrentProcessId(), &processSessionId) != FALSE; + const DWORD consoleSessionId = WTSGetActiveConsoleSessionId(); + state.available = hasProcessSession && consoleSessionId != 0xFFFFFFFF; + state.onConsole = state.available && processSessionId == consoleSessionId; + + if (state.onConsole) { + LPWSTR buffer = nullptr; + DWORD bytes = 0; + if (WTSQuerySessionInformationW( + WTS_CURRENT_SERVER_HANDLE, + processSessionId, + WTSSessionInfoEx, + &buffer, + &bytes) != FALSE && + buffer != nullptr && + bytes >= sizeof(WTSINFOEXW)) { + const auto* info = reinterpret_cast(buffer); + if (info->Level == 1) { + const auto& level = info->Data.WTSInfoExLevel1; + state.loginDone = level.SessionState == WTSActive; + state.screenLocked = + level.SessionFlags == WTS_SESSIONSTATE_LOCK; + } + } + if (buffer != nullptr) { + WTSFreeMemory(buffer); + } + } + + BOOL screenSaverRunning = FALSE; + if (SystemParametersInfoW( + SPI_GETSCREENSAVERRUNNING, + 0, + &screenSaverRunning, + 0) != FALSE) { + state.screenSaverActive = screenSaverRunning != FALSE; + } return state; } -bool WakeDesktopSession(bool) { return false; } -bool OpenPermissionsSettings() { return OpenSettingsUri(L"ms-settings:easeofaccess"); } -bool OpenAccessibilitySettings() { return OpenPermissionsSettings(); } -bool OpenScreenCaptureSettings() { return true; } -bool RequestAccessibilityPermission() { return true; } -bool RequestScreenCapturePermission() { return true; } -AppInfo GetFrontmostApp() { - HWND hwnd = GetForegroundWindow(); +bool ComUsable(const ComScope& com) { + return SUCCEEDED(com.hr) || com.hr == RPC_E_CHANGED_MODE; +} + +std::string ShellItemDisplayName(IShellItem* item, SIGDN kind) { + if (!item) { + return {}; + } + PWSTR raw = nullptr; + std::string result; + if (SUCCEEDED(item->GetDisplayName(kind, &raw)) && raw) { + result = Windows::WideToUtf8(raw); + } + CoTaskMemFree(raw); + return result; +} + +std::string ShellItemProperty(IShellItem2* item, REFPROPERTYKEY key) { + if (!item) { + return {}; + } + PWSTR raw = nullptr; + std::string result; + if (SUCCEEDED(item->GetString(key, &raw)) && raw) { + result = Windows::WideToUtf8(raw); + } + CoTaskMemFree(raw); + return result; +} + +std::vector EnumerateInstalledApps() { + std::vector entries; + ComScope com; + if (!ComUsable(com)) { + return entries; + } + + ComPtr appsFolderItem; + if (FAILED(SHGetKnownFolderItem( + FOLDERID_AppsFolder, + KF_FLAG_DEFAULT, + nullptr, + __uuidof(IShellItem), + reinterpret_cast(appsFolderItem.put()))) || + !appsFolderItem) { + return entries; + } + + ComPtr appsFolder; + if (FAILED(appsFolderItem->BindToHandler( + nullptr, + BHID_SFObject, + __uuidof(IShellFolder), + reinterpret_cast(appsFolder.put()))) || + !appsFolder) { + return entries; + } + + ComPtr children; + if (FAILED(appsFolder->EnumObjects( + nullptr, + SHCONTF_NONFOLDERS, + children.put())) || + !children) { + return entries; + } + + PITEMID_CHILD child = nullptr; + ULONG fetched = 0; + while (children->Next(1, &child, &fetched) == S_OK) { + ComPtr item; + if (SUCCEEDED(SHCreateItemWithParent( + nullptr, + appsFolder.get(), + child, + __uuidof(IShellItem2), + reinterpret_cast(item.put()))) && + item) { + WindowsApps::CatalogEntry entry; + entry.displayName = ShellItemDisplayName( + item.get(), SIGDN_NORMALDISPLAY); + entry.appUserModelId = ShellItemProperty( + item.get(), PKEY_AppUserModel_ID); + entry.executablePath = ShellItemProperty( + item.get(), PKEY_Link_TargetParsingPath); + entry.parsingName = ShellItemDisplayName( + item.get(), SIGDN_DESKTOPABSOLUTEPARSING); + if (!entry.displayName.empty()) { + entries.push_back(std::move(entry)); + } + } + CoTaskMemFree(child); + child = nullptr; + fetched = 0; + } + + std::sort(entries.begin(), entries.end(), [](const auto& left, const auto& right) { + return Lowercase(left.displayName) < Lowercase(right.displayName); + }); + return entries; +} + +std::vector InstalledApps(bool forceRefresh = false) { + struct Cache { + std::mutex mutex; + std::vector entries; + std::chrono::steady_clock::time_point refreshedAt{}; + }; + static Cache cache; + std::lock_guard lock(cache.mutex); + const bool expired = cache.entries.empty() || + std::chrono::steady_clock::now() - cache.refreshedAt > + std::chrono::minutes(5); + if (forceRefresh || expired) { + cache.entries = EnumerateInstalledApps(); + cache.refreshedAt = std::chrono::steady_clock::now(); + } + return cache.entries; +} + +WindowsApps::CatalogMatch ResolveInstalledApp(const std::string& query) { + auto match = WindowsApps::MatchCatalog(InstalledApps(), query); + if (!match.entry && !match.ambiguous) { + match = WindowsApps::MatchCatalog(InstalledApps(true), query); + } + return match; +} + +std::string AppUserModelIdForWindow(HWND hwnd) { + if (!hwnd) { + return {}; + } + ComPtr properties; + if (FAILED(SHGetPropertyStoreForWindow( + hwnd, + __uuidof(IPropertyStore), + reinterpret_cast(properties.put()))) || + !properties) { + return {}; + } + + PROPVARIANT value; + PropVariantInit(&value); + std::string appUserModelId; + if (SUCCEEDED(properties->GetValue(PKEY_AppUserModel_ID, &value))) { + if (value.vt == VT_LPWSTR && value.pwszVal) { + appUserModelId = Windows::WideToUtf8(value.pwszVal); + } else if (value.vt == VT_BSTR && value.bstrVal) { + appUserModelId = Windows::WideToUtf8(value.bstrVal); + } + } + PropVariantClear(&value); + return appUserModelId; +} + +AppInfo AppInfoForWindow(HWND hwnd) { if (!hwnd) { return {}; } @@ -529,11 +723,128 @@ AppInfo GetFrontmostApp() { AppInfo info; info.available = true; info.pid = static_cast(pid); + + ComScope com; + std::string appUserModelId; + if (ComUsable(com)) { + appUserModelId = AppUserModelIdForWindow(hwnd); + } + if (!appUserModelId.empty()) { + info.bundleId = appUserModelId; + auto match = WindowsApps::MatchCatalog( + InstalledApps(), appUserModelId); + info.name = match.entry + ? match.entry->displayName + : appUserModelId; + return info; + } + info.name = ProcessName(pid); info.bundleId = info.name; return info; } +bool ShellExecuteTarget(const std::string& target) { + if (target.empty()) { + return false; + } + const std::wstring wide = Utf8ToWide(target); + return reinterpret_cast(ShellExecuteW( + nullptr, + L"open", + wide.c_str(), + nullptr, + nullptr, + SW_SHOWNORMAL)) > 32; +} + +bool ActivateCatalogEntry( + const WindowsApps::CatalogEntry& entry, + AppInfo& appInfo) { + if (!entry.appUserModelId.empty() && + entry.appUserModelId.find('!') != std::string::npos) { + ComScope com; + if (!ComUsable(com)) { + return false; + } + ComPtr manager; + if (FAILED(CoCreateInstance( + CLSID_ApplicationActivationManager, + nullptr, + CLSCTX_LOCAL_SERVER, + __uuidof(IApplicationActivationManager), + reinterpret_cast(manager.put()))) || + !manager) { + return false; + } + DWORD pid = 0; + const std::wstring appUserModelId = + Utf8ToWide(entry.appUserModelId); + if (FAILED(manager->ActivateApplication( + appUserModelId.c_str(), + nullptr, + AO_NONE, + &pid))) { + return false; + } + appInfo.available = true; + appInfo.pid = static_cast(pid); + appInfo.name = entry.displayName; + appInfo.bundleId = entry.appUserModelId; + return true; + } + + const std::string target = !entry.executablePath.empty() + ? entry.executablePath + : entry.parsingName; + if (!ShellExecuteTarget(target)) { + return false; + } + appInfo.available = true; + appInfo.name = entry.displayName; + appInfo.bundleId = !entry.appUserModelId.empty() + ? entry.appUserModelId + : target; + return true; +} + +bool WakeDesktopSession(bool force) { + const DesktopSessionState state = GetDesktopSessionState(); + if (!state.available || !state.onConsole || !state.loginDone || + (state.screenLocked && !state.screenSaverActive && !force)) { + return false; + } + + // ES_DISPLAY_REQUIRED resets the display idle timer and brings a powered- + // down monitor back without changing authentication state. + const bool displayRequested = SetThreadExecutionState( + ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED) != 0; + + bool activitySent = false; + if (state.screenSaverActive) { + // Native user activity dismisses a screen saver. A paired modifier + // press has no text effect and cannot authenticate a locked desktop. + INPUT activity[2]{}; + activity[0].type = INPUT_KEYBOARD; + activity[0].ki.wVk = VK_SHIFT; + activity[1].type = INPUT_KEYBOARD; + activity[1].ki.wVk = VK_SHIFT; + activity[1].ki.dwFlags = KEYEVENTF_KEYUP; + activitySent = SendInput(2, activity, sizeof(INPUT)) == 2; + } + return displayRequested || activitySent; +} +bool OpenPermissionsSettings() { return OpenSettingsUri(L"ms-settings:easeofaccess"); } +bool OpenAccessibilitySettings() { return OpenPermissionsSettings(); } +bool OpenScreenCaptureSettings() { return true; } +bool RequestAccessibilityPermission() { return true; } +bool RequestScreenCapturePermission() { return true; } + +AppInfo GetFrontmostApp() { + HWND hwnd = GetForegroundWindow(); + return AppInfoForWindow(hwnd); +} + std::string GetFrontmostAppSummary() { auto app = GetFrontmostApp(); return app.available ? app.name + " [" + app.bundleId + "] pid=" + std::to_string(app.pid) : "unavailable"; @@ -641,22 +952,35 @@ bool LaunchOrActivateApp(const std::string& query, AppInfo& appInfo) { for (const auto& window : ListWindows(query)) { if (auto hwnd = HwndFromId(window.id)) { if (!ActivateWindow(*hwnd)) { - return false; + continue; } - appInfo.available = true; - appInfo.pid = window.pid; - appInfo.name = window.appClass; - appInfo.bundleId = window.appClass; + appInfo = AppInfoForWindow(*hwnd); return true; } } - std::wstring wide = Utf8ToWide(query); - if (reinterpret_cast(ShellExecuteW(nullptr, L"open", wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL)) <= 32) { + + if (ShellExecuteTarget(query)) { + appInfo.available = true; + appInfo.name = query; + appInfo.bundleId = query; + return true; + } + + const auto resolved = ResolveInstalledApp(query); + if (!resolved.entry) { return false; } - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - appInfo = GetFrontmostApp(); - return true; + + for (const auto& window : ListWindows(resolved.entry->displayName)) { + if (auto hwnd = HwndFromId(window.id)) { + if (!ActivateWindow(*hwnd)) { + continue; + } + appInfo = AppInfoForWindow(*hwnd); + return true; + } + } + return ActivateCatalogEntry(*resolved.entry, appInfo); } bool OpenUrl(const std::string& url, const std::string&, bool, bool) { diff --git a/src/platform/windows/WindowsAppResolver.cpp b/src/platform/windows/WindowsAppResolver.cpp new file mode 100644 index 0000000..3075f8d --- /dev/null +++ b/src/platform/windows/WindowsAppResolver.cpp @@ -0,0 +1,104 @@ +#include "WindowsAppResolver.h" + +#include "computer_cpp/StringUtils.h" + +#include +#include +#include + +namespace ComputerCpp::Platform::WindowsApps { +namespace { + +bool EqualsCaseInsensitive(const std::string& left, const std::string& right) { + return Lowercase(left) == Lowercase(right); +} + +std::string ExecutableLookupName(const CatalogEntry& entry) { + if (entry.executablePath.empty()) { + return {}; + } + return NormalizeLookupName( + std::filesystem::path(entry.executablePath).filename().string()); +} + +template +std::vector Filter( + const std::vector& entries, + Predicate predicate) { + std::vector matches; + for (const auto& entry : entries) { + if (predicate(entry)) { + matches.push_back(entry); + } + } + return matches; +} + +CatalogMatch Select(std::vector candidates) { + CatalogMatch result; + result.candidates = std::move(candidates); + result.ambiguous = result.candidates.size() > 1; + if (result.candidates.size() == 1) { + result.entry = result.candidates.front(); + } + return result; +} + +} // namespace + +std::string NormalizeLookupName(const std::string& value) { + std::string lower = Lowercase(Trim(value)); + if (lower.size() > 4 && lower.ends_with(".exe")) { + lower.resize(lower.size() - 4); + } + std::string normalized; + normalized.reserve(lower.size()); + for (unsigned char c : lower) { + if (std::isalnum(c)) { + normalized.push_back(static_cast(c)); + } + } + return normalized; +} + +CatalogMatch MatchCatalog( + const std::vector& entries, + const std::string& query) { + if (query.empty()) { + return {}; + } + + auto identityMatches = Filter(entries, [&](const CatalogEntry& entry) { + return (!entry.appUserModelId.empty() && + EqualsCaseInsensitive(query, entry.appUserModelId)) || + (!entry.parsingName.empty() && + EqualsCaseInsensitive(query, entry.parsingName)) || + (!entry.executablePath.empty() && + EqualsCaseInsensitive(query, entry.executablePath)); + }); + if (!identityMatches.empty()) { + return Select(std::move(identityMatches)); + } + + const std::string normalizedQuery = NormalizeLookupName(query); + if (normalizedQuery.empty()) { + return {}; + } + auto exactMatches = Filter(entries, [&](const CatalogEntry& entry) { + return NormalizeLookupName(entry.displayName) == normalizedQuery || + ExecutableLookupName(entry) == normalizedQuery; + }); + if (!exactMatches.empty()) { + return Select(std::move(exactMatches)); + } + + auto partialMatches = Filter(entries, [&](const CatalogEntry& entry) { + const std::string display = NormalizeLookupName(entry.displayName); + const std::string executable = ExecutableLookupName(entry); + return (!display.empty() && display.find(normalizedQuery) != std::string::npos) || + (!executable.empty() && executable.find(normalizedQuery) != std::string::npos); + }); + return Select(std::move(partialMatches)); +} + +} // namespace ComputerCpp::Platform::WindowsApps diff --git a/src/platform/windows/WindowsAppResolver.h b/src/platform/windows/WindowsAppResolver.h new file mode 100644 index 0000000..6c64470 --- /dev/null +++ b/src/platform/windows/WindowsAppResolver.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace ComputerCpp::Platform::WindowsApps { + +struct CatalogEntry { + std::string displayName; + std::string appUserModelId; + std::string executablePath; + std::string parsingName; +}; + +struct CatalogMatch { + std::optional entry; + std::vector candidates; + bool ambiguous = false; +}; + +std::string NormalizeLookupName(const std::string& value); +CatalogMatch MatchCatalog( + const std::vector& entries, + const std::string& query); + +} // namespace ComputerCpp::Platform::WindowsApps diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index 06a5698..0cf199f 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -14,6 +14,10 @@ #include "LinuxPng.h" #include "TestSupport.h" + +#if defined(_WIN32) +#include "WindowsAppResolver.h" +#endif #include "UpdaterInternal.h" #include @@ -66,6 +70,59 @@ void TestStringUtils() { assert(ComputerCpp::Join(keys, ",") == "Cmd,Shift,G"); } +#if defined(_WIN32) +void TestWindowsAppCatalogMatching() { + using ComputerCpp::Platform::WindowsApps::CatalogEntry; + using ComputerCpp::Platform::WindowsApps::MatchCatalog; + using ComputerCpp::Platform::WindowsApps::NormalizeLookupName; + + const std::vector entries = { + { + "Calculator", + "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + "", + "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + }, + { + "Visual Studio Code", + "Microsoft.VisualStudioCode", + "C:\\Program Files\\Microsoft VS Code\\Code.exe", + "", + }, + {"Calculator Preview", "Example.CalculatorPreview!App", "", ""}, + }; + + assert(NormalizeLookupName(" Visual-Studio Code.exe ") == + "visualstudiocode"); + + auto calculator = MatchCatalog(entries, "Calculator"); + assert(calculator.entry.has_value()); + assert(calculator.entry->appUserModelId == + "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"); + assert(!calculator.ambiguous); + + auto calculatorId = MatchCatalog( + entries, + "microsoft.windowscalculator_8wekyb3d8bbwe!app"); + assert(calculatorId.entry.has_value()); + assert(calculatorId.entry->displayName == "Calculator"); + + auto code = MatchCatalog(entries, "code.exe"); + assert(code.entry.has_value()); + assert(code.entry->displayName == "Visual Studio Code"); + + auto ambiguous = MatchCatalog(entries, "calc"); + assert(!ambiguous.entry.has_value()); + assert(ambiguous.ambiguous); + assert(ambiguous.candidates.size() == 2); + + auto missing = MatchCatalog(entries, "Definitely Missing"); + assert(!missing.entry.has_value()); + assert(!missing.ambiguous); + assert(missing.candidates.empty()); +} +#endif + void TestBrowserRegistry() { assert(ComputerCpp::NormalizeBrowserId("Google Chrome") == "chrome"); assert(ComputerCpp::NormalizeBrowserId("msedge.exe") == "edge"); @@ -1189,6 +1246,9 @@ int main() { SetEnvValue("COMPUTER_CPP_HOME", tempHome.string()); RunTest("StringUtils", TestStringUtils); +#if defined(_WIN32) + RunTest("WindowsAppCatalogMatching", TestWindowsAppCatalogMatching); +#endif RunTest("BrowserRegistry", TestBrowserRegistry); RunTest("AppConfigServerRoundTrip", TestAppConfigServerRoundTrip); RunTest("ServerAppNameValidation", TestServerAppNameValidation); From 12f6e25252fb3716af1c2b8fcb17372d2ccf6602 Mon Sep 17 00:00:00 2001 From: Matt Greathouse Date: Fri, 7 Aug 2026 22:35:51 -0400 Subject: [PATCH 2/3] Harden managed browser navigation and Windows input delivery --- src/cli/LuaPrelude.cpp | 127 ++++++++++++++++++++++ src/daemon/DaemonTextInput.cpp | 7 +- src/platform/windows/PlatformWindows.cpp | 56 ++++++++-- src/platform/windows/WindowsNativeInput.h | 19 ++++ tests/CliTests.cpp | 17 +++ tests/CoreTests.cpp | 87 +++++++++++++++ tests/lua/managed-browser-reuse.lua | 46 +++++++- 7 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 src/platform/windows/WindowsNativeInput.h diff --git a/src/cli/LuaPrelude.cpp b/src/cli/LuaPrelude.cpp index 0ed7b45..3421960 100644 --- a/src/cli/LuaPrelude.cpp +++ b/src/cli/LuaPrelude.cpp @@ -2131,6 +2131,133 @@ function ac.browser.managed.focus(opts) return ac.browser.managed.ensure(merge(opts or {}, { newWindow = false })) end +local function managed_navigation_current_url(target_id, opts) + local result = ac.browser.eval("location.href", managed_browser_options(opts, { + targetId = tostring(target_id or ""), + targetUrlPrefix = "", + targetTitle = "", + launch = false, + })) + if not result or not result.ok or not result.data then return nil, result end + return tostring(result.data.value or result.data.targetUrl or ""), result +end + +local function managed_navigation_changed(target_id, previous_url, opts) + local timeout_ms = tonumber(opts.navigationAttemptTimeoutMs) or 2500 + local function inspect() + local current_url = managed_navigation_current_url(target_id, opts) + if current_url ~= nil and current_url ~= previous_url then return current_url end + return false + end + if timeout_ms <= 0 then return inspect() or nil end + return managed_wait(inspect, timeout_ms, opts.navigationPollMs or 100) +end + +local function managed_navigation_input(url, paste) + local selected = ac.request("press", { + keys = { "primary", "l" }, + holdMs = 40, + }, { allow_error = true }) + if not selected or not selected.ok then return false, selected end + managed_delay(80) + local typed = ac.request("type", { + text = url, + paste = paste == true, + holdMs = paste == true and 20 or 1, + }, { allow_error = true }) + if not typed or not typed.ok then return false, typed end + managed_delay(80) + local submitted = ac.request("press", { + keys = "enter", + holdMs = 40, + }, { allow_error = true }) + if not submitted or not submitted.ok then return false, submitted end + return true, submitted +end + +local function managed_navigation_success(surface, previous_url, current_url, attempts) + local data = merge(surface or {}, { + previousUrl = previous_url, + currentUrl = current_url, + attempts = attempts, + retried = attempts > 1, + navigated = current_url ~= previous_url, + }) + return { ok = true, data = data } +end + +function ac.browser.managed.navigate(url, opts) + opts = opts or {} + url = tostring(url or "") + if url == "" or not url:match("^https?://") then + return { + ok = false, + code = "invalid_managed_browser", + error = "managed browser navigation URL must be an absolute HTTP(S) URL", + } + end + + local focused = ac.browser.managed.focus(opts) + if not focused or not focused.ok or not focused.data then return focused end + local surface = focused.data + local target_id = tostring(surface.targetId or "") + local previous_url = managed_navigation_current_url(target_id, opts) + if target_id == "" or previous_url == nil then + return { + ok = false, + code = "browser_navigation_failed", + error = "could not inspect the exact managed browser target before navigation", + data = merge(surface, { currentUrl = tostring(surface.currentUrl or ""), attempts = 0 }), + } + end + if previous_url == url then + return managed_navigation_success(surface, previous_url, previous_url, 0) + end + + local first_dispatched = managed_navigation_input(url, true) + if first_dispatched then + local current_url = managed_navigation_changed(target_id, previous_url, opts) + if current_url then + return managed_navigation_success(surface, previous_url, current_url, 1) + end + end + + -- A successful SendInput call does not prove Chrome consumed it. Rebind and + -- refocus the persisted target before retrying without the clipboard path. + local refocused = ac.browser.managed.focus(opts) + if refocused and refocused.ok and refocused.data then + surface = refocused.data + target_id = tostring(surface.targetId or target_id) + end + local observed_url = managed_navigation_current_url(target_id, opts) or previous_url + if observed_url ~= previous_url then + return managed_navigation_success(surface, previous_url, observed_url, 1) + end + + local second_dispatched = managed_navigation_input(url, false) + if second_dispatched then + local current_url = managed_navigation_changed(target_id, previous_url, opts) + if current_url then + return managed_navigation_success(surface, previous_url, current_url, 2) + end + end + + observed_url = managed_navigation_current_url(target_id, opts) or observed_url + return { + ok = false, + code = "browser_navigation_failed", + error = "managed browser stayed at " .. tostring(observed_url) .. + "; expected navigation to " .. url, + data = merge(surface, { + previousUrl = previous_url, + currentUrl = observed_url, + expectedUrl = url, + attempts = 2, + retried = true, + }), + } +end + )LUA" R"LUA(function ac.wait(opts, request_opts) return ac.request("wait", opts or {}, request_opts or {}) end function ac.wait_frontmost(app, opts) return ac.wait(merge({ frontmost = app }, opts)) end function ac.wait_stable_screen(ms, opts) return ac.wait(merge({ stable_screen_ms = ms }, opts)) end diff --git a/src/daemon/DaemonTextInput.cpp b/src/daemon/DaemonTextInput.cpp index c33d94a..2722e3f 100644 --- a/src/daemon/DaemonTextInput.cpp +++ b/src/daemon/DaemonTextInput.cpp @@ -156,9 +156,14 @@ json RunPressCommand(const json& params) { if (*holdMs < 1 || *holdMs > 5000) { return Error("press holdMs must be between 1 and 5000", "invalid_key"); } + for (const auto& key : keys) { + if (Platform::ResolveKeycode(key) < 0) { + return Error("could not resolve key chord", "invalid_key"); + } + } bool ok = Platform::SendHotkey(keys, *holdMs); if (!ok) { - return Error("could not resolve key chord", "invalid_key"); + return Error("native key input failed", "input_failed"); } return Ok({{"keys", keys}}); } diff --git a/src/platform/windows/PlatformWindows.cpp b/src/platform/windows/PlatformWindows.cpp index fd948d4..4320949 100644 --- a/src/platform/windows/PlatformWindows.cpp +++ b/src/platform/windows/PlatformWindows.cpp @@ -6,6 +6,7 @@ #include "computer_cpp/WindowsUtil.h" #include "WindowsAppResolver.h" +#include "WindowsNativeInput.h" #define NOMINMAX #include @@ -321,22 +322,30 @@ std::optional KeyNameToVirtualKey(const std::string& keyName) { return std::nullopt; } -void SendVirtualKey(WORD vk, bool down) { +WindowsInput::SendInputFunction& NativeInputSender() { + static WindowsInput::SendInputFunction sender = + [](UINT count, LPINPUT inputs, int inputSize) { + return ::SendInput(count, inputs, inputSize); + }; + return sender; +} + +bool SendVirtualKey(WORD vk, bool down) { INPUT input{}; input.type = INPUT_KEYBOARD; input.ki.wVk = vk; if (!down) { input.ki.dwFlags = KEYEVENTF_KEYUP; } - SendInput(1, &input, sizeof(INPUT)); + return NativeInputSender()(1, &input, sizeof(INPUT)) == 1; } -void SendUnicodeChar(wchar_t ch, bool down) { +bool SendUnicodeChar(wchar_t ch, bool down) { INPUT input{}; input.type = INPUT_KEYBOARD; input.ki.wScan = ch; input.ki.dwFlags = KEYEVENTF_UNICODE | (down ? 0 : KEYEVENTF_KEYUP); - SendInput(1, &input, sizeof(INPUT)); + return NativeInputSender()(1, &input, sizeof(INPUT)) == 1; } Image::RgbImage CaptureRegion(int left, int top, int width, int height) { @@ -512,6 +521,18 @@ void AppendElementLines(IUIAutomation* automation, IUIAutomationElement* element } +void WindowsInput::SetSendInputFunctionForTesting(SendInputFunction sender) { + NativeInputSender() = sender + ? std::move(sender) + : SendInputFunction([](UINT count, LPINPUT inputs, int inputSize) { + return ::SendInput(count, inputs, inputSize); + }); +} + +void WindowsInput::ResetSendInputFunctionForTesting() { + SetSendInputFunctionForTesting({}); +} + PermissionStatus CheckPermissions(bool) { return {true, true}; } DesktopSessionState GetDesktopSessionState() { DesktopSessionState state; @@ -1084,18 +1105,31 @@ bool SendHotkey(const std::vector& keys, int holdMs) { if (!vk) return false; vks.push_back(*vk); } - for (WORD vk : vks) SendVirtualKey(vk, true); + if (vks.empty()) return false; + std::vector pressed; + for (WORD vk : vks) { + if (!SendVirtualKey(vk, true)) { + for (auto it = pressed.rbegin(); it != pressed.rend(); ++it) { + SendVirtualKey(*it, false); + } + return false; + } + pressed.push_back(vk); + } std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, holdMs))); - for (auto it = vks.rbegin(); it != vks.rend(); ++it) SendVirtualKey(*it, false); - return true; + bool released = true; + for (auto it = pressed.rbegin(); it != pressed.rend(); ++it) { + released = SendVirtualKey(*it, false) && released; + } + return released; } bool TypeCharacter(const std::string& character, int holdMs) { std::wstring wide = Utf8ToWide(character); for (wchar_t ch : wide) { - SendUnicodeChar(ch, true); + if (!SendUnicodeChar(ch, true)) return false; std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, holdMs))); - SendUnicodeChar(ch, false); + if (!SendUnicodeChar(ch, false)) return false; } return !wide.empty(); } @@ -1103,9 +1137,9 @@ bool TypeCharacter(const std::string& character, int holdMs) { bool TypeText(const std::string& text, int holdMs) { std::wstring wide = Utf8ToWide(text); for (wchar_t ch : wide) { - SendUnicodeChar(ch, true); + if (!SendUnicodeChar(ch, true)) return false; std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, holdMs))); - SendUnicodeChar(ch, false); + if (!SendUnicodeChar(ch, false)) return false; } return true; } diff --git a/src/platform/windows/WindowsNativeInput.h b/src/platform/windows/WindowsNativeInput.h new file mode 100644 index 0000000..dbd271d --- /dev/null +++ b/src/platform/windows/WindowsNativeInput.h @@ -0,0 +1,19 @@ +#pragma once + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include + +namespace ComputerCpp::Platform::WindowsInput { + +using SendInputFunction = std::function; + +// Test seam for exercising partial and failed native input delivery without +// sending real input to the interactive desktop. +void SetSendInputFunctionForTesting(SendInputFunction sender); +void ResetSendInputFunctionForTesting(); + +} diff --git a/tests/CliTests.cpp b/tests/CliTests.cpp index 8954ab0..585d3c3 100644 --- a/tests/CliTests.cpp +++ b/tests/CliTests.cpp @@ -3470,6 +3470,23 @@ void TestManagedBrowserSurfacePersistsAndFocusReuses() { assert(data["new_window_presses"] == 0); assert(data["native_window_requests"] == 1); assert(data["state_exists"] == true); + assert(data["first_navigation_ok"] == true); + assert(data["first_navigation_attempts"] == 1); + assert(data["first_navigation_modes"] == "paste"); + assert(data["retry_navigation_ok"] == true); + assert(data["retry_navigation_attempts"] == 2); + assert(data["retry_navigation_modes"] == "paste,direct"); + assert(data["failed_navigation_ok"] == false); + assert(data["failed_navigation_code"] == "browser_navigation_failed"); + assert(data["failed_navigation_attempts"] == 2); + assert(data["failed_navigation_url"] == "https://example.test/retry"); + assert(data["failed_navigation_modes"] == "paste,direct"); + assert(data["first_navigation_target"] == "target-1"); + assert(data["retry_navigation_target"] == data["first_navigation_target"]); + assert(data["failed_navigation_target"] == data["first_navigation_target"]); + assert(data["first_navigation_window"] == "4242"); + assert(data["retry_navigation_window"] == data["first_navigation_window"]); + assert(data["failed_navigation_window"] == data["first_navigation_window"]); } void TestManagedBrowserSubmitsFilledFlattenedProxyAuthPrompt() { diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index 0cf199f..ef98b76 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -6,6 +6,7 @@ #include "computer_cpp/Image.h" #include "computer_cpp/LuaRunner.h" #include "computer_cpp/NativeDeps.h" +#include "computer_cpp/Platform.h" #include "computer_cpp/RefStore.h" #include "computer_cpp/StringUtils.h" #include "computer_cpp/Timeline.h" @@ -17,6 +18,8 @@ #if defined(_WIN32) #include "WindowsAppResolver.h" +#include "WindowsNativeInput.h" +#include "DaemonTextInput.h" #endif #include "UpdaterInternal.h" @@ -71,6 +74,89 @@ void TestStringUtils() { } #if defined(_WIN32) +class ScopedWindowsInputSender { +public: + explicit ScopedWindowsInputSender( + ComputerCpp::Platform::WindowsInput::SendInputFunction sender) { + ComputerCpp::Platform::WindowsInput::SetSendInputFunctionForTesting( + std::move(sender)); + } + ~ScopedWindowsInputSender() { + ComputerCpp::Platform::WindowsInput::ResetSendInputFunctionForTesting(); + } +}; + +void TestWindowsNativeInputDelivery() { + using ComputerCpp::Platform::SendHotkey; + using ComputerCpp::Platform::TypeText; + + { + int calls = 0; + ScopedWindowsInputSender sender([&](UINT, LPINPUT, int) { + ++calls; + return static_cast(0); + }); + assert(!SendHotkey({"primary", "l"}, 1)); + assert(calls == 1); + + const auto invalid = ComputerCpp::RunPressCommand({ + {"keys", nlohmann::json::array({"not-a-real-key"})}, + {"holdMs", 1}, + }); + assert(invalid["ok"] == false); + assert(invalid["code"] == "invalid_key"); + + const auto failed = ComputerCpp::RunPressCommand({ + {"keys", nlohmann::json::array({"primary", "l"})}, + {"holdMs", 1}, + }); + assert(failed["ok"] == false); + assert(failed["code"] == "input_failed"); + } + + { + std::vector events; + int calls = 0; + ScopedWindowsInputSender sender([&](UINT count, LPINPUT inputs, int) { + events.insert(events.end(), inputs, inputs + count); + ++calls; + return calls == 2 ? static_cast(0) : count; + }); + assert(!SendHotkey({"primary", "l"}, 1)); + assert(events.size() == 3); + assert(events[0].ki.wVk == VK_CONTROL); + assert((events[0].ki.dwFlags & KEYEVENTF_KEYUP) == 0); + assert(events[1].ki.wVk == 'L'); + assert((events[1].ki.dwFlags & KEYEVENTF_KEYUP) == 0); + assert(events[2].ki.wVk == VK_CONTROL); + assert((events[2].ki.dwFlags & KEYEVENTF_KEYUP) != 0); + } + + { + int calls = 0; + ScopedWindowsInputSender sender([&](UINT count, LPINPUT, int) { + ++calls; + return calls == 2 ? static_cast(0) : count; + }); + assert(!TypeText("x", 1)); + assert(calls == 2); + } + + { + std::vector events; + ScopedWindowsInputSender sender([&](UINT count, LPINPUT inputs, int) { + events.insert(events.end(), inputs, inputs + count); + return count; + }); + assert(SendHotkey({"primary", "l"}, 1)); + assert(TypeText("x", 1)); + assert(events.size() == 6); + assert((events[4].ki.dwFlags & KEYEVENTF_UNICODE) != 0); + assert((events[4].ki.dwFlags & KEYEVENTF_KEYUP) == 0); + assert((events[5].ki.dwFlags & KEYEVENTF_KEYUP) != 0); + } +} + void TestWindowsAppCatalogMatching() { using ComputerCpp::Platform::WindowsApps::CatalogEntry; using ComputerCpp::Platform::WindowsApps::MatchCatalog; @@ -1247,6 +1333,7 @@ int main() { RunTest("StringUtils", TestStringUtils); #if defined(_WIN32) + RunTest("WindowsNativeInputDelivery", TestWindowsNativeInputDelivery); RunTest("WindowsAppCatalogMatching", TestWindowsAppCatalogMatching); #endif RunTest("BrowserRegistry", TestBrowserRegistry); diff --git a/tests/lua/managed-browser-reuse.lua b/tests/lua/managed-browser-reuse.lua index 0803a3e..f7187f1 100644 --- a/tests/lua/managed-browser-reuse.lua +++ b/tests/lua/managed-browser-reuse.lua @@ -5,6 +5,9 @@ local typed_url = "" local bootstrap_calls = 0 local new_window_presses = 0 local native_window_requests = 0 +local navigation_mode = "success" +local last_type_used_paste = false +local navigation_type_modes = {} local function browser_data(extra) local data = { @@ -56,13 +59,18 @@ ac.request = function(method, params) end if method == "type" then typed_url = tostring(params.text or "") + last_type_used_paste = params.paste == true + table.insert(navigation_type_modes, last_type_used_paste and "paste" or "direct") return { ok = true, data = {} } end if method == "press" then if type(params.keys) == "table" and params.keys[1] == "primary" and params.keys[2] == "n" then new_window_presses = new_window_presses + 1 elseif params.keys == "enter" then - current_url = typed_url + if navigation_mode == "success" or + (navigation_mode == "retry" and not last_type_used_paste) then + current_url = typed_url + end end return { ok = true, data = {} } end @@ -74,10 +82,29 @@ local options = { startUrl = "https://example.test/start", startUrlPrefix = "https://example.test/", launch = true, + navigationAttemptTimeoutMs = 0, } local first = ac.browser.managed.ensure(options) local focused = ac.browser.managed.focus(options) +navigation_type_modes = {} +navigation_mode = "success" +local first_navigation = ac.browser.managed.navigate( + "https://example.test/first", options) +local first_navigation_modes = table.concat(navigation_type_modes, ",") + +navigation_type_modes = {} +navigation_mode = "retry" +local retry_navigation = ac.browser.managed.navigate( + "https://example.test/retry", options) +local retry_navigation_modes = table.concat(navigation_type_modes, ",") + +navigation_type_modes = {} +navigation_mode = "ignored" +local failed_navigation = ac.browser.managed.navigate( + "https://example.test/ignored", options) +local failed_navigation_modes = table.concat(navigation_type_modes, ",") + local root = os.getenv("COMPUTER_CPP_HOME") local separator = package.config:sub(1, 1) local state_file = io.open(root .. separator .. "managed-browser-surfaces.json", "r") @@ -92,4 +119,21 @@ return { new_window_presses = new_window_presses, native_window_requests = native_window_requests, state_exists = state_exists, + first_navigation_ok = first_navigation and first_navigation.ok == true, + first_navigation_attempts = first_navigation and first_navigation.data and first_navigation.data.attempts, + first_navigation_modes = first_navigation_modes, + first_navigation_target = first_navigation and first_navigation.data and first_navigation.data.targetId, + first_navigation_window = first_navigation and first_navigation.data and first_navigation.data.windowId, + retry_navigation_ok = retry_navigation and retry_navigation.ok == true, + retry_navigation_attempts = retry_navigation and retry_navigation.data and retry_navigation.data.attempts, + retry_navigation_modes = retry_navigation_modes, + retry_navigation_target = retry_navigation and retry_navigation.data and retry_navigation.data.targetId, + retry_navigation_window = retry_navigation and retry_navigation.data and retry_navigation.data.windowId, + failed_navigation_ok = failed_navigation and failed_navigation.ok == true, + failed_navigation_code = failed_navigation and failed_navigation.code, + failed_navigation_attempts = failed_navigation and failed_navigation.data and failed_navigation.data.attempts, + failed_navigation_url = failed_navigation and failed_navigation.data and failed_navigation.data.currentUrl, + failed_navigation_modes = failed_navigation_modes, + failed_navigation_target = failed_navigation and failed_navigation.data and failed_navigation.data.targetId, + failed_navigation_window = failed_navigation and failed_navigation.data and failed_navigation.data.windowId, } From af15f631f04c72dfc9d14b6c63ed789fff36412a Mon Sep 17 00:00:00 2001 From: Matt Greathouse Date: Fri, 7 Aug 2026 23:13:06 -0400 Subject: [PATCH 3/3] Improve desktop session handling and navigation matching --- include/computer_cpp/Platform.h | 2 + scripts/build-windows.ps1 | 5 +- src/cli/LuaPrelude.cpp | 44 +++++++++++-- src/daemon/DaemonDesktop.cpp | 15 +++-- src/daemon/DaemonJson.cpp | 3 +- src/daemon/DaemonTextInput.cpp | 3 +- src/platform/linux/PlatformLinux.cpp | 5 +- src/platform/windows/PlatformWindows.cpp | 82 ++++++++++++++++++------ tests/CliTests.cpp | 11 ++++ tests/CoreTests.cpp | 7 ++ tests/DaemonDispatchTests.cpp | 7 ++ tests/lua/managed-browser-reuse.lua | 37 +++++++++++ 12 files changed, 186 insertions(+), 35 deletions(-) diff --git a/include/computer_cpp/Platform.h b/include/computer_cpp/Platform.h index de961a0..28a1414 100644 --- a/include/computer_cpp/Platform.h +++ b/include/computer_cpp/Platform.h @@ -24,6 +24,7 @@ struct DesktopSessionState { bool detectionSupported = false; bool available = false; bool onConsole = false; + bool interactive = false; bool loginDone = false; bool screenLocked = false; bool screenSaverActive = false; @@ -35,6 +36,7 @@ struct AppInfo { int pid = -1; std::string name; std::string bundleId; + std::string executable; }; struct WindowInfo { diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index de24d5d..96c388a 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -141,8 +141,9 @@ function Invoke-LocalCodeSigning { } $signature = Get-AuthenticodeSignature $artifact.FullName - if ($signature.Status -ne "Valid") { - throw "The signature on $($artifact.FullName) did not validate: $($signature.StatusMessage)" + if (-not $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint) { + throw "The signature on $($artifact.FullName) was not created with the requested development certificate: $($signature.StatusMessage)" } } } diff --git a/src/cli/LuaPrelude.cpp b/src/cli/LuaPrelude.cpp index 3421960..bdca633 100644 --- a/src/cli/LuaPrelude.cpp +++ b/src/cli/LuaPrelude.cpp @@ -2142,11 +2142,39 @@ local function managed_navigation_current_url(target_id, opts) return tostring(result.data.value or result.data.targetUrl or ""), result end -local function managed_navigation_changed(target_id, previous_url, opts) +local function managed_navigation_normalize_url(url) + url = tostring(url or ""):gsub("#.*$", "") + local scheme, authority, remainder = url:match("^([Hh][Tt][Tt][Pp][Ss]?)://([^/?]*)(.*)$") + if not scheme then return url end + scheme = scheme:lower() + authority = authority:lower() + if scheme == "http" then + authority = authority:gsub(":80$", "") + elseif scheme == "https" then + authority = authority:gsub(":443$", "") + end + local path, query = remainder:match("^([^?]*)(.*)$") + path = tostring(path or ""):gsub("/+$", "") + if path == "" then path = "/" end + return scheme .. "://" .. authority .. path .. tostring(query or "") +end + +local function managed_navigation_matches(current_url, expected_url, opts) + if managed_navigation_normalize_url(current_url) == + managed_navigation_normalize_url(expected_url) then return true end + if type(opts.navigationUrlMatches) == "function" then + local ok, matched = pcall(opts.navigationUrlMatches, current_url, expected_url) + if ok and matched == true then return true end + end + return false +end + +local function managed_navigation_reached(target_id, expected_url, opts) local timeout_ms = tonumber(opts.navigationAttemptTimeoutMs) or 2500 local function inspect() local current_url = managed_navigation_current_url(target_id, opts) - if current_url ~= nil and current_url ~= previous_url then return current_url end + if current_url ~= nil and + managed_navigation_matches(current_url, expected_url, opts) then return current_url end return false end if timeout_ms <= 0 then return inspect() or nil end @@ -2210,13 +2238,13 @@ function ac.browser.managed.navigate(url, opts) data = merge(surface, { currentUrl = tostring(surface.currentUrl or ""), attempts = 0 }), } end - if previous_url == url then + if managed_navigation_matches(previous_url, url, opts) then return managed_navigation_success(surface, previous_url, previous_url, 0) end local first_dispatched = managed_navigation_input(url, true) if first_dispatched then - local current_url = managed_navigation_changed(target_id, previous_url, opts) + local current_url = managed_navigation_reached(target_id, url, opts) if current_url then return managed_navigation_success(surface, previous_url, current_url, 1) end @@ -2230,13 +2258,13 @@ function ac.browser.managed.navigate(url, opts) target_id = tostring(surface.targetId or target_id) end local observed_url = managed_navigation_current_url(target_id, opts) or previous_url - if observed_url ~= previous_url then + if managed_navigation_matches(observed_url, url, opts) then return managed_navigation_success(surface, previous_url, observed_url, 1) end local second_dispatched = managed_navigation_input(url, false) if second_dispatched then - local current_url = managed_navigation_changed(target_id, previous_url, opts) + local current_url = managed_navigation_reached(target_id, url, opts) if current_url then return managed_navigation_success(surface, previous_url, current_url, 2) end @@ -2299,7 +2327,9 @@ function ac.desktop.focus_app(app, opts) local session = inspected.data and inspected.data.session or {} local wake = nil if inspected.ok == true and session.detectionSupported == true then - wake = ac.request("desktop_wake", { force = true }, { allow_error = true }) + if session.ready ~= true then + wake = ac.request("desktop_wake", { force = false }, { allow_error = true }) + end elseif inspected.ok ~= true then wake = inspected end diff --git a/src/daemon/DaemonDesktop.cpp b/src/daemon/DaemonDesktop.cpp index d5d097b..096e528 100644 --- a/src/daemon/DaemonDesktop.cpp +++ b/src/daemon/DaemonDesktop.cpp @@ -114,7 +114,7 @@ json DesktopSessionToJson(const Platform::DesktopSessionState& state) { std::string status = "ready"; if (!state.detectionSupported) { status = "unsupported"; - } else if (!state.available || !state.onConsole || !state.loginDone) { + } else if (!state.available || (!state.onConsole && !state.interactive) || !state.loginDone) { status = "unavailable"; } else if (state.screenLocked) { status = "locked"; @@ -127,6 +127,7 @@ json DesktopSessionToJson(const Platform::DesktopSessionState& state) { {"detectionSupported", state.detectionSupported}, {"available", state.available}, {"onConsole", state.onConsole}, + {"interactive", state.interactive}, {"loginDone", state.loginDone}, {"screenLocked", state.screenLocked}, {"screenSaverActive", state.screenSaverActive}, @@ -151,7 +152,7 @@ std::set VisibleWindowIds(const std::vector& bool IsDesktopSessionReady(const Platform::DesktopSessionState& state) { return state.detectionSupported && state.available && - state.onConsole && + (state.onConsole || state.interactive) && state.loginDone && !state.screenLocked && !state.screenSaverActive && @@ -161,7 +162,7 @@ bool IsDesktopSessionReady(const Platform::DesktopSessionState& state) { bool CanAttemptDesktopWake(const Platform::DesktopSessionState& state, bool force) { return state.detectionSupported && state.available && - state.onConsole && + (state.onConsole || state.interactive) && state.loginDone && (!state.screenLocked || state.screenSaverActive || force); } @@ -252,7 +253,7 @@ json RunDesktopWakeCommand(const json& params) { return Error("desktop session detection and wake are not supported on this platform", "desktop_session_unsupported"); } - if (!before.available || !before.onConsole || !before.loginDone) { + if (!before.available || (!before.onConsole && !before.interactive) || !before.loginDone) { return Error("desktop GUI session is unavailable", "desktop_session_unavailable"); } if (!CanAttemptDesktopWake(before, force)) { @@ -470,6 +471,12 @@ json RunAppLaunchCommand(const json& params, const std::string& activeControlTok } auto activeWindow = WaitForOpenedWindow(query, beforeIds); if (activeWindow.available && !activeWindow.id.empty()) { + if (activeWindow.pid > 0) { + app.pid = activeWindow.pid; + } + if (app.executable.empty()) { + app.executable = activeWindow.appClass; + } RegisterControlSessionResource(activeControlToken, "window", activeWindow.id, app.name, WindowToJson(activeWindow)); } return Ok({{"launched", launched}, {"app", AppToJson(app)}, {"window", WindowToJson(activeWindow)}}); diff --git a/src/daemon/DaemonJson.cpp b/src/daemon/DaemonJson.cpp index 35093bf..99e2f1e 100644 --- a/src/daemon/DaemonJson.cpp +++ b/src/daemon/DaemonJson.cpp @@ -21,7 +21,8 @@ json AppToJson(const Platform::AppInfo& app) { {"available", app.available}, {"pid", app.pid}, {"name", app.name}, - {"bundleId", app.bundleId} + {"bundleId", app.bundleId}, + {"executable", app.executable} }; } diff --git a/src/daemon/DaemonTextInput.cpp b/src/daemon/DaemonTextInput.cpp index 2722e3f..7c471d0 100644 --- a/src/daemon/DaemonTextInput.cpp +++ b/src/daemon/DaemonTextInput.cpp @@ -94,7 +94,8 @@ json RunWaitCommand(const json& params) { auto app = Platform::GetFrontmostApp(); auto window = Platform::GetActiveWindow(); matched = matched && ContainsCaseInsensitive( - app.name + " " + app.bundleId + " " + window.appClass + " " + window.title, + app.name + " " + app.bundleId + " " + app.executable + " " + + window.appClass + " " + window.title, frontmost); evidence["frontmostApp"] = AppToJson(app); evidence["frontmostWindow"] = WindowToJson(window); diff --git a/src/platform/linux/PlatformLinux.cpp b/src/platform/linux/PlatformLinux.cpp index 1d99c6c..bcf40d2 100644 --- a/src/platform/linux/PlatformLinux.cpp +++ b/src/platform/linux/PlatformLinux.cpp @@ -1524,7 +1524,10 @@ bool Click(double x, double y, const std::string& button, int clickCount) { } return XTestClick(x, y, button, clickCount); } -int ResolveKeycode(const std::string&) { return -1; } +int ResolveKeycode(const std::string& keyName) { + auto symbol = KeySymForToken(NormalizeKey(keyName)); + return symbol ? static_cast(*symbol) : -1; +} bool SendHotkey(const std::vector& keys, int holdMs) { if (keys.empty()) { return false; diff --git a/src/platform/windows/PlatformWindows.cpp b/src/platform/windows/PlatformWindows.cpp index 4320949..8c0b565 100644 --- a/src/platform/windows/PlatformWindows.cpp +++ b/src/platform/windows/PlatformWindows.cpp @@ -542,10 +542,16 @@ DesktopSessionState GetDesktopSessionState() { const bool hasProcessSession = ProcessIdToSessionId(GetCurrentProcessId(), &processSessionId) != FALSE; const DWORD consoleSessionId = WTSGetActiveConsoleSessionId(); - state.available = hasProcessSession && consoleSessionId != 0xFFFFFFFF; - state.onConsole = state.available && processSessionId == consoleSessionId; - - if (state.onConsole) { + // Session zero is non-interactive. Any other session attached to this + // process may be usable, including RDP even though it is not the console. + state.available = hasProcessSession && processSessionId != 0; + state.onConsole = state.available && consoleSessionId != 0xFFFFFFFF && + processSessionId == consoleSessionId; + state.interactive = state.available; + // A WTS query failure is unknown rather than proof that login is absent. + state.loginDone = state.available; + + if (state.available) { LPWSTR buffer = nullptr; DWORD bytes = 0; if (WTSQuerySessionInformationW( @@ -560,6 +566,7 @@ DesktopSessionState GetDesktopSessionState() { if (info->Level == 1) { const auto& level = info->Data.WTSInfoExLevel1; state.loginDone = level.SessionState == WTSActive; + state.interactive = state.loginDone; state.screenLocked = level.SessionFlags == WTS_SESSIONSTATE_LOCK; } @@ -687,15 +694,17 @@ std::vector InstalledApps(bool forceRefresh = false) std::mutex mutex; std::vector entries; std::chrono::steady_clock::time_point refreshedAt{}; + bool loaded = false; }; static Cache cache; std::lock_guard lock(cache.mutex); - const bool expired = cache.entries.empty() || + const bool expired = !cache.loaded || std::chrono::steady_clock::now() - cache.refreshedAt > std::chrono::minutes(5); if (forceRefresh || expired) { cache.entries = EnumerateInstalledApps(); cache.refreshedAt = std::chrono::steady_clock::now(); + cache.loaded = true; } return cache.entries; } @@ -744,6 +753,7 @@ AppInfo AppInfoForWindow(HWND hwnd) { AppInfo info; info.available = true; info.pid = static_cast(pid); + info.executable = ProcessName(pid); ComScope com; std::string appUserModelId; @@ -760,23 +770,38 @@ AppInfo AppInfoForWindow(HWND hwnd) { return info; } - info.name = ProcessName(pid); + info.name = info.executable; info.bundleId = info.name; return info; } -bool ShellExecuteTarget(const std::string& target) { +bool ShellExecuteTarget(const std::string& target, int* launchedPid = nullptr) { + if (launchedPid) { + *launchedPid = -1; + } if (target.empty()) { return false; } const std::wstring wide = Utf8ToWide(target); - return reinterpret_cast(ShellExecuteW( - nullptr, - L"open", - wide.c_str(), - nullptr, - nullptr, - SW_SHOWNORMAL)) > 32; + SHELLEXECUTEINFOW execute{}; + execute.cbSize = sizeof(execute); + execute.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI; + execute.lpVerb = L"open"; + execute.lpFile = wide.c_str(); + execute.nShow = SW_SHOWNORMAL; + if (ShellExecuteExW(&execute) == FALSE) { + return false; + } + if (execute.hProcess) { + if (launchedPid) { + const DWORD processId = GetProcessId(execute.hProcess); + if (processId > 0) { + *launchedPid = static_cast(processId); + } + } + CloseHandle(execute.hProcess); + } + return true; } bool ActivateCatalogEntry( @@ -812,26 +837,34 @@ bool ActivateCatalogEntry( appInfo.pid = static_cast(pid); appInfo.name = entry.displayName; appInfo.bundleId = entry.appUserModelId; + appInfo.executable = !entry.executablePath.empty() + ? std::filesystem::path(entry.executablePath).filename().string() + : std::string{}; return true; } const std::string target = !entry.executablePath.empty() ? entry.executablePath : entry.parsingName; - if (!ShellExecuteTarget(target)) { + int launchedPid = -1; + if (!ShellExecuteTarget(target, &launchedPid)) { return false; } appInfo.available = true; + appInfo.pid = launchedPid; appInfo.name = entry.displayName; appInfo.bundleId = !entry.appUserModelId.empty() ? entry.appUserModelId : target; + appInfo.executable = !entry.executablePath.empty() + ? std::filesystem::path(entry.executablePath).filename().string() + : std::string{}; return true; } bool WakeDesktopSession(bool force) { const DesktopSessionState state = GetDesktopSessionState(); - if (!state.available || !state.onConsole || !state.loginDone || + if (!state.available || (!state.onConsole && !state.interactive) || !state.loginDone || (state.screenLocked && !state.screenSaverActive && !force)) { return false; } @@ -970,7 +1003,8 @@ bool ActivateAppByPid(int pid) { } bool LaunchOrActivateApp(const std::string& query, AppInfo& appInfo) { - for (const auto& window : ListWindows(query)) { + const auto directWindows = ListWindows(query); + for (const auto& window : directWindows) { if (auto hwnd = HwndFromId(window.id)) { if (!ActivateWindow(*hwnd)) { continue; @@ -979,11 +1013,17 @@ bool LaunchOrActivateApp(const std::string& query, AppInfo& appInfo) { return true; } } + if (!directWindows.empty()) { + return false; + } - if (ShellExecuteTarget(query)) { + int launchedPid = -1; + if (ShellExecuteTarget(query, &launchedPid)) { appInfo.available = true; + appInfo.pid = launchedPid; appInfo.name = query; appInfo.bundleId = query; + appInfo.executable = std::filesystem::path(query).filename().string(); return true; } @@ -992,7 +1032,8 @@ bool LaunchOrActivateApp(const std::string& query, AppInfo& appInfo) { return false; } - for (const auto& window : ListWindows(resolved.entry->displayName)) { + const auto catalogWindows = ListWindows(resolved.entry->displayName); + for (const auto& window : catalogWindows) { if (auto hwnd = HwndFromId(window.id)) { if (!ActivateWindow(*hwnd)) { continue; @@ -1001,6 +1042,9 @@ bool LaunchOrActivateApp(const std::string& query, AppInfo& appInfo) { return true; } } + if (!catalogWindows.empty()) { + return false; + } return ActivateCatalogEntry(*resolved.entry, appInfo); } diff --git a/tests/CliTests.cpp b/tests/CliTests.cpp index 585d3c3..a698383 100644 --- a/tests/CliTests.cpp +++ b/tests/CliTests.cpp @@ -3487,6 +3487,17 @@ void TestManagedBrowserSurfacePersistsAndFocusReuses() { assert(data["first_navigation_window"] == "4242"); assert(data["retry_navigation_window"] == data["first_navigation_window"]); assert(data["failed_navigation_window"] == data["first_navigation_window"]); + assert(data["canonical_navigation_ok"] == true); + assert(data["canonical_navigation_attempts"] == 0); + assert(data["canonical_navigation_modes"] == ""); + assert(data["unrelated_navigation_ok"] == false); + assert(data["unrelated_navigation_code"] == "browser_navigation_failed"); + assert(data["unrelated_navigation_url"] == "https://example.test/unrelated"); + assert(data["unrelated_navigation_modes"] == "paste,direct"); + assert(data["redirect_navigation_ok"] == true); + assert(data["redirect_navigation_attempts"] == 1); + assert(data["redirect_navigation_url"] == "https://example.test/unrelated"); + assert(data["redirect_navigation_modes"] == "paste"); } void TestManagedBrowserSubmitsFilledFlattenedProxyAuthPrompt() { diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index ef98b76..0ab1f97 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -73,6 +73,12 @@ void TestStringUtils() { assert(ComputerCpp::Join(keys, ",") == "Cmd,Shift,G"); } +void TestPlatformKeyResolution() { + assert(ComputerCpp::Platform::ResolveKeycode("primary") >= 0); + assert(ComputerCpp::Platform::ResolveKeycode("enter") >= 0); + assert(ComputerCpp::Platform::ResolveKeycode("not-a-real-key") < 0); +} + #if defined(_WIN32) class ScopedWindowsInputSender { public: @@ -1332,6 +1338,7 @@ int main() { SetEnvValue("COMPUTER_CPP_HOME", tempHome.string()); RunTest("StringUtils", TestStringUtils); + RunTest("PlatformKeyResolution", TestPlatformKeyResolution); #if defined(_WIN32) RunTest("WindowsNativeInputDelivery", TestWindowsNativeInputDelivery); RunTest("WindowsAppCatalogMatching", TestWindowsAppCatalogMatching); diff --git a/tests/DaemonDispatchTests.cpp b/tests/DaemonDispatchTests.cpp index 6657bbf..c63ceef 100644 --- a/tests/DaemonDispatchTests.cpp +++ b/tests/DaemonDispatchTests.cpp @@ -22,6 +22,13 @@ void TestDesktopSessionReadiness() { state.detectionSupported = true; assert(ComputerCpp::IsDesktopSessionReady(state)); + state.onConsole = false; + state.interactive = true; + assert(ComputerCpp::IsDesktopSessionReady(state)); + assert(ComputerCpp::CanAttemptDesktopWake(state)); + state.onConsole = true; + state.interactive = false; + state.screenSaverActive = true; assert(!ComputerCpp::IsDesktopSessionReady(state)); state.screenSaverActive = false; diff --git a/tests/lua/managed-browser-reuse.lua b/tests/lua/managed-browser-reuse.lua index f7187f1..9818e14 100644 --- a/tests/lua/managed-browser-reuse.lua +++ b/tests/lua/managed-browser-reuse.lua @@ -70,6 +70,8 @@ ac.request = function(method, params) if navigation_mode == "success" or (navigation_mode == "retry" and not last_type_used_paste) then current_url = typed_url + elseif navigation_mode == "unrelated" then + current_url = "https://example.test/unrelated" end end return { ok = true, data = {} } @@ -105,6 +107,30 @@ local failed_navigation = ac.browser.managed.navigate( "https://example.test/ignored", options) local failed_navigation_modes = table.concat(navigation_type_modes, ",") +current_url = "https://example.test/canonical/" +navigation_type_modes = {} +local canonical_navigation = ac.browser.managed.navigate( + "https://EXAMPLE.test:443/canonical#section", options) +local canonical_navigation_modes = table.concat(navigation_type_modes, ",") + +current_url = "https://example.test/before-unrelated" +navigation_type_modes = {} +navigation_mode = "unrelated" +local unrelated_navigation = ac.browser.managed.navigate( + "https://example.test/expected", options) +local unrelated_navigation_modes = table.concat(navigation_type_modes, ",") + +current_url = "https://example.test/before-accepted-redirect" +navigation_type_modes = {} +local redirect_options = {} +for key, value in pairs(options) do redirect_options[key] = value end +redirect_options.navigationUrlMatches = function(observed) + return observed == "https://example.test/unrelated" +end +local redirect_navigation = ac.browser.managed.navigate( + "https://example.test/redirecting", redirect_options) +local redirect_navigation_modes = table.concat(navigation_type_modes, ",") + local root = os.getenv("COMPUTER_CPP_HOME") local separator = package.config:sub(1, 1) local state_file = io.open(root .. separator .. "managed-browser-surfaces.json", "r") @@ -136,4 +162,15 @@ return { failed_navigation_modes = failed_navigation_modes, failed_navigation_target = failed_navigation and failed_navigation.data and failed_navigation.data.targetId, failed_navigation_window = failed_navigation and failed_navigation.data and failed_navigation.data.windowId, + canonical_navigation_ok = canonical_navigation and canonical_navigation.ok == true, + canonical_navigation_attempts = canonical_navigation and canonical_navigation.data and canonical_navigation.data.attempts, + canonical_navigation_modes = canonical_navigation_modes, + unrelated_navigation_ok = unrelated_navigation and unrelated_navigation.ok == true, + unrelated_navigation_code = unrelated_navigation and unrelated_navigation.code, + unrelated_navigation_url = unrelated_navigation and unrelated_navigation.data and unrelated_navigation.data.currentUrl, + unrelated_navigation_modes = unrelated_navigation_modes, + redirect_navigation_ok = redirect_navigation and redirect_navigation.ok == true, + redirect_navigation_attempts = redirect_navigation and redirect_navigation.data and redirect_navigation.data.attempts, + redirect_navigation_url = redirect_navigation and redirect_navigation.data and redirect_navigation.data.currentUrl, + redirect_navigation_modes = redirect_navigation_modes, }