gopkg.in/ubuntu-core/snappy.v0@v0.0.0-20210902073436-25a8614f10a6/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/channel" 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 ch *channel.Channel 139 var err error 140 if _, ch, err = theStore.SnapExists(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 a stable channel 147 // (latest or default track). 148 if ch.Risk == "stable" { 149 status[theme] = themeAvailable 150 candidateSnaps[name] = true 151 break 152 } 153 } 154 } 155 return nil 156 } 157 158 func themeStatusAndCandidateSnaps(ctx context.Context, d *Daemon, user *auth.UserState, gtkThemes, iconThemes, soundThemes []string) (status themeStatusResponse, candidateSnaps map[string]bool, err error) { 159 installedGtk, installedIcon, installedSound, err := installedThemes(d.overlord) 160 if err != nil { 161 return themeStatusResponse{}, nil, err 162 } 163 164 theStore := storeFrom(d) 165 status.GtkThemes = make(map[string]themeStatus, len(gtkThemes)) 166 status.IconThemes = make(map[string]themeStatus, len(iconThemes)) 167 status.SoundThemes = make(map[string]themeStatus, len(soundThemes)) 168 candidateSnaps = make(map[string]bool) 169 if err = collectThemeStatusForPrefix(ctx, theStore, user, "gtk-theme-", gtkThemes, installedGtk, status.GtkThemes, candidateSnaps); err != nil { 170 return themeStatusResponse{}, nil, err 171 } 172 if err = collectThemeStatusForPrefix(ctx, theStore, user, "icon-theme-", iconThemes, installedIcon, status.IconThemes, candidateSnaps); err != nil { 173 return themeStatusResponse{}, nil, err 174 } 175 if err = collectThemeStatusForPrefix(ctx, theStore, user, "sound-theme-", soundThemes, installedSound, status.SoundThemes, candidateSnaps); err != nil { 176 return themeStatusResponse{}, nil, err 177 } 178 179 return status, candidateSnaps, nil 180 } 181 182 func checkThemes(c *Command, r *http.Request, user *auth.UserState) Response { 183 ctx := store.WithClientUserAgent(r.Context(), r) 184 q := r.URL.Query() 185 status, _, err := themeStatusAndCandidateSnaps(ctx, c.d, user, q["gtk-theme"], q["icon-theme"], q["sound-theme"]) 186 if err != nil { 187 return InternalError("cannot get theme status: %s", err) 188 } 189 190 return SyncResponse(status) 191 } 192 193 type themeInstallReq struct { 194 GtkThemes []string `json:"gtk-themes"` 195 IconThemes []string `json:"icon-themes"` 196 SoundThemes []string `json:"sound-themes"` 197 } 198 199 func installThemes(c *Command, r *http.Request, user *auth.UserState) Response { 200 decoder := json.NewDecoder(r.Body) 201 var req themeInstallReq 202 if err := decoder.Decode(&req); err != nil { 203 return BadRequest("cannot decode request body: %v", err) 204 } 205 206 ctx := store.WithClientUserAgent(r.Context(), r) 207 _, candidateSnaps, err := themeStatusAndCandidateSnaps(ctx, c.d, user, req.GtkThemes, req.IconThemes, req.SoundThemes) 208 if err != nil { 209 return InternalError("cannot get theme status: %s", err) 210 } 211 212 if len(candidateSnaps) == 0 { 213 return BadRequest("no snaps to install") 214 } 215 216 toInstall := make([]string, 0, len(candidateSnaps)) 217 for pkg := range candidateSnaps { 218 toInstall = append(toInstall, pkg) 219 } 220 sort.Strings(toInstall) 221 222 st := c.d.overlord.State() 223 st.Lock() 224 defer st.Unlock() 225 226 userID := 0 227 if user != nil { 228 userID = user.ID 229 } 230 installed, tasksets, err := snapstateInstallMany(st, toInstall, userID) 231 if err != nil { 232 return InternalError("cannot install themes: %s", err) 233 } 234 var summary string 235 switch len(toInstall) { 236 case 1: 237 summary = fmt.Sprintf(i18n.G("Install snap %q"), toInstall) 238 default: 239 quoted := strutil.Quoted(toInstall) 240 summary = fmt.Sprintf(i18n.G("Install snaps %s"), quoted) 241 } 242 243 var chg *state.Change 244 if len(tasksets) == 0 { 245 chg = st.NewChange("install-themes", summary) 246 chg.SetStatus(state.DoneStatus) 247 } else { 248 chg = newChange(st, "install-themes", summary, tasksets, installed) 249 ensureStateSoon(st) 250 } 251 chg.Set("api-data", map[string]interface{}{"snap-names": installed}) 252 return AsyncResponse(nil, chg.ID()) 253 }