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