ssh-gui
Operate a remote macOS desktop over ssh. Everything runs through tools already on the machine
(cliclick, screencapture, osascript, sips), so nothing has to be installed on the far side
except cliclick itself.
bin/ssh-gui wraps the whole thing. Read the model below before using it, because almost every
failure in this area is a coordinate-space or a permission problem, not a syntax problem.
Hard rules
- That desktop may have a human at it. You are not driving a sandbox, you are moving the real
cursor on a real screen. Check
idle before you start. Restore the cursor with cliclick -r when
you are done. If someone is actively using the machine, say so and stop.
- Never type a password, PIN, recovery code or card number through
type or cliclick t:. Those
keystrokes go into whatever is frontmost, they are visible on screen, and you cannot verify the
target. Ask the person to enter it themselves.
- If the screen is locked, stop. Unlocking requires the password. Report it and wait.
- Bound every Apple Event. Sending one to an app without an Automation grant blocks for the full
Apple Event timeout, 120 seconds by default, before failing with
-1712. Some callers set no
timeout at all and block indefinitely. Always wrap in perl -e 'alarm N; exec @ARGV' or gtimeout.
- Verify after acting, not before. A synthetic click that lands nowhere produces no error. The
only proof is that the screen changed.
- Prefer the accessibility API over pixel coordinates. Coordinates are the fallback, not the default.
The coordinate model
This is the single largest source of "the click landed in the wrong place".
|
space |
typical 4K Retina Mac |
cliclick input |
logical points |
1920 x 1080 |
| accessibility API (position, size) |
logical points |
1920 x 1080 |
screencapture output file |
native pixels |
3840 x 2160 |
screencapture -R x,y,w,h argument |
logical points |
region given in points |
So a raw screenshot is twice the coordinate space you click in. Measure a button at (1500, 900) on
the raw image, click (1500, 900), and you hit something 750 points up and to the left.
Rule: downscale every screenshot to logical size before you measure anything on it. ssh-gui shot
does this automatically and prints the backing scale it used. Then image coordinates equal click
coordinates, one to one.
Do not hardcode the factor as 2. Fractionally scaled displays exist. The helper measures it by
capturing a 100 x 100 point region and reading the resulting pixel width.
Multiple displays share one coordinate plane. Negative absolute coordinates (a display arranged to
the left) need the = prefix in cliclick: c:100,=-200.
Start every session with preflight
ssh-gui HOST preflight
Prints console user, lock state, screensaver, seconds since last human input, display sleep setting,
cliclick path, logical geometry, and a live probe of all three permissions. One round trip. If
anything there is wrong, no amount of clicking will work.
The three permissions, and how each one fails
macOS gates this behind three separate grants, and they fail in three different ways. Knowing which
symptom maps to which grant saves an hour.
| Grant |
Needed for |
Symptom when missing |
| Accessibility |
cliclick posting mouse and key events |
events vanish silently, exit code 0, nothing moves |
| Screen Recording |
screencapture |
you get the wallpaper and cursor only, windows are missing |
| Automation / Apple Events |
osascript talking to an app |
blocks up to 120 s, then -1712 |
They are granted to the binary that spawns your shell, which for ssh is
/usr/libexec/sshd-keygen-wrapper, not to cliclick or to Terminal. Add it under
System Settings, Privacy & Security. The file is hidden, so open the picker and use
Cmd+Shift+G to type the path.
Automation is granted per target app, and the consent dialog appears on the remote screen where
nobody is watching, which is exactly why the call blocks. Two consequences:
- Prefer
System Events as the target. It is usually the one app already granted, and it can
reach every other app's UI through the accessibility tree anyway.
- If you must talk to an app directly and it blocks, someone has to look at that screen once and
approve the dialog. It is a one-time grant.
Not verified by this skill: the exact steps to add sshd-keygen-wrapper on a machine where it has
never been granted. On the machine this was developed against, all three grants already existed.
Efficiency: one round trip per intent
An ssh round trip is roughly 200 ms, or 150 ms multiplexed. That cost dominates everything else, so
the difference between a smooth session and a crawling one is how many calls you make.
Prefer the accessibility tree over pixels
Coordinates rot the moment a window moves. The accessibility API gives you positions that are
correct by construction, and it can click controls directly.
ssh-gui HOST apps # processes that have windows
ssh-gui HOST win "Preview" # front window bounds: "x y w h", logical points
ssh-gui HOST focus "Preview" # bring to front
ssh-gui HOST menu Preview File "Export as PDF…"
Menu navigation through AX is deterministic and needs no coordinates at all. Note that menu bar
items must be qualified by process: menu bar 1 of process "Preview", never a bare menu bar 1.
Composing win with a region capture gives you a clean shot of exactly one window:
ssh-gui HOST shot -R "$(ssh-gui HOST win Preview | tr ' ' ',')" window.png
Deeper element lookup, when you need a control's centre point:
ssh-gui HOST ax 'tell application "System Events" to tell process "Preview"
set b to button "Done" of window 1
set p to position of b
set s to size of b
return ((item 1 of p) + (item 1 of s) / 2 as text) & "," & ((item 2 of p) + (item 2 of s) / 2 as text)
end tell'
Then click that point, or better, click the element through AX and skip the mouse entirely.
Electron and Chromium apps are the exception. Their accessibility tree is off until a client
sets AXManualAccessibility on its AX connection, and osascript cannot do it (it has its own TCC
identity and is not AX-trusted for that). Those apps need a small native AX client, or fall back to
coordinates.
Verification ladder
Check the cheapest thing that can distinguish success from failure.
- A pixel.
ssh-gui HOST color 640 400 returns three bytes. Enough to tell whether a dialog
opened, a toggle flipped, a row highlighted. This is the workhorse.
- AX state.
ssh-gui HOST ax '...' to read a value, a window title, whether a sheet exists.
Text, not an image, and unambiguous.
- A region.
ssh-gui HOST shot -R x,y,w,h. Small, fast, and you already know where to look.
- The whole screen. Only when you are lost and need to re-orient.
Text and keys
Traps
Verified on a real machine, in the order they will bite you.
screencapture silently refuses any destination whose basename starts with a dot, and still
exits 0. screencapture -x -t png /tmp/.shot.png writes nothing and reports success. Use
dot-free names and test the file, never the exit status.
- A blocked Apple Event costs 120 seconds, not forever, and then returns
-1712. Easy to
mistake for a hang. Bound it.
HIDIdleTime is reset by your own synthetic events too. Read it before you start driving; once
you are clicking, it only tells you how long ago you clicked.
- Screen sharing tools sitting on the remote machine can hold focus and swallow synthetic events.
If clicks reach the screen but a dialog ignores Return, check whether a remote-control app is
frontmost. Symptom: a file picker that visibly has focus but does not respond.
- A click on an inactive window only activates it. The first click raises, the second acts. Use
focus first and save yourself the guesswork.
- Hovering opens nothing. Hovering highlights an already-open submenu but does not open a
top-level menu. Click to open, then move.
- Full screen apps hide the menu bar, which removes the AX menu path and any coordinates that
depended on it.
- Do not trust a click you did not verify. There is no error channel for a click that lands on
empty space.
Helper reference
bin/ssh-gui HOST <command>, or set SSH_GUI_HOST and drop the first argument.
| Command |
Does |
preflight |
one-call health and permission report |
shot [out.png] |
full screen, downscaled to logical points |
shot -R x,y,w,h [out.png] |
region, given in logical points |
do <cliclick cmds...> |
batch in one round trip, also reads stdin |
click X Y |
move, settle, click |
type "text" |
type into the frontmost app |
color X Y |
RGB at a point |
idle |
seconds since last human input |
ax '<applescript>' |
bounded AppleScript, System Events preferred |
win <app> |
front window bounds, x y w h |
apps |
visible application processes |
menu <app> <menu> <item> |
click a menu item through AX |
focus <app> |
bring an app to the front |
close |
drop the multiplexed connection |
Tunables: SSH_GUI_WAIT (ms after each event, default 40), SSH_GUI_AX_TIMEOUT (seconds, default 15).
Setting up a fresh host
ssh HOST 'command -v cliclick || brew install cliclick'
- Grant Accessibility, Screen Recording and Automation to
/usr/libexec/sshd-keygen-wrapper, once,
at the machine or through an existing screen sharing session.
- Stop the display from sleeping and the screen from locking, otherwise the desktop disappears
between sessions:
sudo pmset -a displaysleep 0 sleep 0, and turn off "Require password after
screen saver begins".
ssh-gui HOST preflight and confirm every line.
Nothing else is installed on the remote machine, and nothing runs there between your calls.
1---2name: ssh-gui3description: Drive the graphical desktop of a remote macOS machine over plain ssh, with no VNC, no screen sharing and no agent installed on the far side. Gives you clicks, drags, keystrokes, text entry, menu navigation, window geometry and screenshots through cliclick, screencapture and the accessibility API, with a coordinate model that actually lines up on Retina displays and a preflight that tells you which of the three macOS permissions is missing before you waste a click. Use this whenever the task is to operate the GUI of a Mac you reach by ssh: a headless Mac mini or Mac Studio, a build or CI Mac, a second machine on the desk, a Mac in a rack or a colo. Trigger on "control the GUI over ssh", "click something on my mini", "automate the remote Mac desktop", "screenshot the other Mac", "drive an app on the headless Mac", "cliclick over ssh", "no VNC available". Not for controlling a virtual machine's guest OS (drive the guest from inside instead) and not for web pages (use a browser automation tool).4---56# ssh-gui78Operate a remote macOS desktop over ssh. Everything runs through tools already on the machine9(`cliclick`, `screencapture`, `osascript`, `sips`), so nothing has to be installed on the far side10except cliclick itself.1112`bin/ssh-gui` wraps the whole thing. Read the model below before using it, because almost every13failure in this area is a coordinate-space or a permission problem, not a syntax problem.1415## Hard rules1617- **That desktop may have a human at it.** You are not driving a sandbox, you are moving the real18 cursor on a real screen. Check `idle` before you start. Restore the cursor with `cliclick -r` when19 you are done. If someone is actively using the machine, say so and stop.20- **Never type a password, PIN, recovery code or card number** through `type` or `cliclick t:`. Those21 keystrokes go into whatever is frontmost, they are visible on screen, and you cannot verify the22 target. Ask the person to enter it themselves.23- **If the screen is locked, stop.** Unlocking requires the password. Report it and wait.24- **Bound every Apple Event.** Sending one to an app without an Automation grant blocks for the full25 Apple Event timeout, 120 seconds by default, before failing with `-1712`. Some callers set no26 timeout at all and block indefinitely. Always wrap in `perl -e 'alarm N; exec @ARGV'` or `gtimeout`.27- **Verify after acting, not before.** A synthetic click that lands nowhere produces no error. The28 only proof is that the screen changed.29- Prefer the accessibility API over pixel coordinates. Coordinates are the fallback, not the default.3031## The coordinate model3233This is the single largest source of "the click landed in the wrong place".3435| | space | typical 4K Retina Mac |36|---|---|---|37| `cliclick` input | **logical points** | 1920 x 1080 |38| accessibility API (position, size) | **logical points** | 1920 x 1080 |39| `screencapture` output file | **native pixels** | 3840 x 2160 |40| `screencapture -R x,y,w,h` argument | **logical points** | region given in points |4142So a raw screenshot is twice the coordinate space you click in. Measure a button at (1500, 900) on43the raw image, click (1500, 900), and you hit something 750 points up and to the left.4445**Rule: downscale every screenshot to logical size before you measure anything on it.** `ssh-gui shot`46does this automatically and prints the backing scale it used. Then image coordinates equal click47coordinates, one to one.4849Do not hardcode the factor as 2. Fractionally scaled displays exist. The helper measures it by50capturing a 100 x 100 point region and reading the resulting pixel width.5152Multiple displays share one coordinate plane. Negative absolute coordinates (a display arranged to53the left) need the `=` prefix in cliclick: `c:100,=-200`.5455## Start every session with preflight5657```bash58ssh-gui HOST preflight59```6061Prints console user, lock state, screensaver, seconds since last human input, display sleep setting,62cliclick path, logical geometry, and a live probe of all three permissions. One round trip. If63anything there is wrong, no amount of clicking will work.6465## The three permissions, and how each one fails6667macOS gates this behind three separate grants, and they fail in three different ways. Knowing which68symptom maps to which grant saves an hour.6970| Grant | Needed for | Symptom when missing |71|---|---|---|72| **Accessibility** | `cliclick` posting mouse and key events | events vanish silently, exit code 0, nothing moves |73| **Screen Recording** | `screencapture` | you get the wallpaper and cursor only, windows are missing |74| **Automation / Apple Events** | `osascript` talking to an app | blocks up to 120 s, then `-1712` |7576They are granted to the *binary that spawns your shell*, which for ssh is77`/usr/libexec/sshd-keygen-wrapper`, not to `cliclick` or to Terminal. Add it under78System Settings, Privacy & Security. The file is hidden, so open the picker and use79Cmd+Shift+G to type the path.8081Automation is granted per target app, and the consent dialog appears on the remote screen where82nobody is watching, which is exactly why the call blocks. Two consequences:8384- **Prefer `System Events` as the target.** It is usually the one app already granted, and it can85 reach every other app's UI through the accessibility tree anyway.86- If you must talk to an app directly and it blocks, someone has to look at that screen once and87 approve the dialog. It is a one-time grant.8889*Not verified by this skill: the exact steps to add sshd-keygen-wrapper on a machine where it has90never been granted. On the machine this was developed against, all three grants already existed.*9192## Efficiency: one round trip per intent9394An ssh round trip is roughly 200 ms, or 150 ms multiplexed. That cost dominates everything else, so95the difference between a smooth session and a crawling one is how many calls you make.9697- **Batch cliclick.** `cliclick -f -` reads a whole command script from stdin and runs it in one98 invocation, `w:` waits included. Twelve actions in one call, not twelve calls.99 ```bash100 ssh-gui HOST do m:400,300 w:150 c:400,300 w:400 t:"invoice" kp:return101 ```102- **Multiplex the connection.** The helper opens a ControlMaster socket and keeps it for 10 minutes.103 Watch the path length: a ControlPath lives in a `sockaddr_un` and the whole path must stay under104 104 bytes, so keep it in `/tmp`, never in a deep scratch directory.105- **Do not screenshot to think.** See the verification ladder below.106- Use `-w <ms>` for a uniform delay after every event instead of scattering `w:` commands.107108## Prefer the accessibility tree over pixels109110Coordinates rot the moment a window moves. The accessibility API gives you positions that are111correct by construction, and it can click controls directly.112113```bash114ssh-gui HOST apps # processes that have windows115ssh-gui HOST win "Preview" # front window bounds: "x y w h", logical points116ssh-gui HOST focus "Preview" # bring to front117ssh-gui HOST menu Preview File "Export as PDF…"118```119120Menu navigation through AX is deterministic and needs no coordinates at all. Note that menu bar121items must be qualified by process: `menu bar 1 of process "Preview"`, never a bare `menu bar 1`.122123Composing `win` with a region capture gives you a clean shot of exactly one window:124125```bash126ssh-gui HOST shot -R "$(ssh-gui HOST win Preview | tr ' ' ',')" window.png127```128129Deeper element lookup, when you need a control's centre point:130131```bash132ssh-gui HOST ax 'tell application "System Events" to tell process "Preview"133 set b to button "Done" of window 1134 set p to position of b135 set s to size of b136 return ((item 1 of p) + (item 1 of s) / 2 as text) & "," & ((item 2 of p) + (item 2 of s) / 2 as text)137end tell'138```139140Then click that point, or better, `click` the element through AX and skip the mouse entirely.141142**Electron and Chromium apps are the exception.** Their accessibility tree is off until a client143sets `AXManualAccessibility` on its AX connection, and `osascript` cannot do it (it has its own TCC144identity and is not AX-trusted for that). Those apps need a small native AX client, or fall back to145coordinates.146147## Verification ladder148149Check the cheapest thing that can distinguish success from failure.1501511. **A pixel.** `ssh-gui HOST color 640 400` returns three bytes. Enough to tell whether a dialog152 opened, a toggle flipped, a row highlighted. This is the workhorse.1532. **AX state.** `ssh-gui HOST ax '...'` to read a value, a window title, whether a sheet exists.154 Text, not an image, and unambiguous.1553. **A region.** `ssh-gui HOST shot -R x,y,w,h`. Small, fast, and you already know where to look.1564. **The whole screen.** Only when you are lost and need to re-orient.157158## Text and keys159160- `cliclick t:text` types into the frontmost app and handles unicode fine. Activate the target first161 with `focus`, and confirm focus landed before typing anything that matters.162- `kp:` presses one named key (`return`, `tab`, `esc`, `space`, `delete`, `fwd-delete`, `home`, `end`,163 `page-up`, `page-down`, arrows, `f1`..`f16`, numpad, media and brightness keys).164- Modifiers are held and released explicitly: `kd:cmd t:s ku:cmd` is Cmd+S. Always release what you165 press, a stuck modifier poisons every later event.166- For long or awkward text, put it on the remote clipboard instead of typing it:167 ```bash168 printf '%s' "$text" | ssh HOST pbcopy169 ssh-gui HOST do kd:cmd t:v ku:cmd170 ```171 Faster, and immune to keyboard-layout differences.172173## Traps174175Verified on a real machine, in the order they will bite you.176177- **`screencapture` silently refuses any destination whose basename starts with a dot, and still178 exits 0.** `screencapture -x -t png /tmp/.shot.png` writes nothing and reports success. Use179 dot-free names and test the file, never the exit status.180- **A blocked Apple Event costs 120 seconds, not forever, and then returns `-1712`.** Easy to181 mistake for a hang. Bound it.182- **`HIDIdleTime` is reset by your own synthetic events too.** Read it before you start driving; once183 you are clicking, it only tells you how long ago *you* clicked.184- **Screen sharing tools sitting on the remote machine can hold focus and swallow synthetic events.**185 If clicks reach the screen but a dialog ignores Return, check whether a remote-control app is186 frontmost. Symptom: a file picker that visibly has focus but does not respond.187- **A click on an inactive window only activates it.** The first click raises, the second acts. Use188 `focus` first and save yourself the guesswork.189- **Hovering opens nothing.** Hovering highlights an already-open submenu but does not open a190 top-level menu. Click to open, then move.191- **Full screen apps hide the menu bar**, which removes the AX menu path and any coordinates that192 depended on it.193- **Do not trust a click you did not verify.** There is no error channel for a click that lands on194 empty space.195196## Helper reference197198`bin/ssh-gui HOST <command>`, or set `SSH_GUI_HOST` and drop the first argument.199200| Command | Does |201|---|---|202| `preflight` | one-call health and permission report |203| `shot [out.png]` | full screen, downscaled to logical points |204| `shot -R x,y,w,h [out.png]` | region, given in logical points |205| `do <cliclick cmds...>` | batch in one round trip, also reads stdin |206| `click X Y` | move, settle, click |207| `type "text"` | type into the frontmost app |208| `color X Y` | RGB at a point |209| `idle` | seconds since last human input |210| `ax '<applescript>'` | bounded AppleScript, System Events preferred |211| `win <app>` | front window bounds, `x y w h` |212| `apps` | visible application processes |213| `menu <app> <menu> <item>` | click a menu item through AX |214| `focus <app>` | bring an app to the front |215| `close` | drop the multiplexed connection |216217Tunables: `SSH_GUI_WAIT` (ms after each event, default 40), `SSH_GUI_AX_TIMEOUT` (seconds, default 15).218219## Setting up a fresh host2202211. `ssh HOST 'command -v cliclick || brew install cliclick'`2222. Grant Accessibility, Screen Recording and Automation to `/usr/libexec/sshd-keygen-wrapper`, once,223 at the machine or through an existing screen sharing session.2243. Stop the display from sleeping and the screen from locking, otherwise the desktop disappears225 between sessions: `sudo pmset -a displaysleep 0 sleep 0`, and turn off "Require password after226 screen saver begins".2274. `ssh-gui HOST preflight` and confirm every line.228229Nothing else is installed on the remote machine, and nothing runs there between your calls.