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