github.com/ethanhsieh/snapd@v0.0.0-20210615102523-3db9b8e4edc5/usersession/autostart/autostart.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2018 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  package autostart
    21  
    22  import (
    23  	"bufio"
    24  	"bytes"
    25  	"fmt"
    26  	"log/syslog"
    27  	"os"
    28  	"os/exec"
    29  	"os/user"
    30  	"path/filepath"
    31  	"sort"
    32  	"strings"
    33  
    34  	"github.com/snapcore/snapd/dirs"
    35  	"github.com/snapcore/snapd/logger"
    36  	"github.com/snapcore/snapd/osutil"
    37  	"github.com/snapcore/snapd/snap"
    38  	"github.com/snapcore/snapd/strutil"
    39  	"github.com/snapcore/snapd/strutil/shlex"
    40  	"github.com/snapcore/snapd/systemd"
    41  )
    42  
    43  var (
    44  	currentDesktop = splitSkippingEmpty(os.Getenv("XDG_CURRENT_DESKTOP"), ':')
    45  )
    46  
    47  func splitSkippingEmpty(s string, sep rune) []string {
    48  	return strings.FieldsFunc(s, func(r rune) bool { return r == sep })
    49  }
    50  
    51  // expandDesktopFields processes the input string and expands any %<char>
    52  // patterns. '%%' expands to '%', all other patterns expand to empty strings.
    53  func expandDesktopFields(in string) string {
    54  	raw := []rune(in)
    55  	out := make([]rune, 0, len(raw))
    56  
    57  	var hasKey bool
    58  	for _, r := range raw {
    59  		if hasKey {
    60  			hasKey = false
    61  			// only allow %% -> % expansion, drop other keys
    62  			if r == '%' {
    63  				out = append(out, r)
    64  			}
    65  			continue
    66  		} else if r == '%' {
    67  			hasKey = true
    68  			continue
    69  		}
    70  		out = append(out, r)
    71  	}
    72  	return string(out)
    73  }
    74  
    75  type skipDesktopFileError struct {
    76  	reason string
    77  }
    78  
    79  func (s *skipDesktopFileError) Error() string {
    80  	return s.reason
    81  }
    82  
    83  func isOneOfIn(of []string, other []string) bool {
    84  	for _, one := range of {
    85  		if strutil.ListContains(other, one) {
    86  			return true
    87  		}
    88  	}
    89  	return false
    90  }
    91  
    92  func loadAutostartDesktopFile(path string) (command string, err error) {
    93  	f, err := os.Open(path)
    94  	if err != nil {
    95  		return "", err
    96  	}
    97  	defer f.Close()
    98  
    99  	scanner := bufio.NewScanner(f)
   100  	for scanner.Scan() {
   101  		bline := scanner.Bytes()
   102  		if bytes.HasPrefix(bline, []byte("#")) {
   103  			continue
   104  		}
   105  		split := bytes.SplitN(bline, []byte("="), 2)
   106  		if len(split) != 2 {
   107  			continue
   108  		}
   109  		// See https://standards.freedesktop.org/autostart-spec/autostart-spec-latest.html
   110  		// for details on how Hidden, OnlyShowIn, NotShownIn are handled.
   111  		switch string(split[0]) {
   112  		case "Exec":
   113  			command = strings.TrimSpace(expandDesktopFields(string(split[1])))
   114  		case "Hidden":
   115  			if bytes.Equal(split[1], []byte("true")) {
   116  				return "", &skipDesktopFileError{"desktop file is hidden"}
   117  			}
   118  		case "OnlyShowIn":
   119  			onlyIn := splitSkippingEmpty(string(split[1]), ';')
   120  			if !isOneOfIn(currentDesktop, onlyIn) {
   121  				return "", &skipDesktopFileError{fmt.Sprintf("current desktop %q not included in %q", currentDesktop, onlyIn)}
   122  			}
   123  		case "NotShownIn":
   124  			notIn := splitSkippingEmpty(string(split[1]), ';')
   125  			if isOneOfIn(currentDesktop, notIn) {
   126  				return "", &skipDesktopFileError{fmt.Sprintf("current desktop %q excluded by %q", currentDesktop, notIn)}
   127  			}
   128  		case "X-GNOME-Autostart-enabled":
   129  			// GNOME specific extension, see gnome-session:
   130  			// https://github.com/GNOME/gnome-session/blob/c449df5269e02c59ae83021a3110ec1b338a2bba/gnome-session/gsm-autostart-app.c#L110..L145
   131  			if !strutil.ListContains(currentDesktop, "GNOME") {
   132  				// not GNOME
   133  				continue
   134  			}
   135  			if !bytes.Equal(split[1], []byte("true")) {
   136  				return "", &skipDesktopFileError{"desktop file is hidden by X-GNOME-Autostart-enabled extension"}
   137  			}
   138  		}
   139  	}
   140  	if err := scanner.Err(); err != nil {
   141  		return "", err
   142  	}
   143  
   144  	command = strings.TrimSpace(command)
   145  	if command == "" {
   146  		return "", fmt.Errorf("Exec not found or invalid")
   147  	}
   148  	return command, nil
   149  
   150  }
   151  
   152  func autostartCmd(snapName, desktopFilePath string) (*exec.Cmd, error) {
   153  	desktopFile := filepath.Base(desktopFilePath)
   154  
   155  	info, err := snap.ReadCurrentInfo(snapName)
   156  	if err != nil {
   157  		return nil, err
   158  	}
   159  
   160  	var app *snap.AppInfo
   161  	for _, candidate := range info.Apps {
   162  		if candidate.Autostart == desktopFile {
   163  			app = candidate
   164  			break
   165  		}
   166  	}
   167  	if app == nil {
   168  		return nil, fmt.Errorf("cannot match desktop file with snap %s applications", snapName)
   169  	}
   170  
   171  	command, err := loadAutostartDesktopFile(desktopFilePath)
   172  	if err != nil {
   173  		if _, ok := err.(*skipDesktopFileError); ok {
   174  			return nil, fmt.Errorf("skipped: %v", err)
   175  		}
   176  		return nil, fmt.Errorf("cannot determine startup command for application %s in snap %s: %v", app.Name, snapName, err)
   177  	}
   178  	logger.Debugf("exec line: %v", command)
   179  
   180  	split, err := shlex.Split(command)
   181  	if err != nil {
   182  		return nil, fmt.Errorf("invalid application startup command: %v", err)
   183  	}
   184  
   185  	// NOTE: Ignore the actual argv[0] in Exec=.. line and replace it with a
   186  	// command of the snap application. Any arguments passed in the Exec=..
   187  	// line to the original command are preserved.
   188  	cmd := exec.Command(app.WrapperPath(), split[1:]...)
   189  	return cmd, nil
   190  }
   191  
   192  // failedAutostartError keeps track of errors that occurred when starting an
   193  // application for a specific desktop file, desktop file name is as a key
   194  type failedAutostartError map[string]error
   195  
   196  func (f failedAutostartError) Error() string {
   197  	var out bytes.Buffer
   198  
   199  	dfiles := make([]string, 0, len(f))
   200  	for desktopFile := range f {
   201  		dfiles = append(dfiles, desktopFile)
   202  	}
   203  	sort.Strings(dfiles)
   204  	for _, desktopFile := range dfiles {
   205  		fmt.Fprintf(&out, "- %q: %v\n", desktopFile, f[desktopFile])
   206  	}
   207  	return out.String()
   208  }
   209  
   210  func makeStdStreams(identifier string) (stdout *os.File, stderr *os.File) {
   211  	var err error
   212  
   213  	stdout, err = systemd.NewJournalStreamFile(identifier, syslog.LOG_INFO, false)
   214  	if err != nil {
   215  		logger.Noticef("failed to set up stdout journal stream for %q: %v", identifier, err)
   216  		stdout = os.Stdout
   217  	}
   218  
   219  	stderr, err = systemd.NewJournalStreamFile(identifier, syslog.LOG_WARNING, false)
   220  	if err != nil {
   221  		logger.Noticef("failed to set up stderr journal stream for %q: %v", identifier, err)
   222  		stderr = os.Stderr
   223  	}
   224  
   225  	return stdout, stderr
   226  }
   227  
   228  var userCurrent = user.Current
   229  
   230  func MockUserCurrent(f func() (*user.User, error)) (restore func()) {
   231  	osutil.MustBeTestBinary("mocking can only be done in tests")
   232  	old := userCurrent
   233  	userCurrent = f
   234  	return func() {
   235  		userCurrent = old
   236  	}
   237  }
   238  
   239  // AutostartSessionApps starts applications which have placed their desktop
   240  // files in $SNAP_USER_DATA/.config/autostart
   241  //
   242  // NOTE: By the spec, the actual path is $SNAP_USER_DATA/${XDG_CONFIG_DIR}/autostart
   243  func AutostartSessionApps() error {
   244  	usr, err := userCurrent()
   245  	if err != nil {
   246  		return err
   247  	}
   248  
   249  	usrSnapDir := filepath.Join(usr.HomeDir, dirs.UserHomeSnapDir)
   250  
   251  	glob := filepath.Join(usrSnapDir, "*/current/.config/autostart/*.desktop")
   252  	matches, err := filepath.Glob(glob)
   253  	if err != nil {
   254  		return err
   255  	}
   256  
   257  	failedApps := make(failedAutostartError)
   258  	for _, desktopFilePath := range matches {
   259  		desktopFile := filepath.Base(desktopFilePath)
   260  		logger.Debugf("autostart desktop file %v", desktopFile)
   261  
   262  		// /home/foo/snap/some-snap/current/.config/autostart/some-app.desktop ->
   263  		//    some-snap/current/.config/autostart/some-app.desktop
   264  		noHomePrefix := strings.TrimPrefix(desktopFilePath, usrSnapDir+"/")
   265  		// some-snap/current/.config/autostart/some-app.desktop -> some-snap
   266  		snapName := noHomePrefix[0:strings.IndexByte(noHomePrefix, '/')]
   267  
   268  		logger.Debugf("snap name: %q", snapName)
   269  
   270  		cmd, err := autostartCmd(snapName, desktopFilePath)
   271  		if err != nil {
   272  			failedApps[desktopFile] = err
   273  			continue
   274  		}
   275  
   276  		// similarly to gnome-session, use the desktop file name as
   277  		// identifier, see:
   278  		// https://github.com/GNOME/gnome-session/blob/099c19099de8e351f6cc0f2110ad27648780a0fe/gnome-session/gsm-autostart-app.c#L948
   279  		cmd.Stdout, cmd.Stderr = makeStdStreams(desktopFile)
   280  		if err := cmd.Start(); err != nil {
   281  			failedApps[desktopFile] = fmt.Errorf("cannot autostart %q: %v", desktopFile, err)
   282  		}
   283  	}
   284  	if len(failedApps) > 0 {
   285  		return failedApps
   286  	}
   287  	return nil
   288  }