github.com/mutagen-io/mutagen@v0.18.0-rc1/pkg/daemon/register_darwin.go (about)

     1  package daemon
     2  
     3  // The implementation of daemon registration is largely based on these two
     4  // articles:
     5  // https://developer.apple.com/library/content/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html
     6  // https://developer.apple.com/library/content/technotes/tn2083/_index.html#//apple_ref/doc/uid/DTS10003794-CH1-SUBSECTION44
     7  
     8  import (
     9  	"bytes"
    10  	"errors"
    11  	"fmt"
    12  	"os"
    13  	"os/exec"
    14  	"path/filepath"
    15  
    16  	"github.com/mutagen-io/mutagen/pkg/filesystem"
    17  )
    18  
    19  // RegistrationSupported indicates whether or not daemon registration is
    20  // supported on this platform.
    21  const RegistrationSupported = true
    22  
    23  const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
    24  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    25  <plist version="1.0">
    26  <dict>
    27  	<key>Label</key>
    28  	<string>io.mutagen.mutagen</string>
    29  	<key>ProgramArguments</key>
    30  	<array>
    31  		<string>%s</string>
    32  		<string>daemon</string>
    33  		<string>run</string>
    34  	</array>
    35  	<key>LimitLoadToSessionType</key>
    36  	<string>Aqua</string>
    37  	<key>KeepAlive</key>
    38  	<true/>
    39  </dict>
    40  </plist>
    41  `
    42  
    43  const (
    44  	// libraryDirectoryName is the name of the Library directory inside the
    45  	// user's home directory.
    46  	libraryDirectoryName = "Library"
    47  	// libraryDirectoryPermissions are the permissions to use for Library
    48  	// directory creation in the event that it does not exist.
    49  	libraryDirectoryPermissions = 0700
    50  
    51  	// launchAgentsDirectoryName is the name of the LaunchAgents directory
    52  	// inside the Library directory.
    53  	launchAgentsDirectoryName = "LaunchAgents"
    54  	// launchAgentsDirectoryPermissions are the permissions to use for
    55  	// LaunchAgents directory creation in the event that it does not exist.
    56  	launchAgentsDirectoryPermissions = 0755
    57  
    58  	// launchdPlistName is the name of the launchd property list file to create
    59  	// to register the daemon for automatic startup.
    60  	launchdPlistName = "io.mutagen.mutagen.plist"
    61  	// launchdPlistPermissions are the permissions to use for the launchd
    62  	// property list file.
    63  	launchdPlistPermissions = 0644
    64  )
    65  
    66  // Register performs automatic daemon startup registration.
    67  func Register() error {
    68  	// If we're already registered, don't do anything.
    69  	if registered, err := registered(); err != nil {
    70  		return fmt.Errorf("unable to determine registration status: %w", err)
    71  	} else if registered {
    72  		return nil
    73  	}
    74  
    75  	// Acquire the daemon lock to ensure the daemon isn't running. We switch the
    76  	// start and stop mechanism depending on whether or not we're registered, so
    77  	// we need to make sure we don't try to stop a daemon started using a
    78  	// different mechanism.
    79  	lock, err := AcquireLock()
    80  	if err != nil {
    81  		return errors.New("unable to alter registration while daemon is running")
    82  	}
    83  	defer lock.Release()
    84  
    85  	// Compute the path to the user's home directory.
    86  	homeDirectory, err := os.UserHomeDir()
    87  	if err != nil {
    88  		return fmt.Errorf("unable to compute path to home directory: %w", err)
    89  	}
    90  
    91  	// Ensure the user's Library directory exists.
    92  	targetPath := filepath.Join(homeDirectory, libraryDirectoryName)
    93  	if err := os.MkdirAll(targetPath, libraryDirectoryPermissions); err != nil {
    94  		return fmt.Errorf("unable to create Library directory: %w", err)
    95  	}
    96  
    97  	// Ensure the LaunchAgents directory exists.
    98  	targetPath = filepath.Join(targetPath, launchAgentsDirectoryName)
    99  	if err := os.MkdirAll(targetPath, launchAgentsDirectoryPermissions); err != nil {
   100  		return fmt.Errorf("unable to create LaunchAgents directory: %w", err)
   101  	}
   102  
   103  	// Compute the path to the current executable.
   104  	executablePath, err := os.Executable()
   105  	if err != nil {
   106  		return fmt.Errorf("unable to determine executable path: %w", err)
   107  	}
   108  
   109  	// Format a launchd plist.
   110  	plist := fmt.Sprintf(launchdPlistTemplate, executablePath)
   111  
   112  	// Attempt to write the launchd plist.
   113  	targetPath = filepath.Join(targetPath, launchdPlistName)
   114  	if err := filesystem.WriteFileAtomic(targetPath, []byte(plist), launchdPlistPermissions); err != nil {
   115  		return fmt.Errorf("unable to write launchd agent plist: %w", err)
   116  	}
   117  
   118  	// Success.
   119  	return nil
   120  }
   121  
   122  // Unregister performs automatic daemon startup de-registration.
   123  func Unregister() error {
   124  	// If we're not registered, don't do anything.
   125  	if registered, err := registered(); err != nil {
   126  		return fmt.Errorf("unable to determine registration status: %w", err)
   127  	} else if !registered {
   128  		return nil
   129  	}
   130  
   131  	// Acquire the daemon lock to ensure the daemon isn't running. We switch the
   132  	// start and stop mechanism depending on whether or not we're registered, so
   133  	// we need to make sure we don't try to stop a daemon started using a
   134  	// different mechanism.
   135  	lock, err := AcquireLock()
   136  	if err != nil {
   137  		return errors.New("unable to alter registration while daemon is running")
   138  	}
   139  	defer lock.Release()
   140  
   141  	// Compute the path to the user's home directory.
   142  	homeDirectory, err := os.UserHomeDir()
   143  	if err != nil {
   144  		return fmt.Errorf("unable to compute path to home directory: %w", err)
   145  	}
   146  
   147  	// Compute the launchd plist path.
   148  	targetPath := filepath.Join(
   149  		homeDirectory,
   150  		libraryDirectoryName,
   151  		launchAgentsDirectoryName,
   152  		launchdPlistName,
   153  	)
   154  
   155  	// Attempt to remove the launchd plist.
   156  	if err := os.Remove(targetPath); err != nil {
   157  		if !os.IsNotExist(err) {
   158  			return fmt.Errorf("unable to remove launchd agent plist: %w", err)
   159  		}
   160  	}
   161  
   162  	// Success.
   163  	return nil
   164  }
   165  
   166  // launchctlSpuriousErrorFragment is a fragment of text that appears in spurious
   167  // launchctl load/unload command errors when the daemon run command exits due to
   168  // an existing daemon or the launchctl-hosted daemon isn't running. Ignoring
   169  // this fragment is important for user-friendly idempotency when using launchd
   170  // hosting of the daemon.
   171  const launchctlSpuriousErrorFragment = "failed: 5: Input/output error"
   172  
   173  // runLaunchctlIgnoringSpuriousErrors runs a launchctl command and only prints
   174  // out error text if it doesn't contain launchctlSpuriousErrorFragment. The
   175  // standard error stream for the command must not be set.
   176  func runLaunchctlIgnoringSpuriousErrors(command *exec.Cmd) error {
   177  	err := command.Run()
   178  	if err != nil {
   179  		if exitErr, ok := err.(*exec.ExitError); ok {
   180  			if bytes.Contains(exitErr.Stderr, []byte(launchctlSpuriousErrorFragment)) {
   181  				return nil
   182  			}
   183  		}
   184  	}
   185  	return err
   186  }
   187  
   188  // registered determines whether or not automatic daemon startup is currently
   189  // registered.
   190  func registered() (bool, error) {
   191  	// Compute the path to the user's home directory.
   192  	homeDirectory, err := os.UserHomeDir()
   193  	if err != nil {
   194  		return false, fmt.Errorf("unable to compute path to home directory: %w", err)
   195  	}
   196  
   197  	// Compute the launchd plist path.
   198  	targetPath := filepath.Join(
   199  		homeDirectory,
   200  		libraryDirectoryName,
   201  		launchAgentsDirectoryName,
   202  		launchdPlistName,
   203  	)
   204  
   205  	// Check if it exists and is what's expected.
   206  	if info, err := os.Lstat(targetPath); err != nil {
   207  		if os.IsNotExist(err) {
   208  			return false, nil
   209  		}
   210  		return false, fmt.Errorf("unable to query launchd agent plist: %w", err)
   211  	} else if !info.Mode().IsRegular() {
   212  		return false, errors.New("unexpected contents at launchd agent plist path")
   213  	}
   214  
   215  	// Success.
   216  	return true, nil
   217  }
   218  
   219  // RegisteredStart potentially handles daemon start operations if the daemon is
   220  // registered for automatic start with the system. It returns false if the start
   221  // operation was not handled and should be handled by the normal start command.
   222  func RegisteredStart() (bool, error) {
   223  	// Check if we're registered. If not, we don't handle the start request.
   224  	if registered, err := registered(); err != nil {
   225  		return false, fmt.Errorf("unable to determine daemon registration status: %w", err)
   226  	} else if !registered {
   227  		return false, nil
   228  	}
   229  
   230  	// Compute the path to the user's home directory.
   231  	homeDirectory, err := os.UserHomeDir()
   232  	if err != nil {
   233  		return false, fmt.Errorf("unable to compute path to home directory: %w", err)
   234  	}
   235  
   236  	// Compute the launchd plist path.
   237  	targetPath := filepath.Join(
   238  		homeDirectory,
   239  		libraryDirectoryName,
   240  		launchAgentsDirectoryName,
   241  		launchdPlistName,
   242  	)
   243  
   244  	// Attempt to load the daemon.
   245  	load := exec.Command("launchctl", "load", targetPath)
   246  	load.Stdout = os.Stdout
   247  	if err := runLaunchctlIgnoringSpuriousErrors(load); err != nil {
   248  		return false, fmt.Errorf("unable to load launchd plist: %w", err)
   249  	}
   250  
   251  	// Success.
   252  	return true, nil
   253  }
   254  
   255  // RegisteredStop potentially handles stop start operations if the daemon is
   256  // registered for automatic start with the system. It returns false if the stop
   257  // operation was not handled and should be handled by the normal stop command.
   258  func RegisteredStop() (bool, error) {
   259  	// Check if we're registered. If not, we don't handle the stop request.
   260  	if registered, err := registered(); err != nil {
   261  		return false, fmt.Errorf("unable to determine daemon registration status: %w", err)
   262  	} else if !registered {
   263  		return false, nil
   264  	}
   265  
   266  	// Compute the path to the user's home directory.
   267  	homeDirectory, err := os.UserHomeDir()
   268  	if err != nil {
   269  		return false, fmt.Errorf("unable to compute path to home directory: %w", err)
   270  	}
   271  
   272  	// Compute the launchd plist path.
   273  	targetPath := filepath.Join(
   274  		homeDirectory,
   275  		libraryDirectoryName,
   276  		launchAgentsDirectoryName,
   277  		launchdPlistName,
   278  	)
   279  
   280  	// Attempt to unload the daemon.
   281  	unload := exec.Command("launchctl", "unload", targetPath)
   282  	unload.Stdout = os.Stdout
   283  	if err := runLaunchctlIgnoringSpuriousErrors(unload); err != nil {
   284  		return false, fmt.Errorf("unable to unload launchd plist: %w", err)
   285  	}
   286  
   287  	// Success.
   288  	return true, nil
   289  }