Spice is a focus wallpaper manager for Windows and MacOS.
A deep-dive technical guide for implementing new image sources in Spice (v1.1.0+).
Spice uses a Registry Pattern to decouple providers. Providers are standalone packages in pkg/wallpaper/providers/<name>.
pkg/wallpaper/providers/bing/
├── bing.go # Implementation & Registration
├── const.go # Constants (API URL, Regex)
└── bing_test.go # Unit Tests
pkg/provider.ImageProvider)You must implement the following 6 methods.
Name() string:
Title() string:
Type() ProviderType:
provider.TypeOnline (APIs), provider.TypeLocal (Filesystem), or provider.TypeAI (Generative).ParseURL(webURL string) (string, error):
bing.com/images/search?q=foo).search:foo) or the input if it’s already compliant.const.go regex here to reject invalid domains.FetchImages(ctx context.Context, apiURL string, page int) ([]Image, error):
ctx.Done() for cancellation.page is 1-indexed. If the API uses offsets, calculate offset = (page-1) * limit.provider.Image.Image struct fields (Path, ID, Attribution, ViewURL).EnrichImage(ctx, img) (Image, error):
FileType, Path, etc.FetchImages, just return img, nil.GetProviderIcon() fyne.Resource:
fyne.NewStaticResource("Name", []byte{...}). Embed the PNG bytes in code or use //go:embed.Do NOT modify the global Config struct. Use fyne.Preferences.
CreateSettingsPanel)Constructs the “General” tab for your provider (e.g., API Keys).
Input: sm setting.SettingsManager.
returns: fyne.CanvasObject (usually a container.NewVBox).
Widget Types:
CreateTextEntrySetting: For strings (API Keys).
fyne.StringValidator (e.g., validator.NewRegexp(...)).func(s string) error for logic validation (e.g., “Key must start with ‘Bearer ‘”).CreateBoolSetting: For toggles.CreateSelectSetting: For dropdowns.CreateButtonWithConfirmationSetting: For dangerous actions (Reset, Clear Cache).CreateQueryPanel)Constructs the image source list. Pattern:
p.cfg.Preferences.QueryList("queries")? NO.p.cfg.Queries (the unified list). Filter by q.Provider == p.Name().Use Standardized Add Button: Use wallpaper.CreateAddQueryButton (in pkg/wallpaper/ui_add_query.go) to create the “Add” button. This helper handles validation, modal creation, and the critical “Apply” button wiring for you.
addBtn := wallpaper.CreateAddQueryButton(
"Add MyProvider Query",
sm,
wallpaper.AddQueryConfig{
Title: "New Query",
URLPlaceholder: "Search term or URL",
DescPlaceholder: "Description",
ValidateFunc: func(url, desc string) error {
if len(url) == 0 {
return errors.New("URL cannot be empty")
}
// Add provider-specific validation here (e.g., regex check)
return nil
},
AddHandler: func(desc, url string, active bool) (string, error) {
return p.cfg.AddMyProviderQuery(desc, url, active)
},
},
func() {
queryList.Refresh()
sm.SetRefreshFlag("queries")
},
)
Spice uses a Strict Deferred-Save Model. Changes made in the UI must NOT be saved immediately to disk. They must be queued and only committed when the user clicks “Apply”.
You must implement the following wiring in your OnChanged callbacks (e.g., Checkboxes, Entries):
newValue against a variable captured at closure creation (e.g., initialState). This variable becomes stale after an Apply.chk.OnChanged = func(on bool) {
// 1. Fetch Request: Get the TRUE current state from config
// "getDetails" should look up the value in p.cfg
isSavedActive, _ := getDetails(col.Key)
// 2. Define Unique Keys
dirtyKey := fmt.Sprintf("myprovider_%s", col.Key)
callbackKey := fmt.Sprintf("myprovider_cb_%s", col.Key)
// 3. Compare New vs Saved
if on != isSavedActive {
// A. Queue the Save Action
sm.SetSettingChangedCallback(callbackKey, func() {
// This runs ONLY when "Apply" is clicked
if on {
p.cfg.EnableQuery(...)
} else {
p.cfg.DisableQuery(...)
}
})
// B. Flag as Dirty (Enables "Apply")
// Use a unique key. Do NOT use global keys like "queries" here unless necessary for delete.
sm.SetRefreshFlag(dirtyKey)
} else {
// C. Revert: User changed it back to match saved state
sm.RemoveSettingChangedCallback(callbackKey)
sm.UnsetRefreshFlag(dirtyKey)
}
// 4. Update UI
sm.GetCheckAndEnableApplyFunc()()
}
p.cfg.Save() inside the OnChanged callback.
if on != capturedActive.
capturedActive is the truth, but the config has updated. Toggling back will mistakenly leave the “Apply” button enabled.sm.SetRefreshFlag("queries") on toggle.
UnsetRefreshFlag(uniqueKey). If a user reverts a change, the global flag remains, leaving the “Apply” button stuck on. Only use global flags for destructive actions (Delete) or inside the Apply Callback itself.APIs often return results in inconsistent orders (e.g., “Page 2” might contain items from “Page 1”). If your provider supports Pagination AND Shuffling, you must implement the “Cache-First Stable Shuffle” pattern.
map[string][]int for already resolved IDs.
sort.Ints(ids).
cfg.GetImgShuffle() is true, shuffle the sorted list using a session-stable seed.
type Provider struct {
// ...
idCache map[string][]int
idCacheMu sync.RWMutex
}
func (p *Provider) resolveIDs(query string) ([]int, error) {
p.idCacheMu.RLock()
if cached, ok := p.idCache[query]; ok {
p.idCacheMu.RUnlock()
return cached, nil
}
p.idCacheMu.RUnlock()
// 1. Fetch
ids, _ := fetchFromAPI(query)
// 2. Sort (Deterministic Baseline)
sort.Ints(ids)
// 3. Shuffle (If User Wants It)
if p.cfg.GetImgShuffle() {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
r.Shuffle(len(ids), func(i, j int) {
ids[i], ids[j] = ids[j], ids[i]
})
}
// 4. Cache
p.idCacheMu.Lock()
p.idCache[query] = ids
p.idCacheMu.Unlock()
return ids, nil
}
Spice uses a code generation tool (cmd/util/gen_providers) to automatically register all providers found in pkg/wallpaper/providers/.
### 6.1 The Logic
providers/ directory for subdirectories.cmd/spice/zz_generated_providers.go, which contains the necessary _ imports to trigger the init() functions of your providers.go generate (called by make build or make run).### 6.2 Disabling a Provider
To temporarily disable a provider without deleting the code:
.disabled inside the provider’s directory (e.g., pkg/wallpaper/providers/myprovider/.disabled).go generate ./... (or make gen).zz_generated_providers.go, effectively compiling it out of the final binary.### 6.3 Manual imports (Legacy/Debug)
You do not need to manually edit cmd/spice/main.go anymore. The //go:generate directive at the top of main.go handles this.
ParseURL with table-driven tests.http.Client or usage httptest.Server to test FetchImages without real network calls.If your provider supports “copy-pasting” URLs from the browser (like Wallhaven or Pexels), you can integrate with the Spice Safari/Chrome extension.
pkg/wallpaper/providers/<name>/const.go, define a constant for your URL pattern.
^https://bing.com/images/.*).cmd/spice/main.go (e.g., _ "github.com/.../providers/bing"). will automatically parse main.go, find your enabled provider, and extract the regex from your const.go to inject it into the extension's background.js`.make sync-extension
For cultural institutions (Museums, Archives), Spice provides a standardized “Evangelist” UI template designed to drive engagement rather than just utility.
ui_museum.go)wallpaper.CreateMuseumHeader.
MapURL is provided. Use this to drive foot traffic.Instead of raw database categories, frame collections as curated experiences:
widget.NewCheck) for collections.cfg.Enable/DisableQuery.