DEV Community

Cover image for A Cross-Platform Daily Wallpaper Changer in One Python Script (Windows, macOS, Linux)
Kodnex Technologies
Kodnex Technologies

Posted on Originally published at nexwall.kodnextech.com

A Cross-Platform Daily Wallpaper Changer in One Python Script (Windows, macOS, Linux)

Disclosure: I build NexWall, the wallpaper API this script uses. The OS-specific "set wallpaper" code works with any image file, so feel free to point it at a different source.

Full source code: kodnextechnologies/nexwall-python (MIT licensed)

Want your desktop background to change every morning without installing yet another background app? This guide builds a single Python script that:

  1. Asks a wallpaper REST API for one random wallpaper (one request per day)
  2. Downloads it
  3. Fits it to your screen using a blurred-background "letterbox", because the source images are portrait
  4. Sets it as the wallpaper on Windows, macOS, or Linux (GNOME, KDE, or feh)
  5. Runs daily via Task Scheduler, launchd, or a systemd timer

The API

I'm using the NexWall free wallpaper API: 100 requests/day on the free tier, no credit card, and images up to 2160x3840. A daily changer uses one request per day, so the free tier is far more than enough.

Get a key at https://nexwall.kodnextech.com/developers/register, then:

pip install requests pillow
Enter fullscreen mode Exit fullscreen mode

The call we need:

curl "https://nexwall.kodnextech.com/api/developer/v1/wallpapers?sort=random&per_page=1&type=image" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json"
Enter fullscreen mode Exit fullscreen mode

It returns {"data": [{ "id": ..., "image_url": "...", "resolution": "2160x3840", "tags": "...", ... }], ...}.

A note on aspect ratio

NexWall is portrait-first (9:16, built for phones). On a 16:9 monitor, stretching a portrait image looks terrible and cropping it loses most of the picture. So the script does what phone galleries do: it scales the image to cover the screen, blurs that as a background, and centres the full image on top. It looks surprisingly good, especially with AMOLED and minimal styles. If you'd rather not, set FIT_MODE=cover to simply centre-crop.

The script

Save as nexwall_daily.py:

#!/usr/bin/env python3
"""
Daily wallpaper changer using the NexWall wallpaper REST API.
Works on Windows, macOS, and Linux (GNOME, KDE Plasma, or feh).

Env vars:
  NEXWALL_API_KEY      (required)  your API key
  NEXWALL_CATEGORY_ID  (optional)  numeric category id, see GET /categories
  WALLPAPER_SIZE       (optional)  e.g. "2560x1440", default "1920x1080"
  FIT_MODE             (optional)  "blur" (default) or "cover"
"""
import ctypes
import os
import platform
import subprocess
import sys
from pathlib import Path

import requests
from PIL import Image, ImageFilter

API_BASE = "https://nexwall.kodnextech.com/api/developer/v1"
OUT_DIR = Path.home() / ".nexwall"
KEEP_FILES = 7


def fetch_random_wallpaper(api_key: str, category_id: str | None) -> dict:
    params = {"sort": "random", "per_page": 1, "type": "image"}
    if category_id:
        params["category_id"] = int(category_id)

    r = requests.get(
        f"{API_BASE}/wallpapers",
        headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
        params=params,
        timeout=15,
    )
    if r.status_code == 401:
        sys.exit("Invalid or missing API key (HTTP 401).")
    if r.status_code == 429:
        sys.exit(f"Rate limited. Retry after {r.headers.get('Retry-After', '?')} seconds.")
    r.raise_for_status()

    items = r.json().get("data", [])
    if not items:
        sys.exit("No wallpapers returned. Check the category id.")
    return items[0]


def download(url: str, dest: Path) -> Path:
    with requests.get(url, stream=True, timeout=60) as r:
        r.raise_for_status()
        with open(dest, "wb") as f:
            for chunk in r.iter_content(chunk_size=1 << 16):
                f.write(chunk)
    return dest


def fit_to_screen(src: Path, dest: Path, size: tuple[int, int], mode: str) -> Path:
    W, H = size
    lanczos = Image.Resampling.LANCZOS
    with Image.open(src) as im:
        im = im.convert("RGB")

        # "cover": scale so the image fills the screen, then centre-crop.
        s = max(W / im.width, H / im.height)
        cover = im.resize((round(im.width * s), round(im.height * s)), lanczos)
        left, top = (cover.width - W) // 2, (cover.height - H) // 2
        cover = cover.crop((left, top, left + W, top + H))

        if mode == "cover":
            result = cover
        else:
            # "blur": blurred cover as background + full image centred on top.
            result = cover.filter(ImageFilter.GaussianBlur(40))
            s = min(W / im.width, H / im.height)
            fg = im.resize((round(im.width * s), round(im.height * s)), lanczos)
            result.paste(fg, ((W - fg.width) // 2, (H - fg.height) // 2))

        # JPEG: the most widely supported wallpaper format across desktops.
        result.save(dest, "JPEG", quality=92)
    return dest


def set_wallpaper(path: Path) -> None:
    path = path.resolve()
    system = platform.system()

    if system == "Windows":
        SPI_SETDESKWALLPAPER = 20
        SPIF_UPDATEINIFILE_SENDCHANGE = 0x01 | 0x02
        ok = ctypes.windll.user32.SystemParametersInfoW(
            SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE_SENDCHANGE
        )
        if not ok:
            raise OSError("SystemParametersInfoW failed")

    elif system == "Darwin":
        script = (
            'tell application "System Events" to tell every desktop '
            f'to set picture to POSIX file "{path}"'
        )
        subprocess.run(["osascript", "-e", script], check=True)

    else:  # Linux / BSD
        desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
        uri = path.as_uri()
        if any(d in desktop for d in ("gnome", "unity", "cinnamon", "budgie")):
            base = ["gsettings", "set", "org.gnome.desktop.background"]
            subprocess.run(base + ["picture-uri", uri], check=True)
            # GNOME 42+ uses a separate key for dark mode; ignore if missing.
            subprocess.run(base + ["picture-uri-dark", uri], check=False)
            subprocess.run(base + ["picture-options", "zoom"], check=False)
        elif "kde" in desktop:
            subprocess.run(["plasma-apply-wallpaperimage", str(path)], check=True)
        else:
            subprocess.run(["feh", "--bg-fill", str(path)], check=True)


def cleanup(directory: Path, keep: int) -> None:
    files = sorted(directory.glob("wallpaper-*.jpg"), key=lambda p: p.stat().st_mtime)
    for old in files[:-keep]:
        old.unlink(missing_ok=True)


def main() -> None:
    api_key = os.environ.get("NEXWALL_API_KEY")
    if not api_key:
        sys.exit("Set NEXWALL_API_KEY first.")

    w, h = (int(x) for x in os.environ.get("WALLPAPER_SIZE", "1920x1080").lower().split("x"))
    mode = os.environ.get("FIT_MODE", "blur")
    OUT_DIR.mkdir(parents=True, exist_ok=True)

    wp = fetch_random_wallpaper(api_key, os.environ.get("NEXWALL_CATEGORY_ID"))
    raw = download(wp["image_url"], OUT_DIR / "download.tmp")

    # Unique filename per wallpaper: macOS caches by path and may not refresh otherwise.
    final = fit_to_screen(raw, OUT_DIR / f"wallpaper-{wp['id']}.jpg", (w, h), mode)
    raw.unlink(missing_ok=True)

    set_wallpaper(final)
    cleanup(OUT_DIR, KEEP_FILES)
    print(f"Set wallpaper #{wp['id']} ({wp.get('resolution')}) tags: {wp.get('tags')}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

(Requires Python 3.10+ for the str | None hints. Pillow wheels include WebP support, which matters because the API serves .webp files.)

Try it once by hand:

# macOS / Linux
export NEXWALL_API_KEY=YOUR_API_KEY
python3 nexwall_daily.py

# Windows (PowerShell)
$env:NEXWALL_API_KEY = "YOUR_API_KEY"
python nexwall_daily.py
Enter fullscreen mode Exit fullscreen mode

Picking a category

List the categories your plan can access:

curl "https://nexwall.kodnextech.com/api/developer/v1/categories" \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Accept: application/json"
Enter fullscreen mode Exit fullscreen mode

Pick an id (for example, the "AMOLED & Pure Black" category) and set NEXWALL_CATEGORY_ID to it. The free plan covers the non-premium categories.

Running it every day

Windows: Task Scheduler

Store the key as a persistent user environment variable, then create a daily task. Use pythonw.exe so no console window flashes:

setx NEXWALL_API_KEY "YOUR_API_KEY"
setx WALLPAPER_SIZE "2560x1440"

schtasks /Create /SC DAILY /ST 08:00 /TN "NexWall Daily Wallpaper" `
  /TR "\"C:\Path\To\pythonw.exe\" \"C:\Path\To\nexwall_daily.py\""
Enter fullscreen mode Exit fullscreen mode

(Open a new terminal after setx. It doesn't affect the current session.)

macOS: launchd

~/Library/LaunchAgents/com.example.nexwall.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.example.nexwall</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/bin/python3</string>
    <string>/Users/you/scripts/nexwall_daily.py</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>NEXWALL_API_KEY</key><string>YOUR_API_KEY</string>
    <key>WALLPAPER_SIZE</key><string>2880x1800</string>
  </dict>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
  <key>RunAtLoad</key><true/>
</dict>
</plist>
Enter fullscreen mode Exit fullscreen mode
launchctl load ~/Library/LaunchAgents/com.example.nexwall.plist
Enter fullscreen mode Exit fullscreen mode

The first run will ask you to allow the process to control "System Events". Approve it once. Use the Python interpreter that actually has requests and pillow installed (check with which python3).

Linux: systemd user timer

Use a user timer rather than cron. User services run inside your session and can reach the D-Bus session bus that gsettings needs, whereas plain cron jobs often can't.

~/.config/systemd/user/nexwall.service:

[Unit]
Description=NexWall daily wallpaper

[Service]
Type=oneshot
Environment=NEXWALL_API_KEY=YOUR_API_KEY
Environment=WALLPAPER_SIZE=1920x1080
ExecStart=/usr/bin/python3 %h/scripts/nexwall_daily.py
Enter fullscreen mode Exit fullscreen mode

~/.config/systemd/user/nexwall.timer:

[Unit]
Description=Change wallpaper daily

[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode
systemctl --user daemon-reload
systemctl --user enable --now nexwall.timer
Enter fullscreen mode Exit fullscreen mode

Persistent=true means that if your laptop was asleep at 08:00, the change happens the next time it wakes.

Ideas to extend it

  • Search instead of category: add params["search"] = "neon" (2 to 100 characters, matched against tags).
  • Multi-monitor: call fit_to_screen once per resolution and use your desktop's per-monitor setting.
  • "Popular this week" mode: use sort=popular with a small per_page and pick one item locally.
  • Quota awareness: every response includes X-RateLimit-Remaining. Log it if you share the key across scripts.

Is the free tier really enough?

For this use case, yes: one request a day against a limit of 100. The paid plans (Pro $4.99/mo with 10,000 requests/day and premium categories; Ultra $10.99/mo with live MP4 wallpapers) are aimed at apps with many users, not personal scripts.

Links: Docs · OpenAPI spec · Free key

Disclosure: I build NexWall. If the script misbehaves on your desktop environment, drop a comment with your distro/DE and I'll help, and update the post.

Top comments (0)