214 lines
7.4 KiB
Go
214 lines
7.4 KiB
Go
package main
|
|
|
|
// computer:tree — the ACCESSIBILITY TREE of one application's windows: the
|
|
// middle rung of the machine map. Where an app offers no
|
|
// scripting door, this is how the host sees it: the app's OWN names for its
|
|
// controls, their roles, whether they are enabled and where they are — text,
|
|
// not pixels, and exact. It is a READ. Nothing here presses a button.
|
|
//
|
|
// Each OS has one accessibility API and one fixed helper that walks it:
|
|
// Windows UI Automation, through PowerShell's UIAutomationClient assembly
|
|
// (ships with .NET Framework on every Windows).
|
|
// macOS the AX tree through System Events, in JXA (osascript -l
|
|
// JavaScript). Needs the Accessibility permission, like
|
|
// computer:windows.
|
|
// Linux AT-SPI 2, through python3 + gi (Atspi). Absent → says so.
|
|
//
|
|
// "Leech, don't adopt": no COM, AX or D-Bus binding lands in the bastion; the
|
|
// helper scripts are fixed strings, the target pid and the node cap travel in
|
|
// the environment (never in argv, never interpolated), and the helper answers
|
|
// JSON that this side re-validates and re-counts.
|
|
//
|
|
// Args (from the pod, validated here): [pid, max_nodes?]. pid must be a
|
|
// positive integer that computer:windows or the agent scan reported — the
|
|
// host names a window by its owner, never by guessing.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// The caps match the pod side (the host's machine map): a tree is answered to
|
|
// the host through a 100 KB reply wall, and a real node is ~120 bytes, so
|
|
// 500 nodes is what arrives whole. Fewer nodes also keeps the macOS walk (up
|
|
// to nine Apple Events per node) inside the op's timeout.
|
|
const (
|
|
treeDefaultMaxNodes = 500
|
|
treeMaxNodes = 800
|
|
treeMaxDepth = 40
|
|
treeMaxText = 120 // name / id / value are cut to this many characters
|
|
)
|
|
|
|
// computerNode is one element of the tree. Keys are what a recorded step
|
|
// addresses a control by: role + name (what a person sees), then the app's
|
|
// own id when it has one. Bounds are screen pixels.
|
|
type computerNode struct {
|
|
Role string `json:"role"`
|
|
Name string `json:"name,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
X int `json:"x,omitempty"`
|
|
Y int `json:"y,omitempty"`
|
|
W int `json:"w,omitempty"`
|
|
H int `json:"h,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Focused bool `json:"focused,omitempty"`
|
|
Offscreen bool `json:"offscreen,omitempty"`
|
|
Children []*computerNode `json:"children,omitempty"`
|
|
}
|
|
|
|
// treeReport is the wire shape. Windows are the app's top-level windows,
|
|
// each a tree. node_count / truncated are recounted here, not trusted.
|
|
type treeReport struct {
|
|
Platform string `json:"platform"`
|
|
Scan string `json:"scan"`
|
|
Supported bool `json:"supported"`
|
|
Reason string `json:"reason,omitempty"`
|
|
ScannedAt string `json:"scanned_at"`
|
|
PID int `json:"pid,omitempty"`
|
|
App string `json:"app,omitempty"`
|
|
NodeCount int `json:"node_count"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
Windows []*computerNode `json:"windows,omitempty"`
|
|
}
|
|
|
|
// helperTree is what every helper answers: {app, truncated, windows:[node…]}.
|
|
type helperTree struct {
|
|
App string `json:"app"`
|
|
Truncated bool `json:"truncated"`
|
|
Error string `json:"error"`
|
|
Windows []*computerNode `json:"windows"`
|
|
}
|
|
|
|
func parseTreeArgs(args []string) (pid, maxNodes int, err error) {
|
|
if len(args) < 1 {
|
|
return 0, 0, errors.New("computer:tree needs the owning pid (from computer:windows)")
|
|
}
|
|
pid, err = strconv.Atoi(strings.TrimSpace(args[0]))
|
|
if err != nil || pid <= 1 {
|
|
return 0, 0, errors.New("computer:tree: pid must be a positive integer")
|
|
}
|
|
maxNodes = treeDefaultMaxNodes
|
|
if len(args) > 1 && strings.TrimSpace(args[1]) != "" {
|
|
n, err := strconv.Atoi(strings.TrimSpace(args[1]))
|
|
if err != nil || n <= 0 {
|
|
return 0, 0, errors.New("computer:tree: max_nodes must be a positive integer")
|
|
}
|
|
if n > treeMaxNodes {
|
|
n = treeMaxNodes
|
|
}
|
|
maxNodes = n
|
|
}
|
|
return pid, maxNodes, nil
|
|
}
|
|
|
|
// boundTree recounts the helper's answer and enforces the node cap and depth
|
|
// cap on THIS side too — the helper is trusted for nothing but the walk.
|
|
func boundTree(wins []*computerNode, maxNodes int) (out []*computerNode, count int, truncated bool) {
|
|
var walk func(n *computerNode, depth int) *computerNode
|
|
walk = func(n *computerNode, depth int) *computerNode {
|
|
if n == nil || count >= maxNodes || depth > treeMaxDepth {
|
|
truncated = true
|
|
return nil
|
|
}
|
|
count++
|
|
c := *n
|
|
c.Children = nil
|
|
c.Name, c.ID, c.Value = cutRunes(c.Name, treeMaxText), cutRunes(c.ID, treeMaxText), cutRunes(c.Value, treeMaxText)
|
|
for _, ch := range n.Children {
|
|
if k := walk(ch, depth+1); k != nil {
|
|
c.Children = append(c.Children, k)
|
|
}
|
|
}
|
|
return &c
|
|
}
|
|
for _, w := range wins {
|
|
if k := walk(w, 0); k != nil {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out, count, truncated
|
|
}
|
|
|
|
func computerTreeReport(args []string) treeReport {
|
|
rep := treeReport{Platform: runtime.GOOS, Scan: computerTreeKind,
|
|
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
|
|
pid, maxNodes, err := parseTreeArgs(args)
|
|
if err != nil {
|
|
rep.Reason = err.Error()
|
|
return rep
|
|
}
|
|
rep.PID = pid
|
|
if !computerObserveSupported {
|
|
rep.Reason = "no accessibility-tree reader for this platform"
|
|
return rep
|
|
}
|
|
raw, err := readTree(pid, maxNodes)
|
|
if err != nil {
|
|
rep.Reason = err.Error()
|
|
if strings.Contains(rep.Reason, "signal: killed") {
|
|
// The helper hit the op's timeout: a busy app on macOS can cost
|
|
// several Apple Events per node. Say what to do, not "killed".
|
|
rep.Reason = "reading this app's tree took longer than the op allows — ask again with a smaller max_nodes"
|
|
}
|
|
return rep
|
|
}
|
|
var ht helperTree
|
|
if err := json.Unmarshal(raw, &ht); err != nil {
|
|
rep.Reason = "tree helper answered, not with a tree: " + truncate(err.Error(), 200)
|
|
return rep
|
|
}
|
|
if ht.Error != "" {
|
|
rep.Reason = ht.Error
|
|
return rep
|
|
}
|
|
rep.Supported = true
|
|
rep.App = ht.App
|
|
rep.Windows, rep.NodeCount, rep.Truncated = boundTree(ht.Windows, maxNodes)
|
|
rep.Truncated = rep.Truncated || ht.Truncated
|
|
if rep.Windows == nil {
|
|
rep.Windows = []*computerNode{}
|
|
}
|
|
return rep
|
|
}
|
|
|
|
func handleComputerTree(ws *websocket.Conn, _ string, args []string) {
|
|
rep := computerTreeReport(args)
|
|
b, err := json.Marshal(rep)
|
|
if err != nil {
|
|
sendMgmt(ws, 'R', []byte("report: "+err.Error()+"\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
// A bad ARGUMENT is the caller's fault and reads as such (exit 2, the
|
|
// usage-error convention); a blind box is still a true answer (exit 0).
|
|
if !rep.Supported && rep.PID == 0 {
|
|
sendMgmt(ws, 'R', []byte(rep.Reason+"\n"))
|
|
sendMgmtExit(ws, 2)
|
|
return
|
|
}
|
|
sendMgmt(ws, 'O', append(b, '\n'))
|
|
sendMgmtExit(ws, 0)
|
|
}
|
|
|
|
// cutRunes shortens a label to n characters on a character boundary — control
|
|
// names are the user's language, and a byte cut would split a multibyte one.
|
|
func cutRunes(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return string(r[:n]) + "…"
|
|
}
|
|
|
|
// helperEnv is the only channel a caller value takes into a helper.
|
|
func helperEnv(pid, maxNodes int) []string {
|
|
return []string{"PRIVATAE_TARGET_PID=" + strconv.Itoa(pid), "PRIVATAE_MAX_NODES=" + strconv.Itoa(maxNodes)}
|
|
}
|