qwen-code/packages/cua-driver/scripts/_install-common.psm1
顾盼 adc2bce414
feat(cua-driver): vendor qwen-cua-driver with opt-in 0–1000 relative coordinates (#5896)
* feat(cua-driver): vendor trycua/cua driver with 1000-normalized coordinate support

Vendor libs/cua-driver from trycua/cua into packages/cua-driver as the
basis for qwen-code's computer-use backend, adding an opt-in relative
(1000x1000 normalized) coordinate mode for Qwen-VL clients.

- coord_norm.rs: 0-1000 <-> pixel conversion, per-(pid,window_id) size
  cache, tools/list description rewrite (TDD, 27 tests)
- ToolRegistry: normalized field + invoke input/output hooks
- protocol.rs: system-instruction coordinate wording switched by mode
- serve.rs: daemon list path description rewrite (input_schema aware)
- main.rs: CUA_DRIVER_RS_COORDINATE_SPACE env seed

Default coordinate_space=pixels => zero behavior change for existing
pixel clients. Set CUA_DRIVER_RS_COORDINATE_SPACE=normalized_1000 to
enable. Excludes rust/target build output.

* feat(cua-driver): make normalized coordinate scale configurable

Add CUA_DRIVER_RS_COORDINATE_SCALE (default 1000) so the normalization
full-scale can absorb the Qwen 999-vs-1000 cookbook ambiguity without a
recompile. norm_to_px/px_to_norm now take an explicit scale; denormalize_args
reads the process-wide COORDINATE_SCALE seeded once at startup from env.

* ci(cua-driver): add cross-platform release workflow for vendored driver

Standalone GitHub Action that builds, signs, and releases the vendored
cua-driver under packages/cua-driver. Adapted from upstream trycua/cua
cd-rust-cua-driver.yml:

- macOS: universal binary (lipo arm64+x86_64), codesigned + notarized into
  CuaDriver.app using qwen-code's existing secrets (MAC_CSC_LINK cert +
  App Store Connect API key notarization); Developer ID identity is
  auto-discovered from the imported cert.
- Linux: x86_64 + arm64, built in debian:11 for a glibc 2.31 floor.
- Windows: x86_64 + arm64, unsigned (no EV cert, matches upstream).
- Release: softprops/action-gh-release on cua-driver-rs-v* tags or manual
  dispatch, prerelease.

Triggered by tag push (cua-driver-rs-v*) or workflow_dispatch.

* chore(cua-driver): rebrand vendored driver as qwen-cua-driver

Rename the vendored trycua/cua driver so the fork installs and runs
independently of any upstream trycua install:
- binary cua-driver -> qwen-cua-driver
- bundle CuaDriver.app -> QwenCuaDriver.app
- bundle id com.trycua.driver -> com.qwencode.cua-driver

Updates the cargo/uia manifests, Info.plist, bundle/proxy launch paths,
permission/health-report wording, the install/build scripts, and the
cross-platform release workflow.

* feat(cua-driver): finish relative-coordinate mode — toggle, scale, zoom/move_cursor

- CUA_DRIVER_RS_COORDINATE_SPACE is now a 1/0 toggle (via is_env_truthy);
  default off keeps pixel mode byte-identical to upstream.
- Thread CUA_DRIVER_RS_COORDINATE_SCALE through every coordinate surface
  (was hardcoded 1000): input denormalization already used it; now the
  rewritten screenshot dims, the tool/param descriptions, and the agent
  instructions track the configured scale too.
- Normalize zoom (window basis) and move_cursor (screen basis) inputs and
  rewrite their descriptions, alongside click/double_click/right_click/drag.
- Fix zoom on downscaled (Retina) windows: apply the get_window_state resize
  ratio so the crop lands on the region the agent saw. Normalized mode only;
  pixel-mode zoom unchanged.

All coordinate behavior stays gated on the normalized flag, so the default
(pixels) path is unchanged from upstream.

* chore(cua-driver): add upstream-sync script (git subtree unusable here)

`git subtree split --prefix=libs/cua-driver` hangs on a commit deep in
trycua/cua's history, so the subtree add/pull workflow isn't usable for
the vendored driver (and a pull would re-split + re-hang every time).

Add scripts/sync-from-upstream.sh instead: it git-diffs two upstream refs
(never walks the full history, so it dodges the hang), reprefixes the
libs/cua-driver delta to packages/cua-driver, and `git apply --reject`s it
on top of our local changes — conflicts land as *.rej for manual fixup.
Record the vendored version in .vendored-from and document the migration +
sync method in the design doc.

* chore(cua-driver): exclude vendored driver from qwen-code ESLint

The vendored packages/cua-driver tree carries upstream JS (e.g. the
test-harness Electron app) that doesn't follow qwen-code's lint rules and
fails CI. It is not a workspace package (no package.json) and is not
qwen-code TypeScript, so add it to eslint.config.js global ignores —
alongside packages/desktop/** — the standard treatment for vendored code.

* fix(cua-driver): let start_session revive an idle-reaped session

Ports the fix from upstream trycua/cua#2035 into the vendored driver.

When a session is reaped for idleness, a subsequent start_session with the
same id failed instead of resuming it. Revive the ended session in place so
the agent can continue rather than getting a hard error.

* fix(cua-driver): retry daemon socket writes on EAGAIN

Ports the fix from upstream trycua/cua#2036 into the vendored driver.

A non-blocking daemon socket can return EAGAIN/EWOULDBLOCK mid-write when the
peer's receive buffer is momentarily full. The driver treated that as fatal
and dropped the connection. Add a bounded retry/poll loop (mirror of the
read-side socket_io helper) so transient back-pressure no longer kills the
session; only a real timeout or hard error fails the write.

* fix(cua-driver/linux): stop reporting bare "Clicked" for X11 synthetic clicks

Ports the fix from upstream trycua/cua#2025 into the vendored driver.

On X11, clicks are delivered via XSendEvent synthetic events, which many
toolkits (GTK/SDL/Allegro) ignore because send_event is set. The driver still
reported a flat success ("Clicked"), masking that nothing happened. Report
the synthetic-delivery caveat honestly so the agent can fall back instead of
assuming the click landed.

(platform-linux crate is not built on macOS; verified by clean upstream apply
and covered by upstream + release-workflow Linux CI.)

* fix(cua-driver/windows): list empty-/null-title top-level windows

Ports the fix from upstream trycua/cua#2021 into the vendored driver.

list_windows filtered out any top-level window whose title was empty or null,
so legitimate targets (splash screens, some Electron/game windows, tool
windows) were invisible to the agent and unclickable. Include empty-title
windows, using class name / process as a fallback label.

(platform-windows crate is not built on macOS; verified by clean upstream
apply and covered by upstream + release-workflow Windows CI.)

* chore(cua-driver): track cherry-picked upstream PRs; fix vendored-from

The vendored copy is actually at cua-driver-rs-v0.6.7 (workspace version and
all 0.6.7->0.6.8 delta files confirm it), but .vendored-from had drifted to
0.6.8 during an earlier sync-script trial whose code delta was not kept. Left
as-is it would make a future sync diff 0.6.8->newer and silently skip the real
0.6.7->0.6.8 fixes. Correct it back to 0.6.7.

Also record the four not-yet-merged upstream PRs we carry as cherry-picks
(trycua/cua#2021/#2025/#2035/#2036) in .vendored-patches.md, and have
sync-from-upstream.sh point at it so the next sync reconciles them.

* ci(cua-driver): satisfy repo yamllint on the release workflow

The vendored-driver release workflow tripped 114 quoted-strings violations
under the repo's .yamllint (quote-type: single, required). Single-quote all
string scalars to match every other workflow in .github/workflows.

While reformatting, the release-notes body also got its paragraph blank lines
collapsed and still referenced the old CUA_DRIVER_RS_COORDINATE_SPACE=
normalized_1000 value — restore the blank lines and update it to the current
0/1 toggle (default 0 = off; optional CUA_DRIVER_RS_COORDINATE_SCALE=1000).

* chore(cua-driver): sync vendored driver to cua-driver-rs-v0.6.8

First real run of scripts/sync-from-upstream.sh: it 3-way-applied the upstream
0.6.7->0.6.8 delta onto our local fork. 10/12 files applied cleanly; the 2
rejects (install.ps1, _install-rust.sh) were already-applied baked-version
bumps (0.6.6->0.6.7, our copies were already at 0.6.7), i.e. no real conflict.

0.6.8 brings: Wayland input path (platform-linux), linux health_report +
overlay tweaks, a platform-macos build.rs step, and dependency bumps. Version
moved to 0.6.8 across the workspace.

Verified our work survived the sync untouched: the relative-coordinate shim
(coord_norm/protocol) and all four cherry-picked PRs (socket_io/session +
linux/windows) are intact — in particular the 0.6.8 edit to platform-linux
tools/impl_.rs landed alongside our #2025 change with no collision. macOS
cargo check + 132 core tests green. (platform-linux/windows + the binary
integration test build only on their own runners; upstream CI covers those.)

* ci(cua-driver): add a dry_run gate to the release workflow

Mirror the desktop-release / release dry-run pattern: a workflow_dispatch
dry_run boolean input (default true). The cross-platform build + package jobs
always run and upload their artifacts; the GitHub Release job now publishes
only on a tag push or an explicit dry_run=false dispatch.

Lets us rehearse the whole build/package pipeline (dry_run=true, notarize=false)
and inspect the produced artifacts without cutting a release. A branch push
(no tag, not a dispatch) likewise builds without releasing.
2026-06-26 13:06:43 +00:00

320 lines
14 KiB
PowerShell

# _install-common.psm1 - shared helpers for install.ps1 + install-local.ps1.
#
# Both scripts import this module to avoid drift in the daemon-kill logic.
#
# * install-local.ps1 runs from a checked-out repo, so it imports the
# module from disk via $PSScriptRoot/_install-common.psm1.
# * install.ps1 is fetched via `irm | iex` and has no file on disk
# during execution. It uses Import-CuaDriverInstallModule (below)
# which prefers the on-disk copy when available (dev / CI) and
# falls back to fetching the .psm1 from GitHub raw.
#
# Keep this module narrow on purpose - it's loaded over the network in
# the production install path, so every additional line is paid for in
# install latency. Things that DO belong here: kill / wait / probe
# helpers that both scripts genuinely need. Things that DON'T: anything
# that's only used by install-local (it can stay in install-local.ps1
# directly) or anything that pulls in a heavy module dependency.
Set-StrictMode -Version Latest
# Best-effort kill of any running cua-driver / cua-driver-uia processes
# so the next `cua-driver autostart kick` / `cua-driver mcp` starts the
# FRESH binary, not whatever's still in memory. Without this the
# previous daemon keeps running (and keeps drawing its overlay window)
# until the user reboots - which surfaces as "the bug I just fixed is
# still there" because the in-memory code is pre-fix.
#
# Layers of escalation:
# 1. schtasks /End - terminates an autostart-task instance. Task
# Scheduler runs as SYSTEM so it can kill High-IL processes that
# a Medium-IL shell can't. /End on an instance the current user
# registered does NOT need admin.
# 2. taskkill /F /IM - Medium-IL backstop for any process that
# wasn't task-attached.
# 3. Returns the surviving process list so callers can warn the user
# (these are the processes a Medium-IL shell genuinely can't reach
# - High-IL daemons whose parent wasn't `cua-driver-serve`).
function Stop-CuaDriverDaemons {
[CmdletBinding()]
param()
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& schtasks.exe /End /TN "cua-driver-serve" 2>$null | Out-Null
Start-Sleep -Milliseconds 200
& taskkill.exe /F /IM "cua-driver.exe" /T 2>$null | Out-Null
& taskkill.exe /F /IM "cua-driver-uia.exe" /T 2>$null | Out-Null
} finally {
$ErrorActionPreference = $prevEAP
}
Start-Sleep -Milliseconds 200
return @(Get-Process -Name "cua-driver","cua-driver-uia" -ErrorAction SilentlyContinue)
}
# Probe whether `\\.\pipe\cua-driver` is currently accepting connections.
# Distinguishes a *healthy* High-IL daemon (process alive AND pipe
# responsive - install can proceed by deferring to the user to restart
# from elevated PS) from a *stale* daemon (process alive but pipe gone
# - daemon process is hung, no new daemon can bind, MCP is broken until
# someone kills the zombie).
#
# Returns $true iff a real serve daemon is listening. Uses a short
# 200 ms timeout so install latency stays bounded.
function Test-CuaDriverPipeAlive {
[CmdletBinding()]
param()
try {
$fs = [System.IO.File]::Open(
'\\.\pipe\cua-driver',
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::ReadWrite
)
$fs.Close()
return $true
} catch {
return $false
}
}
# Stop-CuaDriverDaemons + stale-daemon detection in one go. Returns a
# PSCustomObject with both the survivor list AND a `Stale` boolean
# flagging "processes are alive but the named pipe is dead". Used by
# install/install-local to swap the user-facing message from "you need
# to kill these from elevated PS" to the stronger "your daemon is
# WEDGED - kill it now or reboot, MCP is currently broken".
function Stop-CuaDriverDaemonsWithHealth {
[CmdletBinding()]
param()
$survivors = Stop-CuaDriverDaemons
$pipeAlive = Test-CuaDriverPipeAlive
# @() forces array context so .Count works even when PowerShell
# collapsed a 1-element array to a single PSObject (strict-mode
# otherwise errors on .Count missing on the bare object).
$count = @($survivors).Count
$stale = ($count -gt 0 -and -not $pipeAlive)
return [pscustomobject]@{
Survivors = $survivors
PipeAlive = $pipeAlive
Stale = $stale
}
}
# Prints a clear hint when Stop-CuaDriverDaemons leaves something
# running (almost always a High-IL daemon spawned by the
# RunLevel=Highest autostart task - Medium-IL kill returns Access
# Denied for those).
function Show-CuaDriverDaemonSurvivors {
[CmdletBinding()]
# AllowNull + non-mandatory: PowerShell collapses `return @()` from
# Stop-CuaDriverDaemons to $null at the call site, which would fail
# a `Mandatory = $true [array]` bind. The body below already treats
# null and empty array as the no-survivors case, so accept both.
#
# `-Stale` toggles the user-facing wording from "the old binary is
# still running" (mild - install completed, OLD binary in memory
# until the daemon restarts) to "MCP is currently broken because
# the daemon process is alive but its named pipe is dead" (loud -
# nothing will work until the zombie process is killed).
param(
[Parameter()][AllowNull()][array]$Survivors,
[switch]$Stale
)
if (-not $Survivors -or $Survivors.Count -eq 0) { return }
$pids = ($Survivors | ForEach-Object { $_.Id }) -join ', '
if ($Stale) {
Write-Host "" -ForegroundColor Red
Write-Host "ERROR: cua-driver daemon is STALE - process alive (pid: $pids) but \\.\pipe\cua-driver" -ForegroundColor Red
Write-Host " is not accepting connections. The daemon is wedged; MCP / CLI calls will fail" -ForegroundColor Red
Write-Host " with 'cannot find the file specified' until this process is killed." -ForegroundColor Red
Write-Host "" -ForegroundColor Red
Write-Host " From an ELEVATED PowerShell (right-click PowerShell, 'Run as Administrator'):" -ForegroundColor Yellow
Write-Host " Stop-Process -Id $pids -Force" -ForegroundColor Yellow
Write-Host " schtasks /Run /TN 'cua-driver-serve'" -ForegroundColor Yellow
Write-Host "" -ForegroundColor Yellow
Write-Host " Reboot also clears it. After the kill+restart, MCP recovers automatically." -ForegroundColor Yellow
} else {
Write-Host "Note: $($Survivors.Count) cua-driver process(es) still running after best-effort kill (pid: $pids)." -ForegroundColor Yellow
Write-Host " They are likely High-IL (spawned by RunLevel=Highest autostart task)." -ForegroundColor Yellow
Write-Host " From an elevated PowerShell:" -ForegroundColor Yellow
Write-Host " taskkill /IM cua-driver.exe /F" -ForegroundColor Yellow
Write-Host " Or just reboot. Until they exit, the OLD binary keeps running." -ForegroundColor Yellow
}
}
# Load this module from disk if `$LocalDir/_install-common.psm1` exists
# (install-local.ps1 / checked-out install.ps1), else fetch the same
# file from GitHub raw and load it as an in-memory module
# (`irm | iex` install.ps1 path).
#
# Either way the caller ends up with Stop-CuaDriverDaemons +
# Show-CuaDriverDaemonSurvivors in scope.
#
# Pulled into the module itself (recursively used) so install.ps1's
# inline bootstrap is one short call. Callers must define
# $CuaDriverInstallPsmUrl before invoking the fallback branch.
function Import-CuaDriverInstallModule {
[CmdletBinding()]
param(
[string]$LocalDir,
[string]$Url
)
if ($LocalDir) {
$localPsm = Join-Path $LocalDir "_install-common.psm1"
if (Test-Path -LiteralPath $localPsm) {
Import-Module -Name $localPsm -Force -ErrorAction Stop
return
}
}
if (-not $Url) {
throw "Import-CuaDriverInstallModule: no local file and no -Url to fetch from."
}
$body = Invoke-RestMethod -Uri $Url -UseBasicParsing
$tmp = Join-Path $env:TEMP ("CuaDriverInstall-" + [Guid]::NewGuid().ToString('N') + ".psm1")
Set-Content -LiteralPath $tmp -Value $body -Encoding UTF8
try {
Import-Module -Name $tmp -Force -ErrorAction Stop
} finally {
# Module is loaded in memory; the file on disk is no longer
# needed. Best-effort delete - harmless if it survives.
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
# Trigger UAC and spawn a brief elevated PowerShell that kills the stale
# (High-IL) cua-driver pids and restarts the scheduled task. Returns
# $true iff the elevated helper actually killed everything AND the
# named pipe is alive again afterward. Returns $false on UAC cancel,
# elevation failure, or post-recovery health-check still failing.
#
# Why a helper rather than self-elevating the whole install: the
# install only needs admin to break the wedge. Binary copy, PATH
# munging, scheduled-task re-registration (which has its own UAC
# prompt anyway) all run fine at Medium IL. Asking for admin just
# for those steps would needlessly broaden the prompt's blast
# radius.
#
# The helper is a single short -Command string so we don't need a
# temp file. UAC users see "Windows PowerShell" as the requesting
# app, with the command in the elevation prompt's "Show details"
# panel.
function Invoke-CuaDriverStaleDaemonKill {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][int[]]$Pids
)
if (-not $Pids -or $Pids.Count -eq 0) { return $true }
$pidList = ($Pids -join ',')
# Build the elevated payload:
# 1. Stop-Process each pid, force, swallow errors (process already gone is OK).
# 2. Re-run the scheduled task so a fresh daemon binds the pipe.
# 3. Brief wait + verify the pipe is reachable.
# The exit code from the helper signals success (0) / pipe still
# dead post-restart (3) so the install-side caller can tell the
# user whether to expect MCP to work.
$payload = @"
`$ErrorActionPreference = 'Continue'
foreach (`$p in @($pidList)) {
try { Stop-Process -Id `$p -Force -ErrorAction Stop } catch {}
}
& schtasks.exe /Run /TN 'cua-driver-serve' | Out-Null
Start-Sleep -Milliseconds 1500
try {
`$fs = [System.IO.File]::Open('\\.\pipe\cua-driver','Open','ReadWrite','ReadWrite')
`$fs.Close()
exit 0
} catch {
exit 3
}
"@
try {
$proc = Start-Process `
-FilePath powershell.exe `
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $payload) `
-Verb RunAs `
-PassThru `
-Wait `
-WindowStyle Hidden `
-ErrorAction Stop
return ($proc.ExitCode -eq 0)
} catch {
# UAC cancel surfaces as System.ComponentModel.Win32Exception ("The
# operation was canceled by the user"). Other failures (no
# interactive desktop, etc.) take the same path. Return false so
# the caller falls back to the printed instructions.
return $false
}
}
# Convenience wrapper: detect stale state, if stale prompt the user, on
# yes invoke the elevated kill, re-probe, report. Either way return the
# post-recovery state so the install script can decide whether to
# proceed quietly or print the manual-recovery instructions.
#
# Designed to be a drop-in replacement for the
# `Stop-CuaDriverDaemonsWithHealth + Show-CuaDriverDaemonSurvivors`
# pair at the call site.
function Repair-CuaDriverStaleDaemon {
[CmdletBinding()]
param(
# Default-true so install scripts auto-prompt. Pass `-AutoConfirm`
# to skip the y/n prompt (CI / automated runs) — UAC still prompts
# for elevation itself.
[switch]$AutoConfirm
)
$result = Stop-CuaDriverDaemonsWithHealth
if (-not $result.Stale) {
Show-CuaDriverDaemonSurvivors -Survivors $result.Survivors
return $result
}
# Stale: tell user what we see, offer to elevate.
$survivorPids = @($result.Survivors | ForEach-Object { $_.Id })
Write-Host ""
Write-Host "Detected STALE cua-driver daemon (pid: $($survivorPids -join ', '))." -ForegroundColor Yellow
Write-Host " Process is alive but \\.\pipe\cua-driver is not accepting connections." -ForegroundColor Yellow
Write-Host " Fixing this requires admin to terminate the High-IL daemon process." -ForegroundColor Yellow
Write-Host ""
$proceed = $AutoConfirm
if (-not $proceed) {
# Single-key prompt; default Y on Enter. Y/yes/[Enter] -> elevate,
# anything else -> skip elevation and fall through to manual-recovery
# instructions.
$ans = Read-Host " Trigger UAC prompt to kill the stale daemon now? [Y/n]"
$proceed = ($ans -eq '' -or $ans -match '^[Yy]')
}
if (-not $proceed) {
Show-CuaDriverDaemonSurvivors -Survivors $result.Survivors -Stale
return $result
}
Write-Host " Triggering UAC prompt (accept to kill the stale daemon)..." -ForegroundColor Cyan
$ok = Invoke-CuaDriverStaleDaemonKill -Pids $survivorPids
if ($ok) {
Write-Host " Stale daemon killed; new cua-driver-serve started; pipe is healthy." -ForegroundColor Green
return [pscustomobject]@{
Survivors = @()
PipeAlive = $true
Stale = $false
}
} else {
Write-Host " Elevated kill did not complete (UAC cancelled or pipe still dead)." -ForegroundColor Red
# Re-probe; if the user accepted UAC but the daemon restart
# failed, the survivors list may have shrunk to just the
# un-killable processes — re-show with current state.
$rep = Stop-CuaDriverDaemonsWithHealth
Show-CuaDriverDaemonSurvivors -Survivors $rep.Survivors -Stale:$rep.Stale
return $rep
}
}
Export-ModuleMember -Function `
Stop-CuaDriverDaemons, `
Stop-CuaDriverDaemonsWithHealth, `
Test-CuaDriverPipeAlive, `
Show-CuaDriverDaemonSurvivors, `
Invoke-CuaDriverStaleDaemonKill, `
Repair-CuaDriverStaleDaemon, `
Import-CuaDriverInstallModule