arblant
firefox-vpn-client / core / internal / tunservice / service_darwin.go
service_darwin.go113 lines · 2.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
package tunservice

import (
	"bytes"
	"encoding/xml"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"os/signal"
	"syscall"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/tunapi"
)

// InstallDir is the installed service's folder.
const InstallDir = "/Library/Application Support/Firefox VPN TUN"

// jobTarget is the service in launchctl's commands.
const jobTarget = "system/" + tunapi.LaunchdLabel

// job is the service's launchd job: started at boot, and again whenever it
// ends (after a crash, and after an update, which ends it); an uninstall
// boots it out (stopForGood).
const job = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>%s</string>
	<key>ProgramArguments</key>
	<array>
		<string>%s</string>
		<string>service</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
	<key>KeepAlive</key>
	<true/>
	<key>ThrottleInterval</key>
	<integer>2</integer>
</dict>
</plist>
`

func initSystemPresent() error { return nil }

// register writes the job for the executable exe and loads it, which
// starts it.
func register(exe string) error {
	if err := replaceFile(tunapi.ServiceFile, []byte(fmt.Sprintf(job, tunapi.LaunchdLabel, xmlText(exe))), 0o644); err != nil {
		return err
	}
	// A job that was disabled (launchctl disable) does not load.
	_ = run("launchctl", "enable", jobTarget)
	var err error
	for range 5 {
		if err = run("launchctl", "bootstrap", "system", tunapi.ServiceFile); err == nil || loaded() {
			return nil
		}
		time.Sleep(time.Second)
	}
	return err
}

// unregister removes the job's file; it does not stop the service, which
// may be the caller.
func unregister() error {
	if err := os.Remove(tunapi.ServiceFile); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	return nil
}

// stopInstalled unloads the job, which stops the service, and waits for it.
func stopInstalled() error {
	if !loaded() {
		return nil
	}
	err := run("launchctl", "bootout", jobTarget)
	deadline := time.Now().Add(30 * time.Second)
	for loaded() {
		if time.Now().After(deadline) {
			if err == nil {
				err = errors.New("the service does not stop")
			}
			return err
		}
		time.Sleep(200 * time.Millisecond)
	}
	return nil
}

// stopForGood unloads the job, whose file is removed, so launchd does not
// start it again; launchd ends this process meanwhile (SIGTERM, no longer
// caught).
func stopForGood() {
	signal.Reset(syscall.SIGTERM, syscall.SIGINT)
	_ = run("launchctl", "bootout", jobTarget)
}

// loaded reports whether launchd has the job.
func loaded() bool {
	return exec.Command("launchctl", "print", jobTarget).Run() == nil
}

// xmlText escapes s for a plist's string.
func xmlText(s string) string {
	var b bytes.Buffer
	_ = xml.EscapeText(&b, []byte(s))
	return b.String()
}