Files
privatae-bastion/client-go/computer_darwin.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

154 lines
5.1 KiB
Go

//go:build darwin
package main
import (
"encoding/json"
"errors"
"os/exec"
"strconv"
"strings"
)
// macOS drivers for computer:*.
//
// Inventory: `system_profiler SPApplicationsDataType -json` — one fixed call,
// the same list the "About This Mac → Applications" report shows, with name,
// version, path and the signing identity (the publisher). Shelling out once
// beats reading a few hundred Info.plist files, half of them binary plists.
//
// Windows: one osascript against System Events listing every window of every
// foreground process with its pid and whether the process is frontmost. Two
// permissions can refuse it, and macOS names them by error code on stderr:
// -1743 is AUTOMATION (privatae-bastion may not send Apple Events to System
// Events), -25211 / "assistive access" is ACCESSIBILITY. The report names the
// pane that fixes the code seen, in the user's terms.
const (
computerObserveSupported = true
computerInventoryKind = "system_profiler"
computerWindowsKind = "osascript+system-events"
)
func listInstalledApps() ([]computerApp, error) {
out, err := runFixed("system_profiler", "SPApplicationsDataType", "-json")
if err != nil {
return nil, errors.New("system_profiler failed: " + execFailure(err))
}
return parseSystemProfiler(out)
}
// parseSystemProfiler reads the JSON report. Apps under /System are Apple's
// own and skipped: the map is what the user installed and can be asked to
// open, not the OS. Signing identity "Developer ID Application: X (TEAM)"
// becomes publisher X.
func parseSystemProfiler(raw []byte) ([]computerApp, error) {
var doc struct {
Apps []struct {
Name string `json:"_name"`
Version string `json:"version"`
Path string `json:"path"`
Obtained string `json:"obtained_from"`
SignedBy []string `json:"signed_by"`
} `json:"SPApplicationsDataType"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, errors.New("system_profiler output unreadable: " + err.Error())
}
var apps []computerApp
for _, a := range doc.Apps {
if strings.HasPrefix(a.Path, "/System/") || a.Obtained == "apple" {
continue
}
apps = append(apps, computerApp{Name: a.Name, Version: a.Version, Path: a.Path,
Publisher: publisherFromSigner(a.SignedBy), Source: "applications"})
}
return apps, nil
}
func publisherFromSigner(signed []string) string {
for _, s := range signed {
s = strings.TrimSpace(s)
if i := strings.Index(s, ":"); i >= 0 {
s = strings.TrimSpace(s[i+1:])
}
if i := strings.LastIndex(s, " ("); i > 0 {
s = s[:i]
}
if s != "" && !strings.HasPrefix(s, "Apple") {
return s
}
}
return ""
}
// One script, one process spawn. Columns are tab-separated per line:
// pid \t process name \t frontmost \t window title. An untitled window's
// name is `missing value`, which cannot be concatenated — it becomes "".
const windowsScript = `tell application "System Events"
set out to ""
repeat with p in (every process whose background only is false)
set pn to name of p
set fm to frontmost of p
set pi to unix id of p
repeat with w in (every window of p)
set wn to name of w
if wn is missing value then set wn to ""
set out to out & pi & tab & pn & tab & fm & tab & wn & linefeed
end repeat
end repeat
return out
end tell`
func listWindows() ([]computerWindow, error) {
out, err := runFixed("osascript", "-e", windowsScript)
if err != nil {
return nil, errors.New(osascriptReason(execFailure(err)))
}
return parseSystemEventsWindows(string(out)), nil
}
// execFailure is the text a failed exec.Cmd.Output() actually left behind:
// its stderr, when there is one, else the exit status. Output() folds stderr
// into *exec.ExitError.Stderr and (*ExitError).Error() says only "exit status
// 1" — reading err.Error() alone loses the reason.
func execFailure(err error) string {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return strings.TrimSpace(string(ee.Stderr))
}
return err.Error()
}
// osascriptReason turns the stderr macOS wrote into the pane the user opens.
func osascriptReason(stderr string) string {
low := strings.ToLower(stderr)
switch {
case strings.Contains(stderr, "-1743"):
return "macOS Automation permission not granted: allow privatae-bastion to control " +
"System Events (System Settings → Privacy & Security → Automation) — the screen cannot be read"
case strings.Contains(stderr, "-25211"), strings.Contains(low, "assistive"):
return "macOS Accessibility permission not granted to privatae-bastion " +
"(System Settings → Privacy & Security → Accessibility) — the screen cannot be read"
}
return "osascript failed: " + stderr
}
func parseSystemEventsWindows(text string) []computerWindow {
var wins []computerWindow
for _, line := range strings.Split(text, "\n") {
f := strings.SplitN(line, "\t", 4)
if len(f) < 4 {
continue
}
title := strings.TrimSpace(f[3])
if title == "" {
continue
}
pid, _ := strconv.Atoi(strings.TrimSpace(f[0]))
wins = append(wins, computerWindow{Title: title, PID: pid, App: strings.TrimSpace(f[1]),
Focused: strings.TrimSpace(f[2]) == "true"})
}
return wins
}