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