qwen-code/packages/cua-driver/scripts/install.ps1
顾盼 21cdb4a4dc
chore(cua-driver): update version refs to 0.7.1 + add fix doc (#6616)
Bump baked version in install scripts, CD workflow default, and
README examples to 0.7.1 so the release pipeline picks up the
correct version. Add docs/0.7.1-coord-norm-fixes.md documenting
all fixes in this release.
2026-07-09 14:43:58 +00:00

1323 lines
60 KiB
PowerShell

# qwen-cua-driver installer (Windows) — download the latest qwen-cua-driver
# release zip from GitHub Releases and wire it up via a chain of
# directory junctions, so future upgrades / rollbacks retarget a
# junction instead of overwriting files. Sudo-free, no Developer Mode
# required, no admin elevation.
#
# Usage (one-liner — recommended):
# irm https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/install.ps1 | iex
#
# Pin a version:
# $env:CUA_DRIVER_RS_VERSION = "0.2.0"
# irm https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/install.ps1 | iex
#
# Layout on disk (three tiers, two directory junctions):
#
# <visibleBinDir> [directory junction → currentDir]
# = %LOCALAPPDATA%\Programs\Qwen\qwen-cua-driver\bin
# <currentDir> [directory junction → release dir]
# = %USERPROFILE%\.qwen-cua-driver\packages\current
# <release dir> [real directory, immutable per version]
# = %USERPROFILE%\.qwen-cua-driver\packages\releases\<version>-<target>
# qwen-cua-driver.exe
#
# Path layout renamed v0.2.14: `Programs\trycua\cua-driver-rs\` →
# `Programs\Cua\cua-driver\` and `.cua-driver-rs\` → `.cua-driver\`. The
# Rust port IS the canonical Windows driver now (no `-rs` suffix needed),
# and `trycua` is the GitHub org prefix that doesn't belong in
# %LOCALAPPDATA%\Programs. Legacy installs are auto-migrated at the next
# `irm install.ps1 | iex` run.
#
# PATH consumers see <visibleBinDir>; the contents are transparently
# served from whichever release the inner junction currently points at.
# Atomic upgrade = retarget <currentDir> at a newer release dir.
# Rollback = retarget <currentDir> at an older release dir already on disk.
#
# Directory junctions (NTFS reparse points, IO_REPARSE_TAG_MOUNT_POINT)
# are creatable by any user without admin rights and without Developer
# Mode — unlike file/dir *symlinks*, which require either elevated
# privileges or Developer Mode. So the whole installer stays sudo-free.
#
# Env overrides:
# $env:CUA_DRIVER_RS_VERSION pin a specific release (e.g. "0.2.0")
# $env:CUA_DRIVER_RS_INSTALL_DIR override the visible PATH-entry dir
# (default %LOCALAPPDATA%\Programs\Qwen\qwen-cua-driver\bin)
# $env:CUA_DRIVER_RS_HOME override the package home
# (default %USERPROFILE%\.qwen-cua-driver)
# $env:CUA_DRIVER_RS_KEEP_VERSIONS keep the N most recent per-version
# release dirs after install; older ones
# are deleted (default 5; set 0 to
# disable GC entirely). Per-target —
# multi-arch dirs are pruned
# independently of each other.
#
# Params:
# -Release release tag to install ("latest" or a bare version like "0.2.0").
# Overridden by $env:CUA_DRIVER_RS_VERSION when set.
# -AutoStart register a Scheduled Task that runs `qwen-cua-driver serve` at
# every logon (Windows-native equivalent of macOS LaunchAgent).
# The task runs with LogonType=Interactive so it lands in
# Session 1+ with an attached desktop — required for the
# GUI tools (click, type_text, screenshot, get_window_state)
# to function. Default off; the post-install message prints
# the registration command so you can opt in later. Safe to
# re-run: existing task is replaced.
# -NoPathUpdate
# skip the auto-append of $VisibleBinDir to the User PATH.
# Default off — the installer auto-adds the bin dir so
# `qwen-cua-driver --version` works in new shells without manual
# `[Environment]::SetEnvironmentVariable` gymnastics. Pass
# this when you manage PATH out-of-band (chezmoi, a dotfiles
# repo, group policy). Idempotent either way: if the dir is
# already on PATH, nothing changes.
#
# Windows installer for the cross-platform qwen-cua-driver Rust implementation.
[CmdletBinding()]
param(
[string]$Release = "latest",
# Default-on: qwen-cua-driver-serve is what makes the agent flow work
# across logon / reboot. Without the scheduled task the user has
# to remember to run `qwen-cua-driver autostart kick` every time, and
# MCP-style flows go silently in-process. Opt out with
# `-AutoStart:$false` or `-NoAutoStart` for CI / sandbox installs
# that specifically don't want a scheduled task registered.
[switch]$AutoStart = $true,
[switch]$NoAutoStart,
[switch]$NoPathUpdate
)
# `-NoAutoStart` is the explicit opt-out and takes precedence over
# the default-true `-AutoStart`.
if ($NoAutoStart) { $AutoStart = $false }
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Invoke-WebRequest's progress bar is ~10x slower than the actual download
# over PowerShell ISE and Windows PowerShell 5 — silence it. Restored
# nowhere on purpose: this script is a one-shot, the user can re-set it.
$ProgressPreference = "SilentlyContinue"
$Repo = "QwenLM/qwen-code"
$TagPrefix = "cua-driver-rs-v"
$BinaryName = "qwen-cua-driver.exe"
# Baked-version constant — kept in lock-step with the latest published
# cua-driver-rs-v* release tag by the CD workflow's bake-version step
# (see .github/workflows/cd-rust-cua-driver.yml). The sentinel-block
# markers must stay byte-identical to the matching block in install.sh
# so the CD `sed` command can update both files with one pattern.
#
# Precedence at resolve time: $env:CUA_DRIVER_RS_VERSION > -Release arg >
# this baked value > GitHub Releases API. Baked means the `irm | iex`
# one-liner against `main` is API-free in the common case; the API is
# only consulted as a fallback when this script is run from a branch
# where the baked line hasn't been updated yet.
#
# ~~~ BAKED_VERSION: auto-updated by CD workflow after each release — do not edit ~~~
$Script:CuaDriverRsBakedVersion = "0.7.1"
# ~~~ END_BAKED_VERSION ~~~
# ---------- Path resolution ------------------------------------------------
if ($env:CUA_DRIVER_RS_INSTALL_DIR) {
$VisibleBinDir = $env:CUA_DRIVER_RS_INSTALL_DIR
} else {
# Path layout renamed v0.2.14: `Programs\trycua\cua-driver-rs\` →
# `Programs\Qwen\qwen-cua-driver\`. The Rust port IS the canonical Windows
# driver now (no more `-rs` suffix needed in user-facing paths), and
# `trycua` is the GitHub org prefix that doesn't belong in
# %LOCALAPPDATA% — vendor folders there are conventionally PascalCase
# company names. The env var name keeps the `_RS_` infix so existing
# automation pinning a custom install dir doesn't break silently.
$VisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\Qwen\qwen-cua-driver\bin"
}
# Legacy install paths from v0.2.13 and earlier. The uninstall path checks
# both; the install path nukes any legacy install before laying down the
# new one, so v0.2.13 → v0.2.14+ is a transparent upgrade.
$LegacyVisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\trycua\cua-driver-rs\bin"
$LegacyVendorDir = Join-Path $env:LOCALAPPDATA "Programs\trycua"
if ($env:CUA_DRIVER_RS_HOME) {
$HomeDir = $env:CUA_DRIVER_RS_HOME
} else {
# Same rename: `.cua-driver-rs/` → `.cua-driver/`. The `-rs` suffix
# was the Rust-port-vs-Swift-driver disambiguator while the Swift one
# still existed for Windows; it doesn't anymore.
$HomeDir = Join-Path $env:USERPROFILE ".qwen-cua-driver"
}
$LegacyHomeDir = Join-Path $env:USERPROFILE ".cua-driver-rs"
$PackagesDir = Join-Path $HomeDir "packages"
$ReleasesDir = Join-Path $PackagesDir "releases"
$CurrentDir = Join-Path $PackagesDir "current"
# Post-install GC: how many per-version release dirs to retain. Validated
# in Resolve-KeepVersions below; 0 means "never GC".
$Script:KeepVersionsDefault = 5
function Resolve-KeepVersions {
$raw = $env:CUA_DRIVER_RS_KEEP_VERSIONS
if (-not $raw) { return $Script:KeepVersionsDefault }
$n = 0
if ([int]::TryParse($raw, [ref]$n) -and $n -ge 0) { return $n }
Write-WarningStep "CUA_DRIVER_RS_KEEP_VERSIONS=$raw is not a non-negative integer; falling back to $($Script:KeepVersionsDefault)"
return $Script:KeepVersionsDefault
}
# ---------- Log helpers ----------------------------------------------------
function Write-Step($message) {
Write-Host "==> $message"
}
function Write-WarningStep($message) {
Write-Host "WARNING: $message" -ForegroundColor Yellow
}
function Write-ErrorStep($message) {
Write-Host "error: $message" -ForegroundColor Red
}
# ---------- Architecture detection -----------------------------------------
#
# RuntimeInformation.OSArchitecture reports the *OS* architecture (not the
# process architecture), so an x64 PowerShell running on an arm64 Windows
# host still reports Arm64. That's exactly what we want — we always pick
# the native target for the host so the binary is fastest, even if the
# shell happens to be running under WOW64.
function Get-TargetTriple {
# Primary source: $env:PROCESSOR_ARCHITECTURE — always set on Windows,
# never trips StrictMode property introspection, no .NET version dependency.
# Fallback: [RuntimeInformation]::OSArchitecture — present since .NET 4.7.1
# but raises 'property cannot be found on this object' under StrictMode
# Latest on some PowerShell 5.1 setups (observed on Windows 11 24H2 with a
# fresh user account, see issue tracker).
$arch = $env:PROCESSOR_ARCHITECTURE
if (-not $arch) {
try {
$arch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString()
} catch {
$arch = "unknown"
}
}
switch ($arch) {
# $env:PROCESSOR_ARCHITECTURE values
"AMD64" { return "x86_64-pc-windows-msvc" }
"ARM64" { return "aarch64-pc-windows-msvc" }
# RuntimeInformation.OSArchitecture .ToString() values (fallback path)
"X64" { return "x86_64-pc-windows-msvc" }
"Arm64" { return "aarch64-pc-windows-msvc" }
default {
Write-ErrorStep "unsupported Windows architecture: $arch"
Write-ErrorStep " qwen-cua-driver ships prebuilts for: x86_64 (AMD64) and arm64 (ARM64)."
Write-ErrorStep " Please file an issue at https://github.com/QwenLM/qwen-code/issues with the output of"
Write-ErrorStep " 'echo `$env:PROCESSOR_ARCHITECTURE'."
exit 1
}
}
}
function Get-AssetArchLabel($targetTriple) {
# Asset filenames use the short label (windows-arm64 / windows-x86_64)
# to match the CD workflow's $stage variable and the macOS / Linux
# asset naming convention.
switch ($targetTriple) {
"x86_64-pc-windows-msvc" { return "windows-x86_64" }
"aarch64-pc-windows-msvc" { return "windows-arm64" }
}
}
# ---------- Directory junction support (P/Invoke) --------------------------
#
# Directory junctions are NTFS reparse points with tag
# IO_REPARSE_TAG_MOUNT_POINT (0xA0000003). Creating one is a single
# DeviceIoControl call with FSCTL_SET_REPARSE_POINT (0x900A4) and a
# REPARSE_DATA_BUFFER payload describing the substitute / print names.
# Reading one back is FSCTL_GET_REPARSE_POINT (0x900A8).
#
# Doing this through P/Invoke (versus shelling out to `cmd /c mklink /J`)
# avoids the cmd dependency, dodges a console window flash, and lets the
# installer report a structured error if the junction can't be created
# (e.g. the path is on a non-NTFS volume).
#
# Junctions vs symlinks (why we picked junctions):
# - Directory symlinks (CreateSymbolicLink with SYMBOLIC_LINK_FLAG_DIRECTORY)
# need either elevation or the per-user "Create symbolic links"
# privilege, which on consumer Windows is gated behind Developer Mode.
# - Directory junctions need none of that — any unprivileged user can
# create one as long as the source path is a real directory on a
# local NTFS volume.
function Add-JunctionSupportType {
if ("CuaDriverInstaller.Junction" -as [type]) { return }
$source = @'
using System;
using System.IO;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace CuaDriverInstaller
{
public static class Junction
{
private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
private const uint GENERIC_READ = 0x80000000;
private const uint GENERIC_WRITE = 0x40000000;
private const uint OPEN_EXISTING = 3;
private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4;
private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8;
private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003;
private const int MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16 * 1024;
[StructLayout(LayoutKind.Sequential)]
private struct REPARSE_DATA_BUFFER
{
public uint ReparseTag;
public ushort ReparseDataLength;
public ushort Reserved;
public ushort SubstituteNameOffset;
public ushort SubstituteNameLength;
public ushort PrintNameOffset;
public ushort PrintNameLength;
// 16 KB minus the fixed-size header — large enough for any
// sane junction target including UNC paths.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = MAXIMUM_REPARSE_DATA_BUFFER_SIZE - 16)]
public byte[] PathBuffer;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeviceIoControl(
SafeFileHandle hDevice,
uint dwIoControlCode,
IntPtr InBuffer,
uint nInBufferSize,
IntPtr OutBuffer,
uint nOutBufferSize,
out uint pBytesReturned,
IntPtr lpOverlapped);
public static void SetTarget(string linkPath, string targetPath)
{
// Junction target must be an absolute, NT-namespace path
// ("\??\C:\foo"). Normalize to a full path first so callers
// can hand us a relative-looking string.
string fullTarget = Path.GetFullPath(targetPath);
string ntTarget = @"\??\" + fullTarget;
byte[] substituteBytes = System.Text.Encoding.Unicode.GetBytes(ntTarget);
byte[] printBytes = System.Text.Encoding.Unicode.GetBytes(fullTarget);
// Layout in PathBuffer: <substitute name>\0<print name>\0
int substituteLen = substituteBytes.Length;
int printLen = printBytes.Length;
int totalBytes = substituteLen + 2 + printLen + 2;
REPARSE_DATA_BUFFER buf = new REPARSE_DATA_BUFFER();
buf.ReparseTag = IO_REPARSE_TAG_MOUNT_POINT;
// ReparseDataLength = size of the variable-length payload
// (the four offset/length fields + the buffer itself).
buf.ReparseDataLength = (ushort)(8 + totalBytes);
buf.Reserved = 0;
buf.SubstituteNameOffset = 0;
buf.SubstituteNameLength = (ushort)substituteLen;
buf.PrintNameOffset = (ushort)(substituteLen + 2);
buf.PrintNameLength = (ushort)printLen;
buf.PathBuffer = new byte[MAXIMUM_REPARSE_DATA_BUFFER_SIZE - 16];
Array.Copy(substituteBytes, 0, buf.PathBuffer, 0, substituteLen);
Array.Copy(printBytes, 0, buf.PathBuffer, substituteLen + 2, printLen);
if (!Directory.Exists(linkPath))
{
Directory.CreateDirectory(linkPath);
}
using (SafeFileHandle handle = CreateFile(
linkPath,
GENERIC_READ | GENERIC_WRITE,
0,
IntPtr.Zero,
OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
IntPtr.Zero))
{
if (handle.IsInvalid)
{
throw new System.ComponentModel.Win32Exception(
Marshal.GetLastWin32Error(),
"CreateFile(" + linkPath + ") failed");
}
int bufferSize = Marshal.SizeOf(buf);
IntPtr inBuffer = Marshal.AllocHGlobal(bufferSize);
try
{
Marshal.StructureToPtr(buf, inBuffer, false);
uint bytesReturned;
// ReparseDataLength + 8-byte header = total bytes to send.
uint inSize = (uint)(buf.ReparseDataLength + 8);
if (!DeviceIoControl(
handle,
FSCTL_SET_REPARSE_POINT,
inBuffer,
inSize,
IntPtr.Zero,
0,
out bytesReturned,
IntPtr.Zero))
{
throw new System.ComponentModel.Win32Exception(
Marshal.GetLastWin32Error(),
"DeviceIoControl(FSCTL_SET_REPARSE_POINT) failed for " + linkPath);
}
}
finally
{
Marshal.FreeHGlobal(inBuffer);
}
}
}
public static string GetTarget(string linkPath)
{
using (SafeFileHandle handle = CreateFile(
linkPath,
GENERIC_READ,
0,
IntPtr.Zero,
OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
IntPtr.Zero))
{
if (handle.IsInvalid)
{
return null;
}
int bufferSize = MAXIMUM_REPARSE_DATA_BUFFER_SIZE;
IntPtr outBuffer = Marshal.AllocHGlobal(bufferSize);
try
{
uint bytesReturned;
if (!DeviceIoControl(
handle,
FSCTL_GET_REPARSE_POINT,
IntPtr.Zero,
0,
outBuffer,
(uint)bufferSize,
out bytesReturned,
IntPtr.Zero))
{
return null;
}
REPARSE_DATA_BUFFER buf = (REPARSE_DATA_BUFFER)Marshal.PtrToStructure(
outBuffer, typeof(REPARSE_DATA_BUFFER));
if (buf.ReparseTag != IO_REPARSE_TAG_MOUNT_POINT)
{
return null;
}
string substitute = System.Text.Encoding.Unicode.GetString(
buf.PathBuffer,
buf.SubstituteNameOffset,
buf.SubstituteNameLength);
// Strip the "\??\" NT-namespace prefix that the kernel
// stores in the substitute name so callers see a normal
// Win32 path.
if (substitute.StartsWith(@"\??\"))
{
substitute = substitute.Substring(4);
}
return substitute;
}
finally
{
Marshal.FreeHGlobal(outBuffer);
}
}
}
}
}
'@
Add-Type -TypeDefinition $source -Language CSharp
}
function Test-IsJunction([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return $false }
$item = Get-Item -LiteralPath $path -Force
# ReparsePoint flag (1024) on a directory == junction or symlink.
# We narrow to junctions by re-reading the reparse tag below; this
# cheap check is good enough to short-circuit common cases.
return (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
}
function Set-JunctionTarget([string]$linkPath, [string]$targetPath) {
Add-JunctionSupportType
[CuaDriverInstaller.Junction]::SetTarget($linkPath, $targetPath)
}
function Get-JunctionTarget([string]$linkPath) {
Add-JunctionSupportType
return [CuaDriverInstaller.Junction]::GetTarget($linkPath)
}
# Ensure a junction at $linkPath points at $targetPath. Refuses to clobber
# an existing non-junction directory at $linkPath — the user may have
# legitimate files there and we don't want to surprise them.
#
# Retarget is in-place and atomic: DeviceIoControl(FSCTL_SET_REPARSE_POINT)
# on an existing reparse point overwrites the reparse-data buffer in a
# single kernel call. The junction is never absent during the swap. This
# is the only race-free retarget primitive NTFS exposes for directory
# reparse points (CreateSymbolicLink + MoveFileEx-replace-existing only
# works for files, not directories). For the initial-create case the path
# is the same: Directory.CreateDirectory followed by SET_REPARSE_POINT.
function Ensure-Junction([string]$linkPath, [string]$targetPath) {
if (Test-Path -LiteralPath $linkPath) {
if (Test-IsJunction $linkPath) {
$existingTarget = Get-JunctionTarget $linkPath
if ($existingTarget -and ($existingTarget.TrimEnd('\') -ieq $targetPath.TrimEnd('\'))) {
Write-Step "junction $linkPath already points at $targetPath (no change)"
return
}
# Fall through to in-place retarget via SetTarget. No
# Remove-Item first — that would open a window where PATH
# consumers see the junction as missing.
}
else {
Write-ErrorStep "found existing non-junction directory at $linkPath; refusing to replace"
Write-ErrorStep " Move or remove $linkPath manually, then re-run the installer."
Write-ErrorStep " (The installer needs to put a directory junction there so future"
Write-ErrorStep " upgrades retarget the junction instead of overwriting your files.)"
exit 1
}
}
# Make sure the parent dir exists — CreateFile won't auto-mkdir.
$parent = Split-Path -Parent $linkPath
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
Set-JunctionTarget $linkPath $targetPath
Write-Step "junction $linkPath -> $targetPath"
}
# ---------- Auto-start Scheduled Task (Windows LaunchAgent equivalent) ---
# Thin wrapper that delegates to `qwen-cua-driver autostart enable`. The binary
# itself owns the platform-specific registration logic so the install
# scripts and the runtime stay in lock-step — when the verb's behavior
# changes, this script picks it up automatically with no edit needed.
function Test-IsElevated {
# Returns $true if the current process is running at High IL (admin token).
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object System.Security.Principal.WindowsPrincipal($id)
return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Stop-CuaDriverDaemons + Show-CuaDriverDaemonSurvivors live in the
# sibling _install-common.psm1 module so install-local.ps1 and this
# script share the daemon-cleanup logic. Two load paths:
# * checked-out tree: the .psm1 sits next to install.ps1 on disk;
# Import-Module from $PSScriptRoot works directly.
# * `irm | iex` install: no file on disk, $PSScriptRoot is empty.
# Fetch the .psm1 from GitHub raw and Import-Module from a temp
# file. See Import-CuaDriverInstallModule below (defined inline so
# it's available before the module load itself).
# Resolve a temp directory that actually exists. $env:TEMP / $env:TMP can point
# at a missing or unresolvable short 8.3 profile path (e.g. C:\Users\SHORTN~1.DOM),
# which makes Set-Content / Remove-Item fail. Pick the first candidate that
# exists, expand 8.3 short components to the long form, and fall back to
# %SystemRoot%\Temp (always present). See issue #1911.
function Get-CuaDriverTempDir {
foreach ($cand in @($env:TEMP, $env:TMP, (Join-Path $env:LOCALAPPDATA 'Temp'), (Join-Path $env:SystemRoot 'Temp'))) {
if ([string]::IsNullOrWhiteSpace($cand)) { continue }
try {
if (Test-Path -LiteralPath $cand) {
return (Get-Item -LiteralPath $cand -ErrorAction Stop).FullName
}
} catch { }
}
$fallback = Join-Path $env:SystemRoot 'Temp'
if (-not (Test-Path -LiteralPath $fallback)) {
New-Item -ItemType Directory -Path $fallback -Force | Out-Null
}
return $fallback
}
function Import-CuaDriverInstallModuleBootstrap {
[CmdletBinding()]
param(
[string]$LocalDir,
[Parameter(Mandatory = $true)][string]$Url
)
if ($LocalDir) {
$localPsm = Join-Path $LocalDir "_install-common.psm1"
if (Test-Path -LiteralPath $localPsm) {
Import-Module -Name $localPsm -Force -ErrorAction Stop
return
}
}
$body = Invoke-RestMethod -Uri $Url -UseBasicParsing
$tmp = Join-Path (Get-CuaDriverTempDir) ("CuaDriverInstall-" + [Guid]::NewGuid().ToString('N') + ".psm1")
Set-Content -LiteralPath $tmp -Value $body -Encoding UTF8
try {
Import-Module -Name $tmp -Force -ErrorAction Stop
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
Import-CuaDriverInstallModuleBootstrap `
-LocalDir $PSScriptRoot `
-Url "https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/_install-common.psm1"
function Register-CuaDriverAutostart {
param([Parameter(Mandatory = $true)][string]$InstalledBinary)
if (-not (Test-Path -LiteralPath $InstalledBinary)) {
throw "binary not found at $InstalledBinary"
}
# The autostart task is registered with RunLevel=Highest so the daemon runs
# at the user's elevated/admin token. This is what lets qwen-cua-driver drive
# UWP / AppContainer apps (Calculator, modern Settings, Photos) — at the
# default Medium IL token, the cross-AppContainer UIA RPC truncates the
# tree to ~1 element (see issue 1602 / 1601). Registering a RunLevel=Highest
# task itself requires admin, so we self-elevate if needed and run only
# the registration step in an elevated PowerShell window. The rest of the
# install (file extraction, junction creation, User PATH update) stays
# unelevated as before.
if (Test-IsElevated) {
& $InstalledBinary autostart enable
if ($LASTEXITCODE -ne 0) {
throw "qwen-cua-driver autostart enable failed (exit $LASTEXITCODE)"
}
return
}
Write-Host ""
Write-Host "Auto-start at logon needs admin one time to register the" -ForegroundColor Yellow
Write-Host "Scheduled Task with RunLevel=Highest. A UAC prompt will appear." -ForegroundColor Yellow
Write-Host "The task itself runs silently at every logon afterwards." -ForegroundColor Yellow
Write-Host ""
$elevCmd = "& `"$InstalledBinary`" autostart enable; `$ec = `$LASTEXITCODE; if (`$ec -ne 0) { Read-Host 'qwen-cua-driver autostart enable failed; press Enter to close' }; exit `$ec"
try {
$proc = Start-Process -FilePath "powershell.exe" `
-ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-Command",$elevCmd `
-Verb RunAs -Wait -PassThru -ErrorAction Stop
if ($proc.ExitCode -ne 0) {
throw "qwen-cua-driver autostart enable failed in elevated session (exit $($proc.ExitCode))"
}
} catch {
throw "elevation cancelled or failed: $($_.Exception.Message). Re-run install.ps1 -AutoStart from an elevated PowerShell to retry."
}
}
# ---------- Concurrent-install lockfile -----------------------------------
#
# A second install kicked off while a first is still running can race
# on the junction retarget and leave a half-installed state. Serialize
# installs per $HomeDir with a process-level mutex.
#
# Primitive: System.IO.FileStream opened with FileShare::None. Windows
# kernel rejects a second open of the same file with sharing=None until
# the first handle is closed, so the open call itself is the mutex
# acquisition (no separate ACL trick or named-mutex registration needed,
# no admin rights, no per-process cleanup gymnastics — close = release).
#
# Stale-lock recovery: if the holder dies without closing the handle
# (process kill, host reboot mid-install), Windows reclaims the file
# handle automatically — but only when the *kernel* notices the process
# has exited. From another process's perspective the file is no longer
# locked once that happens, so a fresh FileStream open succeeds without
# any timeout dance.
#
# However if the prior process is somehow still alive but stuck (e.g.
# wedged on a network call), we still want to recover after a bounded
# wait. After $Script:LockStaleAfterSeconds the polling loop probes
# the lockfile by attempting an exclusive open against the same
# FileShare::None primitive: success means the previous holder really
# is gone (its FileStream handle was reclaimed) and the leftover file
# is safe to delete; IOException means the holder is alive but slow,
# and we keep waiting rather than corrupting an in-flight install.
# This is the Windows equivalent of the Linux mkdir-mutex's "force
# release" path, but guarded by a liveness check instead of a blind
# Remove-Item.
$Script:LockPollIntervalSeconds = 1
$Script:LockStaleAfterSeconds = 600
$Script:StandaloneRoot = $HomeDir
$Script:LockFilePath = Join-Path $Script:StandaloneRoot "install.lock"
$Script:LockStream = $null
function Release-InstallLock {
if ($Script:LockStream) {
try { $Script:LockStream.Close() } catch {}
try { $Script:LockStream.Dispose() } catch {}
$Script:LockStream = $null
}
# Best-effort delete of the lockfile so subsequent installs don't
# see a stale-but-unlocked file (cosmetic — the FileShare::None
# primitive doesn't care if the file exists).
if (Test-Path -LiteralPath $Script:LockFilePath) {
try { Remove-Item -LiteralPath $Script:LockFilePath -Force -ErrorAction SilentlyContinue } catch {}
}
}
function Acquire-InstallLock {
# Ensure parent dir exists before we try to open the file in it.
if (-not (Test-Path -LiteralPath $Script:StandaloneRoot)) {
New-Item -ItemType Directory -Force -Path $Script:StandaloneRoot | Out-Null
}
$waited = 0
$announced = $false
while ($true) {
try {
# Mode=OpenOrCreate so the first install creates the file
# and subsequent installs reuse it. Access=ReadWrite so we
# can stamp pid/timestamp info after acquiring. Share=None
# is the actual mutex — second open returns IOException.
$Script:LockStream = [System.IO.FileStream]::new(
$Script:LockFilePath,
[System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None)
break
}
catch [System.IO.IOException] {
if (-not $announced) {
Write-Step "another qwen-cua-driver install is already in progress (lock at $($Script:LockFilePath)); waiting..."
$announced = $true
}
Start-Sleep -Seconds $Script:LockPollIntervalSeconds
$waited += $Script:LockPollIntervalSeconds
if ($waited -ge $Script:LockStaleAfterSeconds) {
# Don't yank the lock from a live install. Probe the
# file with the same Share=None primitive — if the open
# succeeds, the previous holder's FileStream is really
# gone (process exited, kernel reclaimed the handle)
# and the lockfile is just a stale leftover safe to
# delete. If it still throws IOException, the holder is
# alive but slow (big download, wedged network); keep
# waiting rather than corrupting an in-flight install.
$probeStream = $null
try {
$probeStream = [System.IO.FileStream]::new(
$Script:LockFilePath,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None)
# Acquisition succeeded → previous holder is gone.
# Close the probe so we can Remove-Item cleanly,
# then fall through to the next loop iteration
# which will reopen via the normal OpenOrCreate path
# (and re-stamp the info blob).
$probeStream.Close()
$probeStream.Dispose()
$probeStream = $null
try { Remove-Item -LiteralPath $Script:LockFilePath -Force -ErrorAction SilentlyContinue } catch {}
Write-WarningStep "previous install lock at $($Script:LockFilePath) appeared stale and was released"
$waited = 0
}
catch [System.IO.IOException] {
# Still locked by a live process. Reset waited so we
# re-check after another full window rather than
# spamming this branch every poll interval.
Write-Step "lock at $($Script:LockFilePath) still held by a live process; continuing to wait"
$waited = 0
}
finally {
if ($probeStream) {
try { $probeStream.Close() } catch {}
try { $probeStream.Dispose() } catch {}
}
}
}
}
}
# Stamp pid + ISO timestamp + argv into the lockfile so a user
# investigating a stuck install can see who holds it (Get-Content
# $env:USERPROFILE\.cua-driver-rs\install.lock).
try {
$info = "pid=$PID`nstarted=$([DateTime]::UtcNow.ToString('o'))`ninvocation=install.ps1 -Release $Release`n"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($info)
$Script:LockStream.SetLength(0)
$Script:LockStream.Write($bytes, 0, $bytes.Length)
$Script:LockStream.Flush()
}
catch {
# Non-fatal — failing to stamp info doesn't affect the lock.
}
}
# ---------- Per-version release dir GC ------------------------------------
#
# Prune per-version release dirs under $ReleasesDir for the current
# $target, keeping the N most recent (by LastWriteTime). The dir that
# the `current` junction resolves to is always preserved on top of the
# keep budget — so the worst-case post-GC count is keep + 1 (active
# fell outside the keep window, e.g. after a rollback), common case
# is exactly keep. Per-target filtering keeps a multi-arch dev's
# x86_64 and arm64 histories independent of each other.
function Invoke-OldReleasesGc {
param(
[string]$releasesDir,
[string]$currentDir,
[string]$target,
[int]$keep
)
if ($keep -eq 0) {
Write-Step "version GC disabled (`$env:CUA_DRIVER_RS_KEEP_VERSIONS=0)"
return
}
if (-not (Test-Path -LiteralPath $releasesDir)) { return }
# Resolve $currentDir's junction target so we can exempt it from the
# prune list. Get-JunctionTarget returns $null on non-junction paths,
# in which case nothing is exempted (still safe — we only prune the
# excess past $keep newest).
$currentTarget = $null
if (Test-Path -LiteralPath $currentDir) {
if (Test-IsJunction $currentDir) {
$currentTarget = Get-JunctionTarget $currentDir
if ($currentTarget) { $currentTarget = $currentTarget.TrimEnd('\') }
}
}
# Filter to dirs whose name ends in "-$target". Sort newest-first.
# Wrap in @(...) so a single-result match doesn't get unwrapped to a
# bare object (PowerShell's classic foot-gun).
$candidates = @(Get-ChildItem -LiteralPath $releasesDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "*-$target" } |
Sort-Object LastWriteTime -Descending)
if ($candidates.Count -eq 0) { return }
$toPrune = @()
$kept = 0
foreach ($cand in $candidates) {
$isCurrent = ($currentTarget -and ($cand.FullName.TrimEnd('\') -ieq $currentTarget))
if ($kept -lt $keep) {
$kept += 1
continue
}
if ($isCurrent) {
# Active install fell outside the keep window — preserve
# anyway (never delete the dir backing the active junction).
continue
}
$toPrune += $cand
}
if ($toPrune.Count -eq 0) { return }
Write-Step "pruning $($toPrune.Count) old release dir(s) (keeping $keep most recent for $target):"
foreach ($d in $toPrune) {
Write-Step " - $($d.Name)"
Remove-Item -LiteralPath $d.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ---------- Release resolution --------------------------------------------
function Resolve-Version {
if ($env:CUA_DRIVER_RS_VERSION) {
$v = $env:CUA_DRIVER_RS_VERSION -replace '^v', ''
Write-Step "using version from `$env:CUA_DRIVER_RS_VERSION: $v"
return $v
}
if ($Release -ne "latest") {
$v = $Release -replace '^v', ''
Write-Step "using -Release $v"
return $v
}
# Baked-version fallback — set by the CD workflow after each release
# so the default `irm | iex` install path doesn't hit the GitHub API.
# See the BAKED_VERSION sentinel-block near the top of this file.
if ($Script:CuaDriverRsBakedVersion) {
$v = $Script:CuaDriverRsBakedVersion -replace '^v', ''
Write-Step "using baked release: $TagPrefix$v"
return $v
}
Write-Step "resolving latest $TagPrefix* release via GitHub API"
# Paginate the /releases endpoint until we've seen every release or
# collected enough $TagPrefix* matches to be confident the latest is
# in hand. A single page (even at per_page=100) is not guaranteed to
# include any cua-driver-rs-v* tag — the repo also ships Swift
# cua-driver-v* releases plus other unrelated tags, which can push
# our matches off the first page once the release cadence grows.
#
# Loop guards:
# - max 10 pages = 1000 releases — way more than the repo will
# ever hold, but cheap insurance against an unbounded loop.
# - stop early when a page comes back empty (we've exhausted the
# list).
$matches = @()
for ($page = 1; $page -le 10; $page++) {
$uri = "https://api.github.com/repos/$Repo/releases?per_page=100&page=$page"
$batch = Invoke-RestMethod -Uri $uri -UseBasicParsing
if (-not $batch -or $batch.Count -eq 0) { break }
$matches += @($batch | Where-Object { $_.tag_name -like "$TagPrefix*" })
if ($batch.Count -lt 100) { break }
}
if (-not $matches -or $matches.Count -eq 0) {
Write-ErrorStep "no release matching $TagPrefix* found on $Repo"
exit 1
}
# Sort by SemVer descending. [version] correctly orders dotted triples.
$latest = $matches | Sort-Object {
$v = $_.tag_name.Substring($TagPrefix.Length)
try { [version]$v } catch { [version]"0.0.0" }
} -Descending | Select-Object -First 1
$version = $latest.tag_name.Substring($TagPrefix.Length)
Write-Step "latest release: $($latest.tag_name)"
return $version
}
# ---------- Download + extract --------------------------------------------
function Get-ReleaseAsset([string]$version, [string]$archLabel, [string]$destDir) {
$zipName = "cua-driver-rs-$version-$archLabel.zip"
$url = "https://github.com/$Repo/releases/download/$TagPrefix$version/$zipName"
$zipPath = Join-Path $destDir $zipName
Write-Step "downloading $url"
try {
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
}
catch {
Write-ErrorStep "download failed: $($_.Exception.Message)"
Write-ErrorStep " Try pinning a known-good version via `$env:CUA_DRIVER_RS_VERSION = '<x.y.z>'`."
exit 1
}
Write-Step "extracting $zipName"
$extractDir = Join-Path $destDir "extracted"
if (Test-Path -LiteralPath $extractDir) {
Remove-Item -LiteralPath $extractDir -Force -Recurse
}
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
# Directory zip from the CD workflow expands to
# cua-driver-rs-<v>-<arch>\qwen-cua-driver.exe (+ LICENSE).
$stage = "cua-driver-rs-$version-$archLabel"
$stageDir = Join-Path $extractDir $stage
if (-not (Test-Path -LiteralPath (Join-Path $stageDir $BinaryName))) {
Write-ErrorStep "expected $BinaryName inside $zipName but didn't find it"
Get-ChildItem $extractDir -Recurse | ForEach-Object { Write-Host " $($_.FullName)" }
exit 1
}
return $stageDir
}
# ---------- Main -----------------------------------------------------------
Write-Step "qwen-cua-driver installer (Windows)"
Write-Step " install dir : $VisibleBinDir"
Write-Step " package home: $HomeDir"
function Remove-LegacyInstall {
# Best-effort cleanup of v0.2.13-and-earlier install paths. Runs before
# any new install when default paths are in use (so users who override
# CUA_DRIVER_RS_INSTALL_DIR / CUA_DRIVER_RS_HOME aren't surprised by us
# touching legacy locations). Mirrors uninstall.ps1's logic so the
# transition is symmetric: a single `irm install.ps1 | iex` upgrades
# from v0.2.13 → v0.2.14+ without orphan files at the old layout.
if ($env:CUA_DRIVER_RS_INSTALL_DIR -or $env:CUA_DRIVER_RS_HOME) {
return
}
$hasLegacy = (Test-Path -LiteralPath $LegacyVisibleBinDir) -or `
(Test-Path -LiteralPath $LegacyHomeDir)
if (-not $hasLegacy) { return }
Write-Step "detected legacy install layout (v0.2.13 or earlier); migrating to Cua\cua-driver"
# 1. End the running daemon. Order matters:
#
# a. `schtasks /End` first — Task Scheduler runs as SYSTEM and can
# terminate elevated (RunLevel=Highest, High IL) processes that a
# Medium-IL Stop-Process from the user's shell cannot. This is
# the case any time the legacy daemon was spawned via the
# AtLogon trigger of the v0.2.13 autostart task on a non-RID-500
# admin account (e.g. cuademo). Without this, Step 4 below fails
# with "Access to the path 'cua-driver.exe' is denied" because
# the legacy binary is held open by an unkillable elevated
# process. Discovered during the cuademo v0.2.13 → v0.2.14
# migration dogfood.
# b. taskkill /F /IM as a backstop for any cua-driver process that
# wasn't task-attached (manual `cua-driver serve`, legacy uia
# worker, etc.). taskkill is more permissive than Stop-Process
# for cross-IL termination.
# c. Stop-Process last — catches anything taskkill missed.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
# Ends the running task instance. Returns non-zero when the task
# isn't running or doesn't exist, both of which we swallow.
& schtasks.exe /End /TN "qwen-cua-driver-serve" 2>$null | Out-Null
Start-Sleep -Milliseconds 250
# Force-kill via taskkill — handles High-IL processes that
# Stop-Process can't touch from a Medium-IL caller.
& taskkill.exe /F /IM "qwen-cua-driver.exe" /T 2>$null | Out-Null
& taskkill.exe /F /IM "cua-driver-uia.exe" /T 2>$null | Out-Null
} finally {
$ErrorActionPreference = $prevEAP
}
$procs = Get-Process -Name "qwen-cua-driver","cua-driver-uia" -ErrorAction SilentlyContinue
if ($procs) {
foreach ($p in $procs) {
try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}
}
}
Start-Sleep -Milliseconds 500
# 2. Unregister the autostart Scheduled Task if present. Idempotent —
# schtasks /Delete returns non-zero when the task is absent, which
# we swallow.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& schtasks.exe /Delete /TN "qwen-cua-driver-serve" /F 2>$null | Out-Null
} finally {
$ErrorActionPreference = $prevEAP
}
# 3. Remove the visible bin junction (only when it's actually a reparse
# point — refuse to clobber a real directory). Then walk up and
# remove the empty `trycua` vendor dir if nothing else lives there.
if (Test-Path -LiteralPath $LegacyVisibleBinDir) {
try {
$item = Get-Item -LiteralPath $LegacyVisibleBinDir -Force -ErrorAction Stop
$isReparse = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
if ($isReparse) {
# NTFS junction — delete the link, not the target.
[System.IO.Directory]::Delete($LegacyVisibleBinDir, $false)
} else {
Remove-Item -LiteralPath $LegacyVisibleBinDir -Recurse -Force -ErrorAction SilentlyContinue
}
} catch {
Write-Host " (could not remove $LegacyVisibleBinDir : $($_.Exception.Message))" -ForegroundColor Yellow
}
}
# Remove the parent `cua-driver-rs` dir (now empty) and the vendor
# `trycua` dir if no other apps live under it.
$legacyParent = Split-Path -Parent $LegacyVisibleBinDir
if ((Test-Path -LiteralPath $legacyParent) -and -not (Get-ChildItem -LiteralPath $legacyParent -Force -ErrorAction SilentlyContinue)) {
Remove-Item -LiteralPath $legacyParent -Force -ErrorAction SilentlyContinue
}
if ((Test-Path -LiteralPath $LegacyVendorDir) -and -not (Get-ChildItem -LiteralPath $LegacyVendorDir -Force -ErrorAction SilentlyContinue)) {
Remove-Item -LiteralPath $LegacyVendorDir -Force -ErrorAction SilentlyContinue
}
# 4. Remove the legacy package home tree.
if (Test-Path -LiteralPath $LegacyHomeDir) {
try {
Remove-Item -LiteralPath $LegacyHomeDir -Recurse -Force -ErrorAction Stop
} catch {
Write-Host " (could not remove $LegacyHomeDir : $($_.Exception.Message))" -ForegroundColor Yellow
}
}
# 5. Prune the legacy bin dir from User PATH. The new install will add
# the new path right after this; without removing the old we'd
# accumulate stale PATH entries on every upgrade.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath) {
$legacyNorm = $LegacyVisibleBinDir.TrimEnd('\').ToLowerInvariant()
$cleaned = ($userPath -split ';' |
Where-Object { $_ -and $_.TrimEnd('\').ToLowerInvariant() -ne $legacyNorm }) -join ';'
if ($cleaned -ne $userPath) {
[Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
Write-Step " pruned legacy $LegacyVisibleBinDir from User PATH"
}
}
Write-Step "legacy install removed"
}
Remove-LegacyInstall
# Serialize concurrent installs per $HomeDir. The lock is released in
# the finally below — covers normal exit, errors, and Ctrl-C (which
# triggers PowerShell's pipeline-stop = finally still runs).
Acquire-InstallLock
try {
$target = Get-TargetTriple
$archLabel = Get-AssetArchLabel $target
Write-Step " target : $target"
$version = Resolve-Version
$versionedDir = Join-Path $ReleasesDir "$version-$target"
# If the per-version dir is already populated, skip the download — the
# binary is immutable per version, so a repeat install is a no-op apart
# from retargeting the `current` junction (cheap rollback path).
$skipDownload = $false
if (Test-Path -LiteralPath (Join-Path $versionedDir $BinaryName)) {
Write-Step "release $version is already on disk at $versionedDir (skipping download)"
$skipDownload = $true
}
if (-not $skipDownload) {
$tmpRoot = Join-Path (Get-CuaDriverTempDir) ("qwen-cua-driver-install-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmpRoot | Out-Null
try {
$stageDir = Get-ReleaseAsset $version $archLabel $tmpRoot
New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null
Copy-Item -LiteralPath (Join-Path $stageDir $BinaryName) -Destination (Join-Path $versionedDir $BinaryName) -Force
Write-Step "installed $versionedDir\$BinaryName (version $version, target $target)"
# Optional sibling: the uiAccess'd worker (cua-driver-uia.exe). Started
# shipping with cua-driver-rs-v0.2.8; absent in earlier releases. Copy
# it when present so `qwen-cua-driver autostart enable` can register the
# second ShellExecute-based scheduled task. See #1602.
$uiaStage = Join-Path $stageDir 'cua-driver-uia.exe'
if (Test-Path -LiteralPath $uiaStage) {
Copy-Item -LiteralPath $uiaStage -Destination (Join-Path $versionedDir 'cua-driver-uia.exe') -Force
Write-Step "installed $versionedDir\cua-driver-uia.exe (uiAccess worker)"
}
}
finally {
Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
# Wire up the junction chain. The inner junction (current → releases\<v>)
# is what makes the upgrade atomic; the outer junction (bin → current)
# is what gives users a stable PATH entry.
Ensure-Junction $CurrentDir $versionedDir
Ensure-Junction $VisibleBinDir $CurrentDir
# Post-install GC of old per-version release dirs. Runs AFTER the junction
# retarget above so the about-to-be-active version is never a deletion
# candidate (it's both the newest by mtime and exempted via the
# current-junction check inside Invoke-OldReleasesGc).
$keepVersions = Resolve-KeepVersions
Invoke-OldReleasesGc -releasesDir $ReleasesDir -currentDir $CurrentDir -target $target -keep $keepVersions
# ---------- Fire-and-forget install telemetry ping ------------------------
#
# Same shape as the Linux install.sh path: invoke `qwen-cua-driver telemetry
# install-event` once per install. The binary itself guards against
# double-counting via ~\.cua-driver-rs\.installation_recorded.
$installedBinary = Join-Path $VisibleBinDir $BinaryName
if (Test-Path -LiteralPath $installedBinary) {
try {
Start-Process -FilePath $installedBinary -ArgumentList "telemetry","install-event" `
-WindowStyle Hidden -ErrorAction SilentlyContinue | Out-Null
}
catch {
# Ignore — telemetry must never block install.
}
}
# ---------- PATH update (User scope, idempotent, fallback to manual) ------
#
# We append $VisibleBinDir to the User-scope PATH so `qwen-cua-driver` resolves
# in any newly-spawned shell. User scope (not Machine) keeps the installer
# non-admin — Machine scope would require elevation. The write doesn't
# affect the calling shell's $env:Path; the post-install message tells the
# user to open a new shell (or stitch $env:Path manually for this session).
#
# Failure modes we tolerate gracefully:
# - Locked-down accounts where SetEnvironmentVariable throws (group
# policy, OneDrive-redirected HKCU, etc.). We catch, print the
# manual command, and exit success.
# - User passed -NoPathUpdate. Same fallback message, no write attempt.
#
# Idempotency: if $VisibleBinDir is already on the User PATH we no-op
# silently (same shape as the existing "already added" branch).
function Get-UserPathEntries {
$raw = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not $raw) { return @() }
return @($raw.Split(';') | Where-Object { $_ })
}
function Test-OnUserPath([string]$dir) {
$needle = $dir.TrimEnd('\')
foreach ($entry in (Get-UserPathEntries)) {
if ($entry.TrimEnd('\') -ieq $needle) { return $true }
}
return $false
}
function Add-UserPathEntry([string]$dir) {
$existing = [Environment]::GetEnvironmentVariable("Path", "User")
if ($existing) {
$newValue = ($existing.TrimEnd(';')) + ';' + $dir
} else {
$newValue = $dir
}
[Environment]::SetEnvironmentVariable("Path", $newValue, "User")
# Also update the CURRENT process's $env:Path so `qwen-cua-driver` resolves
# immediately in the same shell — the SetEnvironmentVariable('User') call
# above only writes to the registry; existing processes have their
# $env:Path cached at launch time and don't see the update otherwise.
# The install.ps1 body runs in the caller's shell via iex (per the
# `irm install.ps1 | iex` one-liner), so writing $env:Path here mutates
# exactly the shell the user is typing into. See #1651.
if (-not (($env:Path -split ';') -contains $dir)) {
$env:Path = "$dir;$env:Path"
}
}
function Write-ManualPathInstructions([string]$dir) {
Write-Host "$dir is not on your user PATH yet." -ForegroundColor Yellow
Write-Host "Add it for future shells with:" -ForegroundColor Yellow
Write-Host ""
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"`$([Environment]::GetEnvironmentVariable('Path','User'));$dir`", 'User')"
Write-Host ""
Write-Host "To use it in the current session immediately:"
Write-Host ""
Write-Host " `$env:Path += `";$dir`""
Write-Host ""
}
Write-Host ""
Write-Host "qwen-cua-driver $version installed."
Write-Host ""
$onPath = Test-OnUserPath $VisibleBinDir
if ($onPath) {
Write-Host "$VisibleBinDir is on your user PATH -- qwen-cua-driver should resolve in any new shell."
Write-Host ""
}
elseif ($NoPathUpdate) {
Write-Host "Skipping PATH update (-NoPathUpdate set)." -ForegroundColor Yellow
Write-ManualPathInstructions $VisibleBinDir
}
else {
try {
Add-UserPathEntry $VisibleBinDir
Write-Host "Added $VisibleBinDir to your User PATH." -ForegroundColor Green
Write-Host ' qwen-cua-driver resolves immediately in THIS shell and in any new shell.'
Write-Host ' NOTE: Restart your terminal or IDE for the PATH update to take effect.' -ForegroundColor Yellow
Write-Host ' Opt out next time with: install.ps1 -NoPathUpdate'
Write-Host ""
}
catch {
Write-WarningStep "could not auto-update User PATH: $($_.Exception.Message)"
Write-ManualPathInstructions $VisibleBinDir
}
}
# Kill any qwen-cua-driver / cua-driver-uia process still running off the
# OLD binary, so the next time the daemon is invoked (autostart kick,
# manual `qwen-cua-driver mcp`, MCP client startup) it picks up the freshly-
# installed code. Without this, in-memory daemons keep serving old
# behaviour - which surfaces as "the bug I just patched is still
# there" because the user's in-memory code is pre-fix. Best-effort:
# High-IL daemons from the RunLevel=Highest autostart task survive a
# Medium-IL kill and get reported via Show-CuaDriverDaemonSurvivors.
Write-Host ""
Write-Host "Stopping any previous qwen-cua-driver processes (best-effort; High-IL needs admin)..." -ForegroundColor Cyan
# Repair- handles the wedged-daemon case: detect stale (process alive
# but pipe dead), prompt the user, and on consent self-elevate via
# UAC to kill the High-IL pids + restart the scheduled task. On UAC
# cancel / failure it falls back to printing the manual recovery
# instructions, same as the previous behavior.
$null = Repair-CuaDriverStaleDaemon
if ($AutoStart) {
Write-Host ""
Write-Host "Registering auto-start (qwen-cua-driver autostart enable)..." -ForegroundColor Cyan
try {
Register-CuaDriverAutostart -InstalledBinary $installedBinary
Write-Host " qwen-cua-driver serve will auto-start at every interactive logon (RunLevel=Highest)." -ForegroundColor Green
}
catch {
Write-Host " Failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host ' Install otherwise succeeded; from an elevated shell run: qwen-cua-driver autostart enable'
Write-Host " In THIS shell (if already elevated), use: $installedBinary autostart enable"
Write-Host ""
}
} else {
# No -AutoStart, but if a `qwen-cua-driver-serve` task is already
# registered, re-register it against the fresh binary. Otherwise
# the task <Command> still points at the previous release dir + an
# older binary that may be missing the hidden-console wrapper (#1654)
# or any later autostart-shape fix.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& schtasks.exe /Query /TN "qwen-cua-driver-serve" 2>$null | Out-Null
$hasTask = ($LASTEXITCODE -eq 0)
} finally {
$ErrorActionPreference = $prevEAP
}
if ($hasTask) {
Write-Host ""
Write-Host "Existing 'qwen-cua-driver-serve' autostart task detected - re-registering against the fresh binary..." -ForegroundColor Cyan
try {
Register-CuaDriverAutostart -InstalledBinary $installedBinary
Write-Host " Re-registered. Task action now uses this build's hidden-console wrapper." -ForegroundColor Green
}
catch {
Write-Host " Failed to re-register: $($_.Exception.Message)" -ForegroundColor Red
Write-Host " The existing task still points at the previous binary. Run 'qwen-cua-driver autostart enable' from an elevated shell to update."
}
}
}
# Unified post-install hints come from a single shared text file so the
# 4 Rust installers (this script + _install-rust.sh + install-local.ps1 +
# install-local.sh) never drift. The .txt holds the OS-agnostic bulk
# (Try-it / skill pack / MCP setup / docs link) with {{BINARY}}
# placeholders; OS-specific bits (autostart) stay inline below.
$HintsUrl = "https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/post-install-hints.txt"
try {
$hintsRaw = (Invoke-WebRequest -Uri $HintsUrl -UseBasicParsing -TimeoutSec 10).Content
Write-Host ($hintsRaw -replace '\{\{BINARY\}\}', $installedBinary)
}
catch {
# Network fetch failed — print one-line essentials so users always
# get enough to recover.
Write-Host "Next steps: $installedBinary --version | $installedBinary mcp-config | $installedBinary skills install"
Write-Host "Docs: https://github.com/QwenLM/qwen-code/tree/main/packages/cua-driver/rust"
}
# Windows-specific autostart hint (kept inline; OS-natural location).
Write-Host ""
if ($AutoStart) {
Write-Host "Auto-start: 'qwen-cua-driver-serve' is registered at RunLevel=Highest." -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart status (inspect)" -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart disable (remove)" -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart kick (start now without re-logging)" -ForegroundColor Cyan
} else {
Write-Host "Auto-start at logon (NOT enabled - re-run without -NoAutoStart to register, or:):" -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart enable (Scheduled Task at RunLevel=Highest)" -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart kick (start now without re-logging)" -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart status (inspect)" -ForegroundColor Cyan
Write-Host " qwen-cua-driver autostart disable (remove)" -ForegroundColor Cyan
}
}
finally {
# Always release the lock — success, error, Ctrl-C, or `exit` all
# land here. A partial install + held lock would wedge every
# subsequent install for the full $LockStaleAfterSeconds window.
Release-InstallLock
}