Browse Source

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 2 months ago
parent
commit
d76438b947

+ 75 - 415
src/desktop.go

@@ -1,28 +1,19 @@
 package main
 package main
 
 
 import (
 import (
-	"bytes"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
-	"image"
-	"image/color"
-	"image/draw"
-	_ "image/gif"
-	_ "image/jpeg"
-	"image/png"
-	"math"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
-	"path"
 	"path/filepath"
 	"path/filepath"
 	"strconv"
 	"strconv"
 	"strings"
 	"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/arozfs"
 	"imuslab.com/arozos/mod/filesystem/shortcut"
 	"imuslab.com/arozos/mod/filesystem/shortcut"
 	module "imuslab.com/arozos/mod/modules"
 	module "imuslab.com/arozos/mod/modules"
@@ -30,6 +21,41 @@ import (
 	"imuslab.com/arozos/mod/utils"
 	"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
 // Desktop script initiation
 func DesktopInit() {
 func DesktopInit() {
 	systemWideLogger.PrintAndLog("Desktop", "Starting Desktop Services", nil)
 	systemWideLogger.PrintAndLog("Desktop", "Starting Desktop Services", nil)
@@ -56,13 +82,28 @@ func DesktopInit() {
 	router.HandleFunc("/system/desktop/opr/renameShortcut", desktop_handleShortcutRename)
 	router.HandleFunc("/system/desktop/opr/renameShortcut", desktop_handleShortcutRename)
 
 
 	//Initialize desktop database
 	//Initialize desktop database
-	err := sysdb.NewTable("desktop")
+	err := sysdb.NewTable(desktopDatabaseTable)
 	if err != nil {
 	if err != nil {
 		systemWideLogger.PrintAndLog("System", "Unable to create database table for Desktop. Please validation your installation.", nil)
 		systemWideLogger.PrintAndLog("System", "Unable to create database table for Desktop. Please validation your installation.", nil)
 		systemWideLogger.PrintAndLog("System", fmt.Sprint(err), nil)
 		systemWideLogger.PrintAndLog("System", fmt.Sprint(err), nil)
 		os.Exit(1)
 		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
 	//Register Desktop settings sub-items
 	registerSetting(settingModule{
 	registerSetting(settingModule{
 		Name:     "Wallpaper",
 		Name:     "Wallpaper",
@@ -128,9 +169,8 @@ func desktop_initUserFolderStructure(username string) {
 		userFsa.MkdirAll(userDesktopPath, 0755)
 		userFsa.MkdirAll(userDesktopPath, 0755)
 
 
 		//Copy template file from system folder if exists
 		//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 {
 			for _, tfile := range templateFiles {
 				input, _ := os.ReadFile(tfile)
 				input, _ := os.ReadFile(tfile)
 				userFsa.WriteFile(arozfs.ToSlash(filepath.Join(userDesktopPath, filepath.Base(tfile))), input, 0755)
 				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.
 // functions to handle desktop icon locations. Location is directly written into the center db.
 func getDesktopLocatioFromPath(filename string, username string) (int, int, error) {
 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
 // 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/")
 	fsh, subpath, _ := GetFSHandlerSubpathFromVpath("user:/Desktop/")
 	fshAbs := fsh.FileSystemAbstraction
 	fshAbs := fsh.FileSystemAbstraction
 	desktoppath, _ := fshAbs.VirtualPathToRealPath(subpath, userinfo.Username)
 	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
 	//Check if the file exits
-	if !fshAbs.FileExists(path) {
+	if !fshAbs.FileExists(targetFilepath) {
 		return errors.New("Given filename not exists.")
 		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 {
 	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
 		return err
 	}
 	}
-
-	//systemWideLogger.PrintAndLog(key,string(jsonstring),nil)
-	//Write result to database
-	sysdb.Write("desktop", username+"/filelocation/"+filename, string(jsonstring))
 	return nil
 	return nil
 }
 }
 
 
 func delDesktopLocationFromPath(filename string, username string) {
 func delDesktopLocationFromPath(filename string, username string) {
 	//Delete a file icon location from db
 	//Delete a file icon location from db
-	sysdb.Delete("desktop", username+"/filelocation/"+filename)
+	desktopLayoutManager.RemoveIconLocation(username, filename)
 }
 }
 
 
 // Return the user information to the client
 // 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")
 	loadUserTheme, _ := utils.GetPara(r, "load")
 	if targetTheme == "" && getUserTheme == "" && loadUserTheme == "" {
 	if targetTheme == "" && getUserTheme == "" && loadUserTheme == "" {
 		//List all the currnet themes in the list
 		//List all the currnet themes in the list
-		themes, err := filepath.Glob("web/img/desktop/bg/*")
+		desktopThemeList, err := wallpaper.ListThemes(desktopWallpaperRoot)
 		if err != nil {
 		if err != nil {
 			systemWideLogger.PrintAndLog("Desktop", "Unable to search bg from destkop image root. Are you sure the web data folder exists?", err)
 			systemWideLogger.PrintAndLog("Desktop", "Unable to search bg from destkop image root. Are you sure the web data folder exists?", err)
 			return
 			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
 		//Return the results as JSON string
 		jsonString, err := json.Marshal(desktopThemeList)
 		jsonString, err := json.Marshal(desktopThemeList)
@@ -589,18 +567,9 @@ func desktop_theme_handler(w http.ResponseWriter, r *http.Request) {
 		utils.SendJSONResponse(w, string(jsonString))
 		utils.SendJSONResponse(w, string(jsonString))
 		return
 		return
 	} else if getUserTheme == "true" {
 	} 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 != "" {
 	} else if loadUserTheme != "" {
 		//Load user theme base on folder path
 		//Load user theme base on folder path
 		targetFsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(loadUserTheme)
 		targetFsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(loadUserTheme)
@@ -648,8 +617,7 @@ func desktop_theme_handler(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 		for _, file := range files {
 		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())))
 				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 != "" {
 	} else if targetTheme != "" {
 		//Set the current user theme
 		//Set the current user theme
-		sysdb.Write("desktop", username+"/theme", targetTheme)
+		desktopPrefsManager.SetTheme(username, targetTheme)
 		utils.SendJSONResponse(w, "\"OK\"")
 		utils.SendJSONResponse(w, "\"OK\"")
 		return
 		return
 	}
 	}
@@ -693,19 +661,17 @@ func desktop_preference_handler(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	} else if preferenceType != "" && value == "" && remove == "" {
 	} else if preferenceType != "" && value == "" && remove == "" {
 		//Getting config from the key.
 		//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))
 		utils.SendJSONResponse(w, string(jsonString))
 		return
 		return
 	} else if preferenceType != "" && value == "" && remove == "true" {
 	} else if preferenceType != "" && value == "" && remove == "true" {
 		//Remove mode
 		//Remove mode
-		sysdb.Delete("desktop", username+"/preference/"+preferenceType)
+		desktopPrefsManager.RemovePreference(username, preferenceType)
 		utils.SendOK(w)
 		utils.SendOK(w)
 		return
 		return
 	} else if preferenceType != "" && value != "" {
 	} else if preferenceType != "" && value != "" {
 		//Setting config from the key
 		//Setting config from the key
-		sysdb.Write("desktop", username+"/preference/"+preferenceType, value)
+		desktopPrefsManager.SetPreference(username, preferenceType, value)
 		utils.SendOK(w)
 		utils.SendOK(w)
 		return
 		return
 	} else {
 	} 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
 	//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.
 	//does not end up with a fully filled icon on the desktop.
 	if shortcutType == "module" {
 	if shortcutType == "module" {
-		_, err := desktop_ensureDesktopIcon(shortcutIcon)
+		desktopIconPath, generated, err := desktopIconGenerator.EnsureDesktopIcon(shortcutIcon)
 		if err != nil {
 		if err != nil {
 			systemWideLogger.PrintAndLog("Desktop", "Unable to generate desktop icon for "+shortcutIcon, err)
 			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)
 	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")
+	}
+}