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>
303 lines
7.0 KiB
Go
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))
|
|
}
|