diff --git a/daily-wallpaper/README.md b/daily-wallpaper/README.md
new file mode 100644
index 0000000..fbbe67f
--- /dev/null
+++ b/daily-wallpaper/README.md
@@ -0,0 +1,10 @@
+# Daily Wallpaper
+
+Fetches a daily wallpaper from Bing or NASA and applies it through the Noctalia 5 wallpaper API.
+
+The service checks on startup and then every 10 minutes. It downloads at most one image per source and Bing locale per day, stores its files in a dedicated `daily-wallpaper` directory, and removes cached images older than 5 days. Repeated failures are logged, but error notifications are limited to once per day.
+
+Settings:
+
+- Source: Bing or NASA.
+- Locale: Bing market locale such as `en-US`, `de-DE`, or `fr-FR`. NASA ignores this setting.
diff --git a/daily-wallpaper/daily_wallpaper.luau b/daily-wallpaper/daily_wallpaper.luau
new file mode 100644
index 0000000..dce80ed
--- /dev/null
+++ b/daily-wallpaper/daily_wallpaper.luau
@@ -0,0 +1,278 @@
+--!nonstrict
+-- Daily Wallpaper service for Noctalia 5.
+-- Fetches one Bing or NASA image per day, caches it locally, and applies it via
+-- the native Noctalia wallpaper API.
+
+local CHECK_INTERVAL_MS = 10 * 60 * 1000
+local RETENTION_SECONDS = 5 * 24 * 60 * 60
+local PLUGIN_CACHE_DIR = "daily-wallpaper"
+
+local checking = false
+local lastAppliedPath = ""
+local lastCheckedKey = ""
+local lastErrorNotificationDate = ""
+
+noctalia.setUpdateInterval(CHECK_INTERVAL_MS)
+
+local function todayString()
+ return os.date("%Y-%m-%d")
+end
+
+local function normalizeLocale(value)
+ if type(value) ~= "string" then
+ return "en-us"
+ end
+
+ local locale = noctalia.string.trim(value):lower():gsub("_", "-")
+ if locale == "" or locale:find("[^%w%-]") then
+ return "en-us"
+ end
+ return locale
+end
+
+local function source()
+ local configured = noctalia.getConfig("source")
+ if configured == "nasa" then
+ return "nasa"
+ end
+ return "bing"
+end
+
+local function downloadDir()
+ return noctalia.expandPath(`{noctalia.wallpaperDirectory()}/{PLUGIN_CACHE_DIR}`)
+end
+
+local function cachePath(prefix, date)
+ return `{downloadDir()}/{prefix}-{date}.jpg`
+end
+
+local function decodeHtml(value)
+ local decoded = value:gsub("&", "&")
+ decoded = decoded:gsub(""", '"')
+ decoded = decoded:gsub("'", "'")
+ return decoded
+end
+
+local function absoluteNasaUrl(url)
+ if url == nil or url == "" then
+ return ""
+ end
+ url = decodeHtml(url)
+ if url:match("^https?://") then
+ return url
+ end
+ if url:match("^//") then
+ return `https:{url}`
+ end
+ if url:sub(1, 1) == "/" then
+ return `https://www.nasa.gov{url}`
+ end
+ return `https://www.nasa.gov/{url}`
+end
+
+local function applyWallpaper(path, downloaded)
+ noctalia.setWallpaper(path)
+ lastAppliedPath = path
+ lastCheckedKey = `{source()}:{todayString()}`
+ noctalia.state.set("status", {
+ state = "applied",
+ path = path,
+ downloaded = downloaded,
+ })
+ noctalia.log(`Daily Wallpaper applied: {path}`)
+end
+
+local function cleanupOldWallpapers(dir)
+ local entries = noctalia.listDir(dir)
+ if type(entries) ~= "table" then
+ return
+ end
+
+ local cutoff = os.time() - RETENTION_SECONDS
+ for _, name in ipairs(entries) do
+ if name:match("^bing%-.+%-%d%d%d%d%-%d%d%-%d%d%.jpg$") or name:match("^nasa%-%d%d%d%d%-%d%d%-%d%d%.jpg$") then
+ local path = `{dir}/{name}`
+ local info = noctalia.fileInfo(path)
+ if type(info) == "table" and type(info.mtime) == "number" and info.mtime < cutoff then
+ noctalia.removeFile(path)
+ end
+ end
+ end
+end
+
+local function fail(message)
+ checking = false
+ noctalia.state.set("status", {
+ state = "error",
+ message = message,
+ })
+ local date = todayString()
+ if lastErrorNotificationDate ~= date then
+ lastErrorNotificationDate = date
+ noctalia.notifyError(noctalia.tr("notify.failed"), message)
+ end
+ noctalia.log(`Daily Wallpaper failed: {message}`)
+end
+
+local function downloadAndApply(primaryUrl, fallbackUrl, dest)
+ local function tryDownload(url, fallback)
+ if type(url) ~= "string" or url == "" then
+ if fallback ~= "" then
+ tryDownload(fallback, "")
+ else
+ fail("No wallpaper URL found")
+ end
+ return
+ end
+
+ local started = noctalia.download(url, dest, function(ok)
+ if ok then
+ checking = false
+ cleanupOldWallpapers(downloadDir())
+ applyWallpaper(dest, true)
+ noctalia.notify(noctalia.tr("notify.applied"), dest)
+ return
+ end
+
+ if fallback ~= "" then
+ tryDownload(fallback, "")
+ else
+ fail("Download failed")
+ end
+ end)
+
+ if not started then
+ fail("Could not start wallpaper download")
+ end
+ end
+
+ tryDownload(primaryUrl, fallbackUrl or "")
+end
+
+local function resolveBing(locale, done)
+ local url = `https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt={noctalia.string.urlEncode(locale)}`
+ local started = noctalia.http({ url = url }, function(res)
+ if not res.ok or res.status < 200 or res.status >= 300 then
+ fail(`Bing request failed: HTTP {res.status}`)
+ return
+ end
+
+ local parsed, err = noctalia.json.decode(res.body)
+ if parsed == nil then
+ fail(`Bing response parse failed: {err or "invalid JSON"}`)
+ return
+ end
+
+ local first = parsed.images and parsed.images[1]
+ local urlBase = first and first.urlbase
+ if type(urlBase) ~= "string" or urlBase == "" then
+ fail("Bing response did not include an image URL")
+ return
+ end
+
+ done(`bing-{locale}`, `https://www.bing.com{urlBase}_UHD.jpg`, `https://www.bing.com{urlBase}_1920x1080.jpg`)
+ end)
+
+ if not started then
+ fail("Could not start Bing request")
+ end
+end
+
+local function resolveNasa(done)
+ local started = noctalia.http({ url = "https://www.nasa.gov/image-of-the-day/" }, function(res)
+ if not res.ok or res.status < 200 or res.status >= 300 then
+ fail(`NASA request failed: HTTP {res.status}`)
+ return
+ end
+
+ local page = res.body
+ local primary = page:match('class="[^"]-hds%-gallery%-image[^"]-"[^>]->.-
]-src="([^"]+)"')
+ or page:match('
]-class="[^"]-hds%-gallery%-image[^"]-"[^>]-src="([^"]+)"')
+ local fallback = page:match('