github.com/stolowski/snapd@v0.0.0-20210407085831-115137ce5a22/daemon/api_themes.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2020 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 daemon 21 22 import ( 23 "context" 24 "encoding/json" 25 "fmt" 26 "net/http" 27 "path/filepath" 28 "regexp" 29 "sort" 30 "strings" 31 32 "github.com/snapcore/snapd/i18n" 33 "github.com/snapcore/snapd/interfaces" 34 "github.com/snapcore/snapd/overlord" 35 "github.com/snapcore/snapd/overlord/auth" 36 "github.com/snapcore/snapd/overlord/snapstate" 37 "github.com/snapcore/snapd/overlord/state" 38 "github.com/snapcore/snapd/snap" 39 "github.com/snapcore/snapd/store" 40 "github.com/snapcore/snapd/strutil" 41 ) 42 43 var ( 44 themesCmd = &Command{ 45 Path: "/v2/accessories/themes", 46 UserOK: true, 47 GET: checkThemes, 48 POST: installThemes, 49 } 50 ) 51 52 type themeStatus string 53 54 const ( 55 themeInstalled themeStatus = "installed" 56 themeAvailable themeStatus = "available" 57 themeUnavailable themeStatus = "unavailable" 58 ) 59 60 type themeStatusResponse struct { 61 GtkThemes map[string]themeStatus `json:"gtk-themes"` 62 IconThemes map[string]themeStatus `json:"icon-themes"` 63 SoundThemes map[string]themeStatus `json:"sound-themes"` 64 } 65 66 func installedThemes(overlord *overlord.Overlord) (gtkThemes, iconThemes, soundThemes []string, err error) { 67 infos := overlord.InterfaceManager().Repository().Info(&interfaces.InfoOptions{ 68 Names: []string{"content"}, 69 Slots: true, 70 }) 71 for _, info := range infos { 72 for _, slot := range info.Slots { 73 var content string 74 // The content interface ensures this attribute exists 75 if err := slot.Attr("content", &content); err != nil { 76 return nil, nil, nil, err 77 } 78 var themes *[]string 79 switch content { 80 case "gtk-3-themes": 81 themes = >kThemes 82 case "icon-themes": 83 themes = &iconThemes 84 case "sound-themes": 85 themes = &soundThemes 86 default: 87 continue 88 } 89 var sources []interface{} 90 if err := slot.Attr("source.read", &sources); err != nil { 91 continue 92 } 93 for _, s := range sources { 94 if path, ok := s.(string); ok { 95 *themes = append(*themes, filepath.Base(path)) 96 } 97 } 98 } 99 } 100 sort.Strings(gtkThemes) 101 sort.Strings(iconThemes) 102 sort.Strings(soundThemes) 103 return gtkThemes, iconThemes, soundThemes, nil 104 } 105 106 var badPkgCharRegexp = regexp.MustCompile(`[^a-z0-9]+`) 107 108 func themePackageCandidates(prefix, themeName string) []string { 109 themeName = strings.ToLower(themeName) 110 themeName = badPkgCharRegexp.ReplaceAllString(themeName, "-") 111 themeName = strings.Trim(themeName, "-") 112 113 var packages []string 114 for themeName != "" { 115 packages = append(packages, prefix+themeName) 116 pos := strings.LastIndexByte(themeName, '-') 117 if pos < 0 { 118 break 119 } 120 themeName = themeName[:pos] 121 } 122 return packages 123 } 124 125 func collectThemeStatusForPrefix(ctx context.Context, theStore snapstate.StoreService, user *auth.UserState, prefix string, themes, installed []string, status map[string]themeStatus, candidateSnaps map[string]bool) error { 126 for _, theme := range themes { 127 // Skip duplicates 128 if _, ok := status[theme]; ok { 129 continue 130 } 131 if strutil.SortedListContains(installed, theme) { 132 status[theme] = themeInstalled 133 continue 134 } 135 status[theme] = themeUnavailable 136 for _, name := range themePackageCandidates(prefix, theme) { 137 var info *snap.Info 138 var err error 139 if info, err = theStore.SnapInfo(ctx, store.SnapSpec{Name: name}, user); err == store.ErrSnapNotFound { 140 continue 141 } else if err != nil { 142 return err 143 } 144 // Only mark the theme as available if it has 145 // been published to the stable channel. 146 if info.Channel == "stable" { 147 status[theme] = themeAvailable 148 candidateSnaps[name] = true 149 break 150 } 151 } 152 } 153 return nil 154 } 155 156 func themeStatusAndCandidateSnaps(ctx context.Context, d *Daemon, user *auth.UserState, gtkThemes, iconThemes, soundThemes []string) (status themeStatusResponse, candidateSnaps map[string]bool, err error) { 157 installedGtk, installedIcon, installedSound, err := installedThemes(d.overlord) 158 if err != nil { 159 return themeStatusResponse{}, nil, err 160 } 161 162 theStore := storeFrom(d) 163 status.GtkThemes = make(map[string]themeStatus, len(gtkThemes)) 164 status.IconThemes = make(map[string]themeStatus, len(iconThemes)) 165 status.SoundThemes = make(map[string]themeStatus, len(soundThemes)) 166 candidateSnaps = make(map[string]bool) 167 if err = collectThemeStatusForPrefix(ctx, theStore, user, "gtk-theme-", gtkThemes, installedGtk, status.GtkThemes, candidateSnaps); err != nil { 168 return themeStatusResponse{}, nil, err 169 } 170 if err = collectThemeStatusForPrefix(ctx, theStore, user, "icon-theme-", iconThemes, installedIcon, status.IconThemes, candidateSnaps); err != nil { 171 return themeStatusResponse{}, nil, err 172 } 173 if err = collectThemeStatusForPrefix(ctx, theStore, user, "sound-theme-", soundThemes, installedSound, status.SoundThemes, candidateSnaps); err != nil { 174 return themeStatusResponse{}, nil, err 175 } 176 177 return status, candidateSnaps, nil 178 } 179 180 func checkThemes(c *Command, r *http.Request, user *auth.UserState) Response { 181 ctx := store.WithClientUserAgent(r.Context(), r) 182 q := r.URL.Query() 183 status, _, err := themeStatusAndCandidateSnaps(ctx, c.d, user, q["gtk-theme"], q["icon-theme"], q["sound-theme"]) 184 if err != nil { 185 return InternalError("cannot get theme status: %s", err) 186 } 187 188 return SyncResponse(status, nil) 189 } 190 191 type themeInstallReq struct { 192 GtkThemes []string `json:"gtk-themes"` 193 IconThemes []string `json:"icon-themes"` 194 SoundThemes []string `json:"sound-themes"` 195 } 196 197 func installThemes(c *Command, r *http.Request, user *auth.UserState) Response { 198 decoder := json.NewDecoder(r.Body) 199 var req themeInstallReq 200 if err := decoder.Decode(&req); err != nil { 201 return BadRequest("cannot decode request body: %v", err) 202 } 203 204 ctx := store.WithClientUserAgent(r.Context(), r) 205 _, candidateSnaps, err := themeStatusAndCandidateSnaps(ctx, c.d, user, req.GtkThemes, req.IconThemes, req.SoundThemes) 206 if err != nil { 207 return InternalError("cannot get theme status: %s", err) 208 } 209 210 if len(candidateSnaps) == 0 { 211 return BadRequest("no snaps to install") 212 } 213 214 toInstall := make([]string, 0, len(candidateSnaps)) 215 for pkg := range candidateSnaps { 216 toInstall = append(toInstall, pkg) 217 } 218 sort.Strings(toInstall) 219 220 st := c.d.overlord.State() 221 st.Lock() 222 defer st.Unlock() 223 224 userID := 0 225 if user != nil { 226 userID = user.ID 227 } 228 installed, tasksets, err := snapstateInstallMany(st, toInstall, userID) 229 if err != nil { 230 return InternalError("cannot install themes: %s", err) 231 } 232 var summary string 233 switch len(toInstall) { 234 case 1: 235 summary = fmt.Sprintf(i18n.G("Install snap %q"), toInstall) 236 default: 237 quoted := strutil.Quoted(toInstall) 238 summary = fmt.Sprintf(i18n.G("Install snaps %s"), quoted) 239 } 240 241 var chg *state.Change 242 if len(tasksets) == 0 { 243 chg = st.NewChange("install-themes", summary) 244 chg.SetStatus(state.DoneStatus) 245 } else { 246 chg = newChange(st, "install-themes", summary, tasksets, installed) 247 ensureStateSoon(st) 248 } 249 chg.Set("api-data", map[string]interface{}{"snap-names": installed}) 250 return AsyncResponse(nil, &Meta{Change: chg.ID()}) 251 }