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 = &gtkThemes
    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  }