Просмотр исходного кода

Refactor desktop logic into modular packages

Extract desktop responsibilities from `desktop.go` into new `mod/desktop` packages: `icons`, `layout`, `prefs`, and `wallpaper`. The desktop handlers now initialize and use dedicated managers/generators for icon rendering, icon position persistence, user preferences/themes, and wallpaper theme discovery.

This removes a large amount of inlined desktop/icon code from `desktop.go`, centralizes desktop constants, and keeps existing behavior while improving separation of concerns and testability. The old monolithic desktop icon tests were replaced with focused package-level tests for each new module.
Toby Chui 3 недель назад
Родитель
Сommit
d76438b947

+ 75 - 415
src/desktop.go

@@ -1,28 +1,19 @@
 package main
 
 import (
-	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"image"
-	"image/color"
-	"image/draw"
-	_ "image/gif"
-	_ "image/jpeg"
-	"image/png"
-	"math"
 	"net/http"
 	"os"
-	"path"
 	"path/filepath"
 	"strconv"
 	"strings"
 
-	"github.com/disintegration/imaging"
-	"github.com/srwiley/oksvg"
-	"github.com/srwiley/rasterx"
-	fs "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/desktop/icons"
+	"imuslab.com/arozos/mod/desktop/layout"
+	"imuslab.com/arozos/mod/desktop/prefs"
+	"imuslab.com/arozos/mod/desktop/wallpaper"
 	"imuslab.com/arozos/mod/filesystem/arozfs"
 	"imuslab.com/arozos/mod/filesystem/shortcut"
 	module "imuslab.com/arozos/mod/modules"
@@ -30,6 +21,41 @@ import (
 	"imuslab.com/arozos/mod/utils"
 )
 
+/*
+	desktop.go
+
+	HTTP handlers backing the ArozOS web desktop. The reusable logic behind them
+	lives in mod/desktop/*: icon generation (icons), desktop icon positions
+	(layout), per-user preferences and theme (prefs) and wallpaper discovery
+	(wallpaper).
+*/
+
+const (
+	//desktopDatabaseTable is the system database table holding all desktop
+	//related per-user state
+	desktopDatabaseTable = "desktop"
+
+	//desktopWebRoot is the folder the web apps and their icons are served from
+	desktopWebRoot = "./web"
+
+	//desktopWallpaperRoot is the folder holding the bundled wallpaper themes
+	desktopWallpaperRoot = "./web/img/desktop/bg"
+
+	//desktopTemplateFolder holds the shortcuts copied onto a new user's desktop
+	desktopTemplateFolder = "./system/desktop/template/"
+)
+
+var (
+	//desktopIconGenerator renders desktop icons for web apps missing one
+	desktopIconGenerator *icons.Generator
+
+	//desktopLayoutManager stores where each icon sits on a user's desktop
+	desktopLayoutManager *layout.Manager
+
+	//desktopPrefsManager stores per-user desktop preferences and theme
+	desktopPrefsManager *prefs.Manager
+)
+
 // Desktop script initiation
 func DesktopInit() {
 	systemWideLogger.PrintAndLog("Desktop", "Starting Desktop Services", nil)
@@ -56,13 +82,28 @@ func DesktopInit() {
 	router.HandleFunc("/system/desktop/opr/renameShortcut", desktop_handleShortcutRename)
 
 	//Initialize desktop database
-	err := sysdb.NewTable("desktop")
+	err := sysdb.NewTable(desktopDatabaseTable)
 	if err != nil {
 		systemWideLogger.PrintAndLog("System", "Unable to create database table for Desktop. Please validation your installation.", nil)
 		systemWideLogger.PrintAndLog("System", fmt.Sprint(err), nil)
 		os.Exit(1)
 	}
 
+	//Start the desktop sub-modules
+	desktopIconGenerator = icons.NewGenerator(desktopWebRoot)
+
+	desktopLayoutManager, err = layout.NewManager(sysdb, desktopDatabaseTable)
+	if err != nil {
+		systemWideLogger.PrintAndLog("System", "Unable to start Desktop layout manager. Please validation your installation.", err)
+		os.Exit(1)
+	}
+
+	desktopPrefsManager, err = prefs.NewManager(sysdb, desktopDatabaseTable)
+	if err != nil {
+		systemWideLogger.PrintAndLog("System", "Unable to start Desktop preference manager. Please validation your installation.", err)
+		os.Exit(1)
+	}
+
 	//Register Desktop settings sub-items
 	registerSetting(settingModule{
 		Name:     "Wallpaper",
@@ -128,9 +169,8 @@ func desktop_initUserFolderStructure(username string) {
 		userFsa.MkdirAll(userDesktopPath, 0755)
 
 		//Copy template file from system folder if exists
-		templateFolder := "./system/desktop/template/"
-		if fs.FileExists(templateFolder) {
-			templateFiles, _ := filepath.Glob(templateFolder + "*")
+		if utils.FileExists(desktopTemplateFolder) {
+			templateFiles, _ := filepath.Glob(desktopTemplateFolder + "*")
 			for _, tfile := range templateFiles {
 				input, _ := os.ReadFile(tfile)
 				userFsa.WriteFile(arozfs.ToSlash(filepath.Join(userDesktopPath, filepath.Base(tfile))), input, 0755)
@@ -346,24 +386,7 @@ func desktop_listFiles(w http.ResponseWriter, r *http.Request) {
 
 // functions to handle desktop icon locations. Location is directly written into the center db.
 func getDesktopLocatioFromPath(filename string, username string) (int, int, error) {
-	//As path include username, there is no different if there are username in the key
-	locationdata := ""
-	err := sysdb.Read("desktop", username+"/filelocation/"+filename, &locationdata)
-	if err != nil {
-		//The file location is not set. Return error
-		return -1, -1, errors.New("This file do not have a location registry")
-	}
-	type iconLocation struct {
-		X int
-		Y int
-	}
-	thisFileLocation := iconLocation{
-		X: -1,
-		Y: -1,
-	}
-	//Start parsing the from the json data
-	json.Unmarshal([]byte(locationdata), &thisFileLocation)
-	return thisFileLocation.X, thisFileLocation.Y, nil
+	return desktopLayoutManager.GetIconLocation(username, filename)
 }
 
 // Set the icon location of a given filepath
@@ -373,37 +396,24 @@ func setDesktopLocationFromPath(filename string, username string, x int, y int)
 	fsh, subpath, _ := GetFSHandlerSubpathFromVpath("user:/Desktop/")
 	fshAbs := fsh.FileSystemAbstraction
 	desktoppath, _ := fshAbs.VirtualPathToRealPath(subpath, userinfo.Username)
-	path := filepath.Join(desktoppath, filename)
-	type iconLocation struct {
-		X int
-		Y int
-	}
-
-	newLocation := new(iconLocation)
-	newLocation.X = x
-	newLocation.Y = y
+	targetFilepath := filepath.Join(desktoppath, filename)
 
 	//Check if the file exits
-	if !fshAbs.FileExists(path) {
+	if !fshAbs.FileExists(targetFilepath) {
 		return errors.New("Given filename not exists.")
 	}
 
-	//Parse the location to json
-	jsonstring, err := json.Marshal(newLocation)
+	err := desktopLayoutManager.SetIconLocation(username, filename, x, y)
 	if err != nil {
-		systemWideLogger.PrintAndLog("Desktop", "Unable to parse new file location on desktop for file: "+path, err)
+		systemWideLogger.PrintAndLog("Desktop", "Unable to store new file location on desktop for file: "+targetFilepath, err)
 		return err
 	}
-
-	//systemWideLogger.PrintAndLog(key,string(jsonstring),nil)
-	//Write result to database
-	sysdb.Write("desktop", username+"/filelocation/"+filename, string(jsonstring))
 	return nil
 }
 
 func delDesktopLocationFromPath(filename string, username string) {
 	//Delete a file icon location from db
-	sysdb.Delete("desktop", username+"/filelocation/"+filename)
+	desktopLayoutManager.RemoveIconLocation(username, filename)
 }
 
 // Return the user information to the client
@@ -541,43 +551,11 @@ func desktop_theme_handler(w http.ResponseWriter, r *http.Request) {
 	loadUserTheme, _ := utils.GetPara(r, "load")
 	if targetTheme == "" && getUserTheme == "" && loadUserTheme == "" {
 		//List all the currnet themes in the list
-		themes, err := filepath.Glob("web/img/desktop/bg/*")
+		desktopThemeList, err := wallpaper.ListThemes(desktopWallpaperRoot)
 		if err != nil {
 			systemWideLogger.PrintAndLog("Desktop", "Unable to search bg from destkop image root. Are you sure the web data folder exists?", err)
 			return
 		}
-		//Prase the results to json array
-		//Tips: You must use captial letter for varable in struct that is accessable as public :)
-		type desktopTheme struct {
-			Theme  string
-			Bglist []string
-		}
-
-		var desktopThemeList []desktopTheme
-		acceptBGFormats := []string{
-			".jpg",
-			".png",
-			".gif",
-		}
-		for _, file := range themes {
-			if fs.IsDir(file) {
-				thisTheme := new(desktopTheme)
-				thisTheme.Theme = filepath.Base(file)
-				bglist, _ := filepath.Glob(file + "/*")
-				var thisbglist []string
-				for _, bg := range bglist {
-					ext := filepath.Ext(bg)
-					//if (sliceutil.Contains(acceptBGFormats, ext) ){
-					if utils.StringInArray(acceptBGFormats, ext) {
-						//This file extension is supported
-						thisbglist = append(thisbglist, filepath.Base(bg))
-					}
-
-				}
-				thisTheme.Bglist = thisbglist
-				desktopThemeList = append(desktopThemeList, *thisTheme)
-			}
-		}
 
 		//Return the results as JSON string
 		jsonString, err := json.Marshal(desktopThemeList)
@@ -589,18 +567,9 @@ func desktop_theme_handler(w http.ResponseWriter, r *http.Request) {
 		utils.SendJSONResponse(w, string(jsonString))
 		return
 	} else if getUserTheme == "true" {
-		//Get the user's theme from database
-		result := ""
-		sysdb.Read("desktop", username+"/theme", &result)
-		if result == "" {
-			//This user has not set a theme yet. Use default
-			utils.SendJSONResponse(w, string("\"default\""))
-			return
-		} else {
-			//This user already set a theme. Use its set theme
-			utils.SendJSONResponse(w, string("\""+result+"\""))
-			return
-		}
+		//Get the user's theme from database, falling back to the default theme
+		utils.SendJSONResponse(w, string("\""+desktopPrefsManager.GetTheme(username)+"\""))
+		return
 	} else if loadUserTheme != "" {
 		//Load user theme base on folder path
 		targetFsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(loadUserTheme)
@@ -648,8 +617,7 @@ func desktop_theme_handler(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 		for _, file := range files {
-			ext := filepath.Ext(file.Name())
-			if utils.StringInArray([]string{".png", ".jpg", ".gif"}, ext) {
+			if wallpaper.IsSupportedWallpaper(file.Name()) {
 				imageList = append(imageList, arozfs.ToSlash(filepath.Join(rpath, file.Name())))
 			}
 		}
@@ -670,7 +638,7 @@ func desktop_theme_handler(w http.ResponseWriter, r *http.Request) {
 
 	} else if targetTheme != "" {
 		//Set the current user theme
-		sysdb.Write("desktop", username+"/theme", targetTheme)
+		desktopPrefsManager.SetTheme(username, targetTheme)
 		utils.SendJSONResponse(w, "\"OK\"")
 		return
 	}
@@ -693,19 +661,17 @@ func desktop_preference_handler(w http.ResponseWriter, r *http.Request) {
 		return
 	} else if preferenceType != "" && value == "" && remove == "" {
 		//Getting config from the key.
-		result := ""
-		sysdb.Read("desktop", username+"/preference/"+preferenceType, &result)
-		jsonString, _ := json.Marshal(result)
+		jsonString, _ := json.Marshal(desktopPrefsManager.GetPreference(username, preferenceType))
 		utils.SendJSONResponse(w, string(jsonString))
 		return
 	} else if preferenceType != "" && value == "" && remove == "true" {
 		//Remove mode
-		sysdb.Delete("desktop", username+"/preference/"+preferenceType)
+		desktopPrefsManager.RemovePreference(username, preferenceType)
 		utils.SendOK(w)
 		return
 	} else if preferenceType != "" && value != "" {
 		//Setting config from the key
-		sysdb.Write("desktop", username+"/preference/"+preferenceType, value)
+		desktopPrefsManager.SetPreference(username, preferenceType, value)
 		utils.SendOK(w)
 		return
 	} else {
@@ -792,9 +758,11 @@ func desktop_shortcutHandler(w http.ResponseWriter, r *http.Request) {
 	//icon for the web app if it does not ship one of its own, so the shortcut
 	//does not end up with a fully filled icon on the desktop.
 	if shortcutType == "module" {
-		_, err := desktop_ensureDesktopIcon(shortcutIcon)
+		desktopIconPath, generated, err := desktopIconGenerator.EnsureDesktopIcon(shortcutIcon)
 		if err != nil {
 			systemWideLogger.PrintAndLog("Desktop", "Unable to generate desktop icon for "+shortcutIcon, err)
+		} else if generated {
+			systemWideLogger.PrintAndLog("Desktop", "Generated desktop icon for "+desktopIconPath, nil)
 		}
 	}
 
@@ -807,311 +775,3 @@ func desktop_shortcutHandler(w http.ResponseWriter, r *http.Request) {
 	}
 	utils.SendOK(w)
 }
-
-/*
-	Desktop Icon Generator
-
-	A web app's module icon (the one declared in its init.agi) is designed to be
-	edge to edge, which looks wrong when placed on the desktop where icons are
-	expected to have padding around them. When a web app does not ship its own
-	desktop_icon.png, the functions below render one on the fly: the module icon
-	is scaled down and centered on top of an opaque squircle backplate, which is
-	then written back into the web app folder next to the module icon.
-*/
-
-const (
-	/*
-		desktopIconSquircleFactor is the "squareness" factor f of the generated
-		squircle backplate. The backplate is the superellipse (Lame curve)
-
-			|x/r|^n + |y/r|^n = 1,  where n = 2 / (1 - f)
-
-		so f = 0 renders a perfect circle, f = 0.5 the classic squircle and
-		f approaching 1 approaches a plain square. Tune this value to change the
-		roundness of every generated desktop icon.
-	*/
-	desktopIconSquircleFactor = 0.75
-
-	//desktopIconSize is the output resolution in px of the generated
-	//desktop_icon.png, matching the hand made desktop icons of the built-in apps
-	desktopIconSize = 128
-
-	//desktopIconBackplateRatio is the width of the squircle backplate relative
-	//to the canvas size. The hand drawn desktop icons of the built-in web apps
-	//all sit at around 0.70 of their canvas, so match that to keep the generated
-	//icons the same visual size as the rest of the desktop.
-	desktopIconBackplateRatio = 0.70
-
-	//desktopIconContentRatio is the width of the module icon relative to the
-	//*backplate* (not the canvas), so the backplate size can be tuned above
-	//without having to re-balance the padding. The remainder is the padding
-	//drawn around the icon.
-	desktopIconContentRatio = 0.66
-
-	//desktopIconSampleSteps is the supersampling grid size (n x n samples per
-	//pixel) used to antialias the edge of the squircle backplate
-	desktopIconSampleSteps = 4
-
-	//desktopIconLumaThreshold is the perceived luminance (0 - 1) above which a
-	//module icon counts as "bright" and gets a black backplate instead of white
-	desktopIconLumaThreshold = 0.5
-
-	//desktopIconSVGRenderSize is the resolution the SVG module icons are
-	//rasterized at before being scaled down onto the backplate
-	desktopIconSVGRenderSize = 512
-)
-
-// desktop_ensureDesktopIcon makes sure a desktop_icon.png exists next to the
-// given module icon. moduleIconPath is a path relative to the web root, e.g.
-// "Photo/img/module_icon.png". If the desktop icon is already there nothing is
-// done; otherwise one is generated from the module icon and written into the
-// web app folder. The web root relative path of the desktop icon is returned.
-func desktop_ensureDesktopIcon(moduleIconPath string) (string, error) {
-	moduleIconRel, err := desktop_resolveWebAssetPath(moduleIconPath)
-	if err != nil {
-		return "", err
-	}
-
-	desktopIconRel := path.Join(path.Dir(moduleIconRel), "desktop_icon.png")
-	desktopIconAbs := filepath.Join("./web", filepath.FromSlash(desktopIconRel))
-	if utils.FileExists(desktopIconAbs) {
-		//This web app already ships a desktop icon. Nothing to do.
-		return desktopIconRel, nil
-	}
-
-	if strings.EqualFold(path.Base(moduleIconRel), "desktop_icon.png") {
-		//The module icon is the desktop icon we are trying to create. Bail out
-		//instead of recursing on a file that does not exist.
-		return "", errors.New("desktop icon not found and cannot be generated from itself")
-	}
-
-	moduleIcon, err := desktop_loadWebImage(moduleIconRel)
-	if err != nil {
-		return "", err
-	}
-
-	var generatedIcon bytes.Buffer
-	err = png.Encode(&generatedIcon, desktop_renderDesktopIcon(moduleIcon))
-	if err != nil {
-		return "", err
-	}
-
-	err = os.WriteFile(desktopIconAbs, generatedIcon.Bytes(), 0775)
-	if err != nil {
-		return "", err
-	}
-
-	systemWideLogger.PrintAndLog("Desktop", "Generated desktop icon for "+desktopIconRel, nil)
-	return desktopIconRel, nil
-}
-
-// desktop_resolveWebAssetPath cleans a user supplied web root relative asset
-// path and rejects anything that tries to escape the web root.
-func desktop_resolveWebAssetPath(relPath string) (string, error) {
-	relPath = strings.TrimSpace(strings.ReplaceAll(relPath, "\\", "/"))
-	if relPath == "" {
-		return "", errors.New("empty asset path")
-	}
-
-	//Cleaning against the root collapses any ".." segments trying to climb out
-	//of the web root. Use path (not filepath) so this behaves the same on the
-	//platforms where the OS separator is not a slash.
-	cleanedPath := strings.TrimPrefix(path.Clean("/"+strings.TrimLeft(relPath, "/")), "/")
-	if cleanedPath == "" || cleanedPath == "." {
-		return "", errors.New("invalid asset path: " + relPath)
-	}
-
-	return cleanedPath, nil
-}
-
-// desktop_loadWebImage decodes an image stored under the web root. Both the
-// raster formats used by the web apps and SVG module icons are supported.
-func desktop_loadWebImage(webRelPath string) (image.Image, error) {
-	imageContent, err := os.ReadFile(filepath.Join("./web", filepath.FromSlash(webRelPath)))
-	if err != nil {
-		return nil, err
-	}
-
-	if strings.EqualFold(path.Ext(webRelPath), ".svg") {
-		return desktop_rasterizeSVG(imageContent, desktopIconSVGRenderSize)
-	}
-
-	loadedImage, _, err := image.Decode(bytes.NewReader(imageContent))
-	return loadedImage, err
-}
-
-// desktop_rasterizeSVG renders an SVG into a square RGBA image of the given
-// size, preserving the aspect ratio of the source viewBox.
-func desktop_rasterizeSVG(svgContent []byte, renderSize int) (image.Image, error) {
-	parsedIcon, err := oksvg.ReadIconStream(bytes.NewReader(svgContent))
-	if err != nil {
-		return nil, err
-	}
-
-	viewWidth := parsedIcon.ViewBox.W
-	viewHeight := parsedIcon.ViewBox.H
-	if viewWidth <= 0 || viewHeight <= 0 {
-		viewWidth, viewHeight = float64(renderSize), float64(renderSize)
-	}
-
-	scale := math.Min(float64(renderSize)/viewWidth, float64(renderSize)/viewHeight)
-	targetWidth := viewWidth * scale
-	targetHeight := viewHeight * scale
-	parsedIcon.SetTarget((float64(renderSize)-targetWidth)/2, (float64(renderSize)-targetHeight)/2, targetWidth, targetHeight)
-	desktop_scaleSVGStrokes(parsedIcon, scale)
-
-	renderedIcon := image.NewRGBA(image.Rect(0, 0, renderSize, renderSize))
-	scanner := rasterx.NewScannerGV(renderSize, renderSize, renderedIcon, renderedIcon.Bounds())
-	parsedIcon.Draw(rasterx.NewDasher(renderSize, renderSize, scanner), 1.0)
-	return renderedIcon, nil
-}
-
-// desktop_scaleSVGStrokes multiplies the stroke widths of an SVG icon by the
-// given scale factor.
-//
-// oksvg only applies the SetTarget transform to the path geometry: the stroke
-// width handed to the rasterizer is the raw value in viewBox units. Rendering a
-// 64x64 viewBox at 512px therefore leaves every stroke 8 times too thin, which
-// makes line art module icons come out as hairlines. Pre-scaling the stroke
-// styling to match the transform restores the intended thickness.
-func desktop_scaleSVGStrokes(parsedIcon *oksvg.SvgIcon, scale float64) {
-	if scale <= 0 || scale == 1 {
-		return
-	}
-
-	for i := range parsedIcon.SVGPaths {
-		svgPath := &parsedIcon.SVGPaths[i]
-		svgPath.LineWidth *= scale
-		svgPath.DashOffset *= scale
-		for j := range svgPath.Dash {
-			svgPath.Dash[j] *= scale
-		}
-	}
-}
-
-// desktop_renderDesktopIcon composites a module icon onto a padded squircle
-// backplate and returns the resulting desktop icon image.
-func desktop_renderDesktopIcon(moduleIcon image.Image) image.Image {
-	canvas := image.NewNRGBA(image.Rect(0, 0, desktopIconSize, desktopIconSize))
-
-	//Paint the antialiased squircle backplate
-	backplateColor := desktop_pickBackplateColor(moduleIcon)
-	exponent := desktop_squircleExponent(desktopIconSquircleFactor)
-	center := float64(desktopIconSize) / 2
-	radius := float64(desktopIconSize) * desktopIconBackplateRatio / 2
-	for y := 0; y < desktopIconSize; y++ {
-		for x := 0; x < desktopIconSize; x++ {
-			coverage := desktop_squircleCoverage(float64(x), float64(y), center, radius, exponent)
-			if coverage <= 0 {
-				continue
-			}
-			pixelColor := backplateColor
-			pixelColor.A = uint8(math.Round(float64(backplateColor.A) * coverage))
-			canvas.SetNRGBA(x, y, pixelColor)
-		}
-	}
-
-	//Scale the module icon into the padded content box and center it
-	contentBox := int(math.Round(float64(desktopIconSize) * desktopIconBackplateRatio * desktopIconContentRatio))
-	scaledIcon := desktop_scaleIconToBox(moduleIcon, contentBox)
-	if scaledIcon != nil {
-		offset := image.Pt((desktopIconSize-scaledIcon.Bounds().Dx())/2, (desktopIconSize-scaledIcon.Bounds().Dy())/2)
-		draw.Draw(canvas, scaledIcon.Bounds().Add(offset), scaledIcon, scaledIcon.Bounds().Min, draw.Over)
-	}
-
-	return canvas
-}
-
-// desktop_scaleIconToBox resizes an icon so its longest side matches boxSize
-// while keeping its aspect ratio. Returns nil for degenerate source images.
-func desktop_scaleIconToBox(moduleIcon image.Image, boxSize int) image.Image {
-	sourceWidth := moduleIcon.Bounds().Dx()
-	sourceHeight := moduleIcon.Bounds().Dy()
-	if sourceWidth <= 0 || sourceHeight <= 0 || boxSize <= 0 {
-		return nil
-	}
-
-	scale := math.Min(float64(boxSize)/float64(sourceWidth), float64(boxSize)/float64(sourceHeight))
-	scaledWidth := int(math.Round(float64(sourceWidth) * scale))
-	scaledHeight := int(math.Round(float64(sourceHeight) * scale))
-	if scaledWidth < 1 {
-		scaledWidth = 1
-	}
-	if scaledHeight < 1 {
-		scaledHeight = 1
-	}
-
-	return imaging.Resize(moduleIcon, scaledWidth, scaledHeight, imaging.Lanczos)
-}
-
-// desktop_squircleExponent converts the squircle "squareness" factor f into the
-// superellipse exponent n = 2 / (1 - f).
-func desktop_squircleExponent(squircleFactor float64) float64 {
-	if squircleFactor < 0 {
-		squircleFactor = 0
-	} else if squircleFactor > 0.99 {
-		//Keep the exponent finite so the math below stays well behaved
-		squircleFactor = 0.99
-	}
-	return 2 / (1 - squircleFactor)
-}
-
-// desktop_squircleCoverage returns how much (0 - 1) of the pixel at the given
-// top-left coordinates falls inside the squircle, supersampled for antialiasing.
-func desktop_squircleCoverage(pixelX float64, pixelY float64, center float64, radius float64, exponent float64) float64 {
-	if radius <= 0 {
-		return 0
-	}
-
-	sampleStep := 1.0 / float64(desktopIconSampleSteps)
-	samplesInside := 0
-	for sampleY := 0; sampleY < desktopIconSampleSteps; sampleY++ {
-		for sampleX := 0; sampleX < desktopIconSampleSteps; sampleX++ {
-			offsetX := math.Abs(pixelX+(float64(sampleX)+0.5)*sampleStep-center) / radius
-			offsetY := math.Abs(pixelY+(float64(sampleY)+0.5)*sampleStep-center) / radius
-			if math.Pow(offsetX, exponent)+math.Pow(offsetY, exponent) <= 1 {
-				samplesInside++
-			}
-		}
-	}
-
-	return float64(samplesInside) / float64(desktopIconSampleSteps*desktopIconSampleSteps)
-}
-
-// desktop_pickBackplateColor samples the theme color of a module icon and picks
-// the backplate that keeps the icon readable: black behind a bright icon, white
-// behind a dark one.
-func desktop_pickBackplateColor(moduleIcon image.Image) color.NRGBA {
-	white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
-	black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
-
-	bounds := moduleIcon.Bounds()
-	lumaSum := 0.0
-	weightSum := 0.0
-	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
-		for x := bounds.Min.X; x < bounds.Max.X; x++ {
-			r, g, b, a := moduleIcon.At(x, y).RGBA()
-			if a == 0 {
-				//Fully transparent pixels carry no theme color
-				continue
-			}
-
-			//RGBA() is alpha-premultiplied, so divide by alpha to get the real
-			//channel values and weight the sample by how opaque the pixel is
-			luma := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / float64(a)
-			alpha := float64(a) / 65535
-			lumaSum += luma * alpha
-			weightSum += alpha
-		}
-	}
-
-	if weightSum == 0 {
-		//Blank icon, fall back to the light backplate
-		return white
-	}
-
-	if lumaSum/weightSum > desktopIconLumaThreshold {
-		return black
-	}
-	return white
-}

+ 0 - 299
src/desktop_icon_test.go

@@ -1,299 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"image"
-	"image/color"
-	"math"
-	"testing"
-
-	"github.com/srwiley/oksvg"
-)
-
-func TestDesktopResolveWebAssetPath(t *testing.T) {
-	tests := []struct {
-		name      string
-		input     string
-		want      string
-		expectErr bool
-	}{
-		{"plain path", "Photo/img/module_icon.png", "Photo/img/module_icon.png", false},
-		{"windows separator", "Photo\\img\\module_icon.png", "Photo/img/module_icon.png", false},
-		{"redundant segments", "Photo/./img//module_icon.png", "Photo/img/module_icon.png", false},
-		{"leading traversal", "../../etc/passwd", "etc/passwd", false},
-		{"embedded traversal", "Photo/img/../../Music/img/icon.png", "Music/img/icon.png", false},
-		{"leading slash", "/Photo/img/icon.png", "Photo/img/icon.png", false},
-		{"empty", "", "", true},
-		{"whitespace only", "   ", "", true},
-		{"root only", "/", "", true},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, err := desktop_resolveWebAssetPath(tt.input)
-			if tt.expectErr {
-				if err == nil {
-					t.Fatalf("desktop_resolveWebAssetPath(%q) = %q, want error", tt.input, got)
-				}
-				return
-			}
-			if err != nil {
-				t.Fatalf("desktop_resolveWebAssetPath(%q) returned unexpected error: %v", tt.input, err)
-			}
-			if got != tt.want {
-				t.Errorf("desktop_resolveWebAssetPath(%q) = %q, want %q", tt.input, got, tt.want)
-			}
-		})
-	}
-}
-
-func TestDesktopSquircleExponent(t *testing.T) {
-	tests := []struct {
-		name string
-		f    float64
-		want float64
-	}{
-		{"circle", 0, 2},
-		{"classic squircle", 0.5, 4},
-		{"default factor", desktopIconSquircleFactor, 8},
-		{"negative clamped to circle", -1, 2},
-		{"above one clamped", 5, 200},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got := desktop_squircleExponent(tt.f)
-			if math.Abs(got-tt.want) > 1e-9 {
-				t.Errorf("desktop_squircleExponent(%v) = %v, want %v", tt.f, got, tt.want)
-			}
-		})
-	}
-}
-
-func TestDesktopSquircleCoverage(t *testing.T) {
-	exponent := desktop_squircleExponent(desktopIconSquircleFactor)
-	center := 50.0
-	radius := 40.0
-
-	tests := []struct {
-		name   string
-		x, y   float64
-		want   float64
-		strict bool
-	}{
-		{"center is fully covered", center, center, 1, true},
-		{"far outside is empty", 0, 0, 0, true},
-		{"just inside the right edge", center + radius - 2, center, 1, true},
-		{"just outside the right edge", center + radius + 1, center, 0, true},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got := desktop_squircleCoverage(tt.x, tt.y, center, radius, exponent)
-			if got != tt.want {
-				t.Errorf("desktop_squircleCoverage(%v, %v) = %v, want %v", tt.x, tt.y, got, tt.want)
-			}
-		})
-	}
-
-	//A pixel sitting exactly on the boundary must be partially covered so the
-	//edge of the squircle ends up antialiased instead of hard cut
-	edgeCoverage := desktop_squircleCoverage(center+radius-0.5, center, center, radius, exponent)
-	if edgeCoverage <= 0 || edgeCoverage >= 1 {
-		t.Errorf("boundary pixel coverage = %v, want a value between 0 and 1", edgeCoverage)
-	}
-
-	if got := desktop_squircleCoverage(center, center, center, 0, exponent); got != 0 {
-		t.Errorf("desktop_squircleCoverage with zero radius = %v, want 0", got)
-	}
-}
-
-// buildTestIcon creates a solid square icon of the given size and color
-func buildTestIcon(size int, fill color.NRGBA) image.Image {
-	icon := image.NewNRGBA(image.Rect(0, 0, size, size))
-	for y := 0; y < size; y++ {
-		for x := 0; x < size; x++ {
-			icon.SetNRGBA(x, y, fill)
-		}
-	}
-	return icon
-}
-
-func TestDesktopPickBackplateColor(t *testing.T) {
-	white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
-	black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
-
-	tests := []struct {
-		name string
-		icon image.Image
-		want color.NRGBA
-	}{
-		{"bright icon gets black backplate", buildTestIcon(8, white), black},
-		{"dark icon gets white backplate", buildTestIcon(8, black), white},
-		{"fully transparent icon falls back to white", buildTestIcon(8, color.NRGBA{R: 255, G: 255, B: 255, A: 0}), white},
-		{"translucent bright icon still reads as bright", buildTestIcon(8, color.NRGBA{R: 255, G: 255, B: 255, A: 40}), black},
-		{"mid grey icon stays below threshold", buildTestIcon(8, color.NRGBA{R: 100, G: 100, B: 100, A: 255}), white},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got := desktop_pickBackplateColor(tt.icon)
-			if got != tt.want {
-				t.Errorf("desktop_pickBackplateColor() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
-
-func TestDesktopScaleIconToBox(t *testing.T) {
-	tests := []struct {
-		name       string
-		icon       image.Image
-		boxSize    int
-		wantW      int
-		wantH      int
-		wantNilOut bool
-	}{
-		{"square icon is scaled down", buildTestIcon(256, color.NRGBA{A: 255}), 64, 64, 64, false},
-		{"small icon is scaled up", buildTestIcon(16, color.NRGBA{A: 255}), 64, 64, 64, false},
-		{"wide icon keeps aspect ratio", image.NewNRGBA(image.Rect(0, 0, 200, 100)), 80, 80, 40, false},
-		{"tall icon keeps aspect ratio", image.NewNRGBA(image.Rect(0, 0, 100, 200)), 80, 40, 80, false},
-		{"empty icon returns nil", image.NewNRGBA(image.Rect(0, 0, 0, 0)), 64, 0, 0, true},
-		{"zero box returns nil", buildTestIcon(32, color.NRGBA{A: 255}), 0, 0, 0, true},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got := desktop_scaleIconToBox(tt.icon, tt.boxSize)
-			if tt.wantNilOut {
-				if got != nil {
-					t.Fatalf("desktop_scaleIconToBox() = %v, want nil", got.Bounds())
-				}
-				return
-			}
-			if got == nil {
-				t.Fatal("desktop_scaleIconToBox() = nil, want an image")
-			}
-			if got.Bounds().Dx() != tt.wantW || got.Bounds().Dy() != tt.wantH {
-				t.Errorf("desktop_scaleIconToBox() size = %dx%d, want %dx%d",
-					got.Bounds().Dx(), got.Bounds().Dy(), tt.wantW, tt.wantH)
-			}
-		})
-	}
-}
-
-func TestDesktopRenderDesktopIcon(t *testing.T) {
-	darkIcon := buildTestIcon(64, color.NRGBA{R: 20, G: 20, B: 20, A: 255})
-	rendered := desktop_renderDesktopIcon(darkIcon)
-
-	if rendered.Bounds().Dx() != desktopIconSize || rendered.Bounds().Dy() != desktopIconSize {
-		t.Fatalf("rendered icon size = %dx%d, want %dx%d",
-			rendered.Bounds().Dx(), rendered.Bounds().Dy(), desktopIconSize, desktopIconSize)
-	}
-
-	//The corners sit outside the squircle and must stay fully transparent
-	corners := [][2]int{{0, 0}, {desktopIconSize - 1, 0}, {0, desktopIconSize - 1}, {desktopIconSize - 1, desktopIconSize - 1}}
-	for _, corner := range corners {
-		if _, _, _, a := rendered.At(corner[0], corner[1]).RGBA(); a != 0 {
-			t.Errorf("corner (%d, %d) alpha = %d, want 0", corner[0], corner[1], a)
-		}
-	}
-
-	//The center of a dark icon should be drawn on top of a white backplate
-	if _, _, _, a := rendered.At(desktopIconSize/2, desktopIconSize/2).RGBA(); a == 0 {
-		t.Error("center pixel is transparent, want the module icon drawn there")
-	}
-
-	//The padding ring between the icon and the backplate edge must show the
-	//backplate color rather than the module icon
-	contentRadius := float64(desktopIconSize) * desktopIconBackplateRatio * desktopIconContentRatio / 2
-	backplateRadius := float64(desktopIconSize) * desktopIconBackplateRatio / 2
-	paddingOffset := int(math.Round((contentRadius + backplateRadius) / 2))
-	pr, pg, pb, pa := rendered.At(desktopIconSize/2+paddingOffset, desktopIconSize/2).RGBA()
-	if pa != 0xffff || pr != 0xffff || pg != 0xffff || pb != 0xffff {
-		t.Errorf("padding pixel = (%d, %d, %d, %d), want opaque white backplate", pr, pg, pb, pa)
-	}
-
-	//The generated icon must occupy roughly the same fraction of the canvas as
-	//the hand drawn desktop icons shipped with the built-in web apps
-	opaqueWidth := 0
-	for x := 0; x < desktopIconSize; x++ {
-		if _, _, _, a := rendered.At(x, desktopIconSize/2).RGBA(); a > 0 {
-			opaqueWidth++
-		}
-	}
-	occupancy := float64(opaqueWidth) / float64(desktopIconSize)
-	if occupancy < 0.66 || occupancy > 0.74 {
-		t.Errorf("icon occupancy = %.3f of canvas, want roughly 0.70 to match the bundled icons", occupancy)
-	}
-}
-
-func TestDesktopScaleSVGStrokes(t *testing.T) {
-	svgSource := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-		<path d="M22 16 V48" stroke="#ffffff" stroke-width="4" fill="none"/>
-	</svg>`)
-
-	parsedIcon, err := oksvg.ReadIconStream(bytes.NewReader(svgSource))
-	if err != nil {
-		t.Fatalf("unable to parse test SVG: %v", err)
-	}
-	if len(parsedIcon.SVGPaths) == 0 {
-		t.Fatal("test SVG parsed into zero paths")
-	}
-
-	originalWidth := parsedIcon.SVGPaths[0].LineWidth
-	if originalWidth <= 0 {
-		t.Fatalf("test SVG stroke width = %v, want a positive width", originalWidth)
-	}
-
-	tests := []struct {
-		name  string
-		scale float64
-		want  float64
-	}{
-		{"scaled up", 8, originalWidth * 8},
-		{"scaled down", 0.5, originalWidth * 0.5},
-		{"identity is a no-op", 1, originalWidth},
-		{"zero scale is ignored", 0, originalWidth},
-		{"negative scale is ignored", -2, originalWidth},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			icon, err := oksvg.ReadIconStream(bytes.NewReader(svgSource))
-			if err != nil {
-				t.Fatalf("unable to parse test SVG: %v", err)
-			}
-			desktop_scaleSVGStrokes(icon, tt.scale)
-			if got := icon.SVGPaths[0].LineWidth; math.Abs(got-tt.want) > 1e-9 {
-				t.Errorf("LineWidth after scaling by %v = %v, want %v", tt.scale, got, tt.want)
-			}
-		})
-	}
-}
-
-func TestDesktopRasterizeSVGKeepsStrokeWeight(t *testing.T) {
-	//A 64 unit wide viewBox with a 4 unit stroke rendered at 512px should draw
-	//a 32px wide line. Without stroke scaling oksvg would draw it 4px wide.
-	svgSource := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-		<path d="M32 8 V56" stroke="#ffffff" stroke-width="4" fill="none"/>
-	</svg>`)
-
-	const renderSize = 512
-	rasterized, err := desktop_rasterizeSVG(svgSource, renderSize)
-	if err != nil {
-		t.Fatalf("desktop_rasterizeSVG() returned error: %v", err)
-	}
-
-	drawnWidth := 0
-	for x := 0; x < renderSize; x++ {
-		if _, _, _, a := rasterized.At(x, renderSize/2).RGBA(); a > 0x7fff {
-			drawnWidth++
-		}
-	}
-
-	expectedWidth := 4.0 / 64.0 * renderSize
-	if math.Abs(float64(drawnWidth)-expectedWidth) > 2 {
-		t.Errorf("rasterized stroke width = %dpx, want about %.0fpx", drawnWidth, expectedWidth)
-	}
-}

+ 149 - 0
src/mod/desktop/icons/icons.go

@@ -0,0 +1,149 @@
+package icons
+
+import (
+	"bytes"
+	"errors"
+	"image"
+	"image/png"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+)
+
+/*
+	icons.go
+
+	Desktop icon generation for ArozOS web apps.
+
+	A web app's module icon (the one declared in its init.agi) is designed to be
+	edge to edge, which looks wrong on the desktop where icons are expected to
+	carry padding around them. When a web app does not ship its own
+	desktop_icon.png, this package renders one: the module icon is scaled down
+	and centered on top of an opaque squircle backplate coloured to contrast with
+	the icon, then written back into the web app folder next to the module icon.
+
+	Author: tobychui
+*/
+
+// DesktopIconFilename is the filename a web app is expected to use for the icon
+// shown on the desktop, resolved relative to its module icon folder.
+const DesktopIconFilename = "desktop_icon.png"
+
+// Generator renders and caches desktop icons for the web apps under a web root.
+type Generator struct {
+	webRoot string
+	options Options
+}
+
+// NewGenerator creates a generator writing into the given web root (e.g. "./web")
+// using the default rendering options.
+func NewGenerator(webRoot string) *Generator {
+	return NewGeneratorWithOptions(webRoot, DefaultOptions())
+}
+
+// NewGeneratorWithOptions creates a generator with custom rendering options.
+// Zero valued fields in the given options fall back to their defaults.
+func NewGeneratorWithOptions(webRoot string, options Options) *Generator {
+	return &Generator{
+		webRoot: webRoot,
+		options: options.withDefaults(),
+	}
+}
+
+// Options returns the rendering options in use by this generator.
+func (g *Generator) Options() Options {
+	return g.options
+}
+
+// EnsureDesktopIcon makes sure a desktop_icon.png exists next to the given
+// module icon. moduleIconPath is a path relative to the web root, for example
+// "Photo/img/module_icon.png". If the desktop icon is already there nothing is
+// done; otherwise one is generated from the module icon and written into the web
+// app folder.
+//
+// The web root relative path of the desktop icon is returned, together with a
+// flag telling whether this call was the one that created it.
+func (g *Generator) EnsureDesktopIcon(moduleIconPath string) (string, bool, error) {
+	moduleIconRel, err := ResolveWebAssetPath(moduleIconPath)
+	if err != nil {
+		return "", false, err
+	}
+
+	desktopIconRel := path.Join(path.Dir(moduleIconRel), DesktopIconFilename)
+	desktopIconAbs := g.absPath(desktopIconRel)
+	if fileExists(desktopIconAbs) {
+		//This web app already ships a desktop icon. Nothing to do.
+		return desktopIconRel, false, nil
+	}
+
+	if strings.EqualFold(path.Base(moduleIconRel), DesktopIconFilename) {
+		//The module icon is the desktop icon we are trying to create. Bail out
+		//instead of recursing on a file that does not exist.
+		return "", false, errors.New("desktop icon not found and cannot be generated from itself")
+	}
+
+	moduleIcon, err := g.LoadWebImage(moduleIconRel)
+	if err != nil {
+		return "", false, err
+	}
+
+	var generatedIcon bytes.Buffer
+	err = png.Encode(&generatedIcon, g.Render(moduleIcon))
+	if err != nil {
+		return "", false, err
+	}
+
+	err = os.WriteFile(desktopIconAbs, generatedIcon.Bytes(), 0775)
+	if err != nil {
+		return "", false, err
+	}
+
+	return desktopIconRel, true, nil
+}
+
+// LoadWebImage decodes an image stored under the web root. Both the raster
+// formats used by the web apps and SVG module icons are supported.
+func (g *Generator) LoadWebImage(webRelPath string) (image.Image, error) {
+	imageContent, err := os.ReadFile(g.absPath(webRelPath))
+	if err != nil {
+		return nil, err
+	}
+
+	if strings.EqualFold(path.Ext(webRelPath), ".svg") {
+		return RasterizeSVG(imageContent, g.options.SVGRenderSize)
+	}
+
+	loadedImage, _, err := image.Decode(bytes.NewReader(imageContent))
+	return loadedImage, err
+}
+
+// absPath maps a web root relative path to a path on the host filesystem.
+func (g *Generator) absPath(webRelPath string) string {
+	return filepath.Join(g.webRoot, filepath.FromSlash(webRelPath))
+}
+
+// ResolveWebAssetPath cleans a caller supplied web root relative asset path and
+// collapses any ".." segments trying to climb out of the web root.
+func ResolveWebAssetPath(relPath string) (string, error) {
+	relPath = strings.TrimSpace(strings.ReplaceAll(relPath, "\\", "/"))
+	if relPath == "" {
+		return "", errors.New("empty asset path")
+	}
+
+	//Cleaning against the root collapses any ".." segments. Use path (not
+	//filepath) so this behaves the same on the platforms where the OS separator
+	//is not a slash.
+	cleanedPath := strings.TrimPrefix(path.Clean("/"+strings.TrimLeft(relPath, "/")), "/")
+	if cleanedPath == "" || cleanedPath == "." {
+		return "", errors.New("invalid asset path: " + relPath)
+	}
+
+	return cleanedPath, nil
+}
+
+// fileExists reports whether the given host path points at an existing file
+func fileExists(hostPath string) bool {
+	_, err := os.Stat(hostPath)
+	return err == nil
+}

+ 275 - 0
src/mod/desktop/icons/icons_test.go

@@ -0,0 +1,275 @@
+package icons
+
+import (
+	"bytes"
+	"image"
+	"image/color"
+	"image/png"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// writeTestPNG writes a solid square PNG of the given size and color
+func writeTestPNG(t *testing.T, hostPath string, size int, fill color.NRGBA) {
+	t.Helper()
+	if err := os.MkdirAll(filepath.Dir(hostPath), 0755); err != nil {
+		t.Fatalf("unable to create folder for %s: %v", hostPath, err)
+	}
+
+	var encoded bytes.Buffer
+	if err := png.Encode(&encoded, solidIcon(size, fill)); err != nil {
+		t.Fatalf("unable to encode test PNG: %v", err)
+	}
+	if err := os.WriteFile(hostPath, encoded.Bytes(), 0644); err != nil {
+		t.Fatalf("unable to write test PNG: %v", err)
+	}
+}
+
+func TestResolveWebAssetPath(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		want      string
+		expectErr bool
+	}{
+		{"plain path", "Photo/img/module_icon.png", "Photo/img/module_icon.png", false},
+		{"windows separator", "Photo\\img\\module_icon.png", "Photo/img/module_icon.png", false},
+		{"redundant segments", "Photo/./img//module_icon.png", "Photo/img/module_icon.png", false},
+		{"leading traversal", "../../etc/passwd", "etc/passwd", false},
+		{"embedded traversal", "Photo/img/../../Music/img/icon.png", "Music/img/icon.png", false},
+		{"leading slash", "/Photo/img/icon.png", "Photo/img/icon.png", false},
+		{"double leading slash", "//Photo/img/icon.png", "Photo/img/icon.png", false},
+		{"empty", "", "", true},
+		{"whitespace only", "   ", "", true},
+		{"root only", "/", "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ResolveWebAssetPath(tt.input)
+			if tt.expectErr {
+				if err == nil {
+					t.Fatalf("ResolveWebAssetPath(%q) = %q, want error", tt.input, got)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("ResolveWebAssetPath(%q) returned unexpected error: %v", tt.input, err)
+			}
+			if got != tt.want {
+				t.Errorf("ResolveWebAssetPath(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestEnsureDesktopIconGeneratesMissingIcon(t *testing.T) {
+	webRoot := t.TempDir()
+	writeTestPNG(t, filepath.Join(webRoot, "Photo", "img", "module_icon.png"), 64, color.NRGBA{R: 20, G: 20, B: 20, A: 255})
+
+	generator := NewGenerator(webRoot)
+	iconPath, generated, err := generator.EnsureDesktopIcon("Photo/img/module_icon.png")
+	if err != nil {
+		t.Fatalf("EnsureDesktopIcon() returned error: %v", err)
+	}
+	if !generated {
+		t.Error("EnsureDesktopIcon() reported generated = false on first call, want true")
+	}
+	if iconPath != "Photo/img/desktop_icon.png" {
+		t.Errorf("EnsureDesktopIcon() = %q, want %q", iconPath, "Photo/img/desktop_icon.png")
+	}
+
+	writtenIcon, err := os.Open(filepath.Join(webRoot, "Photo", "img", "desktop_icon.png"))
+	if err != nil {
+		t.Fatalf("generated desktop icon was not written: %v", err)
+	}
+	defer writtenIcon.Close()
+
+	decodedIcon, format, err := image.Decode(writtenIcon)
+	if err != nil {
+		t.Fatalf("generated desktop icon could not be decoded: %v", err)
+	}
+	if format != "png" {
+		t.Errorf("generated desktop icon format = %q, want png", format)
+	}
+	if decodedIcon.Bounds().Dx() != DefaultIconSize || decodedIcon.Bounds().Dy() != DefaultIconSize {
+		t.Errorf("generated desktop icon size = %dx%d, want %dx%d",
+			decodedIcon.Bounds().Dx(), decodedIcon.Bounds().Dy(), DefaultIconSize, DefaultIconSize)
+	}
+}
+
+func TestEnsureDesktopIconKeepsExistingIcon(t *testing.T) {
+	webRoot := t.TempDir()
+	writeTestPNG(t, filepath.Join(webRoot, "Photo", "img", "module_icon.png"), 64, color.NRGBA{R: 20, G: 20, B: 20, A: 255})
+
+	//A hand drawn desktop icon that must never be overwritten
+	desktopIconHostPath := filepath.Join(webRoot, "Photo", "img", "desktop_icon.png")
+	writeTestPNG(t, desktopIconHostPath, 16, color.NRGBA{R: 1, G: 2, B: 3, A: 255})
+	originalContent, err := os.ReadFile(desktopIconHostPath)
+	if err != nil {
+		t.Fatalf("unable to read the pre-existing desktop icon: %v", err)
+	}
+
+	generator := NewGenerator(webRoot)
+	iconPath, generated, err := generator.EnsureDesktopIcon("Photo/img/module_icon.png")
+	if err != nil {
+		t.Fatalf("EnsureDesktopIcon() returned error: %v", err)
+	}
+	if generated {
+		t.Error("EnsureDesktopIcon() reported generated = true, want false for an existing icon")
+	}
+	if iconPath != "Photo/img/desktop_icon.png" {
+		t.Errorf("EnsureDesktopIcon() = %q, want %q", iconPath, "Photo/img/desktop_icon.png")
+	}
+
+	currentContent, err := os.ReadFile(desktopIconHostPath)
+	if err != nil {
+		t.Fatalf("unable to re-read the desktop icon: %v", err)
+	}
+	if !bytes.Equal(originalContent, currentContent) {
+		t.Error("the pre-existing desktop icon was overwritten, want it left untouched")
+	}
+}
+
+func TestEnsureDesktopIconErrors(t *testing.T) {
+	webRoot := t.TempDir()
+	writeTestPNG(t, filepath.Join(webRoot, "Photo", "img", "module_icon.png"), 64, color.NRGBA{A: 255})
+	if err := os.MkdirAll(filepath.Join(webRoot, "Broken", "img"), 0755); err != nil {
+		t.Fatalf("unable to create test folder: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(webRoot, "Broken", "img", "module_icon.png"), []byte("not an image"), 0644); err != nil {
+		t.Fatalf("unable to write corrupted test icon: %v", err)
+	}
+
+	tests := []struct {
+		name  string
+		input string
+	}{
+		{"missing module icon", "Missing/img/module_icon.png"},
+		{"corrupted module icon", "Broken/img/module_icon.png"},
+		{"cannot generate from itself", "Missing/img/desktop_icon.png"},
+		{"empty path", ""},
+	}
+
+	generator := NewGenerator(webRoot)
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, generated, err := generator.EnsureDesktopIcon(tt.input)
+			if err == nil {
+				t.Errorf("EnsureDesktopIcon(%q) = nil error, want an error", tt.input)
+			}
+			if generated {
+				t.Errorf("EnsureDesktopIcon(%q) reported generated = true on failure", tt.input)
+			}
+		})
+	}
+}
+
+func TestEnsureDesktopIconDoesNotEscapeWebRoot(t *testing.T) {
+	parentDir := t.TempDir()
+	webRoot := filepath.Join(parentDir, "web")
+	writeTestPNG(t, filepath.Join(webRoot, "Photo", "img", "module_icon.png"), 64, color.NRGBA{A: 255})
+
+	generator := NewGenerator(webRoot)
+	//The traversal is collapsed, so this resolves inside the web root and the
+	//lookup fails rather than reaching into the parent folder
+	iconPath, _, err := generator.EnsureDesktopIcon("../../Photo/img/module_icon.png")
+	if err != nil {
+		t.Fatalf("EnsureDesktopIcon() returned error: %v", err)
+	}
+	if iconPath != "Photo/img/desktop_icon.png" {
+		t.Errorf("EnsureDesktopIcon() = %q, want the traversal collapsed to %q", iconPath, "Photo/img/desktop_icon.png")
+	}
+	if _, err := os.Stat(filepath.Join(parentDir, "Photo")); err == nil {
+		t.Error("an icon was written outside the web root")
+	}
+}
+
+func TestGeneratorLoadWebImage(t *testing.T) {
+	webRoot := t.TempDir()
+	writeTestPNG(t, filepath.Join(webRoot, "Photo", "img", "module_icon.png"), 40, color.NRGBA{R: 10, G: 200, B: 30, A: 255})
+	if err := os.MkdirAll(filepath.Join(webRoot, "Vector", "img"), 0755); err != nil {
+		t.Fatalf("unable to create test folder: %v", err)
+	}
+	svgSource := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
+		<rect x="0" y="0" width="32" height="32" fill="#ff0000"/>
+	</svg>`)
+	if err := os.WriteFile(filepath.Join(webRoot, "Vector", "img", "icon.svg"), svgSource, 0644); err != nil {
+		t.Fatalf("unable to write test SVG: %v", err)
+	}
+
+	generator := NewGenerator(webRoot)
+
+	tests := []struct {
+		name      string
+		input     string
+		wantSize  int
+		expectErr bool
+	}{
+		{"png is decoded at its native size", "Photo/img/module_icon.png", 40, false},
+		{"svg is rasterized at the render size", "Vector/img/icon.svg", DefaultSVGRenderSize, false},
+		{"missing file errors", "Photo/img/nope.png", 0, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			loaded, err := generator.LoadWebImage(tt.input)
+			if tt.expectErr {
+				if err == nil {
+					t.Fatalf("LoadWebImage(%q) = nil error, want an error", tt.input)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("LoadWebImage(%q) returned error: %v", tt.input, err)
+			}
+			if loaded.Bounds().Dx() != tt.wantSize {
+				t.Errorf("LoadWebImage(%q) width = %d, want %d", tt.input, loaded.Bounds().Dx(), tt.wantSize)
+			}
+		})
+	}
+}
+
+func TestOptionsWithDefaults(t *testing.T) {
+	defaults := DefaultOptions()
+
+	tests := []struct {
+		name  string
+		input Options
+		want  Options
+	}{
+		{"empty options fall back entirely", Options{}, defaults},
+		{
+			"set fields are kept",
+			Options{SquircleFactor: 0.5, IconSize: 256},
+			Options{
+				SquircleFactor: 0.5,
+				IconSize:       256,
+				BackplateRatio: defaults.BackplateRatio,
+				ContentRatio:   defaults.ContentRatio,
+				SampleSteps:    defaults.SampleSteps,
+				LumaThreshold:  defaults.LumaThreshold,
+				SVGRenderSize:  defaults.SVGRenderSize,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := tt.input.withDefaults(); got != tt.want {
+				t.Errorf("withDefaults() = %+v, want %+v", got, tt.want)
+			}
+		})
+	}
+
+	//The generator must expose the filled in options, not the raw ones
+	generator := NewGeneratorWithOptions(t.TempDir(), Options{IconSize: 64})
+	if generator.Options().SquircleFactor != defaults.SquircleFactor {
+		t.Errorf("generator SquircleFactor = %v, want the default %v",
+			generator.Options().SquircleFactor, defaults.SquircleFactor)
+	}
+	if generator.Options().IconSize != 64 {
+		t.Errorf("generator IconSize = %v, want the overridden 64", generator.Options().IconSize)
+	}
+}

+ 103 - 0
src/mod/desktop/icons/options.go

@@ -0,0 +1,103 @@
+package icons
+
+/*
+	options.go
+
+	Tunable parameters of the desktop icon renderer. Adjust the Default* values
+	below to change the look of every generated desktop icon, or pass a custom
+	Options value to NewGeneratorWithOptions for a one-off.
+*/
+
+const (
+	/*
+		DefaultSquircleFactor is the "squareness" factor f of the generated
+		squircle backplate. The backplate is the superellipse (Lame curve)
+
+			|x/r|^n + |y/r|^n = 1,  where n = 2 / (1 - f)
+
+		so f = 0 renders a perfect circle, f = 0.5 the classic squircle and
+		f approaching 1 approaches a plain square.
+	*/
+	DefaultSquircleFactor = 0.75
+
+	//DefaultIconSize is the output resolution in px of the generated
+	//desktop_icon.png, matching the hand drawn desktop icons of the built-in apps
+	DefaultIconSize = 128
+
+	//DefaultBackplateRatio is the width of the squircle backplate relative to
+	//the canvas size. The hand drawn desktop icons of the built-in web apps all
+	//sit at around 0.70 of their canvas, so match that to keep the generated
+	//icons the same visual size as the rest of the desktop.
+	DefaultBackplateRatio = 0.70
+
+	//DefaultContentRatio is the width of the module icon relative to the
+	//*backplate* (not the canvas), so the backplate size can be tuned without
+	//having to re-balance the padding. The remainder is the padding drawn
+	//around the icon.
+	DefaultContentRatio = 0.66
+
+	//DefaultSampleSteps is the supersampling grid size (n x n samples per pixel)
+	//used to antialias the edge of the squircle backplate
+	DefaultSampleSteps = 4
+
+	//DefaultLumaThreshold is the perceived luminance (0 - 1) above which a
+	//module icon counts as "bright" and gets a black backplate instead of white
+	DefaultLumaThreshold = 0.5
+
+	//DefaultSVGRenderSize is the resolution SVG module icons are rasterized at
+	//before being scaled down onto the backplate
+	DefaultSVGRenderSize = 512
+)
+
+// Options controls how a desktop icon is rendered. See the Default* constants
+// above for the meaning of each field.
+type Options struct {
+	SquircleFactor float64
+	IconSize       int
+	BackplateRatio float64
+	ContentRatio   float64
+	SampleSteps    int
+	LumaThreshold  float64
+	SVGRenderSize  int
+}
+
+// DefaultOptions returns the rendering options used by NewGenerator.
+func DefaultOptions() Options {
+	return Options{
+		SquircleFactor: DefaultSquircleFactor,
+		IconSize:       DefaultIconSize,
+		BackplateRatio: DefaultBackplateRatio,
+		ContentRatio:   DefaultContentRatio,
+		SampleSteps:    DefaultSampleSteps,
+		LumaThreshold:  DefaultLumaThreshold,
+		SVGRenderSize:  DefaultSVGRenderSize,
+	}
+}
+
+// withDefaults fills in any unset field with its default so a caller can
+// override just the parameters it cares about.
+func (o Options) withDefaults() Options {
+	defaults := DefaultOptions()
+	if o.SquircleFactor <= 0 {
+		o.SquircleFactor = defaults.SquircleFactor
+	}
+	if o.IconSize <= 0 {
+		o.IconSize = defaults.IconSize
+	}
+	if o.BackplateRatio <= 0 {
+		o.BackplateRatio = defaults.BackplateRatio
+	}
+	if o.ContentRatio <= 0 {
+		o.ContentRatio = defaults.ContentRatio
+	}
+	if o.SampleSteps <= 0 {
+		o.SampleSteps = defaults.SampleSteps
+	}
+	if o.LumaThreshold <= 0 {
+		o.LumaThreshold = defaults.LumaThreshold
+	}
+	if o.SVGRenderSize <= 0 {
+		o.SVGRenderSize = defaults.SVGRenderSize
+	}
+	return o
+}

+ 148 - 0
src/mod/desktop/icons/render.go

@@ -0,0 +1,148 @@
+package icons
+
+import (
+	"image"
+	"image/color"
+	"image/draw"
+	_ "image/gif"
+	_ "image/jpeg"
+	"math"
+
+	"github.com/disintegration/imaging"
+)
+
+/*
+	render.go
+
+	Composition of the desktop icon: an antialiased squircle backplate with the
+	module icon scaled down and centered on top of it.
+*/
+
+// Render composites a module icon onto a padded squircle backplate and returns
+// the resulting desktop icon image.
+func (g *Generator) Render(moduleIcon image.Image) image.Image {
+	iconSize := g.options.IconSize
+	canvas := image.NewNRGBA(image.Rect(0, 0, iconSize, iconSize))
+
+	//Paint the antialiased squircle backplate
+	backplateColor := PickBackplateColor(moduleIcon, g.options.LumaThreshold)
+	exponent := SquircleExponent(g.options.SquircleFactor)
+	center := float64(iconSize) / 2
+	radius := float64(iconSize) * g.options.BackplateRatio / 2
+	for y := 0; y < iconSize; y++ {
+		for x := 0; x < iconSize; x++ {
+			coverage := SquircleCoverage(float64(x), float64(y), center, radius, exponent, g.options.SampleSteps)
+			if coverage <= 0 {
+				continue
+			}
+			pixelColor := backplateColor
+			pixelColor.A = uint8(math.Round(float64(backplateColor.A) * coverage))
+			canvas.SetNRGBA(x, y, pixelColor)
+		}
+	}
+
+	//Scale the module icon into the padded content box and center it
+	contentBox := int(math.Round(float64(iconSize) * g.options.BackplateRatio * g.options.ContentRatio))
+	scaledIcon := ScaleToBox(moduleIcon, contentBox)
+	if scaledIcon != nil {
+		offset := image.Pt((iconSize-scaledIcon.Bounds().Dx())/2, (iconSize-scaledIcon.Bounds().Dy())/2)
+		draw.Draw(canvas, scaledIcon.Bounds().Add(offset), scaledIcon, scaledIcon.Bounds().Min, draw.Over)
+	}
+
+	return canvas
+}
+
+// ScaleToBox resizes an icon so its longest side matches boxSize while keeping
+// its aspect ratio. Returns nil for degenerate source images.
+func ScaleToBox(moduleIcon image.Image, boxSize int) image.Image {
+	sourceWidth := moduleIcon.Bounds().Dx()
+	sourceHeight := moduleIcon.Bounds().Dy()
+	if sourceWidth <= 0 || sourceHeight <= 0 || boxSize <= 0 {
+		return nil
+	}
+
+	scale := math.Min(float64(boxSize)/float64(sourceWidth), float64(boxSize)/float64(sourceHeight))
+	scaledWidth := int(math.Round(float64(sourceWidth) * scale))
+	scaledHeight := int(math.Round(float64(sourceHeight) * scale))
+	if scaledWidth < 1 {
+		scaledWidth = 1
+	}
+	if scaledHeight < 1 {
+		scaledHeight = 1
+	}
+
+	return imaging.Resize(moduleIcon, scaledWidth, scaledHeight, imaging.Lanczos)
+}
+
+// SquircleExponent converts the squircle "squareness" factor f into the
+// superellipse exponent n = 2 / (1 - f).
+func SquircleExponent(squircleFactor float64) float64 {
+	if squircleFactor < 0 {
+		squircleFactor = 0
+	} else if squircleFactor > 0.99 {
+		//Keep the exponent finite so the math below stays well behaved
+		squircleFactor = 0.99
+	}
+	return 2 / (1 - squircleFactor)
+}
+
+// SquircleCoverage returns how much (0 - 1) of the pixel at the given top-left
+// coordinates falls inside the squircle, supersampled on a sampleSteps x
+// sampleSteps grid for antialiasing.
+func SquircleCoverage(pixelX float64, pixelY float64, center float64, radius float64, exponent float64, sampleSteps int) float64 {
+	if radius <= 0 || sampleSteps <= 0 {
+		return 0
+	}
+
+	sampleStep := 1.0 / float64(sampleSteps)
+	samplesInside := 0
+	for sampleY := 0; sampleY < sampleSteps; sampleY++ {
+		for sampleX := 0; sampleX < sampleSteps; sampleX++ {
+			offsetX := math.Abs(pixelX+(float64(sampleX)+0.5)*sampleStep-center) / radius
+			offsetY := math.Abs(pixelY+(float64(sampleY)+0.5)*sampleStep-center) / radius
+			if math.Pow(offsetX, exponent)+math.Pow(offsetY, exponent) <= 1 {
+				samplesInside++
+			}
+		}
+	}
+
+	return float64(samplesInside) / float64(sampleSteps*sampleSteps)
+}
+
+// PickBackplateColor samples the theme color of a module icon and picks the
+// backplate that keeps the icon readable: black behind a bright icon, white
+// behind a dark one.
+func PickBackplateColor(moduleIcon image.Image, lumaThreshold float64) color.NRGBA {
+	white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
+	black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
+
+	bounds := moduleIcon.Bounds()
+	lumaSum := 0.0
+	weightSum := 0.0
+	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+		for x := bounds.Min.X; x < bounds.Max.X; x++ {
+			r, g, b, a := moduleIcon.At(x, y).RGBA()
+			if a == 0 {
+				//Fully transparent pixels carry no theme color
+				continue
+			}
+
+			//RGBA() is alpha-premultiplied, so divide by alpha to get the real
+			//channel values and weight the sample by how opaque the pixel is
+			luma := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / float64(a)
+			alpha := float64(a) / 65535
+			lumaSum += luma * alpha
+			weightSum += alpha
+		}
+	}
+
+	if weightSum == 0 {
+		//Blank icon, fall back to the light backplate
+		return white
+	}
+
+	if lumaSum/weightSum > lumaThreshold {
+		return black
+	}
+	return white
+}

+ 201 - 0
src/mod/desktop/icons/render_test.go

@@ -0,0 +1,201 @@
+package icons
+
+import (
+	"image"
+	"image/color"
+	"math"
+	"testing"
+)
+
+// solidIcon creates a solid square icon of the given size and color
+func solidIcon(size int, fill color.NRGBA) image.Image {
+	icon := image.NewNRGBA(image.Rect(0, 0, size, size))
+	for y := 0; y < size; y++ {
+		for x := 0; x < size; x++ {
+			icon.SetNRGBA(x, y, fill)
+		}
+	}
+	return icon
+}
+
+func TestSquircleExponent(t *testing.T) {
+	tests := []struct {
+		name string
+		f    float64
+		want float64
+	}{
+		{"circle", 0, 2},
+		{"classic squircle", 0.5, 4},
+		{"default factor", DefaultSquircleFactor, 8},
+		{"negative clamped to circle", -1, 2},
+		{"above one clamped", 5, 200},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := SquircleExponent(tt.f)
+			if math.Abs(got-tt.want) > 1e-9 {
+				t.Errorf("SquircleExponent(%v) = %v, want %v", tt.f, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestSquircleCoverage(t *testing.T) {
+	exponent := SquircleExponent(DefaultSquircleFactor)
+	center := 50.0
+	radius := 40.0
+
+	tests := []struct {
+		name string
+		x, y float64
+		want float64
+	}{
+		{"center is fully covered", center, center, 1},
+		{"far outside is empty", 0, 0, 0},
+		{"just inside the right edge", center + radius - 2, center, 1},
+		{"just outside the right edge", center + radius + 1, center, 0},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := SquircleCoverage(tt.x, tt.y, center, radius, exponent, DefaultSampleSteps)
+			if got != tt.want {
+				t.Errorf("SquircleCoverage(%v, %v) = %v, want %v", tt.x, tt.y, got, tt.want)
+			}
+		})
+	}
+
+	//A pixel sitting exactly on the boundary must be partially covered so the
+	//edge of the squircle ends up antialiased instead of hard cut
+	edgeCoverage := SquircleCoverage(center+radius-0.5, center, center, radius, exponent, DefaultSampleSteps)
+	if edgeCoverage <= 0 || edgeCoverage >= 1 {
+		t.Errorf("boundary pixel coverage = %v, want a value between 0 and 1", edgeCoverage)
+	}
+
+	if got := SquircleCoverage(center, center, center, 0, exponent, DefaultSampleSteps); got != 0 {
+		t.Errorf("SquircleCoverage with zero radius = %v, want 0", got)
+	}
+	if got := SquircleCoverage(center, center, center, radius, exponent, 0); got != 0 {
+		t.Errorf("SquircleCoverage with zero sample steps = %v, want 0", got)
+	}
+}
+
+func TestPickBackplateColor(t *testing.T) {
+	white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
+	black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
+
+	tests := []struct {
+		name string
+		icon image.Image
+		want color.NRGBA
+	}{
+		{"bright icon gets black backplate", solidIcon(8, white), black},
+		{"dark icon gets white backplate", solidIcon(8, black), white},
+		{"fully transparent icon falls back to white", solidIcon(8, color.NRGBA{R: 255, G: 255, B: 255, A: 0}), white},
+		{"translucent bright icon still reads as bright", solidIcon(8, color.NRGBA{R: 255, G: 255, B: 255, A: 40}), black},
+		{"mid grey icon stays below threshold", solidIcon(8, color.NRGBA{R: 100, G: 100, B: 100, A: 255}), white},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := PickBackplateColor(tt.icon, DefaultLumaThreshold)
+			if got != tt.want {
+				t.Errorf("PickBackplateColor() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestScaleToBox(t *testing.T) {
+	tests := []struct {
+		name       string
+		icon       image.Image
+		boxSize    int
+		wantW      int
+		wantH      int
+		wantNilOut bool
+	}{
+		{"square icon is scaled down", solidIcon(256, color.NRGBA{A: 255}), 64, 64, 64, false},
+		{"small icon is scaled up", solidIcon(16, color.NRGBA{A: 255}), 64, 64, 64, false},
+		{"wide icon keeps aspect ratio", image.NewNRGBA(image.Rect(0, 0, 200, 100)), 80, 80, 40, false},
+		{"tall icon keeps aspect ratio", image.NewNRGBA(image.Rect(0, 0, 100, 200)), 80, 40, 80, false},
+		{"empty icon returns nil", image.NewNRGBA(image.Rect(0, 0, 0, 0)), 64, 0, 0, true},
+		{"zero box returns nil", solidIcon(32, color.NRGBA{A: 255}), 0, 0, 0, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := ScaleToBox(tt.icon, tt.boxSize)
+			if tt.wantNilOut {
+				if got != nil {
+					t.Fatalf("ScaleToBox() = %v, want nil", got.Bounds())
+				}
+				return
+			}
+			if got == nil {
+				t.Fatal("ScaleToBox() = nil, want an image")
+			}
+			if got.Bounds().Dx() != tt.wantW || got.Bounds().Dy() != tt.wantH {
+				t.Errorf("ScaleToBox() size = %dx%d, want %dx%d",
+					got.Bounds().Dx(), got.Bounds().Dy(), tt.wantW, tt.wantH)
+			}
+		})
+	}
+}
+
+func TestRender(t *testing.T) {
+	generator := NewGenerator(t.TempDir())
+	darkIcon := solidIcon(64, color.NRGBA{R: 20, G: 20, B: 20, A: 255})
+	rendered := generator.Render(darkIcon)
+
+	if rendered.Bounds().Dx() != DefaultIconSize || rendered.Bounds().Dy() != DefaultIconSize {
+		t.Fatalf("rendered icon size = %dx%d, want %dx%d",
+			rendered.Bounds().Dx(), rendered.Bounds().Dy(), DefaultIconSize, DefaultIconSize)
+	}
+
+	//The corners sit outside the squircle and must stay fully transparent
+	corners := [][2]int{{0, 0}, {DefaultIconSize - 1, 0}, {0, DefaultIconSize - 1}, {DefaultIconSize - 1, DefaultIconSize - 1}}
+	for _, corner := range corners {
+		if _, _, _, a := rendered.At(corner[0], corner[1]).RGBA(); a != 0 {
+			t.Errorf("corner (%d, %d) alpha = %d, want 0", corner[0], corner[1], a)
+		}
+	}
+
+	//The center of a dark icon should be drawn on top of a white backplate
+	if _, _, _, a := rendered.At(DefaultIconSize/2, DefaultIconSize/2).RGBA(); a == 0 {
+		t.Error("center pixel is transparent, want the module icon drawn there")
+	}
+
+	//The padding ring between the icon and the backplate edge must show the
+	//backplate color rather than the module icon
+	contentRadius := float64(DefaultIconSize) * DefaultBackplateRatio * DefaultContentRatio / 2
+	backplateRadius := float64(DefaultIconSize) * DefaultBackplateRatio / 2
+	paddingOffset := int(math.Round((contentRadius + backplateRadius) / 2))
+	pr, pg, pb, pa := rendered.At(DefaultIconSize/2+paddingOffset, DefaultIconSize/2).RGBA()
+	if pa != 0xffff || pr != 0xffff || pg != 0xffff || pb != 0xffff {
+		t.Errorf("padding pixel = (%d, %d, %d, %d), want opaque white backplate", pr, pg, pb, pa)
+	}
+
+	//The generated icon must occupy roughly the same fraction of the canvas as
+	//the hand drawn desktop icons shipped with the built-in web apps
+	opaqueWidth := 0
+	for x := 0; x < DefaultIconSize; x++ {
+		if _, _, _, a := rendered.At(x, DefaultIconSize/2).RGBA(); a > 0 {
+			opaqueWidth++
+		}
+	}
+	occupancy := float64(opaqueWidth) / float64(DefaultIconSize)
+	if occupancy < 0.66 || occupancy > 0.74 {
+		t.Errorf("icon occupancy = %.3f of canvas, want roughly 0.70 to match the bundled icons", occupancy)
+	}
+}
+
+func TestRenderHonoursCustomOptions(t *testing.T) {
+	generator := NewGeneratorWithOptions(t.TempDir(), Options{IconSize: 64})
+	rendered := generator.Render(solidIcon(32, color.NRGBA{R: 20, G: 20, B: 20, A: 255}))
+
+	if rendered.Bounds().Dx() != 64 || rendered.Bounds().Dy() != 64 {
+		t.Errorf("rendered icon size = %dx%d, want 64x64", rendered.Bounds().Dx(), rendered.Bounds().Dy())
+	}
+}

+ 74 - 0
src/mod/desktop/icons/svg.go

@@ -0,0 +1,74 @@
+package icons
+
+import (
+	"bytes"
+	"errors"
+	"image"
+	"math"
+
+	"github.com/srwiley/oksvg"
+	"github.com/srwiley/rasterx"
+)
+
+/*
+	svg.go
+
+	Rasterization of SVG module icons, working around oksvg only transforming
+	path geometry and not the stroke styling that goes with it.
+*/
+
+// RasterizeSVG renders an SVG into a square RGBA image of the given size,
+// preserving the aspect ratio of the source viewBox.
+func RasterizeSVG(svgContent []byte, renderSize int) (image.Image, error) {
+	parsedIcon, err := oksvg.ReadIconStream(bytes.NewReader(svgContent))
+	if err != nil {
+		return nil, err
+	}
+
+	if len(parsedIcon.SVGPaths) == 0 {
+		//oksvg happily parses content that is not an SVG at all and hands back
+		//an icon with nothing to draw. Refuse it rather than writing out a blank
+		//desktop icon, which would then permanently shadow the module icon.
+		return nil, errors.New("svg contains no drawable path")
+	}
+
+	viewWidth := parsedIcon.ViewBox.W
+	viewHeight := parsedIcon.ViewBox.H
+	if viewWidth <= 0 || viewHeight <= 0 {
+		viewWidth, viewHeight = float64(renderSize), float64(renderSize)
+	}
+
+	scale := math.Min(float64(renderSize)/viewWidth, float64(renderSize)/viewHeight)
+	targetWidth := viewWidth * scale
+	targetHeight := viewHeight * scale
+	parsedIcon.SetTarget((float64(renderSize)-targetWidth)/2, (float64(renderSize)-targetHeight)/2, targetWidth, targetHeight)
+	ScaleSVGStrokes(parsedIcon, scale)
+
+	renderedIcon := image.NewRGBA(image.Rect(0, 0, renderSize, renderSize))
+	scanner := rasterx.NewScannerGV(renderSize, renderSize, renderedIcon, renderedIcon.Bounds())
+	parsedIcon.Draw(rasterx.NewDasher(renderSize, renderSize, scanner), 1.0)
+	return renderedIcon, nil
+}
+
+// ScaleSVGStrokes multiplies the stroke widths of an SVG icon by the given
+// scale factor.
+//
+// oksvg only applies the SetTarget transform to the path geometry: the stroke
+// width handed to the rasterizer is the raw value in viewBox units. Rendering a
+// 64x64 viewBox at 512px therefore leaves every stroke 8 times too thin, which
+// makes line art module icons come out as hairlines. Pre-scaling the stroke
+// styling to match the transform restores the intended thickness.
+func ScaleSVGStrokes(parsedIcon *oksvg.SvgIcon, scale float64) {
+	if scale <= 0 || scale == 1 {
+		return
+	}
+
+	for i := range parsedIcon.SVGPaths {
+		svgPath := &parsedIcon.SVGPaths[i]
+		svgPath.LineWidth *= scale
+		svgPath.DashOffset *= scale
+		for j := range svgPath.Dash {
+			svgPath.Dash[j] *= scale
+		}
+	}
+}

+ 124 - 0
src/mod/desktop/icons/svg_test.go

@@ -0,0 +1,124 @@
+package icons
+
+import (
+	"bytes"
+	"math"
+	"testing"
+
+	"github.com/srwiley/oksvg"
+)
+
+var strokedTestSVG = []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+	<path d="M32 8 V56" stroke="#ffffff" stroke-width="4" fill="none"/>
+</svg>`)
+
+func TestScaleSVGStrokes(t *testing.T) {
+	referenceIcon, err := oksvg.ReadIconStream(bytes.NewReader(strokedTestSVG))
+	if err != nil {
+		t.Fatalf("unable to parse test SVG: %v", err)
+	}
+	if len(referenceIcon.SVGPaths) == 0 {
+		t.Fatal("test SVG parsed into zero paths")
+	}
+
+	originalWidth := referenceIcon.SVGPaths[0].LineWidth
+	if originalWidth <= 0 {
+		t.Fatalf("test SVG stroke width = %v, want a positive width", originalWidth)
+	}
+
+	tests := []struct {
+		name  string
+		scale float64
+		want  float64
+	}{
+		{"scaled up", 8, originalWidth * 8},
+		{"scaled down", 0.5, originalWidth * 0.5},
+		{"identity is a no-op", 1, originalWidth},
+		{"zero scale is ignored", 0, originalWidth},
+		{"negative scale is ignored", -2, originalWidth},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			parsedIcon, err := oksvg.ReadIconStream(bytes.NewReader(strokedTestSVG))
+			if err != nil {
+				t.Fatalf("unable to parse test SVG: %v", err)
+			}
+			ScaleSVGStrokes(parsedIcon, tt.scale)
+			if got := parsedIcon.SVGPaths[0].LineWidth; math.Abs(got-tt.want) > 1e-9 {
+				t.Errorf("LineWidth after scaling by %v = %v, want %v", tt.scale, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestRasterizeSVGKeepsStrokeWeight(t *testing.T) {
+	//A 64 unit wide viewBox with a 4 unit stroke rendered at 512px should draw
+	//a 32px wide line. Without stroke scaling oksvg would draw it 4px wide.
+	const renderSize = 512
+	rasterized, err := RasterizeSVG(strokedTestSVG, renderSize)
+	if err != nil {
+		t.Fatalf("RasterizeSVG() returned error: %v", err)
+	}
+
+	if rasterized.Bounds().Dx() != renderSize || rasterized.Bounds().Dy() != renderSize {
+		t.Fatalf("rasterized size = %dx%d, want %dx%d",
+			rasterized.Bounds().Dx(), rasterized.Bounds().Dy(), renderSize, renderSize)
+	}
+
+	drawnWidth := 0
+	for x := 0; x < renderSize; x++ {
+		if _, _, _, a := rasterized.At(x, renderSize/2).RGBA(); a > 0x7fff {
+			drawnWidth++
+		}
+	}
+
+	expectedWidth := 4.0 / 64.0 * renderSize
+	if math.Abs(float64(drawnWidth)-expectedWidth) > 2 {
+		t.Errorf("rasterized stroke width = %dpx, want about %.0fpx", drawnWidth, expectedWidth)
+	}
+}
+
+func TestRasterizeSVGPreservesAspectRatio(t *testing.T) {
+	//A 2:1 viewBox must render letterboxed rather than stretched to the square
+	wideSVG := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32" width="64" height="32">
+		<rect x="0" y="0" width="64" height="32" fill="#ffffff"/>
+	</svg>`)
+
+	const renderSize = 128
+	rasterized, err := RasterizeSVG(wideSVG, renderSize)
+	if err != nil {
+		t.Fatalf("RasterizeSVG() returned error: %v", err)
+	}
+
+	drawnHeight := 0
+	for y := 0; y < renderSize; y++ {
+		if _, _, _, a := rasterized.At(renderSize/2, y).RGBA(); a > 0x7fff {
+			drawnHeight++
+		}
+	}
+
+	if math.Abs(float64(drawnHeight)-renderSize/2) > 2 {
+		t.Errorf("drawn height = %dpx, want about %dpx for a 2:1 viewBox", drawnHeight, renderSize/2)
+	}
+}
+
+func TestRasterizeSVGRejectsUndrawableInput(t *testing.T) {
+	tests := []struct {
+		name  string
+		input []byte
+	}{
+		{"plain text", []byte("this is not an svg")},
+		{"empty input", []byte("")},
+		{"svg with nothing to draw", []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"></svg>`)},
+		{"malformed markup", []byte(`<svg><rect`)},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if _, err := RasterizeSVG(tt.input, 64); err == nil {
+				t.Error("RasterizeSVG() = nil error, want an error rather than a blank icon")
+			}
+		})
+	}
+}

+ 85 - 0
src/mod/desktop/layout/layout.go

@@ -0,0 +1,85 @@
+package layout
+
+import (
+	"encoding/json"
+	"errors"
+
+	"imuslab.com/arozos/mod/database"
+)
+
+/*
+	layout.go
+
+	Persistence of where each icon sits on a user's desktop grid. Positions are
+	stored in the system database keyed by user so they follow the account
+	across devices.
+
+	Author: tobychui
+*/
+
+// IconLocation is the grid position of a single desktop icon
+type IconLocation struct {
+	X int
+	Y int
+}
+
+// Manager reads and writes desktop icon positions for all users
+type Manager struct {
+	db    *database.Database
+	table string
+}
+
+// NewManager creates a layout manager storing positions in the given database
+// table. The table is created if it does not exist yet.
+func NewManager(db *database.Database, tableName string) (*Manager, error) {
+	if db == nil {
+		return nil, errors.New("no database given")
+	}
+
+	err := db.NewTable(tableName)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Manager{db: db, table: tableName}, nil
+}
+
+// GetIconLocation returns the stored grid position of a desktop file. An error
+// is returned when the file has never been positioned by the user.
+func (m *Manager) GetIconLocation(username string, filename string) (int, int, error) {
+	storedLocation := ""
+	err := m.db.Read(m.table, m.locationKey(username, filename), &storedLocation)
+	if err != nil || storedLocation == "" {
+		//The file location is not set
+		return -1, -1, errors.New("this file do not have a location registry")
+	}
+
+	iconLocation := IconLocation{X: -1, Y: -1}
+	err = json.Unmarshal([]byte(storedLocation), &iconLocation)
+	if err != nil {
+		return -1, -1, err
+	}
+
+	return iconLocation.X, iconLocation.Y, nil
+}
+
+// SetIconLocation stores the grid position of a desktop file
+func (m *Manager) SetIconLocation(username string, filename string, x int, y int) error {
+	newLocation, err := json.Marshal(IconLocation{X: x, Y: y})
+	if err != nil {
+		return err
+	}
+
+	return m.db.Write(m.table, m.locationKey(username, filename), string(newLocation))
+}
+
+// RemoveIconLocation forgets the stored position of a desktop file
+func (m *Manager) RemoveIconLocation(username string, filename string) error {
+	return m.db.Delete(m.table, m.locationKey(username, filename))
+}
+
+// locationKey builds the database key holding one user's icon position. As the
+// key already includes the username, positions of different users never collide.
+func (m *Manager) locationKey(username string, filename string) string {
+	return username + "/filelocation/" + filename
+}

+ 119 - 0
src/mod/desktop/layout/layout_test.go

@@ -0,0 +1,119 @@
+package layout
+
+import (
+	"path/filepath"
+	"testing"
+
+	"imuslab.com/arozos/mod/database"
+)
+
+// newTestManager spins up a throwaway database backed layout manager
+func newTestManager(t *testing.T) *Manager {
+	t.Helper()
+	db, err := database.NewDatabase(filepath.Join(t.TempDir(), "test.db"), false)
+	if err != nil {
+		t.Fatalf("unable to create test database: %v", err)
+	}
+	t.Cleanup(func() { db.Close() })
+
+	manager, err := NewManager(db, "desktop")
+	if err != nil {
+		t.Fatalf("unable to create layout manager: %v", err)
+	}
+	return manager
+}
+
+func TestNewManagerRejectsNilDatabase(t *testing.T) {
+	if _, err := NewManager(nil, "desktop"); err == nil {
+		t.Error("NewManager(nil) = nil error, want an error")
+	}
+}
+
+func TestSetAndGetIconLocation(t *testing.T) {
+	manager := newTestManager(t)
+
+	tests := []struct {
+		name     string
+		username string
+		filename string
+		x        int
+		y        int
+	}{
+		{"plain position", "alice", "Photo.shortcut", 3, 5},
+		{"origin", "alice", "Music.shortcut", 0, 0},
+		{"negative position", "alice", "Video.shortcut", -1, -1},
+		{"filename with spaces", "alice", "My Documents.shortcut", 7, 2},
+		{"other user same filename", "bob", "Photo.shortcut", 9, 9},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := manager.SetIconLocation(tt.username, tt.filename, tt.x, tt.y); err != nil {
+				t.Fatalf("SetIconLocation() returned error: %v", err)
+			}
+
+			gotX, gotY, err := manager.GetIconLocation(tt.username, tt.filename)
+			if err != nil {
+				t.Fatalf("GetIconLocation() returned error: %v", err)
+			}
+			if gotX != tt.x || gotY != tt.y {
+				t.Errorf("GetIconLocation() = (%d, %d), want (%d, %d)", gotX, gotY, tt.x, tt.y)
+			}
+		})
+	}
+
+	//Positions must be scoped per user, so alice's Photo must not have moved
+	gotX, gotY, err := manager.GetIconLocation("alice", "Photo.shortcut")
+	if err != nil {
+		t.Fatalf("GetIconLocation() returned error: %v", err)
+	}
+	if gotX != 3 || gotY != 5 {
+		t.Errorf("alice's position = (%d, %d), want (3, 5) unaffected by bob", gotX, gotY)
+	}
+}
+
+func TestGetIconLocationUnset(t *testing.T) {
+	manager := newTestManager(t)
+
+	x, y, err := manager.GetIconLocation("alice", "NeverPlaced.shortcut")
+	if err == nil {
+		t.Error("GetIconLocation() = nil error for an unplaced file, want an error")
+	}
+	if x != -1 || y != -1 {
+		t.Errorf("GetIconLocation() = (%d, %d) for an unplaced file, want (-1, -1)", x, y)
+	}
+}
+
+func TestSetIconLocationOverwrites(t *testing.T) {
+	manager := newTestManager(t)
+
+	if err := manager.SetIconLocation("alice", "Photo.shortcut", 1, 1); err != nil {
+		t.Fatalf("SetIconLocation() returned error: %v", err)
+	}
+	if err := manager.SetIconLocation("alice", "Photo.shortcut", 4, 8); err != nil {
+		t.Fatalf("SetIconLocation() returned error: %v", err)
+	}
+
+	x, y, err := manager.GetIconLocation("alice", "Photo.shortcut")
+	if err != nil {
+		t.Fatalf("GetIconLocation() returned error: %v", err)
+	}
+	if x != 4 || y != 8 {
+		t.Errorf("GetIconLocation() = (%d, %d), want the overwritten (4, 8)", x, y)
+	}
+}
+
+func TestRemoveIconLocation(t *testing.T) {
+	manager := newTestManager(t)
+
+	if err := manager.SetIconLocation("alice", "Photo.shortcut", 2, 2); err != nil {
+		t.Fatalf("SetIconLocation() returned error: %v", err)
+	}
+	if err := manager.RemoveIconLocation("alice", "Photo.shortcut"); err != nil {
+		t.Fatalf("RemoveIconLocation() returned error: %v", err)
+	}
+
+	if _, _, err := manager.GetIconLocation("alice", "Photo.shortcut"); err == nil {
+		t.Error("GetIconLocation() = nil error after removal, want an error")
+	}
+}

+ 84 - 0
src/mod/desktop/prefs/prefs.go

@@ -0,0 +1,84 @@
+package prefs
+
+import (
+	"errors"
+
+	"imuslab.com/arozos/mod/database"
+)
+
+/*
+	prefs.go
+
+	Per-user desktop preferences and wallpaper theme selection, stored in the
+	system database so they follow the account across devices.
+
+	Author: tobychui
+*/
+
+// DefaultTheme is the wallpaper theme served to a user who has never picked one
+const DefaultTheme = "default"
+
+// Manager reads and writes desktop preferences for all users
+type Manager struct {
+	db    *database.Database
+	table string
+}
+
+// NewManager creates a preference manager storing values in the given database
+// table. The table is created if it does not exist yet.
+func NewManager(db *database.Database, tableName string) (*Manager, error) {
+	if db == nil {
+		return nil, errors.New("no database given")
+	}
+
+	err := db.NewTable(tableName)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Manager{db: db, table: tableName}, nil
+}
+
+// GetPreference returns a stored preference value, or an empty string when the
+// user has never set it.
+func (m *Manager) GetPreference(username string, preferenceType string) string {
+	storedValue := ""
+	m.db.Read(m.table, m.preferenceKey(username, preferenceType), &storedValue)
+	return storedValue
+}
+
+// SetPreference stores a preference value for a user
+func (m *Manager) SetPreference(username string, preferenceType string, value string) error {
+	return m.db.Write(m.table, m.preferenceKey(username, preferenceType), value)
+}
+
+// RemovePreference forgets a stored preference, reverting the user to default
+func (m *Manager) RemovePreference(username string, preferenceType string) error {
+	return m.db.Delete(m.table, m.preferenceKey(username, preferenceType))
+}
+
+// GetTheme returns the wallpaper theme picked by a user, falling back to
+// DefaultTheme when none has been set.
+func (m *Manager) GetTheme(username string) string {
+	selectedTheme := ""
+	m.db.Read(m.table, m.themeKey(username), &selectedTheme)
+	if selectedTheme == "" {
+		return DefaultTheme
+	}
+	return selectedTheme
+}
+
+// SetTheme stores the wallpaper theme picked by a user
+func (m *Manager) SetTheme(username string, theme string) error {
+	return m.db.Write(m.table, m.themeKey(username), theme)
+}
+
+// preferenceKey builds the database key holding one user's preference value
+func (m *Manager) preferenceKey(username string, preferenceType string) string {
+	return username + "/preference/" + preferenceType
+}
+
+// themeKey builds the database key holding one user's wallpaper theme
+func (m *Manager) themeKey(username string) string {
+	return username + "/theme"
+}

+ 145 - 0
src/mod/desktop/prefs/prefs_test.go

@@ -0,0 +1,145 @@
+package prefs
+
+import (
+	"path/filepath"
+	"testing"
+
+	"imuslab.com/arozos/mod/database"
+)
+
+// newTestManager spins up a throwaway database backed preference manager
+func newTestManager(t *testing.T) *Manager {
+	t.Helper()
+	db, err := database.NewDatabase(filepath.Join(t.TempDir(), "test.db"), false)
+	if err != nil {
+		t.Fatalf("unable to create test database: %v", err)
+	}
+	t.Cleanup(func() { db.Close() })
+
+	manager, err := NewManager(db, "desktop")
+	if err != nil {
+		t.Fatalf("unable to create preference manager: %v", err)
+	}
+	return manager
+}
+
+func TestNewManagerRejectsNilDatabase(t *testing.T) {
+	if _, err := NewManager(nil, "desktop"); err == nil {
+		t.Error("NewManager(nil) = nil error, want an error")
+	}
+}
+
+func TestSetAndGetPreference(t *testing.T) {
+	manager := newTestManager(t)
+
+	tests := []struct {
+		name           string
+		username       string
+		preferenceType string
+		value          string
+	}{
+		{"simple value", "alice", "showDesktopIcons", "true"},
+		{"json value", "alice", "listview", `{"sort":"name"}`},
+		{"value with slashes", "alice", "wallpaper", "user:/Photo/bg.jpg"},
+		{"same key other user", "bob", "showDesktopIcons", "false"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := manager.SetPreference(tt.username, tt.preferenceType, tt.value); err != nil {
+				t.Fatalf("SetPreference() returned error: %v", err)
+			}
+			if got := manager.GetPreference(tt.username, tt.preferenceType); got != tt.value {
+				t.Errorf("GetPreference() = %q, want %q", got, tt.value)
+			}
+		})
+	}
+
+	//Preferences must be scoped per user
+	if got := manager.GetPreference("alice", "showDesktopIcons"); got != "true" {
+		t.Errorf("alice's preference = %q, want %q unaffected by bob", got, "true")
+	}
+}
+
+func TestGetPreferenceUnset(t *testing.T) {
+	manager := newTestManager(t)
+
+	if got := manager.GetPreference("alice", "neverSet"); got != "" {
+		t.Errorf("GetPreference() = %q for an unset key, want an empty string", got)
+	}
+}
+
+func TestRemovePreference(t *testing.T) {
+	manager := newTestManager(t)
+
+	if err := manager.SetPreference("alice", "showDesktopIcons", "true"); err != nil {
+		t.Fatalf("SetPreference() returned error: %v", err)
+	}
+	if err := manager.RemovePreference("alice", "showDesktopIcons"); err != nil {
+		t.Fatalf("RemovePreference() returned error: %v", err)
+	}
+	if got := manager.GetPreference("alice", "showDesktopIcons"); got != "" {
+		t.Errorf("GetPreference() = %q after removal, want an empty string", got)
+	}
+}
+
+func TestTheme(t *testing.T) {
+	manager := newTestManager(t)
+
+	tests := []struct {
+		name     string
+		username string
+		set      string
+		want     string
+	}{
+		{"never set falls back to default", "alice", "", DefaultTheme},
+		{"set theme is returned", "bob", "winxp", "winxp"},
+		{"theme with spaces", "carol", "my custom theme", "my custom theme"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.set != "" {
+				if err := manager.SetTheme(tt.username, tt.set); err != nil {
+					t.Fatalf("SetTheme() returned error: %v", err)
+				}
+			}
+			if got := manager.GetTheme(tt.username); got != tt.want {
+				t.Errorf("GetTheme() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestThemeOverwrites(t *testing.T) {
+	manager := newTestManager(t)
+
+	if err := manager.SetTheme("alice", "winxp"); err != nil {
+		t.Fatalf("SetTheme() returned error: %v", err)
+	}
+	if err := manager.SetTheme("alice", "macos"); err != nil {
+		t.Fatalf("SetTheme() returned error: %v", err)
+	}
+	if got := manager.GetTheme("alice"); got != "macos" {
+		t.Errorf("GetTheme() = %q, want the overwritten %q", got, "macos")
+	}
+}
+
+// A preference and a theme must not collide even though they share a table
+func TestThemeAndPreferenceAreIndependent(t *testing.T) {
+	manager := newTestManager(t)
+
+	if err := manager.SetTheme("alice", "winxp"); err != nil {
+		t.Fatalf("SetTheme() returned error: %v", err)
+	}
+	if err := manager.SetPreference("alice", "theme", "not-a-theme"); err != nil {
+		t.Fatalf("SetPreference() returned error: %v", err)
+	}
+
+	if got := manager.GetTheme("alice"); got != "winxp" {
+		t.Errorf("GetTheme() = %q, want %q", got, "winxp")
+	}
+	if got := manager.GetPreference("alice", "theme"); got != "not-a-theme" {
+		t.Errorf("GetPreference() = %q, want %q", got, "not-a-theme")
+	}
+}

+ 81 - 0
src/mod/desktop/wallpaper/wallpaper.go

@@ -0,0 +1,81 @@
+package wallpaper
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+/*
+	wallpaper.go
+
+	Discovery of the desktop wallpapers shipped with ArozOS. Wallpapers are
+	grouped into themes, one folder per theme, each holding the background images
+	that theme offers.
+
+	Author: tobychui
+*/
+
+// SupportedExtensions are the image formats accepted as a desktop wallpaper
+var SupportedExtensions = []string{".jpg", ".png", ".gif"}
+
+// Theme is one wallpaper theme folder and the backgrounds it contains
+type Theme struct {
+	Theme  string
+	Bglist []string
+}
+
+// ListThemes scans a wallpaper root folder (e.g. "web/img/desktop/bg") and
+// returns one Theme per sub-folder, each listing the wallpaper filenames it
+// holds. Themes are returned in a stable alphabetical order.
+func ListThemes(wallpaperRoot string) ([]Theme, error) {
+	themeFolders, err := os.ReadDir(wallpaperRoot)
+	if err != nil {
+		return nil, err
+	}
+
+	themeList := []Theme{}
+	for _, themeFolder := range themeFolders {
+		if !themeFolder.IsDir() {
+			continue
+		}
+
+		backgrounds, err := os.ReadDir(filepath.Join(wallpaperRoot, themeFolder.Name()))
+		if err != nil {
+			//Unreadable theme folder, skip it instead of failing the whole scan
+			continue
+		}
+
+		var backgroundList []string
+		for _, background := range backgrounds {
+			if background.IsDir() || !IsSupportedWallpaper(background.Name()) {
+				continue
+			}
+			backgroundList = append(backgroundList, background.Name())
+		}
+
+		themeList = append(themeList, Theme{
+			Theme:  themeFolder.Name(),
+			Bglist: backgroundList,
+		})
+	}
+
+	sort.Slice(themeList, func(i, j int) bool {
+		return themeList[i].Theme < themeList[j].Theme
+	})
+
+	return themeList, nil
+}
+
+// IsSupportedWallpaper reports whether a filename carries an extension that can
+// be used as a desktop wallpaper.
+func IsSupportedWallpaper(filename string) bool {
+	fileExtension := strings.ToLower(filepath.Ext(filename))
+	for _, supportedExtension := range SupportedExtensions {
+		if fileExtension == supportedExtension {
+			return true
+		}
+	}
+	return false
+}

+ 96 - 0
src/mod/desktop/wallpaper/wallpaper_test.go

@@ -0,0 +1,96 @@
+package wallpaper
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+)
+
+// buildWallpaperRoot creates a wallpaper root holding the given theme folders
+// and the files inside each of them
+func buildWallpaperRoot(t *testing.T, themes map[string][]string) string {
+	t.Helper()
+	wallpaperRoot := t.TempDir()
+	for themeName, files := range themes {
+		themeFolder := filepath.Join(wallpaperRoot, themeName)
+		if err := os.MkdirAll(themeFolder, 0755); err != nil {
+			t.Fatalf("unable to create theme folder %s: %v", themeName, err)
+		}
+		for _, file := range files {
+			if err := os.WriteFile(filepath.Join(themeFolder, file), []byte("x"), 0644); err != nil {
+				t.Fatalf("unable to create wallpaper %s: %v", file, err)
+			}
+		}
+	}
+	return wallpaperRoot
+}
+
+func TestIsSupportedWallpaper(t *testing.T) {
+	tests := []struct {
+		name     string
+		filename string
+		want     bool
+	}{
+		{"jpg", "bg.jpg", true},
+		{"png", "bg.png", true},
+		{"gif", "bg.gif", true},
+		{"uppercase extension", "BG.PNG", true},
+		{"mixed case extension", "bg.JpG", true},
+		{"unsupported format", "bg.bmp", false},
+		{"webp is not accepted", "bg.webp", false},
+		{"no extension", "bg", false},
+		{"extension only", ".png", true},
+		{"empty", "", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := IsSupportedWallpaper(tt.filename); got != tt.want {
+				t.Errorf("IsSupportedWallpaper(%q) = %v, want %v", tt.filename, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestListThemes(t *testing.T) {
+	wallpaperRoot := buildWallpaperRoot(t, map[string][]string{
+		"default": {"bg1.jpg", "bg2.png", "notes.txt"},
+		"winxp":   {"bliss.jpg"},
+		"empty":   {},
+	})
+	//A loose file at the root is not a theme and must be ignored
+	if err := os.WriteFile(filepath.Join(wallpaperRoot, "readme.md"), []byte("x"), 0644); err != nil {
+		t.Fatalf("unable to create loose file: %v", err)
+	}
+
+	themes, err := ListThemes(wallpaperRoot)
+	if err != nil {
+		t.Fatalf("ListThemes() returned error: %v", err)
+	}
+
+	want := []Theme{
+		{Theme: "default", Bglist: []string{"bg1.jpg", "bg2.png"}},
+		{Theme: "empty", Bglist: nil},
+		{Theme: "winxp", Bglist: []string{"bliss.jpg"}},
+	}
+	if !reflect.DeepEqual(themes, want) {
+		t.Errorf("ListThemes() = %+v, want %+v", themes, want)
+	}
+}
+
+func TestListThemesEmptyRoot(t *testing.T) {
+	themes, err := ListThemes(t.TempDir())
+	if err != nil {
+		t.Fatalf("ListThemes() returned error: %v", err)
+	}
+	if len(themes) != 0 {
+		t.Errorf("ListThemes() = %+v, want an empty list", themes)
+	}
+}
+
+func TestListThemesMissingRoot(t *testing.T) {
+	if _, err := ListThemes(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
+		t.Error("ListThemes() = nil error for a missing root, want an error")
+	}
+}