From e3aa57aa54fa4fa759de77ebc752e75b29213903 Mon Sep 17 00:00:00 2001 From: Felix <254968786+felixbk98@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:31:11 +0200 Subject: [PATCH] Handle "Set as Background" through the wallpaper portal File managers and image viewers such as Nautilus hand "Set as Background" to xdg-desktop-portal. On Hyprland and other window managers no backend implements the Wallpaper portal, so the menu entry does nothing. aether --wallpaper-portal now serves org.freedesktop.impl.portal.Wallpaper on the session bus. A request runs aether --generate on the picture, so the wallpaper and the generated theme are applied together. - contrib/portal holds the portal file and the D-Bus service file that starts the backend on demand. make install copies both to ~/.local/share. - Requests for the lock screen only, non-file URIs and non-image files are rejected. - docs/wallpaper-portal.md explains the setup, including the portals.conf line that selects Aether as the Wallpaper backend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dP9hm4WABZdJS5YHbZRrE --- Makefile | 3 + README.md | 1 + cli/cli.go | 3 + cli/wallpaper_portal.go | 98 +++++++++++++++++++ cli/wallpaper_portal_test.go | 42 ++++++++ contrib/portal/aether.portal | 3 + ...desktop.impl.portal.desktop.aether.service | 3 + docs/wallpaper-portal.md | 39 ++++++++ 8 files changed, 192 insertions(+) create mode 100644 cli/wallpaper_portal.go create mode 100644 cli/wallpaper_portal_test.go create mode 100644 contrib/portal/aether.portal create mode 100644 contrib/portal/org.freedesktop.impl.portal.desktop.aether.service create mode 100644 docs/wallpaper-portal.md diff --git a/Makefile b/Makefile index d3317017..e207fa2b 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,9 @@ else -update-desktop-database $(HOME)/.local/share/applications 2>/dev/null -gtk-update-icon-cache -f $(HOME)/.local/share/icons/hicolor 2>/dev/null -xdg-mime default li.oever.aether.url-handler.desktop x-scheme-handler/aether 2>/dev/null + mkdir -p $(HOME)/.local/share/xdg-desktop-portal/portals $(HOME)/.local/share/dbus-1/services + cp contrib/portal/aether.portal $(HOME)/.local/share/xdg-desktop-portal/portals/ + cp contrib/portal/org.freedesktop.impl.portal.desktop.aether.service $(HOME)/.local/share/dbus-1/services/ @if command -v omarchy >/dev/null 2>&1; then $(MAKE) install-omarchy-plugins; fi endif diff --git a/README.md b/README.md index 1cc1fbf1..32fdf89c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ From `frontend/`, run `npm ci`, `npm run check`, `npm test`, and `npm run build` | [GitHub Source](docs/github-source.md) | Browse repository wallpapers | | [Favorites](docs/favorites.md) | Save wallpapers and export a collection | | [Blueprints](docs/blueprints.md) | Save and restore themes | +| [Set as Background](docs/wallpaper-portal.md) | Theme from a file manager's context menu | | [Custom Templates](docs/custom-templates.md) | Add support for your apps | | [Custom Apps](docs/custom-apps.md) | Per-app template system | | [File System](docs/filesystem.md) | Where Aether stores files | diff --git a/cli/cli.go b/cli/cli.go index d8d401de..9c0fff5f 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -34,6 +34,8 @@ func Run(args []string, templatesFS embed.FS) int { return runImportColorsToml(args[1:], templatesFS) case "--handle-url": return runHandleURL(args[1:], templatesFS) + case "--wallpaper-portal": + return runWallpaperPortal(args[1:]) // --- Color utilities --- case "--color-convert": @@ -160,6 +162,7 @@ Import commands: aether://apply?wallpaper=URL aether://apply?mode=light|dark aether://apply?as_omarchy_theme=NAME + aether --wallpaper-portal Serve "Set as Background" for file managers (started by D-Bus) Color utilities: aether --color-info Show all color representations diff --git a/cli/wallpaper_portal.go b/cli/wallpaper_portal.go new file mode 100644 index 00000000..6fb4177c --- /dev/null +++ b/cli/wallpaper_portal.go @@ -0,0 +1,98 @@ +package cli + +import ( + "fmt" + "net/url" + "os" + "os/exec" + + "github.com/godbus/dbus/v5" + + "aether/internal/theme" +) + +const ( + wallpaperPortalBusName = "org.freedesktop.impl.portal.desktop.aether" + wallpaperPortalPath = "/org/freedesktop/portal/desktop" + wallpaperPortalInterface = "org.freedesktop.impl.portal.Wallpaper" + + // Portal response codes. + portalResponseSuccess = 0 + portalResponseOther = 2 +) + +// runWallpaperPortal serves the xdg-desktop-portal Wallpaper backend on the +// session bus. File managers and image viewers call it for "Set as +// Background"; Aether then applies the picture with a generated theme, as +// --generate does. D-Bus starts it on demand via the service file in +// contrib/portal. +func runWallpaperPortal(args []string) int { + conn, err := dbus.ConnectSessionBus() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: session bus: %v\n", err) + return 1 + } + defer conn.Close() + + if err := conn.Export(wallpaperPortal{}, wallpaperPortalPath, wallpaperPortalInterface); err != nil { + fmt.Fprintf(os.Stderr, "Error: export portal: %v\n", err) + return 1 + } + reply, err := conn.RequestName(wallpaperPortalBusName, dbus.NameFlagDoNotQueue) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: request bus name: %v\n", err) + return 1 + } + if reply != dbus.RequestNameReplyPrimaryOwner { + fmt.Fprintln(os.Stderr, "Wallpaper portal is already running") + return 0 + } + + select {} +} + +type wallpaperPortal struct{} + +// SetWallpaperURI implements org.freedesktop.impl.portal.Wallpaper. Aether +// themes the desktop background only, so a lock-screen-only request fails. +func (wallpaperPortal) SetWallpaperURI(handle dbus.ObjectPath, appID, parentWindow, uri string, options map[string]dbus.Variant) (uint32, *dbus.Error) { + if setOn, ok := options["set-on"].Value().(string); ok && setOn == "lockscreen" { + return portalResponseOther, nil + } + path, err := wallpaperPathFromURI(uri) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return portalResponseOther, nil + } + + self, err := os.Executable() + if err != nil { + self = "aether" + } + cmd := exec.Command(self, "--generate", path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Error: start aether --generate: %v\n", err) + return portalResponseOther, nil + } + // Reply right away: the caller only waits for the request to be + // accepted, and generating a theme can take a few seconds. + go cmd.Wait() + return portalResponseSuccess, nil +} + +// wallpaperPathFromURI turns the portal's file:// URI into a local image path. +func wallpaperPathFromURI(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil { + return "", fmt.Errorf("invalid wallpaper URI %q: %w", uri, err) + } + if u.Scheme != "file" || u.Path == "" { + return "", fmt.Errorf("unsupported wallpaper URI %q (expected file://)", uri) + } + if !theme.IsImageFile(u.Path) { + return "", fmt.Errorf("not an image file: %s", u.Path) + } + return u.Path, nil +} diff --git a/cli/wallpaper_portal_test.go b/cli/wallpaper_portal_test.go new file mode 100644 index 00000000..74948543 --- /dev/null +++ b/cli/wallpaper_portal_test.go @@ -0,0 +1,42 @@ +package cli + +import ( + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestWallpaperPathFromURI(t *testing.T) { + tests := []struct { + uri string + want string + ok bool + }{ + {"file:///home/me/Pictures/sky.jpg", "/home/me/Pictures/sky.jpg", true}, + {"file:///home/me/My%20Pictures/sky.png", "/home/me/My Pictures/sky.png", true}, + {"https://example.com/sky.jpg", "", false}, + {"file:///home/me/notes.txt", "", false}, + {"file://", "", false}, + } + for _, tt := range tests { + got, err := wallpaperPathFromURI(tt.uri) + if (err == nil) != tt.ok || got != tt.want { + t.Errorf("wallpaperPathFromURI(%q) = %q, %v; want %q, ok=%v", tt.uri, got, err, tt.want, tt.ok) + } + } +} + +func TestSetWallpaperURIRejectsLockScreenOnly(t *testing.T) { + options := map[string]dbus.Variant{"set-on": dbus.MakeVariant("lockscreen")} + got, err := wallpaperPortal{}.SetWallpaperURI("/request", "", "", "file:///home/me/sky.jpg", options) + if err != nil || got != portalResponseOther { + t.Fatalf("SetWallpaperURI() = %d, %v; want %d", got, err, portalResponseOther) + } +} + +func TestSetWallpaperURIRejectsNonImage(t *testing.T) { + got, err := wallpaperPortal{}.SetWallpaperURI("/request", "", "", "file:///home/me/notes.txt", nil) + if err != nil || got != portalResponseOther { + t.Fatalf("SetWallpaperURI() = %d, %v; want %d", got, err, portalResponseOther) + } +} diff --git a/contrib/portal/aether.portal b/contrib/portal/aether.portal new file mode 100644 index 00000000..b2550edd --- /dev/null +++ b/contrib/portal/aether.portal @@ -0,0 +1,3 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.aether +Interfaces=org.freedesktop.impl.portal.Wallpaper; diff --git a/contrib/portal/org.freedesktop.impl.portal.desktop.aether.service b/contrib/portal/org.freedesktop.impl.portal.desktop.aether.service new file mode 100644 index 00000000..33e73b71 --- /dev/null +++ b/contrib/portal/org.freedesktop.impl.portal.desktop.aether.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.impl.portal.desktop.aether +Exec=/usr/bin/aether --wallpaper-portal diff --git a/docs/wallpaper-portal.md b/docs/wallpaper-portal.md new file mode 100644 index 00000000..22abc26e --- /dev/null +++ b/docs/wallpaper-portal.md @@ -0,0 +1,39 @@ +# Set as Background + +Aether can handle "Set as Background" in file managers and image viewers. It applies the picture with a generated theme, as `aether --generate` does. + +These apps hand the picture to xdg-desktop-portal. On GNOME the portal sets the wallpaper itself. On Hyprland and other window managers no portal backend does, so the menu entry does nothing. + +## Setup + +`make install` installs two files: + +```text +~/.local/share/xdg-desktop-portal/portals/aether.portal +~/.local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.aether.service +``` + +Packages install them to `/usr/share/xdg-desktop-portal/portals/` and `/usr/share/dbus-1/services/`. D-Bus starts `aether --wallpaper-portal` when an app asks for it. + +Then select Aether as the Wallpaper backend for your desktop. On Hyprland, add the last line to `~/.config/xdg-desktop-portal/hyprland-portals.conf`. Without it, the portal picks the first configured backend that offers Wallpaper (usually `gtk`, which only works on GNOME). + +```ini +[preferred] +default=hyprland;gtk +org.freedesktop.impl.portal.Wallpaper=aether +``` + +Copy the `default=` line from `/usr/share/xdg-desktop-portal/hyprland-portals.conf`, because the user file replaces it. For other desktops use `-portals.conf`, where `` is `$XDG_CURRENT_DESKTOP` in lowercase. + +Restart the portal: + +```bash +systemctl --user restart xdg-desktop-portal +``` + +The first time an app sets a background, the portal asks whether apps may change it. Choose Allow. + +## Limits + +- Aether themes the desktop background. A request for the lock screen only is rejected. +- Only local still images are accepted.