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 }