Files
probo/pkg/deviceagent/tray/autostart_darwin.go
Ludovic Vielle 754d12d583 Skip tray re-register on deep-link enroll
Browser enrollment succeeds once the device is ACTIVE, but the
macOS URL handler failed whenever install re-bootstrapped a tray
LaunchAgent the PKG had already installed. Skip registration when
the plist is current, treat live bootstrap as best-effort, and
exit successfully if the device is already enrolled so retries
do not show "Enrollment failed".

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:35 +02:00

303 lines
7.0 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin
package tray
import (
"bytes"
_ "embed"
"encoding/xml"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"text/template"
)
const (
trayLabel = "com.probo.agent.tray"
trayPlistPath = "/Library/LaunchAgents/com.probo.agent.tray.plist"
)
var (
//go:embed launchagent.plist.tmpl
launchAgentPlistTmpl string
launchAgentPlist = template.Must(
template.New("launchagent").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchAgentPlistTmpl),
)
)
func xmlEscape(v string) (string, error) {
var sb strings.Builder
if err := xml.EscapeText(&sb, []byte(v)); err != nil {
return "", err
}
return sb.String(), nil
}
type launchAgentData struct {
Label string
ExePath string
RunDir string
}
func RegisterAutoStart(exePath string, runDir string) error {
if exePath == "" {
return fmt.Errorf("executable path is required")
}
if runDir == "" {
return fmt.Errorf("enrollment run directory is required")
}
current, err := launchAgentIsCurrent(trayPlistPath, exePath, runDir)
if err != nil {
return err
}
if current {
return nil
}
if err := writeTrayLaunchAgentPlist(exePath, runDir); err != nil {
return err
}
uids := activeGUIUserUIDs()
if len(uids) == 0 {
return nil
}
bootstrapTrayForUIDs(uids)
return nil
}
func renderLaunchAgentPlist(exePath string, runDir string) ([]byte, error) {
var buf bytes.Buffer
if err := launchAgentPlist.Execute(
&buf,
launchAgentData{
Label: trayLabel,
ExePath: exePath,
RunDir: runDir,
},
); err != nil {
return nil, fmt.Errorf("cannot render LaunchAgent plist: %w", err)
}
return buf.Bytes(), nil
}
// launchAgentIsCurrent reports whether plistPath already contains the
// LaunchAgent definition for exePath and runDir.
func launchAgentIsCurrent(plistPath string, exePath string, runDir string) (bool, error) {
existing, err := os.ReadFile(plistPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("cannot read LaunchAgent plist: %w", err)
}
desired, err := renderLaunchAgentPlist(exePath, runDir)
if err != nil {
return false, err
}
return bytes.Equal(existing, desired), nil
}
func writeTrayLaunchAgentPlist(exePath string, runDir string) error {
agentsDir := filepath.Dir(trayPlistPath)
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
return fmt.Errorf("cannot ensure launch agents directory: %w", err)
}
desired, err := renderLaunchAgentPlist(exePath, runDir)
if err != nil {
return err
}
if err := os.WriteFile(trayPlistPath, desired, 0o644); err != nil {
return fmt.Errorf("cannot write plist (need root?): %w", err)
}
return nil
}
// UnregisterAutoStart stops the tray LaunchAgent and removes its plist.
func UnregisterAutoStart() error {
bootoutTrayForUIDs(activeGUIUserUIDs())
if err := os.Remove(trayPlistPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot remove plist: %w", err)
}
return nil
}
// bootstrapTrayForUIDs best-effort loads the tray LaunchAgent into each
// GUI session. Failures are warnings only: the plist is enough for the
// next login (same policy as the macOS PKG postinstall script).
func bootstrapTrayForUIDs(uids []int) {
for _, uid := range uids {
target := fmt.Sprintf("gui/%d/%s", uid, trayLabel)
_ = exec.Command("launchctl", "bootout", target).Run()
if out, err := exec.Command(
"launchctl",
"bootstrap",
fmt.Sprintf("gui/%d", uid),
trayPlistPath,
).CombinedOutput(); err != nil {
fmt.Fprintf(
os.Stderr,
"warning: could not start tray helper for uid %d; it will start at next GUI login: %v: %s\n",
uid,
err,
strings.TrimSpace(string(out)),
)
}
}
}
func bootoutTrayForUIDs(uids []int) {
for _, uid := range uids {
target := fmt.Sprintf("gui/%d/%s", uid, trayLabel)
_ = exec.Command("launchctl", "bootout", target).Run()
}
}
func activeGUIUserUIDs() []int {
names := loggedInUsernames()
if len(names) == 0 {
if uid, err := currentGUIUserUID(); err == nil {
return []int{uid}
}
return nil
}
seen := make(map[int]struct{}, len(names))
uids := make([]int, 0, len(names))
for _, name := range names {
uid, err := uidForUsername(name)
if err != nil {
continue
}
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
uids = append(uids, uid)
}
if len(uids) == 0 {
if uid, err := currentGUIUserUID(); err == nil {
return []int{uid}
}
}
return uids
}
func loggedInUsernames() []string {
out, err := exec.Command("users").Output()
if err != nil {
return nil
}
return parseLoggedInUsernames(string(out))
}
func parseLoggedInUsernames(usersOutput string) []string {
tokens := strings.Fields(usersOutput)
seen := make(map[string]struct{}, len(tokens))
names := make([]string, 0, len(tokens))
for _, name := range tokens {
name = strings.TrimSpace(name)
if name == "" || name == "root" || name == "loginwindow" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func currentGUIUserUID() (int, error) {
name := strings.TrimSpace(consoleUserName())
if name == "" || name == "root" || name == "loginwindow" {
name = strings.TrimSpace(os.Getenv("USER"))
}
if name == "" || name == "root" || name == "loginwindow" {
return 0, fmt.Errorf("cannot resolve active GUI user")
}
return uidForUsername(name)
}
func uidForUsername(name string) (int, error) {
uidOut, err := exec.Command("id", "-u", name).Output()
if err != nil {
return 0, fmt.Errorf("cannot resolve uid for %s: %w", name, err)
}
uidRaw := strings.TrimSpace(string(uidOut))
uid, err := strconv.Atoi(uidRaw)
if err != nil {
return 0, fmt.Errorf("invalid uid %q", uidRaw)
}
return uid, nil
}
func consoleUserName() string {
out, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}