Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -285,6 +286,7 @@ elseif(WIN32)
shell32
user32
uuid
wtsapi32
Comment thread
matt-greathouse marked this conversation as resolved.
ws2_32
)
elseif(UNIX)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions include/computer_cpp/Platform.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +36,7 @@ struct AppInfo {
int pid = -1;
std::string name;
std::string bundleId;
std::string executable;
};

struct WindowInfo {
Expand Down
94 changes: 93 additions & 1 deletion scripts/build-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -58,6 +62,92 @@ 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 (-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)"
}
}
}

Enter-ComputerCppDeveloperShell

$cmakeCache = Join-Path $BuildDir "CMakeCache.txt"
Expand All @@ -79,3 +169,5 @@ if (-not (Test-Path $cmakeCache)) {
if ($LASTEXITCODE -ne 0) {
throw "Build failed with exit code $LASTEXITCODE."
}

Invoke-LocalCodeSigning
192 changes: 191 additions & 1 deletion src/cli/LuaPrelude.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2131,6 +2131,161 @@ 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_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
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
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,
Comment thread
matt-greathouse marked this conversation as resolved.
})
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 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_reached(target_id, 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 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_reached(target_id, 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
Expand Down Expand Up @@ -2166,6 +2321,41 @@ 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
if session.ready ~= true then
wake = ac.request("desktop_wake", { force = false }, { allow_error = true })
end
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 } },
{
Expand All @@ -2178,7 +2368,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
Expand Down
Loading
Loading