mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
test: add mouse-input addon for setIgnoreMouseEvents specs (#54535)
* test: add Windows mouse-input addon for setIgnoreMouseEvents specs
Adds @electron-ci/mouse-input, a Windows-only native test addon that
injects real mouse input with SendInput(). Unlike
webContents.sendInputEvent() these events go through the WH_MOUSE_LL
hook that setIgnoreMouseEvents(true, { forward: true }) relies on.
Uses it for serial specs covering click-through, forwarding of moves,
mouseenter/mouseleave stability inside an element and :hover being
cleared when the cursor moves off an element. On CI the suite fails
instead of skipping when SendInput cannot inject input.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* test: extend mouse-input addon to macOS and Linux X11
Adds CGEventPost (macOS) and XTEST (Linux X11, dlopen()ed so no dev
headers are needed) backends behind the existing JS API and generalizes
the setIgnoreMouseEvents real-input spec: hit testing on Windows and X11,
click-through on all three, forwarding cases on macOS and Windows only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: install libXtst in Linux test jobs that lack it
The mouse-input spec addon loads libXtst.so.6 at runtime for XTEST. The
x64 build image ships it but the arm64 test image does not, so install
the runtime package there before running the specs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: dismiss the first sign-in privacy window on Windows test runners
The windows-11-arm image shows Windows' "Choose privacy settings for
your device" window full screen above even topmost windows, so real
mouse input clicked it instead of the windows under test and the OS hit
tested it at the test point (seen in the mouse-input spike's
screenshots). Stop UserOOBEBroker/WWAHost and set the policies that keep
it from coming back, logging the windows on screen before and after.
Add describeWindowAtPoint() to the Windows addon backend and report it
when the hit test or click-through cases time out, so a covering window
is named in the failure.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: also close the Start menu on Windows test runners
With the first sign-in privacy window gone, windows-11-arm had the Start
menu open over the test area: describeWindowAtPoint() reported its
CoreWindow at the hit test point, and the click-through case's click then
launched Calculator from it, which covered the point for the retries.
Stop StartMenuExperienceHost (it restarts with the menu closed) and log
what the OS hit tests on screen afterwards.
Before clicking, the click-through case now waits for the window below to
be what the OS hit tests at the point, where the platform can say, so a
covering window fails the case with its name instead of taking the click.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* test: keep ordinary windows from covering the click-through case
On windows-11-arm the runner's terminal (a Windows Terminal window
running wsl.exe) came up over the non-topmost window below after the
cleanup step ran, so the case's hit test check found it there. Make the
window below topmost too; the ignoring window is shown after it and
stays above it.
The cleanup step also found a System Properties dialog left at the
centre of the screen, so it now minimizes (dialogs: closes) every
visible, uncloaked top level window besides the desktop and taskbar
and logs each one, rather than stopping known processes only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: close the Start menu with Escape instead of stopping its host
Stopping StartMenuExperienceHost on the Windows test runners was followed
by WebContentsView visibilityState specs reading 'hidden' on
windows-11-arm in both runs that did it, and in none before. Only press
Escape when the Start menu's CoreWindow is actually showing (uncloaked),
and log its state.
Clearing the desktop now repeats until nothing is left: closing the
leftover System Properties dialog left its Performance Options dialog
over the test area.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: sample window occlusion inputs during Windows specs (diagnostic)
With the Start menu closed on windows-11-arm, api-web-contents-view.spec.ts
sees freshly shown windows as hidden for a while (3 of 3 runs). Log the
display, screen saver and power request settings and the WSL/terminal
processes, then run a background sampler that records, whenever it changes,
the foreground window and any window able to occlude Electron's that sits
above the topmost Electron window, plus display on/off and session lock
changes. Its log is printed after the specs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: stop the windows-11-arm WSL update from covering the specs
The occlusion sampler showed what hides api-web-contents-view.spec.ts's
windows on windows-11-arm: the image runs
`wsl.exe --update --confirm --prompt-before-exit` and relaunches it about
every 90 seconds, each time in a new Windows Terminal window that takes the
foreground over most of the 1024x768 desktop for about a minute, so
Chromium's native occlusion tracking reports Electron windows beneath it as
hidden. The display never turned off (no display timeout on AC, display
status stayed on), so that is ruled out.
Log what launches the update, stop it (and its launcher when that is a
script host outside the step's own ancestry), and have the sampler minimize
any such window that still shows up.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
* ci: trim the Windows occlusion sampler down to a WSL terminal minimizer
The sampler found what hid api-web-contents-view.spec.ts's windows on
windows-11-arm; drop its diagnostic sampling and the power and screen saver
dumps, and keep only what fixes it: a background minimizer, now
script/actions/minimize-wsl-terminals.ps1, that minimizes each
`wsl.exe --update` terminal as it appears and logs its title, pid and
parent process, printed after the specs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5cq8qhU8xUitP6SzyT2FV
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
4c10d222cf
commit
78d8f05222
15 files changed
+2054
No files matched your search
@@ -304,6 +304,20 @@ jobs:
|
||||
fi
|
||||
- name: Add Depot Tools to PATH
|
||||
run: echo "$(pwd)/depot_tools" >> $GITHUB_PATH
|
||||
# The real mouse input specs (spec/fixtures/native-addon/mouse-input) load
|
||||
# libXtst at runtime. The x64 build image ships it; the arm64 test image
|
||||
# does not. Runs after the parallel group so it cannot race the CUPS
|
||||
# step's apt-get for the dpkg lock.
|
||||
- name: Install libXtst runtime (linux)
|
||||
if: ${{ inputs.target-platform == 'linux' }}
|
||||
run: |
|
||||
if ldconfig -p | grep -q 'libXtst\.so\.6'; then
|
||||
echo "libXtst.so.6 already present"
|
||||
else
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y --no-install-recommends libxtst6
|
||||
fi
|
||||
- name: Restore Generated Artifacts
|
||||
run: ./src/electron/script/actions/restore-artifacts.sh
|
||||
- name: Unzip Dist (win)
|
||||
@@ -367,6 +381,166 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# The windows-11-arm image has not finished its first sign-in and leaves
|
||||
# windows over the test area: real mouse input
|
||||
# (spec/fixtures/native-addon/mouse-input) clicks them instead of the
|
||||
# windows under test, and Chromium's native occlusion tracking reports
|
||||
# test windows beneath them as hidden. Close the privacy settings window
|
||||
# (WWAHost.exe, started by UserOOBEBroker.exe) and keep it from coming
|
||||
# back, close the Start menu with Escape (stopping its host made the
|
||||
# occlusion tracking misreport), close or minimize the other windows left
|
||||
# open, and log what the OS hit tests on screen. The image also keeps
|
||||
# relaunching `wsl.exe --update` in a new terminal window that takes the
|
||||
# foreground about every 90 seconds: stop the running one and keep
|
||||
# minimizing any that follow while the specs run.
|
||||
- name: Dismiss first sign-in windows (Windows)
|
||||
if: ${{ inputs.target-platform == 'win' }}
|
||||
timeout-minutes: 5
|
||||
# Best effort: if the window stays up, the specs report what covers
|
||||
# them (describeWindowAtPoint) rather than failing here.
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE'
|
||||
New-Item -Path $policy -Force | Out-Null
|
||||
Set-ItemProperty -Path $policy -Name DisablePrivacyExperience -Type DWord -Value 1
|
||||
$engagement = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\UserProfileEngagement'
|
||||
New-Item -Path $engagement -Force | Out-Null
|
||||
Set-ItemProperty -Path $engagement -Name ScoobeSystemSettingEnabled -Type DWord -Value 0
|
||||
$windows = { Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } | Format-Table -AutoSize Id, ProcessName, MainWindowTitle | Out-String }
|
||||
Write-Host "Processes with windows before:`n$(& $windows)"
|
||||
foreach ($name in 'UserOOBEBroker', 'WWAHost') {
|
||||
Get-Process -Name $name -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-Host ("Stopping {0} ({1}): '{2}'" -f $_.ProcessName, $_.Id, $_.MainWindowTitle)
|
||||
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host "Processes with windows after:`n$(& $windows)"
|
||||
Add-Type -Namespace ElectronCI -Name HitTest -MemberDefinition @'
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||
[DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hWnd, uint flags);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, System.Text.StringBuilder s, int n);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder s, int n);
|
||||
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int cmd);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr w, IntPtr l);
|
||||
[DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attr, out int value, int size);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindow(string cls, string title);
|
||||
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
|
||||
// Whether the Start menu is showing: its CoreWindow always exists but
|
||||
// is cloaked while the menu is closed.
|
||||
public static string StartMenuState() {
|
||||
IntPtr h = FindWindow("Windows.UI.Core.CoreWindow", "Start");
|
||||
if (h == IntPtr.Zero) return "absent";
|
||||
int cloaked = 0;
|
||||
DwmGetWindowAttribute(h, 14, out cloaked, 4);
|
||||
return (IsWindowVisible(h) && cloaked == 0) ? "open" : "closed";
|
||||
}
|
||||
public static void PressEscape() {
|
||||
keybd_event(0x1B, 0, 0, UIntPtr.Zero);
|
||||
keybd_event(0x1B, 0, 2, UIntPtr.Zero); // KEYEVENTF_KEYUP
|
||||
}
|
||||
// Minimizes (dialogs: closes) visible, uncloaked top level windows
|
||||
// other than the shell's desktop and taskbar. Returns what it did.
|
||||
public static System.Collections.Generic.List<string> ClearDesktop() {
|
||||
var log = new System.Collections.Generic.List<string>();
|
||||
var keep = new System.Collections.Generic.HashSet<string> { "Progman", "WorkerW", "Shell_TrayWnd", "Shell_SecondaryTrayWnd" };
|
||||
EnumWindows((h, l) => {
|
||||
if (!IsWindowVisible(h) || IsIconic(h)) return true;
|
||||
int cloaked = 0;
|
||||
if (DwmGetWindowAttribute(h, 14, out cloaked, 4) == 0 && cloaked != 0) return true;
|
||||
var cls = new System.Text.StringBuilder(256);
|
||||
var title = new System.Text.StringBuilder(256);
|
||||
GetClassName(h, cls, 256);
|
||||
GetWindowText(h, title, 256);
|
||||
if (keep.Contains(cls.ToString())) return true;
|
||||
uint pid;
|
||||
GetWindowThreadProcessId(h, out pid);
|
||||
string proc = "?";
|
||||
try { proc = System.Diagnostics.Process.GetProcessById((int)pid).ProcessName; } catch {}
|
||||
bool dialog = cls.ToString() == "#32770";
|
||||
if (dialog) PostMessage(h, 0x0010, IntPtr.Zero, IntPtr.Zero); // WM_CLOSE
|
||||
else ShowWindow(h, 6); // SW_MINIMIZE
|
||||
log.Add(string.Format("{0} class '{1}' title '{2}' process {3} ({4})", dialog ? "Closed" : "Minimized", cls, title, proc, pid));
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return log;
|
||||
}
|
||||
'@
|
||||
$start = [ElectronCI.HitTest]::StartMenuState()
|
||||
Write-Host "Start menu: $start"
|
||||
if ($start -eq 'open') {
|
||||
[ElectronCI.HitTest]::PressEscape()
|
||||
Start-Sleep -Seconds 1
|
||||
Write-Host "Start menu after Escape: $([ElectronCI.HitTest]::StartMenuState())"
|
||||
}
|
||||
# Closing a dialog can reveal another (System Properties leaves its
|
||||
# Performance Options behind), so repeat until nothing is left.
|
||||
for ($pass = 1; $pass -le 3; $pass++) {
|
||||
$done = [ElectronCI.HitTest]::ClearDesktop()
|
||||
$done | ForEach-Object { Write-Host "Pass ${pass}: $_" }
|
||||
Start-Sleep -Seconds 1
|
||||
if ($done.Count -eq 0) { break }
|
||||
}
|
||||
foreach ($xy in @(@(300, 300), @(512, 384))) {
|
||||
$p = New-Object ElectronCI.HitTest+POINT
|
||||
$p.X = $xy[0]; $p.Y = $xy[1]
|
||||
$h = [ElectronCI.HitTest]::GetAncestor([ElectronCI.HitTest]::WindowFromPoint($p), 2)
|
||||
$cls = New-Object System.Text.StringBuilder 256
|
||||
$title = New-Object System.Text.StringBuilder 256
|
||||
[void][ElectronCI.HitTest]::GetClassName($h, $cls, 256)
|
||||
[void][ElectronCI.HitTest]::GetWindowText($h, $title, 256)
|
||||
[uint32]$procId = 0
|
||||
[void][ElectronCI.HitTest]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$proc = (Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName
|
||||
Write-Host ("Window at ({0},{1}): class '{2}' title '{3}' process {4} ({5})" -f $p.X, $p.Y, $cls, $title, $proc, $procId)
|
||||
}
|
||||
# Stop the running WSL update, and its launcher when that is a script
|
||||
# host outside this step's own ancestry.
|
||||
try {
|
||||
$ancestors = @()
|
||||
$cur = Get-CimInstance Win32_Process -Filter "ProcessId=$PID"
|
||||
while ($cur -and $ancestors.Count -lt 10) { $ancestors += $cur.ProcessId; $cur = Get-CimInstance Win32_Process -Filter "ProcessId=$($cur.ParentProcessId)" }
|
||||
$scriptHosts = 'powershell.exe', 'pwsh.exe', 'cmd.exe', 'wscript.exe', 'cscript.exe'
|
||||
Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" | Where-Object { $_.CommandLine -match '--update' } | ForEach-Object {
|
||||
Write-Host "WSL update $($_.ProcessId): $($_.CommandLine)"
|
||||
$parent = Get-CimInstance Win32_Process -Filter "ProcessId=$($_.ParentProcessId)"
|
||||
$p = $parent
|
||||
for ($i = 0; $p -and $i -lt 4; $i++) {
|
||||
Write-Host " launched by $($p.ProcessId) $($p.Name): $($p.CommandLine)"
|
||||
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$($p.ParentProcessId)"
|
||||
}
|
||||
if ($parent -and $scriptHosts -contains $parent.Name -and $ancestors -notcontains $parent.ProcessId) {
|
||||
Write-Host " stopping launcher $($parent.ProcessId)"
|
||||
Stop-Process -Id $parent.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Stopping the WSL update failed: $_"
|
||||
}
|
||||
$minimizer = Join-Path $env:GITHUB_WORKSPACE 'src\electron\script\actions\minimize-wsl-terminals.ps1'
|
||||
$log = Join-Path $env:RUNNER_TEMP 'minimize-wsl-terminals.log'
|
||||
$empty = Join-Path $env:RUNNER_TEMP 'minimize-wsl-terminals.in'
|
||||
New-Item -ItemType File -Force -Path $empty | Out-Null
|
||||
# Keep the step's output pipes out of the background minimizer, so
|
||||
# the runner does not wait for it to exit.
|
||||
Add-Type -Namespace ElectronCI -Name Handles -MemberDefinition @'
|
||||
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n);
|
||||
[DllImport("kernel32.dll")] public static extern bool SetHandleInformation(IntPtr h, int mask, int flags);
|
||||
'@
|
||||
foreach ($n in -10, -11, -12) { [void][ElectronCI.Handles]::SetHandleInformation([ElectronCI.Handles]::GetStdHandle($n), 1, 0) }
|
||||
Start-Process powershell.exe -NoNewWindow -RedirectStandardInput $empty -RedirectStandardOutput "$log.out" -RedirectStandardError "$log.err" `
|
||||
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $minimizer, '-Log', $log, '-Seconds', '3600'
|
||||
for ($i = 0; $i -lt 30 -and -not (Test-Path $log); $i++) { Start-Sleep -Seconds 1 }
|
||||
if (Test-Path $log) { Get-Content $log | Write-Host } else { Write-Host "WSL terminal minimizer did not start: $(Get-Content -Raw "$log.err" -ErrorAction SilentlyContinue)" }
|
||||
|
||||
- name: Run Electron Tests
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
@@ -472,6 +646,15 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
- name: Print minimized WSL terminals (Windows)
|
||||
if: ${{ always() && inputs.target-platform == 'win' }}
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$log = Join-Path $env:RUNNER_TEMP 'minimize-wsl-terminals.log'
|
||||
foreach ($f in "$log.err", "$log.out", $log) {
|
||||
if (Test-Path $f) { Write-Host "--- $f"; Get-Content $f | Write-Host }
|
||||
}
|
||||
- name: Verify custom mksnapshot blobs reach the main process
|
||||
# electron-mksnapshot / LoadBrowserProcessSpecificV8Snapshot users replace
|
||||
# the V8 snapshot blobs; check on one native leg that a build's own
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Minimizes the WSL update terminals windows-11-arm keeps opening; see
|
||||
// minimize-wsl-terminals.ps1.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
public static class WslTerminalMinimizer {
|
||||
public delegate bool EnumProc(IntPtr h, IntPtr l);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
struct PROCESSENTRY32 {
|
||||
public uint dwSize; public uint cntUsage; public uint th32ProcessID; public IntPtr th32DefaultHeapID;
|
||||
public uint th32ModuleID; public uint cntThreads; public uint th32ParentProcessID; public int pcPriClassBase;
|
||||
public uint dwFlags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l);
|
||||
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll")] static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("kernel32.dll")] static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "Process32FirstW")] static extern bool Process32First(IntPtr snap, ref PROCESSENTRY32 e);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "Process32NextW")] static extern bool Process32Next(IntPtr snap, ref PROCESSENTRY32 e);
|
||||
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
|
||||
|
||||
static string logPath;
|
||||
static readonly HashSet<IntPtr> seen = new HashSet<IntPtr>();
|
||||
|
||||
static void Log(string line) {
|
||||
File.AppendAllText(logPath, DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff") + "Z " + line + Environment.NewLine);
|
||||
}
|
||||
|
||||
// Name and parent of every running process, keyed by pid.
|
||||
static Dictionary<uint, KeyValuePair<string, uint>> Processes() {
|
||||
var result = new Dictionary<uint, KeyValuePair<string, uint>>();
|
||||
IntPtr snap = CreateToolhelp32Snapshot(2, 0); // TH32CS_SNAPPROCESS
|
||||
if (snap == new IntPtr(-1)) return result;
|
||||
var e = new PROCESSENTRY32();
|
||||
e.dwSize = (uint)Marshal.SizeOf(typeof(PROCESSENTRY32));
|
||||
for (bool ok = Process32First(snap, ref e); ok; ok = Process32Next(snap, ref e)) {
|
||||
result[e.th32ProcessID] = new KeyValuePair<string, uint>(e.szExeFile, e.th32ParentProcessID);
|
||||
}
|
||||
CloseHandle(snap);
|
||||
return result;
|
||||
}
|
||||
|
||||
// A Windows Terminal window is titled "Terminal" until its tab reports the
|
||||
// command line; a console window hosting wsl.exe directly is titled with it.
|
||||
static bool IsWslTerminal(string cls, string title) {
|
||||
bool wsl = title.IndexOf("wsl.exe", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
if (cls == "CASCADIA_HOSTING_WINDOW_CLASS") return wsl || title == "Terminal";
|
||||
return cls == "ConsoleWindowClass" && wsl;
|
||||
}
|
||||
|
||||
static void Sweep() {
|
||||
EnumWindows(delegate (IntPtr h, IntPtr l) {
|
||||
if (!IsWindowVisible(h) || IsIconic(h)) return true;
|
||||
var cls = new StringBuilder(64);
|
||||
var title = new StringBuilder(256);
|
||||
GetClassName(h, cls, 64);
|
||||
GetWindowText(h, title, 256);
|
||||
if (!IsWslTerminal(cls.ToString(), title.ToString())) return true;
|
||||
ShowWindow(h, 6); // SW_MINIMIZE
|
||||
if (seen.Add(h)) {
|
||||
uint pid;
|
||||
GetWindowThreadProcessId(h, out pid);
|
||||
var procs = Processes();
|
||||
KeyValuePair<string, uint> proc, parent;
|
||||
string name = procs.TryGetValue(pid, out proc) ? proc.Key : "?";
|
||||
uint parentPid = proc.Value;
|
||||
string parentName = procs.TryGetValue(parentPid, out parent) ? parent.Key : "(exited)";
|
||||
Log(string.Format("minimized '{0}' {1} ({2}), parent {3} ({4})", title, name, pid, parentName, parentPid));
|
||||
}
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
}
|
||||
|
||||
public static void Run(string path, int seconds) {
|
||||
logPath = path;
|
||||
Log("watching for WSL terminals");
|
||||
DateTime deadline = DateTime.UtcNow.AddSeconds(seconds);
|
||||
while (DateTime.UtcNow < deadline) {
|
||||
try { Sweep(); } catch (Exception e) { Log("sweep failed: " + e.Message); }
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# The windows-11-arm image keeps relaunching `wsl.exe --update`, each time in
|
||||
# a new terminal window that takes the foreground over the test area, so
|
||||
# Chromium's native occlusion tracking reports Electron's windows beneath it
|
||||
# as hidden. Minimize each one as it appears and log it with its parent
|
||||
# process. Runs in Windows PowerShell 5.1 so the C# compiles with the in-box
|
||||
# .NET Framework compiler.
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Log,
|
||||
[int]$Seconds = 3600
|
||||
)
|
||||
Add-Type -TypeDefinition (Get-Content -Raw (Join-Path $PSScriptRoot 'minimize-wsl-terminals.cs'))
|
||||
[WslTerminalMinimizer]::Run($Log, $Seconds)
|
||||
@@ -0,0 +1,329 @@
|
||||
// End-to-end coverage for setIgnoreMouseEvents() driven by real OS input (see
|
||||
// spec/fixtures/native-addon/mouse-input): SendInput() on Windows,
|
||||
// CGEventPost() on macOS and XTEST on Linux X11.
|
||||
//
|
||||
// Why real input: webContents.sendInputEvent() feeds Blink directly, so it
|
||||
// cannot tell whether the OS lets clicks through the window or whether moves
|
||||
// are forwarded. On Windows a forwarding window is WS_EX_LAYERED |
|
||||
// WS_EX_TRANSPARENT, so the OS never delivers mouse messages to it and
|
||||
// NativeWindowViews gets the cursor position from a WH_MOUSE_LL hook instead;
|
||||
// only SendInput() (or a physical mouse) goes through low level hooks. On
|
||||
// macOS the window ignores mouse events at the window server and forwarding
|
||||
// relies on AppKit delivering mouse-moved events to it. On Linux the window
|
||||
// gets an empty input shape (X11) or input region (Wayland), and the forward
|
||||
// option is not supported (docs/api/browser-window.md).
|
||||
import { BrowserWindow, screen } from 'electron/main';
|
||||
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { once } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { ifdescribe, ifit, isWayland, waitUntil } from './lib/spec-helpers.ts';
|
||||
import { closeAllWindows } from './lib/window-helpers.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
type MouseInput = {
|
||||
move(x: number, y: number): Promise<unknown>;
|
||||
click(button?: 'left' | 'right' | 'middle'): Promise<unknown>;
|
||||
getCursorPos(): { x?: number; y?: number; error?: number };
|
||||
isWindowAtPoint(handle: Buffer, x: number, y: number): boolean;
|
||||
describeWindowAtPoint(x: number, y: number): Record<string, unknown> | null;
|
||||
getDiagnostics(): { available?: boolean; sendInputProbeSent?: number; inputDesktop?: string } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
};
|
||||
|
||||
type PageEvent = { type: string; x?: number; y?: number };
|
||||
|
||||
// Nothing can inject input into a Wayland compositor from a client, so only
|
||||
// X11 is covered on Linux.
|
||||
const hasRealInput =
|
||||
process.platform === 'win32' || process.platform === 'darwin' || (process.platform === 'linux' && !isWayland);
|
||||
// { forward: true } is macOS and Windows only.
|
||||
const canForward = process.platform === 'win32' || process.platform === 'darwin';
|
||||
// Only these can ask the OS which window it hit tests at a point.
|
||||
const canHitTest = process.platform === 'win32' || process.platform === 'linux';
|
||||
|
||||
// #target turns orange while hovered. Element.matches(':hover') is not usable
|
||||
// here: it stays false while the (never focused) window is inactive even
|
||||
// though the hover style is applied, so check the computed style instead.
|
||||
const HOVER_COLOR = 'rgb(255, 136, 0)';
|
||||
const PAGE = `data:text/html,${encodeURIComponent(`
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #fff; }
|
||||
#target { position: absolute; left: 50px; top: 50px; width: 300px; height: 300px; background: #08f; }
|
||||
#target:hover { background: ${HOVER_COLOR}; }
|
||||
</style>
|
||||
<div id="target"></div>
|
||||
<script>
|
||||
window.events = [];
|
||||
for (const type of ['mouseenter', 'mouseleave']) {
|
||||
target.addEventListener(type, () => events.push({ type }));
|
||||
}
|
||||
for (const type of ['mousemove', 'mousedown']) {
|
||||
document.addEventListener(type, (e) => events.push({ type, x: e.clientX, y: e.clientY }));
|
||||
}
|
||||
</script>
|
||||
`)}`;
|
||||
|
||||
// Window placement in DIPs. OUTSIDE is well clear of the window, MARGIN is
|
||||
// inside the window but outside #target.
|
||||
const BOUNDS = { x: 100, y: 100, width: 400, height: 400 };
|
||||
const OUTSIDE = { x: 30, y: 30 };
|
||||
const MARGIN = { x: 20, y: 20 };
|
||||
const TARGET_CENTER = { x: 200, y: 200 };
|
||||
|
||||
ifdescribe(hasRealInput && !process.env.ELECTRON_SKIP_NATIVE_MODULE_TESTS)(
|
||||
'BrowserWindow.setIgnoreMouseEvents() with real mouse input',
|
||||
{ tags: ['serial'] },
|
||||
() => {
|
||||
let mouse: MouseInput;
|
||||
let initialCursor: { x?: number; y?: number } | undefined;
|
||||
|
||||
// Converts DIPs to the coordinates the addon takes: physical pixels on
|
||||
// Windows and X11, points (which are DIPs) on macOS.
|
||||
const toScreen = (dip: Electron.Point) => (process.platform === 'darwin' ? dip : screen.dipToScreenPoint(dip));
|
||||
const inWindow = ({ x, y }: Electron.Point) => ({ x: BOUNDS.x + x, y: BOUNDS.y + y });
|
||||
|
||||
// Moves the real cursor to a point in DIPs and waits until the OS reports
|
||||
// it there.
|
||||
const moveTo = async (dip: Electron.Point, timeout?: number) => {
|
||||
const p = toScreen(dip);
|
||||
await mouse.move(p.x, p.y);
|
||||
await waitUntil(
|
||||
() => {
|
||||
const { x, y } = mouse.getCursorPos();
|
||||
return x === p.x && y === p.y;
|
||||
},
|
||||
{ timeout }
|
||||
);
|
||||
};
|
||||
|
||||
// Why input injection does not work on this machine, or undefined if it
|
||||
// does.
|
||||
const cannotInject = async () => {
|
||||
const diagnostics = mouse.getDiagnostics();
|
||||
if (process.platform === 'win32') {
|
||||
if (diagnostics.sendInputProbeSent !== 1 || diagnostics.inputDesktop !== 'Default') {
|
||||
return `SendInput cannot inject mouse input on this machine: ${JSON.stringify(diagnostics)}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!diagnostics.available) {
|
||||
return `mouse input is unavailable: ${JSON.stringify(diagnostics)}`;
|
||||
}
|
||||
// macOS drops posted events silently without the Accessibility
|
||||
// permission, so check that a move lands.
|
||||
try {
|
||||
await moveTo(OUTSIDE, 2000);
|
||||
} catch {
|
||||
return `injected moves do not move the cursor: ${JSON.stringify({ ...diagnostics, cursor: mouse.getCursorPos() })}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
before(async function () {
|
||||
mouse = require('@electron-ci/mouse-input');
|
||||
initialCursor = mouse.getCursorPos();
|
||||
const reason = await cannotInject();
|
||||
if (reason) {
|
||||
// On CI this must not turn into a silent skip, or a runner image
|
||||
// change would quietly disable the suite.
|
||||
if (process.env.CI) throw new Error(reason);
|
||||
console.warn(`Skipping real mouse input specs. ${reason}`);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (mouse && initialCursor?.x !== undefined && initialCursor?.y !== undefined) {
|
||||
await mouse.move(initialCursor.x, initialCursor.y);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(closeAllWindows);
|
||||
|
||||
const events = (w: BrowserWindow): Promise<PageEvent[]> => w.webContents.executeJavaScript('events');
|
||||
const count = async (w: BrowserWindow, type: string) => (await events(w)).filter((e) => e.type === type).length;
|
||||
const lastMove = async (w: BrowserWindow) => (await events(w)).filter((e) => e.type === 'mousemove').at(-1);
|
||||
const isHovered = (w: BrowserWindow): Promise<boolean> =>
|
||||
w.webContents.executeJavaScript(
|
||||
`getComputedStyle(document.getElementById('target')).backgroundColor === '${HOVER_COLOR}'`
|
||||
);
|
||||
|
||||
// Moves the cursor to a point in window client coordinates and waits for
|
||||
// the forwarded mousemove to reach the page.
|
||||
const moveInWindow = async (w: BrowserWindow, point: Electron.Point) => {
|
||||
await moveTo(inWindow(point));
|
||||
// Allow a pixel of DIP rounding on scaled displays.
|
||||
const near = (a: number | undefined, b: number) => a !== undefined && Math.abs(a - b) <= 1;
|
||||
try {
|
||||
await waitUntil(async () => {
|
||||
const move = await lastMove(w);
|
||||
return near(move?.x, point.x) && near(move?.y, point.y);
|
||||
});
|
||||
} catch {
|
||||
const move = await lastMove(w);
|
||||
throw new Error(`no mousemove at ${JSON.stringify(point)} reached the page, last was ${JSON.stringify(move)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Moves the cursor in steps, so the OS sees a realistic series of moves.
|
||||
const glide = async (from: Electron.Point, to: Electron.Point, steps = 5) => {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await moveTo({
|
||||
x: Math.round(from.x + ((to.x - from.x) * i) / steps),
|
||||
y: Math.round(from.y + ((to.y - from.y) * i) / steps)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Waits for |condition|; on timeout fails with what the OS hit tests at
|
||||
// the physical screen point |p|, since the usual cause is another window
|
||||
// covering ours.
|
||||
const waitUntilAt = async (p: Electron.Point, what: string, condition: () => boolean | Promise<boolean>) => {
|
||||
try {
|
||||
await waitUntil(condition);
|
||||
} catch {
|
||||
const atPoint = JSON.stringify(mouse.describeWindowAtPoint(p.x, p.y));
|
||||
throw new Error(`${what} did not happen; window at ${JSON.stringify(p)}: ${atPoint}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createWindow = async (options: Electron.BrowserWindowConstructorOptions = {}) => {
|
||||
const w = new BrowserWindow({ ...BOUNDS, frame: false, show: false, useContentSize: true, ...options });
|
||||
await w.loadURL(PAGE);
|
||||
return w;
|
||||
};
|
||||
|
||||
// Shows a frameless, always-on-top window that forwards mouse moves while
|
||||
// ignoring them, and leaves the cursor in its MARGIN once the page has
|
||||
// seen a forwarded move.
|
||||
//
|
||||
// On Windows the window stays inactive: the hook forwards moves to any
|
||||
// window. On macOS it has to be the key window, as Chromium drops
|
||||
// mouse-moved events in inactive windows (RenderWidgetHostViewCocoa's
|
||||
// -shouldIgnoreMouseEvent:).
|
||||
//
|
||||
// The first moves after showing a window do not always arrive (seen on
|
||||
// windows-11-arm), so nudge the cursor until one does before handing the
|
||||
// window to the test.
|
||||
const createForwardingWindow = async () => {
|
||||
await moveTo(OUTSIDE);
|
||||
const w = await createWindow();
|
||||
w.setAlwaysOnTop(true);
|
||||
if (process.platform === 'darwin') {
|
||||
const focused = once(w, 'focus');
|
||||
w.show();
|
||||
await focused;
|
||||
} else {
|
||||
w.showInactive();
|
||||
}
|
||||
w.setIgnoreMouseEvents(true, { forward: true });
|
||||
|
||||
let offset = 0;
|
||||
await waitUntil(
|
||||
async () => {
|
||||
offset = offset === 0 ? 2 : 0;
|
||||
await moveTo(inWindow({ x: MARGIN.x + offset, y: MARGIN.y + offset }));
|
||||
return (await count(w, 'mousemove')) > 0;
|
||||
},
|
||||
{ rate: 100 }
|
||||
);
|
||||
await moveInWindow(w, MARGIN);
|
||||
await w.webContents.executeJavaScript('events.length = 0');
|
||||
return w;
|
||||
};
|
||||
|
||||
ifit(canHitTest)('removes the window from OS hit testing', async () => {
|
||||
await moveTo(OUTSIDE);
|
||||
const w = await createWindow();
|
||||
w.setAlwaysOnTop(true);
|
||||
w.showInactive();
|
||||
const p = toScreen(inWindow(TARGET_CENTER));
|
||||
// Also confirms the window is on screen before it starts ignoring.
|
||||
await waitUntilAt(p, 'hit testing the window before it ignores', () =>
|
||||
mouse.isWindowAtPoint(w.getNativeWindowHandle(), p.x, p.y)
|
||||
);
|
||||
w.setIgnoreMouseEvents(true, { forward: canForward });
|
||||
// Synchronous on both: Windows changes the extended style in place, and
|
||||
// on X11 the new input shape is applied when the call returns.
|
||||
expect(mouse.isWindowAtPoint(w.getNativeWindowHandle(), p.x, p.y)).to.be.false();
|
||||
});
|
||||
|
||||
// Only clicks are checked in the window below. Hover there is not: on X11
|
||||
// (seen with Electron 44 under Xvfb) a window below an ignoring Electron
|
||||
// window gets no mousemove until it is clicked, although the X server
|
||||
// delivers the motion events to it.
|
||||
it('passes clicks through to the window below', async () => {
|
||||
await moveTo(OUTSIDE);
|
||||
// acceptFirstMouse: macOS only delivers a click that activates an
|
||||
// inactive window when the window asks for it.
|
||||
const below = await createWindow({ acceptFirstMouse: true });
|
||||
// Topmost like the window above it (which is shown later, so stays on
|
||||
// top of it), so that no ordinary window can come between them and
|
||||
// take the click: on windows-11-arm the runner's terminal once did.
|
||||
below.setAlwaysOnTop(true);
|
||||
below.showInactive();
|
||||
const above = await createWindow();
|
||||
above.setAlwaysOnTop(true);
|
||||
above.showInactive();
|
||||
above.setIgnoreMouseEvents(true);
|
||||
|
||||
await glide(OUTSIDE, inWindow(TARGET_CENTER));
|
||||
// Where the OS can say, make sure the click lands in the window below
|
||||
// rather than in whatever else might cover the point (on CI a system
|
||||
// window once did, and the click launched an app from it).
|
||||
if (canHitTest) {
|
||||
const p = toScreen(inWindow(TARGET_CENTER));
|
||||
await waitUntilAt(p, 'the window below being hit tested under the ignoring window', () =>
|
||||
mouse.isWindowAtPoint(below.getNativeWindowHandle(), p.x, p.y)
|
||||
);
|
||||
}
|
||||
await mouse.click();
|
||||
await waitUntilAt(
|
||||
toScreen(inWindow(TARGET_CENTER)),
|
||||
'a mousedown in the window below',
|
||||
async () => (await count(below, 'mousedown')) > 0
|
||||
);
|
||||
expect(await count(above, 'mousedown')).to.equal(0);
|
||||
});
|
||||
|
||||
ifit(canForward)('forwards mouse moves to the page', async () => {
|
||||
const w = await createForwardingWindow();
|
||||
await glide(inWindow(MARGIN), inWindow(TARGET_CENTER));
|
||||
await moveInWindow(w, { x: 210, y: 220 });
|
||||
await waitUntil(() => isHovered(w));
|
||||
expect(await count(w, 'mouseenter')).to.equal(1);
|
||||
});
|
||||
|
||||
// https://github.com/electron/electron/issues/30808
|
||||
ifit(canForward)('does not oscillate mouseenter/mouseleave while moving inside an element', async () => {
|
||||
const w = await createForwardingWindow();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await moveInWindow(w, { x: 100 + i * 10, y: 100 + i * 10 });
|
||||
}
|
||||
expect(await count(w, 'mouseenter')).to.equal(1);
|
||||
expect(await count(w, 'mouseleave')).to.equal(0);
|
||||
});
|
||||
|
||||
// Leaves #target through the window's margin. Leaving the window straight
|
||||
// from #target is a different case: only moves inside the window are
|
||||
// forwarded and on Windows WM_MOUSELEAVE is swallowed while forwarding,
|
||||
// see https://github.com/electron/electron/issues/51521.
|
||||
ifit(canForward)('clears :hover and fires one mouseleave when the cursor moves off an element', async () => {
|
||||
const w = await createForwardingWindow();
|
||||
await glide(inWindow(MARGIN), inWindow(TARGET_CENTER));
|
||||
await waitUntil(() => isHovered(w));
|
||||
await glide(inWindow(TARGET_CENTER), inWindow(MARGIN));
|
||||
await moveInWindow(w, MARGIN);
|
||||
await waitUntil(async () => !(await isHovered(w)));
|
||||
await moveTo(OUTSIDE);
|
||||
expect(await count(w, 'mouseenter')).to.equal(1);
|
||||
expect(await count(w, 'mouseleave')).to.equal(1);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
/node_modules
|
||||
/build
|
||||
*.swp
|
||||
*.log
|
||||
*~
|
||||
.node-version
|
||||
package-lock.json
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
'msvs_disabled_warnings': [
|
||||
4530, # C++ exception handler used, but unwind semantics are not enabled
|
||||
4506, # no definition for inline function
|
||||
],
|
||||
}],
|
||||
],
|
||||
},
|
||||
'targets': [
|
||||
{
|
||||
'target_name': 'mouse_input',
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
'sources': [
|
||||
'src/main_win.cc',
|
||||
],
|
||||
'libraries': [
|
||||
'user32.lib',
|
||||
'advapi32.lib',
|
||||
],
|
||||
}],
|
||||
['OS=="mac"', {
|
||||
'sources': [
|
||||
'src/impl.h',
|
||||
'src/impl_mac.cc',
|
||||
'src/main_posix.cc',
|
||||
],
|
||||
'libraries': [
|
||||
'$(SDKROOT)/System/Library/Frameworks/ApplicationServices.framework',
|
||||
],
|
||||
'xcode_settings': {
|
||||
# CGPreflightPostEventAccess() is macOS 10.15+.
|
||||
'MACOSX_DEPLOYMENT_TARGET': '11.0',
|
||||
},
|
||||
}],
|
||||
# X11 only. libX11 and libXtst are dlopen()ed, so no X11 development
|
||||
# headers or link time dependencies are needed.
|
||||
['OS=="linux"', {
|
||||
'sources': [
|
||||
'src/impl.h',
|
||||
'src/impl_linux.cc',
|
||||
'src/main_posix.cc',
|
||||
],
|
||||
'libraries': [
|
||||
'-ldl',
|
||||
],
|
||||
}],
|
||||
['OS not in ["win", "mac", "linux"]', {
|
||||
'type': 'none',
|
||||
}],
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Real OS level mouse input for specs: SendInput() on Windows
|
||||
// (src/main_win.cc), CGEventPost() on macOS (src/impl_mac.cc) and XTEST on
|
||||
// Linux X11 (src/impl_linux.cc). Coordinates are what the platform's input
|
||||
// APIs use: physical screen pixels on Windows and X11 (convert DIPs with
|
||||
// screen.dipToScreenPoint() first), points (DIPs) on macOS.
|
||||
const binding = require('../build/Release/mouse_input.node');
|
||||
|
||||
const check = (what) => (result) => {
|
||||
if (result.sent !== result.expected) {
|
||||
throw new Error(`mouse-input ${what}: injected ${result.sent}/${result.expected} events, error ${result.error}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
move: (x, y) => binding.move(Math.round(x), Math.round(y)).then(check('move')),
|
||||
down: (button = 'left') => binding.button(button, 'down').then(check('down')),
|
||||
up: (button = 'left') => binding.button(button, 'up').then(check('up')),
|
||||
click: (button = 'left') => binding.button(button, 'click').then(check('click')),
|
||||
wheel: (delta, horizontal = false) => binding.wheel(delta, horizontal).then(check('wheel')),
|
||||
getCursorPos: binding.getCursorPos,
|
||||
isWindowAtPoint: binding.isWindowAtPoint,
|
||||
// Windows only; elsewhere returns null.
|
||||
describeWindowAtPoint: binding.describeWindowAtPoint ?? (() => null),
|
||||
getDiagnostics: binding.getDiagnostics
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@electron-ci/mouse-input",
|
||||
"version": "0.0.1",
|
||||
"main": "./lib/index.js",
|
||||
"private": true,
|
||||
"licenses": "MIT",
|
||||
"scripts": {
|
||||
"install": "node-gyp configure && node-gyp build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Platform backends for the macOS and Linux (X11) builds of the addon. The
|
||||
// Windows build is self contained in main_win.cc.
|
||||
//
|
||||
// Coordinates are global screen coordinates as the platform's input APIs use
|
||||
// them: points (DIPs) with the origin at the top left of the main display on
|
||||
// macOS, root window pixels on X11.
|
||||
|
||||
#ifndef MOUSE_INPUT_SRC_IMPL_H_
|
||||
#define MOUSE_INPUT_SRC_IMPL_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mouse_input {
|
||||
|
||||
enum class Button { kLeft, kRight, kMiddle };
|
||||
|
||||
// Mirrors what SendInput() reports on Windows so lib/index.js can check all
|
||||
// platforms the same way: |sent| out of |expected| events were injected,
|
||||
// |error| is a platform specific code when they were not.
|
||||
struct InjectResult {
|
||||
int sent = 0;
|
||||
int expected = 0;
|
||||
int64_t error = 0;
|
||||
};
|
||||
|
||||
// These may block briefly and are called on a libuv worker thread.
|
||||
InjectResult Move(int x, int y);
|
||||
InjectResult Press(Button button, bool down);
|
||||
// |delta| is in Windows WHEEL_DELTA (120) units per notch; positive scrolls
|
||||
// up (vertical) or right (horizontal).
|
||||
InjectResult Wheel(int delta, bool horizontal);
|
||||
|
||||
// Called on the JS thread.
|
||||
bool GetCursorPos(int* x, int* y, int64_t* error);
|
||||
|
||||
// Whether the window identified by |handle| (the bytes of
|
||||
// BrowserWindow.getNativeWindowHandle()) is what the OS hit tests at the
|
||||
// screen point. Returns false and sets |supported| to false where there is no
|
||||
// reliable way to ask.
|
||||
bool IsWindowAtPoint(const void* handle,
|
||||
size_t handle_size,
|
||||
int x,
|
||||
int y,
|
||||
bool* supported);
|
||||
|
||||
// Key/value pairs describing whether input injection can work on this
|
||||
// machine. Values are strings, integers or booleans.
|
||||
struct DiagnosticValue {
|
||||
enum class Type { kString, kInt, kBool };
|
||||
Type type = Type::kString;
|
||||
std::string string_value;
|
||||
int64_t int_value = 0;
|
||||
bool bool_value = false;
|
||||
};
|
||||
using Diagnostics = std::vector<std::pair<std::string, DiagnosticValue>>;
|
||||
Diagnostics GetDiagnostics();
|
||||
|
||||
inline DiagnosticValue StringValue(std::string value) {
|
||||
DiagnosticValue v;
|
||||
v.type = DiagnosticValue::Type::kString;
|
||||
v.string_value = std::move(value);
|
||||
return v;
|
||||
}
|
||||
inline DiagnosticValue IntValue(int64_t value) {
|
||||
DiagnosticValue v;
|
||||
v.type = DiagnosticValue::Type::kInt;
|
||||
v.int_value = value;
|
||||
return v;
|
||||
}
|
||||
inline DiagnosticValue BoolValue(bool value) {
|
||||
DiagnosticValue v;
|
||||
v.type = DiagnosticValue::Type::kBool;
|
||||
v.bool_value = value;
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace mouse_input
|
||||
|
||||
#endif // MOUSE_INPUT_SRC_IMPL_H_
|
||||
@@ -0,0 +1,314 @@
|
||||
// X11 backend: injects input with the XTEST extension, which the X server
|
||||
// processes exactly like events from a physical device (pointer grabs, input
|
||||
// shapes and window stacking all apply). Xvfb enables XTEST by default.
|
||||
//
|
||||
// libX11 and libXtst are loaded at runtime and the handful of functions used
|
||||
// are declared here, so the addon builds without the X11 development headers
|
||||
// (the Linux arm64 test image has none) and still loads, reporting itself as
|
||||
// unavailable, when there is no X display (the Wayland test leg).
|
||||
//
|
||||
// The addon keeps one Display connection of its own for the life of the
|
||||
// process and serializes all use of it with a mutex, so Xlib never sees
|
||||
// concurrent calls and XInitThreads() is not needed. It is never closed: when
|
||||
// the last client of an X server disconnects the server resets, which among
|
||||
// other things puts the pointer back in the middle of the screen. The addon
|
||||
// never installs an Xlib error handler, which is process wide and would
|
||||
// replace GTK's.
|
||||
|
||||
#include "impl.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <mutex>
|
||||
|
||||
namespace mouse_input {
|
||||
|
||||
namespace {
|
||||
|
||||
struct Display; // Opaque Xlib display.
|
||||
using Window = unsigned long;
|
||||
using Bool = int;
|
||||
constexpr Window kNone = 0;
|
||||
constexpr unsigned long kCurrentTime = 0;
|
||||
|
||||
struct XLib {
|
||||
Display* (*OpenDisplay)(const char*) = nullptr;
|
||||
int (*CloseDisplay)(Display*) = nullptr;
|
||||
int (*Sync)(Display*, Bool) = nullptr;
|
||||
Window (*DefaultRootWindow)(Display*) = nullptr;
|
||||
Bool (*QueryPointer)(Display*,
|
||||
Window,
|
||||
Window*,
|
||||
Window*,
|
||||
int*,
|
||||
int*,
|
||||
int*,
|
||||
int*,
|
||||
unsigned int*) = nullptr;
|
||||
Bool (*TestQueryExtension)(Display*, int*, int*, int*, int*) = nullptr;
|
||||
int (*TestFakeMotionEvent)(Display*, int, int, int, unsigned long) = nullptr;
|
||||
int (*TestFakeButtonEvent)(Display*,
|
||||
unsigned int,
|
||||
Bool,
|
||||
unsigned long) = nullptr;
|
||||
std::string load_error;
|
||||
|
||||
bool ok() const { return load_error.empty(); }
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
bool Resolve(void* library, const char* name, T* out, std::string* error) {
|
||||
*out = reinterpret_cast<T>(::dlsym(library, name));
|
||||
if (!*out && error->empty())
|
||||
*error = std::string("dlsym(") + name + ") failed";
|
||||
return *out != nullptr;
|
||||
}
|
||||
|
||||
const XLib& GetXLib() {
|
||||
static const XLib* xlib = [] {
|
||||
auto* lib = new XLib();
|
||||
void* x11 = ::dlopen("libX11.so.6", RTLD_NOW | RTLD_LOCAL);
|
||||
void* xtst = ::dlopen("libXtst.so.6", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!x11 || !xtst) {
|
||||
const char* reason = ::dlerror();
|
||||
lib->load_error = reason ? reason : "dlopen failed";
|
||||
return lib;
|
||||
}
|
||||
std::string& e = lib->load_error;
|
||||
Resolve(x11, "XOpenDisplay", &lib->OpenDisplay, &e);
|
||||
Resolve(x11, "XCloseDisplay", &lib->CloseDisplay, &e);
|
||||
Resolve(x11, "XSync", &lib->Sync, &e);
|
||||
Resolve(x11, "XDefaultRootWindow", &lib->DefaultRootWindow, &e);
|
||||
Resolve(x11, "XQueryPointer", &lib->QueryPointer, &e);
|
||||
Resolve(xtst, "XTestQueryExtension", &lib->TestQueryExtension, &e);
|
||||
Resolve(xtst, "XTestFakeMotionEvent", &lib->TestFakeMotionEvent, &e);
|
||||
Resolve(xtst, "XTestFakeButtonEvent", &lib->TestFakeButtonEvent, &e);
|
||||
return lib;
|
||||
}();
|
||||
return *xlib;
|
||||
}
|
||||
|
||||
// Error codes reported in InjectResult::error.
|
||||
constexpr int64_t kErrorNoLibrary = 1;
|
||||
constexpr int64_t kErrorNoDisplay = 2;
|
||||
constexpr int64_t kErrorNoXTest = 3;
|
||||
|
||||
// Holds the addon's display connection, opening it on first use, and the
|
||||
// lock that serializes access to it.
|
||||
class Connection {
|
||||
public:
|
||||
Connection() : lock_(Mutex()) {
|
||||
const XLib& x = GetXLib();
|
||||
if (!x.ok()) {
|
||||
error_ = kErrorNoLibrary;
|
||||
return;
|
||||
}
|
||||
static Display* display = nullptr;
|
||||
// XOpenDisplay(nullptr) uses $DISPLAY. Retried while it fails.
|
||||
if (!display)
|
||||
display = x.OpenDisplay(nullptr);
|
||||
display_ = display;
|
||||
if (!display_)
|
||||
error_ = kErrorNoDisplay;
|
||||
}
|
||||
Connection(const Connection&) = delete;
|
||||
Connection& operator=(const Connection&) = delete;
|
||||
|
||||
Display* display() const { return display_; }
|
||||
int64_t error() const { return error_; }
|
||||
|
||||
bool HasXTest() const {
|
||||
int event_base, error_base, major, minor;
|
||||
return display_ && GetXLib().TestQueryExtension(
|
||||
display_, &event_base, &error_base, &major, &minor);
|
||||
}
|
||||
|
||||
// Waits until the server has processed everything sent so far.
|
||||
void Sync() const { GetXLib().Sync(display_, 0); }
|
||||
|
||||
private:
|
||||
static std::mutex& Mutex() {
|
||||
static std::mutex mutex;
|
||||
return mutex;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock_;
|
||||
Display* display_ = nullptr;
|
||||
int64_t error_ = 0;
|
||||
};
|
||||
|
||||
InjectResult Fail(int expected, int64_t error) {
|
||||
InjectResult result;
|
||||
result.expected = expected;
|
||||
result.error = error;
|
||||
return result;
|
||||
}
|
||||
|
||||
unsigned int ButtonNumber(Button button) {
|
||||
switch (button) {
|
||||
case Button::kLeft:
|
||||
return 1;
|
||||
case Button::kMiddle:
|
||||
return 2;
|
||||
case Button::kRight:
|
||||
return 3;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InjectResult Move(int x, int y) {
|
||||
Connection c;
|
||||
if (!c.display())
|
||||
return Fail(1, c.error());
|
||||
InjectResult result;
|
||||
result.expected = 1;
|
||||
// Screen -1 is the screen the pointer is on.
|
||||
if (GetXLib().TestFakeMotionEvent(c.display(), -1, x, y, kCurrentTime))
|
||||
result.sent = 1;
|
||||
else
|
||||
result.error = kErrorNoXTest;
|
||||
c.Sync();
|
||||
return result;
|
||||
}
|
||||
|
||||
InjectResult Press(Button button, bool down) {
|
||||
Connection c;
|
||||
if (!c.display())
|
||||
return Fail(1, c.error());
|
||||
InjectResult result;
|
||||
result.expected = 1;
|
||||
if (GetXLib().TestFakeButtonEvent(c.display(), ButtonNumber(button),
|
||||
down ? 1 : 0, kCurrentTime))
|
||||
result.sent = 1;
|
||||
else
|
||||
result.error = kErrorNoXTest;
|
||||
c.Sync();
|
||||
return result;
|
||||
}
|
||||
|
||||
InjectResult Wheel(int delta, bool horizontal) {
|
||||
// X11 has no wheel events: each notch is a press and release of button 4
|
||||
// (up) / 5 (down) / 6 (left) / 7 (right).
|
||||
const int notches = std::abs(delta) / 120;
|
||||
unsigned int button;
|
||||
if (horizontal)
|
||||
button = delta > 0 ? 7 : 6;
|
||||
else
|
||||
button = delta > 0 ? 4 : 5;
|
||||
Connection c;
|
||||
if (!c.display())
|
||||
return Fail(notches * 2, c.error());
|
||||
InjectResult result;
|
||||
result.expected = notches * 2;
|
||||
for (int i = 0; i < notches; ++i) {
|
||||
for (Bool press : {1, 0}) {
|
||||
if (GetXLib().TestFakeButtonEvent(c.display(), button, press,
|
||||
kCurrentTime))
|
||||
++result.sent;
|
||||
else
|
||||
result.error = kErrorNoXTest;
|
||||
}
|
||||
}
|
||||
c.Sync();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool GetCursorPos(int* x, int* y, int64_t* error) {
|
||||
Connection c;
|
||||
if (!c.display()) {
|
||||
*error = c.error();
|
||||
return false;
|
||||
}
|
||||
Window root = kNone, child = kNone;
|
||||
int win_x, win_y;
|
||||
unsigned int mask;
|
||||
if (!GetXLib().QueryPointer(c.display(),
|
||||
GetXLib().DefaultRootWindow(c.display()), &root,
|
||||
&child, x, y, &win_x, &win_y, &mask)) {
|
||||
// The pointer is on another screen.
|
||||
*error = kErrorNoDisplay;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The X server hit tests the pointer against input shapes, and XQueryPointer
|
||||
// reports the child of the queried window that contains the pointer as hit
|
||||
// tested, so descend from the root while the pointer is at (x, y). This moves
|
||||
// the pointer there first.
|
||||
bool IsWindowAtPoint(const void* handle,
|
||||
size_t handle_size,
|
||||
int x,
|
||||
int y,
|
||||
bool* supported) {
|
||||
// BrowserWindow.getNativeWindowHandle() is the XID, as a 32 bit
|
||||
// gfx::AcceleratedWidget or a 64 bit Window depending on the build.
|
||||
Window target = kNone;
|
||||
if (handle_size == sizeof(uint32_t)) {
|
||||
uint32_t id;
|
||||
std::memcpy(&id, handle, sizeof(id));
|
||||
target = id;
|
||||
} else if (handle_size == sizeof(uint64_t)) {
|
||||
uint64_t id;
|
||||
std::memcpy(&id, handle, sizeof(id));
|
||||
target = static_cast<Window>(id);
|
||||
}
|
||||
if (target == kNone)
|
||||
return false;
|
||||
|
||||
Connection c;
|
||||
if (!c.display() || !c.HasXTest()) {
|
||||
*supported = false;
|
||||
return false;
|
||||
}
|
||||
GetXLib().TestFakeMotionEvent(c.display(), -1, x, y, kCurrentTime);
|
||||
c.Sync();
|
||||
|
||||
// Only windows known to exist are queried: the root, then the child it
|
||||
// reports. Without a window manager (Xvfb in CI) Electron's window is a
|
||||
// child of the root; with one it is inside a frame window. The depth limit
|
||||
// stops at the web contents' own child windows, which are not of interest.
|
||||
Window window = GetXLib().DefaultRootWindow(c.display());
|
||||
for (int depth = 0; depth < 4 && window != kNone; ++depth) {
|
||||
Window root = kNone, child = kNone;
|
||||
int root_x, root_y, win_x, win_y;
|
||||
unsigned int mask;
|
||||
if (!GetXLib().QueryPointer(c.display(), window, &root, &child, &root_x,
|
||||
&root_y, &win_x, &win_y, &mask))
|
||||
return false;
|
||||
if (child == target)
|
||||
return true;
|
||||
window = child;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Diagnostics GetDiagnostics() {
|
||||
Diagnostics d;
|
||||
const XLib& x = GetXLib();
|
||||
d.emplace_back("backend", StringValue("xtest"));
|
||||
const char* display_env = std::getenv("DISPLAY");
|
||||
d.emplace_back("display", StringValue(display_env ? display_env : ""));
|
||||
const char* session_type = std::getenv("XDG_SESSION_TYPE");
|
||||
d.emplace_back("sessionType", StringValue(session_type ? session_type : ""));
|
||||
d.emplace_back("libraryLoaded", BoolValue(x.ok()));
|
||||
if (!x.ok()) {
|
||||
d.emplace_back("libraryError", StringValue(x.load_error));
|
||||
d.emplace_back("available", BoolValue(false));
|
||||
return d;
|
||||
}
|
||||
Connection c;
|
||||
d.emplace_back("displayOpened", BoolValue(c.display() != nullptr));
|
||||
const bool has_xtest = c.HasXTest();
|
||||
d.emplace_back("xtest", BoolValue(has_xtest));
|
||||
d.emplace_back("available", BoolValue(has_xtest));
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace mouse_input
|
||||
@@ -0,0 +1,158 @@
|
||||
// macOS backend: posts Quartz events at the HID event tap, the point where
|
||||
// events from physical devices enter the system. They move the cursor, are
|
||||
// routed by the window server (so windows that ignore mouse events are skipped)
|
||||
// and reach AppKit as ordinary NSEvents.
|
||||
//
|
||||
// Posting events needs the Accessibility (kTCCServicePostEvent) permission of
|
||||
// the responsible process. Without it CGEventPost() drops events silently, so
|
||||
// getDiagnostics() reports CGPreflightPostEventAccess() and specs should also
|
||||
// check that a move actually moves the cursor.
|
||||
|
||||
#include "impl.h"
|
||||
|
||||
#include <ApplicationServices/ApplicationServices.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace mouse_input {
|
||||
|
||||
namespace {
|
||||
|
||||
// Buttons currently held down through this addon. While one is, moves are
|
||||
// posted as drags so AppKit sees the same sequence as from a real mouse.
|
||||
std::atomic<int> g_pressed_buttons{0};
|
||||
|
||||
int ButtonBit(Button button) {
|
||||
switch (button) {
|
||||
case Button::kLeft:
|
||||
return 1;
|
||||
case Button::kRight:
|
||||
return 2;
|
||||
case Button::kMiddle:
|
||||
return 4;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
CGPoint CurrentLocation() {
|
||||
CGEventRef event = CGEventCreate(nullptr);
|
||||
CGPoint location = event ? CGEventGetLocation(event) : CGPointZero;
|
||||
if (event)
|
||||
CFRelease(event);
|
||||
return location;
|
||||
}
|
||||
|
||||
// Posts |event| (taking ownership) and reports it the way SendInput() would.
|
||||
InjectResult Post(CGEventRef event) {
|
||||
InjectResult result;
|
||||
result.expected = 1;
|
||||
if (!event) {
|
||||
result.error = 1;
|
||||
return result;
|
||||
}
|
||||
CGEventPost(kCGHIDEventTap, event);
|
||||
CFRelease(event);
|
||||
result.sent = 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InjectResult Move(int x, int y) {
|
||||
const int pressed = g_pressed_buttons.load();
|
||||
CGEventType type = kCGEventMouseMoved;
|
||||
CGMouseButton button = kCGMouseButtonLeft;
|
||||
if (pressed & ButtonBit(Button::kLeft)) {
|
||||
type = kCGEventLeftMouseDragged;
|
||||
} else if (pressed & ButtonBit(Button::kRight)) {
|
||||
type = kCGEventRightMouseDragged;
|
||||
button = kCGMouseButtonRight;
|
||||
} else if (pressed & ButtonBit(Button::kMiddle)) {
|
||||
type = kCGEventOtherMouseDragged;
|
||||
button = kCGMouseButtonCenter;
|
||||
}
|
||||
return Post(
|
||||
CGEventCreateMouseEvent(nullptr, type, CGPointMake(x, y), button));
|
||||
}
|
||||
|
||||
InjectResult Press(Button button, bool down) {
|
||||
CGEventType type = down ? kCGEventLeftMouseDown : kCGEventLeftMouseUp;
|
||||
CGMouseButton cg_button = kCGMouseButtonLeft;
|
||||
switch (button) {
|
||||
case Button::kLeft:
|
||||
type = down ? kCGEventLeftMouseDown : kCGEventLeftMouseUp;
|
||||
cg_button = kCGMouseButtonLeft;
|
||||
break;
|
||||
case Button::kRight:
|
||||
type = down ? kCGEventRightMouseDown : kCGEventRightMouseUp;
|
||||
cg_button = kCGMouseButtonRight;
|
||||
break;
|
||||
case Button::kMiddle:
|
||||
type = down ? kCGEventOtherMouseDown : kCGEventOtherMouseUp;
|
||||
cg_button = kCGMouseButtonCenter;
|
||||
break;
|
||||
}
|
||||
CGEventRef event =
|
||||
CGEventCreateMouseEvent(nullptr, type, CurrentLocation(), cg_button);
|
||||
if (event)
|
||||
CGEventSetIntegerValueField(event, kCGMouseEventClickState, 1);
|
||||
if (down)
|
||||
g_pressed_buttons |= ButtonBit(button);
|
||||
else
|
||||
g_pressed_buttons &= ~ButtonBit(button);
|
||||
return Post(event);
|
||||
}
|
||||
|
||||
InjectResult Wheel(int delta, bool horizontal) {
|
||||
const int32_t lines = delta / 120;
|
||||
// Positive wheel1 scrolls up; positive wheel2 scrolls left, so negate it to
|
||||
// match Windows' MOUSEEVENTF_HWHEEL, where positive scrolls right.
|
||||
CGEventRef event = horizontal
|
||||
? CGEventCreateScrollWheelEvent(
|
||||
nullptr, kCGScrollEventUnitLine, 2, 0, -lines)
|
||||
: CGEventCreateScrollWheelEvent(
|
||||
nullptr, kCGScrollEventUnitLine, 1, lines);
|
||||
return Post(event);
|
||||
}
|
||||
|
||||
bool GetCursorPos(int* x, int* y, int64_t* error) {
|
||||
const CGPoint location = CurrentLocation();
|
||||
*x = static_cast<int>(std::lround(location.x));
|
||||
*y = static_cast<int>(std::lround(location.y));
|
||||
return true;
|
||||
}
|
||||
|
||||
// There is no public API that hit tests the way the window server routes
|
||||
// events (CGWindowList and -[NSWindow windowNumberAtPoint:...] do not account
|
||||
// for ignoresMouseEvents), so specs check click-through by clicking instead.
|
||||
bool IsWindowAtPoint(const void* handle,
|
||||
size_t handle_size,
|
||||
int x,
|
||||
int y,
|
||||
bool* supported) {
|
||||
*supported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
Diagnostics GetDiagnostics() {
|
||||
Diagnostics d;
|
||||
d.emplace_back("backend", StringValue("quartz"));
|
||||
d.emplace_back("postEventAccess", BoolValue(CGPreflightPostEventAccess()));
|
||||
d.emplace_back("accessibilityTrusted", BoolValue(AXIsProcessTrusted()));
|
||||
const CGPoint location = CurrentLocation();
|
||||
d.emplace_back("cursorX", IntValue(std::lround(location.x)));
|
||||
d.emplace_back("cursorY", IntValue(std::lround(location.y)));
|
||||
const CGRect main_display = CGDisplayBounds(CGMainDisplayID());
|
||||
d.emplace_back("mainDisplayWidth",
|
||||
IntValue(std::lround(main_display.size.width)));
|
||||
d.emplace_back("mainDisplayHeight",
|
||||
IntValue(std::lround(main_display.size.height)));
|
||||
// Posting without the permission is not an error, the events just vanish;
|
||||
// the specs confirm with a real move.
|
||||
d.emplace_back("available", BoolValue(true));
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace mouse_input
|
||||
@@ -0,0 +1,291 @@
|
||||
// N-API surface of the addon on macOS and Linux, matching main_win.cc: the
|
||||
// same functions with the same result shapes, backed by impl_mac.mm
|
||||
// (CGEventPost) or impl_linux.cc (XTEST).
|
||||
|
||||
#include <js_native_api.h>
|
||||
#include <node_api.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "impl.h"
|
||||
|
||||
namespace {
|
||||
|
||||
#define NAPI_CALL(env, call) \
|
||||
do { \
|
||||
if ((call) != napi_ok) { \
|
||||
napi_throw_error((env), nullptr, "N-API call failed: " #call); \
|
||||
return nullptr; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
void SetInt(napi_env env, napi_value obj, const char* key, int64_t value) {
|
||||
napi_value v;
|
||||
napi_create_int64(env, value, &v);
|
||||
napi_set_named_property(env, obj, key, v);
|
||||
}
|
||||
|
||||
void SetBool(napi_env env, napi_value obj, const char* key, bool value) {
|
||||
napi_value v;
|
||||
napi_get_boolean(env, value, &v);
|
||||
napi_set_named_property(env, obj, key, v);
|
||||
}
|
||||
|
||||
void SetString(napi_env env,
|
||||
napi_value obj,
|
||||
const char* key,
|
||||
const std::string& value) {
|
||||
napi_value v;
|
||||
napi_create_string_utf8(env, value.c_str(), value.size(), &v);
|
||||
napi_set_named_property(env, obj, key, v);
|
||||
}
|
||||
|
||||
bool GetInt32Arg(napi_env env, napi_value value, int32_t* out) {
|
||||
return napi_get_value_int32(env, value, out) == napi_ok;
|
||||
}
|
||||
|
||||
bool GetStringArg(napi_env env, napi_value value, std::string* out) {
|
||||
size_t length = 0;
|
||||
if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok)
|
||||
return false;
|
||||
out->resize(length);
|
||||
size_t copied = 0;
|
||||
if (napi_get_value_string_utf8(env, value, &(*out)[0], length + 1, &copied) !=
|
||||
napi_ok)
|
||||
return false;
|
||||
out->resize(copied);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Injection runs on a libuv worker thread, like SendInput() on Windows, so the
|
||||
// main thread keeps pumping while the OS processes the input.
|
||||
|
||||
enum class Op { kMove, kButton, kWheel };
|
||||
|
||||
struct InjectWork {
|
||||
napi_async_work work = nullptr;
|
||||
napi_deferred deferred = nullptr;
|
||||
Op op = Op::kMove;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
mouse_input::Button button = mouse_input::Button::kLeft;
|
||||
bool down = false;
|
||||
bool up = false;
|
||||
int delta = 0;
|
||||
bool horizontal = false;
|
||||
mouse_input::InjectResult result;
|
||||
};
|
||||
|
||||
void Accumulate(mouse_input::InjectResult* total,
|
||||
const mouse_input::InjectResult& part) {
|
||||
total->sent += part.sent;
|
||||
total->expected += part.expected;
|
||||
if (part.error)
|
||||
total->error = part.error;
|
||||
}
|
||||
|
||||
void ExecuteInject(napi_env env, void* data) {
|
||||
auto* work = static_cast<InjectWork*>(data);
|
||||
switch (work->op) {
|
||||
case Op::kMove:
|
||||
work->result = mouse_input::Move(work->x, work->y);
|
||||
break;
|
||||
case Op::kButton:
|
||||
if (work->down)
|
||||
Accumulate(&work->result, mouse_input::Press(work->button, true));
|
||||
if (work->up)
|
||||
Accumulate(&work->result, mouse_input::Press(work->button, false));
|
||||
break;
|
||||
case Op::kWheel:
|
||||
work->result = mouse_input::Wheel(work->delta, work->horizontal);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CompleteInject(napi_env env, napi_status status, void* data) {
|
||||
auto* work = static_cast<InjectWork*>(data);
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
SetInt(env, result, "sent", work->result.sent);
|
||||
SetInt(env, result, "expected", work->result.expected);
|
||||
SetInt(env, result, "error", work->result.error);
|
||||
napi_resolve_deferred(env, work->deferred, result);
|
||||
napi_delete_async_work(env, work->work);
|
||||
delete work;
|
||||
}
|
||||
|
||||
napi_value QueueInject(napi_env env, InjectWork* work) {
|
||||
napi_value promise;
|
||||
napi_value name;
|
||||
napi_create_promise(env, &work->deferred, &promise);
|
||||
napi_create_string_utf8(env, "mouseInputInject", NAPI_AUTO_LENGTH, &name);
|
||||
napi_create_async_work(env, nullptr, name, ExecuteInject, CompleteInject,
|
||||
work, &work->work);
|
||||
napi_queue_async_work(env, work->work);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// move(x, y): absolute move to screen point (x, y).
|
||||
napi_value MoveJs(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
int32_t x = 0, y = 0;
|
||||
if (argc < 2 || !GetInt32Arg(env, args[0], &x) ||
|
||||
!GetInt32Arg(env, args[1], &y)) {
|
||||
napi_throw_type_error(env, nullptr, "move(x, y) expects two integers");
|
||||
return nullptr;
|
||||
}
|
||||
auto* work = new InjectWork();
|
||||
work->op = Op::kMove;
|
||||
work->x = x;
|
||||
work->y = y;
|
||||
return QueueInject(env, work);
|
||||
}
|
||||
|
||||
// button(button, action): button is 'left' | 'right' | 'middle', action is
|
||||
// 'down' | 'up' | 'click'. Presses at the current cursor position.
|
||||
napi_value ButtonJs(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
std::string button, action;
|
||||
if (argc < 2 || !GetStringArg(env, args[0], &button) ||
|
||||
!GetStringArg(env, args[1], &action)) {
|
||||
napi_throw_type_error(env, nullptr,
|
||||
"button(button, action) expects two strings");
|
||||
return nullptr;
|
||||
}
|
||||
mouse_input::Button which;
|
||||
if (button == "left") {
|
||||
which = mouse_input::Button::kLeft;
|
||||
} else if (button == "right") {
|
||||
which = mouse_input::Button::kRight;
|
||||
} else if (button == "middle") {
|
||||
which = mouse_input::Button::kMiddle;
|
||||
} else {
|
||||
napi_throw_range_error(env, nullptr, "unknown mouse button");
|
||||
return nullptr;
|
||||
}
|
||||
const bool down = action == "down" || action == "click";
|
||||
const bool up = action == "up" || action == "click";
|
||||
if (!down && !up) {
|
||||
napi_throw_range_error(env, nullptr, "action must be down, up or click");
|
||||
return nullptr;
|
||||
}
|
||||
auto* work = new InjectWork();
|
||||
work->op = Op::kButton;
|
||||
work->button = which;
|
||||
work->down = down;
|
||||
work->up = up;
|
||||
return QueueInject(env, work);
|
||||
}
|
||||
|
||||
// wheel(delta, horizontal?): delta in WHEEL_DELTA (120) units per notch.
|
||||
napi_value WheelJs(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
int32_t delta = 0;
|
||||
if (argc < 1 || !GetInt32Arg(env, args[0], &delta)) {
|
||||
napi_throw_type_error(env, nullptr, "wheel(delta) expects an integer");
|
||||
return nullptr;
|
||||
}
|
||||
bool horizontal = false;
|
||||
if (argc >= 2)
|
||||
napi_get_value_bool(env, args[1], &horizontal);
|
||||
auto* work = new InjectWork();
|
||||
work->op = Op::kWheel;
|
||||
work->delta = delta;
|
||||
work->horizontal = horizontal;
|
||||
return QueueInject(env, work);
|
||||
}
|
||||
|
||||
// getCursorPos(): { x, y }, or { error } when the position is unavailable.
|
||||
napi_value GetCursorPosJs(napi_env env, napi_callback_info info) {
|
||||
napi_value result;
|
||||
NAPI_CALL(env, napi_create_object(env, &result));
|
||||
int x = 0, y = 0;
|
||||
int64_t error = 0;
|
||||
if (!mouse_input::GetCursorPos(&x, &y, &error)) {
|
||||
SetInt(env, result, "error", error);
|
||||
return result;
|
||||
}
|
||||
SetInt(env, result, "x", x);
|
||||
SetInt(env, result, "y", y);
|
||||
return result;
|
||||
}
|
||||
|
||||
// isWindowAtPoint(handle, x, y): see impl.h. Throws where unsupported.
|
||||
napi_value IsWindowAtPointJs(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 3;
|
||||
napi_value args[3];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
bool is_buffer = false;
|
||||
void* data = nullptr;
|
||||
size_t length = 0;
|
||||
int32_t x = 0, y = 0;
|
||||
if (argc < 3 || napi_is_buffer(env, args[0], &is_buffer) != napi_ok ||
|
||||
!is_buffer ||
|
||||
napi_get_buffer_info(env, args[0], &data, &length) != napi_ok ||
|
||||
!GetInt32Arg(env, args[1], &x) || !GetInt32Arg(env, args[2], &y)) {
|
||||
napi_throw_type_error(env, nullptr,
|
||||
"isWindowAtPoint(handle, x, y) expects a native "
|
||||
"window handle Buffer and two integers");
|
||||
return nullptr;
|
||||
}
|
||||
bool supported = true;
|
||||
const bool hit = mouse_input::IsWindowAtPoint(data, length, x, y, &supported);
|
||||
if (!supported) {
|
||||
napi_throw_error(env, nullptr,
|
||||
"isWindowAtPoint() is not supported on this platform");
|
||||
return nullptr;
|
||||
}
|
||||
napi_value result;
|
||||
napi_get_boolean(env, hit, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
napi_value GetDiagnosticsJs(napi_env env, napi_callback_info info) {
|
||||
napi_value result;
|
||||
NAPI_CALL(env, napi_create_object(env, &result));
|
||||
for (const auto& [key, value] : mouse_input::GetDiagnostics()) {
|
||||
switch (value.type) {
|
||||
case mouse_input::DiagnosticValue::Type::kString:
|
||||
SetString(env, result, key.c_str(), value.string_value);
|
||||
break;
|
||||
case mouse_input::DiagnosticValue::Type::kInt:
|
||||
SetInt(env, result, key.c_str(), value.int_value);
|
||||
break;
|
||||
case mouse_input::DiagnosticValue::Type::kBool:
|
||||
SetBool(env, result, key.c_str(), value.bool_value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
napi_value Init(napi_env env, napi_value exports) {
|
||||
napi_property_descriptor descriptors[] = {
|
||||
{"move", nullptr, MoveJs, nullptr, nullptr, nullptr, napi_default,
|
||||
nullptr},
|
||||
{"button", nullptr, ButtonJs, nullptr, nullptr, nullptr, napi_default,
|
||||
nullptr},
|
||||
{"wheel", nullptr, WheelJs, nullptr, nullptr, nullptr, napi_default,
|
||||
nullptr},
|
||||
{"getCursorPos", nullptr, GetCursorPosJs, nullptr, nullptr, nullptr,
|
||||
napi_default, nullptr},
|
||||
{"isWindowAtPoint", nullptr, IsWindowAtPointJs, nullptr, nullptr, nullptr,
|
||||
napi_default, nullptr},
|
||||
{"getDiagnostics", nullptr, GetDiagnosticsJs, nullptr, nullptr, nullptr,
|
||||
napi_default, nullptr},
|
||||
};
|
||||
napi_define_properties(
|
||||
env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors);
|
||||
return exports;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
||||
@@ -0,0 +1,481 @@
|
||||
// Test-only helper that drives the real Windows input stack from specs.
|
||||
//
|
||||
// Everything here goes through SendInput(), so events are injected into the
|
||||
// system input queue exactly like a physical mouse: they are seen by low level
|
||||
// mouse hooks (WH_MOUSE_LL, with LLMHF_INJECTED set), hit tested against the
|
||||
// window under the cursor, and delivered as WM_MOUSEMOVE / WM_*BUTTON* /
|
||||
// WM_MOUSEWHEEL / WM_NC* messages. This is what webContents.sendInputEvent()
|
||||
// cannot do, since that feeds Blink directly and never touches Win32.
|
||||
//
|
||||
// All coordinates are physical screen pixels (use screen.dipToScreenPoint()).
|
||||
|
||||
#include <js_native_api.h>
|
||||
#include <node_api.h>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
#define NAPI_CALL(env, call) \
|
||||
do { \
|
||||
if ((call) != napi_ok) { \
|
||||
napi_throw_error((env), nullptr, "N-API call failed: " #call); \
|
||||
return nullptr; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small N-API helpers.
|
||||
|
||||
napi_value MakeInt(napi_env env, int64_t value) {
|
||||
napi_value result;
|
||||
napi_create_int64(env, value, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
napi_value MakeBool(napi_env env, bool value) {
|
||||
napi_value result;
|
||||
napi_get_boolean(env, value, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
napi_value MakeString(napi_env env, const std::string& value) {
|
||||
napi_value result;
|
||||
napi_create_string_utf8(env, value.c_str(), value.size(), &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void SetInt(napi_env env, napi_value obj, const char* key, int64_t value) {
|
||||
napi_set_named_property(env, obj, key, MakeInt(env, value));
|
||||
}
|
||||
|
||||
void SetBool(napi_env env, napi_value obj, const char* key, bool value) {
|
||||
napi_set_named_property(env, obj, key, MakeBool(env, value));
|
||||
}
|
||||
|
||||
void SetString(napi_env env,
|
||||
napi_value obj,
|
||||
const char* key,
|
||||
const std::string& value) {
|
||||
napi_set_named_property(env, obj, key, MakeString(env, value));
|
||||
}
|
||||
|
||||
bool GetInt32Arg(napi_env env, napi_value value, int32_t* out) {
|
||||
return napi_get_value_int32(env, value, out) == napi_ok;
|
||||
}
|
||||
|
||||
bool GetStringArg(napi_env env, napi_value value, std::string* out) {
|
||||
size_t length = 0;
|
||||
if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok)
|
||||
return false;
|
||||
out->resize(length);
|
||||
size_t copied = 0;
|
||||
if (napi_get_value_string_utf8(env, value, &(*out)[0], length + 1, &copied) !=
|
||||
napi_ok)
|
||||
return false;
|
||||
out->resize(copied);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The buffer returned by BrowserWindow.getNativeWindowHandle().
|
||||
bool GetHwndArg(napi_env env, napi_value value, HWND* out) {
|
||||
bool is_buffer = false;
|
||||
if (napi_is_buffer(env, value, &is_buffer) != napi_ok || !is_buffer)
|
||||
return false;
|
||||
void* data = nullptr;
|
||||
size_t length = 0;
|
||||
if (napi_get_buffer_info(env, value, &data, &length) != napi_ok ||
|
||||
length != sizeof(HWND))
|
||||
return false;
|
||||
std::memcpy(out, data, sizeof(HWND));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string WideToUtf8(const wchar_t* wide) {
|
||||
int size =
|
||||
::WideCharToMultiByte(CP_UTF8, 0, wide, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (size <= 1)
|
||||
return std::string();
|
||||
std::string result(size - 1, '\0');
|
||||
::WideCharToMultiByte(CP_UTF8, 0, wide, -1, &result[0], size, nullptr,
|
||||
nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Name of a desktop or window station handle, e.g. "Default" or "Winlogon"
|
||||
// (secure desktop), "WinSta0" (interactive window station).
|
||||
std::string ObjectName(HANDLE desktop) {
|
||||
wchar_t name[256] = {0};
|
||||
DWORD needed = 0;
|
||||
if (!::GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name),
|
||||
&needed))
|
||||
return std::string();
|
||||
return WideToUtf8(name);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SendInput plumbing. Injection runs on a libuv worker thread so that the
|
||||
// Electron UI thread -- which owns the WH_MOUSE_LL hook installed by
|
||||
// setIgnoreMouseEvents(true, { forward: true }) -- is free to service the hook
|
||||
// while the input is being processed.
|
||||
|
||||
constexpr int kMaxInputs = 4;
|
||||
|
||||
struct SendInputWork {
|
||||
napi_async_work work = nullptr;
|
||||
napi_deferred deferred = nullptr;
|
||||
INPUT inputs[kMaxInputs] = {};
|
||||
UINT count = 0;
|
||||
UINT sent = 0;
|
||||
DWORD error = 0;
|
||||
};
|
||||
|
||||
void ExecuteSendInput(napi_env env, void* data) {
|
||||
auto* work = static_cast<SendInputWork*>(data);
|
||||
::SetLastError(0);
|
||||
work->sent = ::SendInput(work->count, work->inputs, sizeof(INPUT));
|
||||
work->error = work->sent == work->count ? 0 : ::GetLastError();
|
||||
}
|
||||
|
||||
void CompleteSendInput(napi_env env, napi_status status, void* data) {
|
||||
auto* work = static_cast<SendInputWork*>(data);
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
SetInt(env, result, "sent", work->sent);
|
||||
SetInt(env, result, "expected", work->count);
|
||||
// 5 == ERROR_ACCESS_DENIED: typically UIPI (target at a higher integrity
|
||||
// level) or the calling thread's desktop is not the input desktop (locked
|
||||
// workstation, secure desktop, session 0 / service without a desktop).
|
||||
SetInt(env, result, "error", work->error);
|
||||
napi_resolve_deferred(env, work->deferred, result);
|
||||
napi_delete_async_work(env, work->work);
|
||||
delete work;
|
||||
}
|
||||
|
||||
napi_value QueueSendInput(napi_env env, SendInputWork* work) {
|
||||
napi_value promise;
|
||||
napi_value name;
|
||||
napi_create_promise(env, &work->deferred, &promise);
|
||||
napi_create_string_utf8(env, "mouseInputSendInput", NAPI_AUTO_LENGTH, &name);
|
||||
napi_create_async_work(env, nullptr, name, ExecuteSendInput,
|
||||
CompleteSendInput, work, &work->work);
|
||||
napi_queue_async_work(env, work->work);
|
||||
return promise;
|
||||
}
|
||||
|
||||
INPUT MouseInput(DWORD flags, LONG dx = 0, LONG dy = 0, DWORD data = 0) {
|
||||
INPUT input = {};
|
||||
input.type = INPUT_MOUSE;
|
||||
input.mi.dx = dx;
|
||||
input.mi.dy = dy;
|
||||
input.mi.mouseData = data;
|
||||
input.mi.dwFlags = flags;
|
||||
return input;
|
||||
}
|
||||
|
||||
// Normalizes a physical screen coordinate to the 0..65535 range that
|
||||
// MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK expects, aiming at the
|
||||
// centre of the pixel so rounding inside win32k lands on |pos|.
|
||||
LONG Normalize(int pos, int origin, int extent) {
|
||||
if (extent <= 0)
|
||||
return 0;
|
||||
return static_cast<LONG>(
|
||||
((static_cast<int64_t>(pos - origin) * 2 + 1) * 65536) / (extent * 2));
|
||||
}
|
||||
|
||||
// move(x, y): absolute move to physical screen pixel (x, y).
|
||||
napi_value Move(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
int32_t x = 0, y = 0;
|
||||
if (argc < 2 || !GetInt32Arg(env, args[0], &x) ||
|
||||
!GetInt32Arg(env, args[1], &y)) {
|
||||
napi_throw_type_error(env, nullptr, "move(x, y) expects two integers");
|
||||
return nullptr;
|
||||
}
|
||||
const int vx = ::GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
const int vy = ::GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
const int vw = ::GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||
const int vh = ::GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||
|
||||
auto* work = new SendInputWork();
|
||||
work->inputs[0] = MouseInput(
|
||||
MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
|
||||
Normalize(x, vx, vw), Normalize(y, vy, vh));
|
||||
work->count = 1;
|
||||
return QueueSendInput(env, work);
|
||||
}
|
||||
|
||||
bool ButtonFlags(const std::string& button, bool down, DWORD* flags) {
|
||||
if (button == "left")
|
||||
*flags = down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP;
|
||||
else if (button == "right")
|
||||
*flags = down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP;
|
||||
else if (button == "middle")
|
||||
*flags = down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP;
|
||||
else
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// button(button, action): button is 'left' | 'right' | 'middle', action is
|
||||
// 'down' | 'up' | 'click'. Presses at the current cursor position.
|
||||
napi_value Button(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
std::string button, action;
|
||||
if (argc < 2 || !GetStringArg(env, args[0], &button) ||
|
||||
!GetStringArg(env, args[1], &action)) {
|
||||
napi_throw_type_error(env, nullptr,
|
||||
"button(button, action) expects two strings");
|
||||
return nullptr;
|
||||
}
|
||||
DWORD down_flags = 0, up_flags = 0;
|
||||
if (!ButtonFlags(button, true, &down_flags) ||
|
||||
!ButtonFlags(button, false, &up_flags)) {
|
||||
napi_throw_range_error(env, nullptr, "unknown mouse button");
|
||||
return nullptr;
|
||||
}
|
||||
auto* work = new SendInputWork();
|
||||
if (action == "down" || action == "click")
|
||||
work->inputs[work->count++] = MouseInput(down_flags);
|
||||
if (action == "up" || action == "click")
|
||||
work->inputs[work->count++] = MouseInput(up_flags);
|
||||
if (work->count == 0) {
|
||||
delete work;
|
||||
napi_throw_range_error(env, nullptr, "action must be down, up or click");
|
||||
return nullptr;
|
||||
}
|
||||
return QueueSendInput(env, work);
|
||||
}
|
||||
|
||||
// wheel(delta, horizontal?): delta in WHEEL_DELTA (120) units per notch.
|
||||
napi_value Wheel(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
int32_t delta = 0;
|
||||
if (argc < 1 || !GetInt32Arg(env, args[0], &delta)) {
|
||||
napi_throw_type_error(env, nullptr, "wheel(delta) expects an integer");
|
||||
return nullptr;
|
||||
}
|
||||
bool horizontal = false;
|
||||
if (argc >= 2)
|
||||
napi_get_value_bool(env, args[1], &horizontal);
|
||||
auto* work = new SendInputWork();
|
||||
work->inputs[0] =
|
||||
MouseInput(horizontal ? MOUSEEVENTF_HWHEEL : MOUSEEVENTF_WHEEL, 0, 0,
|
||||
static_cast<DWORD>(delta));
|
||||
work->count = 1;
|
||||
return QueueSendInput(env, work);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synchronous helpers.
|
||||
|
||||
// getCursorPos(): { x, y } in physical pixels, or null (with the error code)
|
||||
// when the calling thread's desktop is not the input desktop.
|
||||
napi_value GetCursorPosition(napi_env env, napi_callback_info info) {
|
||||
POINT point = {};
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
if (!::GetCursorPos(&point)) {
|
||||
SetInt(env, result, "error", ::GetLastError());
|
||||
return result;
|
||||
}
|
||||
SetInt(env, result, "x", point.x);
|
||||
SetInt(env, result, "y", point.y);
|
||||
return result;
|
||||
}
|
||||
|
||||
// isWindowAtPoint(hwnd, x, y): whether ::WindowFromPoint() at the physical
|
||||
// screen point resolves to |hwnd| or one of its children. A window made
|
||||
// click-through by setIgnoreMouseEvents(true) is expected to report false.
|
||||
napi_value IsWindowAtPoint(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 3;
|
||||
napi_value args[3];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
HWND hwnd = nullptr;
|
||||
int32_t x = 0, y = 0;
|
||||
if (argc < 3 || !GetHwndArg(env, args[0], &hwnd) ||
|
||||
!GetInt32Arg(env, args[1], &x) || !GetInt32Arg(env, args[2], &y)) {
|
||||
napi_throw_type_error(env, nullptr,
|
||||
"isWindowAtPoint(handle, x, y) expects a native "
|
||||
"window handle Buffer and two integers");
|
||||
return nullptr;
|
||||
}
|
||||
POINT point = {x, y};
|
||||
HWND hit = ::WindowFromPoint(point);
|
||||
return MakeBool(env, hit && (hit == hwnd || ::IsChild(hwnd, hit) ||
|
||||
::GetAncestor(hit, GA_ROOT) == hwnd));
|
||||
}
|
||||
|
||||
// describeWindowAtPoint(x, y): what ::WindowFromPoint() hit tests at the
|
||||
// physical screen point, for failure messages: the top level window's class,
|
||||
// title, owning process and extended style.
|
||||
napi_value DescribeWindowAtPoint(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 2;
|
||||
napi_value args[2];
|
||||
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, nullptr, nullptr));
|
||||
int32_t x = 0, y = 0;
|
||||
if (argc < 2 || !GetInt32Arg(env, args[0], &x) ||
|
||||
!GetInt32Arg(env, args[1], &y)) {
|
||||
napi_throw_type_error(env, nullptr,
|
||||
"describeWindowAtPoint(x, y) expects two integers");
|
||||
return nullptr;
|
||||
}
|
||||
napi_value result;
|
||||
NAPI_CALL(env, napi_create_object(env, &result));
|
||||
POINT point = {x, y};
|
||||
HWND hit = ::WindowFromPoint(point);
|
||||
if (!hit)
|
||||
return result;
|
||||
HWND root = ::GetAncestor(hit, GA_ROOT);
|
||||
if (!root)
|
||||
root = hit;
|
||||
SetInt(env, result, "hwnd", reinterpret_cast<intptr_t>(root));
|
||||
wchar_t text[256] = {0};
|
||||
if (::GetClassNameW(root, text, 256))
|
||||
SetString(env, result, "className", WideToUtf8(text));
|
||||
text[0] = 0;
|
||||
::GetWindowTextW(root, text, 256);
|
||||
SetString(env, result, "title", WideToUtf8(text));
|
||||
const LONG ex_style = ::GetWindowLongW(root, GWL_EXSTYLE);
|
||||
SetInt(env, result, "exStyle", ex_style);
|
||||
SetBool(env, result, "topmost", (ex_style & WS_EX_TOPMOST) != 0);
|
||||
DWORD pid = 0;
|
||||
::GetWindowThreadProcessId(root, &pid);
|
||||
SetInt(env, result, "pid", pid);
|
||||
SetBool(env, result, "ownProcess", pid == ::GetCurrentProcessId());
|
||||
HANDLE process = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if (process) {
|
||||
wchar_t image[MAX_PATH] = {0};
|
||||
DWORD size = MAX_PATH;
|
||||
if (::QueryFullProcessImageNameW(process, 0, image, &size))
|
||||
SetString(env, result, "process", WideToUtf8(image));
|
||||
::CloseHandle(process);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// getDiagnostics(): everything needed to tell from a CI log whether real input
|
||||
// injection can work on this machine.
|
||||
napi_value GetDiagnostics(napi_env env, napi_callback_info info) {
|
||||
napi_value result;
|
||||
NAPI_CALL(env, napi_create_object(env, &result));
|
||||
|
||||
DWORD session_id = 0;
|
||||
::ProcessIdToSessionId(::GetCurrentProcessId(), &session_id);
|
||||
SetInt(env, result, "sessionId", session_id);
|
||||
SetInt(env, result, "activeConsoleSessionId",
|
||||
::WTSGetActiveConsoleSessionId());
|
||||
|
||||
// The desktop that currently receives input. "Default" is what we want;
|
||||
// "Winlogon" means locked / UAC secure desktop; failure usually means a
|
||||
// service or otherwise non-interactive window station.
|
||||
HDESK input_desktop = ::OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||
if (input_desktop) {
|
||||
SetString(env, result, "inputDesktop", ObjectName(input_desktop));
|
||||
::CloseDesktop(input_desktop);
|
||||
} else {
|
||||
SetString(env, result, "inputDesktop", "");
|
||||
SetInt(env, result, "inputDesktopError", ::GetLastError());
|
||||
}
|
||||
SetString(env, result, "threadDesktop",
|
||||
ObjectName(::GetThreadDesktop(::GetCurrentThreadId())));
|
||||
|
||||
HWINSTA station = ::GetProcessWindowStation();
|
||||
SetString(env, result, "windowStation", ObjectName(station));
|
||||
USEROBJECTFLAGS flags = {};
|
||||
if (::GetUserObjectInformationW(station, UOI_FLAGS, &flags, sizeof(flags),
|
||||
nullptr)) {
|
||||
SetBool(env, result, "windowStationVisible",
|
||||
(flags.dwFlags & WSF_VISIBLE) != 0);
|
||||
}
|
||||
|
||||
// Integrity level RID: 0x2000 medium, 0x3000 high, 0x4000 system.
|
||||
HANDLE token = nullptr;
|
||||
if (::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) {
|
||||
BYTE buffer[64] = {};
|
||||
DWORD size = 0;
|
||||
if (::GetTokenInformation(token, TokenIntegrityLevel, buffer,
|
||||
sizeof(buffer), &size)) {
|
||||
auto* label = reinterpret_cast<TOKEN_MANDATORY_LABEL*>(buffer);
|
||||
PSID sid = label->Label.Sid;
|
||||
DWORD rid = *::GetSidSubAuthority(
|
||||
sid, static_cast<DWORD>(*::GetSidSubAuthorityCount(sid) - 1));
|
||||
SetInt(env, result, "integrityLevel", rid);
|
||||
}
|
||||
::CloseHandle(token);
|
||||
}
|
||||
|
||||
SetInt(env, result, "mousePresent", ::GetSystemMetrics(SM_MOUSEPRESENT));
|
||||
SetInt(env, result, "mouseButtons", ::GetSystemMetrics(SM_CMOUSEBUTTONS));
|
||||
SetInt(env, result, "remoteSession", ::GetSystemMetrics(SM_REMOTESESSION));
|
||||
|
||||
napi_value virtual_screen;
|
||||
napi_create_object(env, &virtual_screen);
|
||||
SetInt(env, virtual_screen, "x", ::GetSystemMetrics(SM_XVIRTUALSCREEN));
|
||||
SetInt(env, virtual_screen, "y", ::GetSystemMetrics(SM_YVIRTUALSCREEN));
|
||||
SetInt(env, virtual_screen, "width", ::GetSystemMetrics(SM_CXVIRTUALSCREEN));
|
||||
SetInt(env, virtual_screen, "height", ::GetSystemMetrics(SM_CYVIRTUALSCREEN));
|
||||
napi_set_named_property(env, result, "virtualScreen", virtual_screen);
|
||||
|
||||
POINT cursor = {};
|
||||
if (::GetCursorPos(&cursor)) {
|
||||
napi_value cursor_value;
|
||||
napi_create_object(env, &cursor_value);
|
||||
SetInt(env, cursor_value, "x", cursor.x);
|
||||
SetInt(env, cursor_value, "y", cursor.y);
|
||||
napi_set_named_property(env, result, "cursor", cursor_value);
|
||||
} else {
|
||||
SetInt(env, result, "cursorError", ::GetLastError());
|
||||
}
|
||||
|
||||
CURSORINFO cursor_info = {};
|
||||
cursor_info.cbSize = sizeof(cursor_info);
|
||||
if (::GetCursorInfo(&cursor_info)) {
|
||||
// CURSOR_SUPPRESSED (2) is reported on touch / no-mouse machines.
|
||||
SetInt(env, result, "cursorFlags", cursor_info.flags);
|
||||
}
|
||||
|
||||
// A zero-distance relative move: harmless, but tells us whether SendInput is
|
||||
// accepted at all from this process.
|
||||
INPUT probe = MouseInput(MOUSEEVENTF_MOVE);
|
||||
::SetLastError(0);
|
||||
UINT sent = ::SendInput(1, &probe, sizeof(INPUT));
|
||||
SetInt(env, result, "sendInputProbeSent", sent);
|
||||
SetInt(env, result, "sendInputProbeError", sent == 1 ? 0 : ::GetLastError());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
napi_value Init(napi_env env, napi_value exports) {
|
||||
napi_property_descriptor descriptors[] = {
|
||||
{"move", nullptr, Move, nullptr, nullptr, nullptr, napi_default, nullptr},
|
||||
{"button", nullptr, Button, nullptr, nullptr, nullptr, napi_default,
|
||||
nullptr},
|
||||
{"wheel", nullptr, Wheel, nullptr, nullptr, nullptr, napi_default,
|
||||
nullptr},
|
||||
{"getCursorPos", nullptr, GetCursorPosition, nullptr, nullptr, nullptr,
|
||||
napi_default, nullptr},
|
||||
{"isWindowAtPoint", nullptr, IsWindowAtPoint, nullptr, nullptr, nullptr,
|
||||
napi_default, nullptr},
|
||||
{"describeWindowAtPoint", nullptr, DescribeWindowAtPoint, nullptr,
|
||||
nullptr, nullptr, napi_default, nullptr},
|
||||
{"getDiagnostics", nullptr, GetDiagnostics, nullptr, nullptr, nullptr,
|
||||
napi_default, nullptr},
|
||||
};
|
||||
napi_define_properties(
|
||||
env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors);
|
||||
return exports;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
||||
@@ -12,6 +12,7 @@
|
||||
"@electron-ci/echo": "*",
|
||||
"@electron-ci/external-ab": "*",
|
||||
"@electron-ci/is-valid-window": "*",
|
||||
"@electron-ci/mouse-input": "*",
|
||||
"@electron-ci/object-wrap": "*",
|
||||
"@electron-ci/uv-dlopen": "*",
|
||||
"@electron-ci/virtual-display": "*",
|
||||
|
||||
@@ -337,6 +337,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@electron-ci/mouse-input@npm:*, @electron-ci/mouse-input@workspace:spec/fixtures/native-addon/mouse-input":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@electron-ci/mouse-input@workspace:spec/fixtures/native-addon/mouse-input"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@electron-ci/object-wrap@npm:*, @electron-ci/object-wrap@workspace:spec/fixtures/native-addon/object-wrap":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@electron-ci/object-wrap@workspace:spec/fixtures/native-addon/object-wrap"
|
||||
@@ -2501,6 +2507,7 @@ __metadata:
|
||||
"@electron-ci/echo": "npm:*"
|
||||
"@electron-ci/external-ab": "npm:*"
|
||||
"@electron-ci/is-valid-window": "npm:*"
|
||||
"@electron-ci/mouse-input": "npm:*"
|
||||
"@electron-ci/object-wrap": "npm:*"
|
||||
"@electron-ci/uv-dlopen": "npm:*"
|
||||
"@electron-ci/virtual-display": "npm:*"
|
||||
|
||||
Reference in new issue
Block a user