github.com/meulengracht/snapd@v0.0.0-20210719210640-8bde69bcc84e/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  // AddSnapDesktopFiles puts in place the desktop files for the applications from the snap.
   239  func AddSnapDesktopFiles(s *snap.Info) (err error) {
   240  	var created []string
   241  	defer func() {
   242  		if err == nil {
   243  			return
   244  		}
   245  
   246  		for _, fn := range created {
   247  			os.Remove(fn)
   248  		}
   249  	}()
   250  
   251  	if err := os.MkdirAll(dirs.SnapDesktopFilesDir, 0755); err != nil {
   252  		return err
   253  	}
   254  
   255  	baseDir := s.MountDir()
   256  
   257  	desktopFiles, err := filepath.Glob(filepath.Join(baseDir, "meta", "gui", "*.desktop"))
   258  	if err != nil {
   259  		return fmt.Errorf("cannot get desktop files for %v: %s", baseDir, err)
   260  	}
   261  
   262  	for _, df := range desktopFiles {
   263  		content, err := ioutil.ReadFile(df)
   264  		if err != nil {
   265  			return err
   266  		}
   267  
   268  		// FIXME: don't blindly use the snap desktop filename, mangle it
   269  		// but we can't just use the app name because a desktop file
   270  		// may call the same app with multiple parameters, e.g.
   271  		// --create-new, --open-existing etc
   272  		installedDesktopFileName := filepath.Join(dirs.SnapDesktopFilesDir, fmt.Sprintf("%s_%s", s.DesktopPrefix(), filepath.Base(df)))
   273  		content = sanitizeDesktopFile(s, installedDesktopFileName, content)
   274  		if err := osutil.AtomicWriteFile(installedDesktopFileName, content, 0755, 0); err != nil {
   275  			return err
   276  		}
   277  		created = append(created, installedDesktopFileName)
   278  	}
   279  
   280  	// updates mime info etc
   281  	if err := updateDesktopDatabase(desktopFiles); err != nil {
   282  		return err
   283  	}
   284  
   285  	return nil
   286  }
   287  
   288  // RemoveSnapDesktopFiles removes the added desktop files for the applications in the snap.
   289  func RemoveSnapDesktopFiles(s *snap.Info) error {
   290  	removedDesktopFiles := make([]string, 0, len(s.Apps))
   291  
   292  	desktopFiles, err := filepath.Glob(filepath.Join(dirs.SnapDesktopFilesDir, fmt.Sprintf("%s_*.desktop", s.DesktopPrefix())))
   293  	if err != nil {
   294  		return nil
   295  	}
   296  	for _, df := range desktopFiles {
   297  		if err := os.Remove(df); err != nil {
   298  			if !os.IsNotExist(err) {
   299  				return err
   300  			}
   301  		} else {
   302  			removedDesktopFiles = append(removedDesktopFiles, df)
   303  		}
   304  	}
   305  
   306  	// updates mime info etc
   307  	if err := updateDesktopDatabase(removedDesktopFiles); err != nil {
   308  		return err
   309  	}
   310  
   311  	return nil
   312  }