github.com/rigado/snapd@v2.42.5-go-mod+incompatible/wrappers/desktop.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2014-2016 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 wrappers
    21  
    22  import (
    23  	"bufio"
    24  	"bytes"
    25  	"fmt"
    26  	"io/ioutil"
    27  	"os"
    28  	"os/exec"
    29  	"path/filepath"
    30  	"regexp"
    31  	"strings"
    32  
    33  	"github.com/snapcore/snapd/dirs"
    34  	"github.com/snapcore/snapd/logger"
    35  	"github.com/snapcore/snapd/osutil"
    36  	"github.com/snapcore/snapd/snap"
    37  )
    38  
    39  // From the freedesktop Desktop Entry Specification¹,
    40  //
    41  //    Keys with type localestring may be postfixed by [LOCALE], where
    42  //    LOCALE is the locale type of the entry. LOCALE must be of the form
    43  //    lang_COUNTRY.ENCODING@MODIFIER, where _COUNTRY, .ENCODING, and
    44  //    @MODIFIER may be omitted. If a postfixed key occurs, the same key
    45  //    must be also present without the postfix.
    46  //
    47  //    When reading in the desktop entry file, the value of the key is
    48  //    selected by matching the current POSIX locale for the LC_MESSAGES
    49  //    category against the LOCALE postfixes of all occurrences of the
    50  //    key, with the .ENCODING part stripped.
    51  //
    52  // sadly POSIX doesn't mention what values are valid for LC_MESSAGES,
    53  // beyond mentioning² that it's implementation-defined (and can be of
    54  // the form [language[_territory][.codeset][@modifier]])
    55  //
    56  // 1. https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s04.html
    57  // 2. http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_02
    58  //
    59  // So! The following is simplistic, and based on the contents of
    60  // PROVIDED_LOCALES in locales.config, and does not cover all of
    61  // "locales -m" (and ignores XSetLocaleModifiers(3), which may or may
    62  // not be related). Patches welcome, as long as it's readable.
    63  //
    64  // REVIEWERS: this could also be left as `(?:\[[@_.A-Za-z-]\])?=` if even
    65  // the following is hard to read:
    66  const localizedSuffix = `(?:\[[a-z]+(?:_[A-Z]+)?(?:\.[0-9A-Z-]+)?(?:@[a-z]+)?\])?=`
    67  
    68  var isValidDesktopFileLine = regexp.MustCompile(strings.Join([]string{
    69  	// NOTE (mostly to self): as much as possible keep the
    70  	// individual regexp simple, optimize for legibility
    71  	//
    72  	// empty lines and comments
    73  	`^\s*$`,
    74  	`^\s*#`,
    75  	// headers
    76  	`^\[Desktop Entry\]$`,
    77  	`^\[Desktop Action [0-9A-Za-z-]+\]$`,
    78  	`^\[[A-Za-z0-9-]+ Shortcut Group\]$`,
    79  	// https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s05.html
    80  	"^Type=",
    81  	"^Version=",
    82  	"^Name" + localizedSuffix,
    83  	"^GenericName" + localizedSuffix,
    84  	"^NoDisplay=",
    85  	"^Comment" + localizedSuffix,
    86  	"^Icon=",
    87  	"^Hidden=",
    88  	"^OnlyShowIn=",
    89  	"^NotShowIn=",
    90  	"^Exec=",
    91  	// Note that we do not support TryExec, it does not make sense
    92  	// in the snap context
    93  	"^Terminal=",
    94  	"^Actions=",
    95  	"^MimeType=",
    96  	"^Categories=",
    97  	"^Keywords" + localizedSuffix,
    98  	"^StartupNotify=",
    99  	"^StartupWMClass=",
   100  	// unity extension
   101  	"^X-Ayatana-Desktop-Shortcuts=",
   102  	"^TargetEnvironment=",
   103  }, "|")).Match
   104  
   105  // rewriteExecLine rewrites a "Exec=" line to use the wrapper path for snap application.
   106  func rewriteExecLine(s *snap.Info, desktopFile, line string) (string, error) {
   107  	env := fmt.Sprintf("env BAMF_DESKTOP_FILE_HINT=%s ", desktopFile)
   108  
   109  	cmd := strings.SplitN(line, "=", 2)[1]
   110  	for _, app := range s.Apps {
   111  		wrapper := app.WrapperPath()
   112  		validCmd := filepath.Base(wrapper)
   113  		if s.InstanceKey != "" {
   114  			// wrapper uses s.InstanceName(), with the instance key
   115  			// set the command will be 'snap_foo.app' instead of
   116  			// 'snap.app', need to account for that
   117  			validCmd = snap.JoinSnapApp(s.SnapName(), app.Name)
   118  		}
   119  		// check the prefix to allow %flag style args
   120  		// this is ok because desktop files are not run through sh
   121  		// so we don't have to worry about the arguments too much
   122  		if cmd == validCmd {
   123  			return "Exec=" + env + wrapper, nil
   124  		} else if strings.HasPrefix(cmd, validCmd+" ") {
   125  			return fmt.Sprintf("Exec=%s%s%s", env, wrapper, line[len("Exec=")+len(validCmd):]), nil
   126  		}
   127  	}
   128  
   129  	logger.Noticef("cannot use line %q for desktop file %q (snap %s)", line, desktopFile, s.InstanceName())
   130  	// The Exec= line in the desktop file is invalid. Instead of failing
   131  	// hard we rewrite the Exec= line. The convention is that the desktop
   132  	// file has the same name as the application we can use this fact here.
   133  	df := filepath.Base(desktopFile)
   134  	desktopFileApp := strings.TrimSuffix(df, filepath.Ext(df))
   135  	app, ok := s.Apps[desktopFileApp]
   136  	if ok {
   137  		newExec := fmt.Sprintf("Exec=%s%s", env, app.WrapperPath())
   138  		logger.Noticef("rewriting desktop file %q to %q", desktopFile, newExec)
   139  		return newExec, nil
   140  	}
   141  
   142  	return "", fmt.Errorf("invalid exec command: %q", cmd)
   143  }
   144  
   145  func rewriteIconLine(s *snap.Info, line string) (string, error) {
   146  	icon := strings.SplitN(line, "=", 2)[1]
   147  
   148  	// If there is a path separator, assume the icon is a path name
   149  	if strings.ContainsRune(icon, filepath.Separator) {
   150  		if !strings.HasPrefix(icon, "${SNAP}/") {
   151  			return "", fmt.Errorf("icon path %q is not part of the snap", icon)
   152  		}
   153  		if filepath.Clean(icon) != icon {
   154  			return "", fmt.Errorf("icon path %q is not canonicalized, did you mean %q?", icon, filepath.Clean(icon))
   155  		}
   156  		return line, nil
   157  	}
   158  
   159  	// If the icon is prefixed with "snap.${SNAP_NAME}.", rewrite
   160  	// to the instance name.
   161  	snapIconPrefix := fmt.Sprintf("snap.%s.", s.SnapName())
   162  	if strings.HasPrefix(icon, snapIconPrefix) {
   163  		return fmt.Sprintf("Icon=snap.%s.%s", s.InstanceName(), icon[len(snapIconPrefix):]), nil
   164  	}
   165  
   166  	// If the icon has any other "snap." prefix, treat this as an error.
   167  	if strings.HasPrefix(icon, "snap.") {
   168  		return "", fmt.Errorf("invalid icon name: %q, must start with %q", icon, snapIconPrefix)
   169  	}
   170  
   171  	// Allow other icons names through unchanged.
   172  	return line, nil
   173  }
   174  
   175  func sanitizeDesktopFile(s *snap.Info, desktopFile string, rawcontent []byte) []byte {
   176  	var newContent bytes.Buffer
   177  	mountDir := []byte(s.MountDir())
   178  	scanner := bufio.NewScanner(bytes.NewReader(rawcontent))
   179  	for i := 0; scanner.Scan(); i++ {
   180  		bline := scanner.Bytes()
   181  
   182  		if !isValidDesktopFileLine(bline) {
   183  			logger.Debugf("ignoring line %d (%q) in source of desktop file %q", i, bline, filepath.Base(desktopFile))
   184  			continue
   185  		}
   186  
   187  		// rewrite exec lines to an absolute path for the binary
   188  		if bytes.HasPrefix(bline, []byte("Exec=")) {
   189  			var err error
   190  			line, err := rewriteExecLine(s, desktopFile, string(bline))
   191  			if err != nil {
   192  				// something went wrong, ignore the line
   193  				continue
   194  			}
   195  			bline = []byte(line)
   196  		}
   197  
   198  		// rewrite icon line if it references an icon theme icon
   199  		if bytes.HasPrefix(bline, []byte("Icon=")) {
   200  			line, err := rewriteIconLine(s, string(bline))
   201  			if err != nil {
   202  				logger.Debugf("ignoring icon in source desktop file %q: %s", filepath.Base(desktopFile), err)
   203  				continue
   204  			}
   205  			bline = []byte(line)
   206  		}
   207  
   208  		// do variable substitution
   209  		bline = bytes.Replace(bline, []byte("${SNAP}"), mountDir, -1)
   210  
   211  		newContent.Grow(len(bline) + 1)
   212  		newContent.Write(bline)
   213  		newContent.WriteByte('\n')
   214  
   215  		// insert snap name
   216  		if bytes.Equal(bline, []byte("[Desktop Entry]")) {
   217  			newContent.Write([]byte("X-SnapInstanceName=" + s.InstanceName() + "\n"))
   218  		}
   219  	}
   220  
   221  	return newContent.Bytes()
   222  }
   223  
   224  func updateDesktopDatabase(desktopFiles []string) error {
   225  	if len(desktopFiles) == 0 {
   226  		return nil
   227  	}
   228  
   229  	if _, err := exec.LookPath("update-desktop-database"); err == nil {
   230  		if output, err := exec.Command("update-desktop-database", dirs.SnapDesktopFilesDir).CombinedOutput(); err != nil {
   231  			return fmt.Errorf("cannot update-desktop-database %q: %s", output, err)
   232  		}
   233  		logger.Debugf("update-desktop-database successful")
   234  	}
   235  	return nil
   236  }
   237  
   238  // desktopPrefix returns the prefix string for the desktop files that
   239  // belongs to the given snapInstance. We need to do something custom
   240  // here because a) we need to be compatible with the world before we had
   241  // parallel installs b) we can't just use the usual "_" parallel installs
   242  // separator because that is already used as the separator between snap
   243  // and desktop filename.
   244  func desktopPrefix(s *snap.Info) string {
   245  	if s.SnapName() == s.InstanceName() {
   246  		return s.SnapName()
   247  	}
   248  	// we cannot use the usual "_" separator because that is also used
   249  	// to separate "$snap_$desktopfile"
   250  	return fmt.Sprintf("%s+%s", s.SnapName(), s.InstanceKey)
   251  }
   252  
   253  // AddSnapDesktopFiles puts in place the desktop files for the applications from the snap.
   254  func AddSnapDesktopFiles(s *snap.Info) (err error) {
   255  	var created []string
   256  	defer func() {
   257  		if err == nil {
   258  			return
   259  		}
   260  
   261  		for _, fn := range created {
   262  			os.Remove(fn)
   263  		}
   264  	}()
   265  
   266  	if err := os.MkdirAll(dirs.SnapDesktopFilesDir, 0755); err != nil {
   267  		return err
   268  	}
   269  
   270  	baseDir := s.MountDir()
   271  
   272  	desktopFiles, err := filepath.Glob(filepath.Join(baseDir, "meta", "gui", "*.desktop"))
   273  	if err != nil {
   274  		return fmt.Errorf("cannot get desktop files for %v: %s", baseDir, err)
   275  	}
   276  
   277  	for _, df := range desktopFiles {
   278  		content, err := ioutil.ReadFile(df)
   279  		if err != nil {
   280  			return err
   281  		}
   282  
   283  		// FIXME: don't blindly use the snap desktop filename, mangle it
   284  		// but we can't just use the app name because a desktop file
   285  		// may call the same app with multiple parameters, e.g.
   286  		// --create-new, --open-existing etc
   287  		installedDesktopFileName := filepath.Join(dirs.SnapDesktopFilesDir, fmt.Sprintf("%s_%s", desktopPrefix(s), filepath.Base(df)))
   288  		content = sanitizeDesktopFile(s, installedDesktopFileName, content)
   289  		if err := osutil.AtomicWriteFile(installedDesktopFileName, content, 0755, 0); err != nil {
   290  			return err
   291  		}
   292  		created = append(created, installedDesktopFileName)
   293  	}
   294  
   295  	// updates mime info etc
   296  	if err := updateDesktopDatabase(desktopFiles); err != nil {
   297  		return err
   298  	}
   299  
   300  	return nil
   301  }
   302  
   303  // RemoveSnapDesktopFiles removes the added desktop files for the applications in the snap.
   304  func RemoveSnapDesktopFiles(s *snap.Info) error {
   305  	removedDesktopFiles := make([]string, 0, len(s.Apps))
   306  
   307  	desktopFiles, err := filepath.Glob(filepath.Join(dirs.SnapDesktopFilesDir, fmt.Sprintf("%s_*.desktop", desktopPrefix(s))))
   308  	if err != nil {
   309  		return nil
   310  	}
   311  	for _, df := range desktopFiles {
   312  		if err := os.Remove(df); err != nil {
   313  			if !os.IsNotExist(err) {
   314  				return err
   315  			}
   316  		} else {
   317  			removedDesktopFiles = append(removedDesktopFiles, df)
   318  		}
   319  	}
   320  
   321  	// updates mime info etc
   322  	if err := updateDesktopDatabase(removedDesktopFiles); err != nil {
   323  		return err
   324  	}
   325  
   326  	return nil
   327  }