diff --git a/bing-daily@keithdriscoll.nyc/README.md b/bing-daily@keithdriscoll.nyc/README.md
new file mode 100644
index 00000000000..fba84b1c110
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/README.md
@@ -0,0 +1,171 @@
+# Bing Daily โ Cinnamon Applet
+
+Sets your desktop wallpaper to the **Bing Image of the Day**, every day โ fetched exclusively via the [Peapix API](https://peapix.com). No connection to `bing.com` is ever made.
+
+
+
+---
+
+## Features
+
+- ๐ผ **Daily wallpaper** โ automatically fetches and sets the Bing Image of the Day
+- ๐ **Region selection** โ choose your region for locally relevant images and cultural events (e.g. July 4th in the US, Cherry Blossom season in Japan, Diwali in India)
+- ๐
**Flexible frequency** โ update daily, weekly, or monthly
+- ๐ **Custom update time** โ choose what time of day the refresh runs
+- ๐ **Image history** โ browse backwards and forwards through past wallpapers
+- ๐ฆ **Populate History** โ bulk-download the last ~8 days in one click
+- ๐๏ธ **Clear All Images** โ wipe the local cache from the menu
+- ๐ **Privacy first** โ zero Microsoft connections, zero telemetry
+- โ๏ธ **systemd timer** โ reliable background scheduling, no polling loops
+- ๐ **Zero dependencies** โ Python 3 stdlib only, nothing to `pip install`
+- ๐จ **Symbolic icon** โ follows your panel theme, light or dark
+
+---
+
+## Requirements
+
+| Component | Provided by |
+|-----------|-------------|
+| Linux Mint 21.x or 22.x with Cinnamon | โ |
+| Python 3 (stdlib only) | Pre-installed on all Ubuntu/Mint |
+| systemd (user session) | Pre-installed on all Ubuntu/Mint |
+| `gsettings` | Pre-installed with Cinnamon |
+
+No additional packages need to be installed.
+
+---
+
+## Install
+
+### Via Cinnamon Spices (recommended)
+
+1. Right-click your Cinnamon panel โ **Applets**
+2. Go to the **Download** tab
+3. Search for **Bing Daily** โ click **Install**
+4. Switch to the **Manage** tab โ find **Bing Daily** โ click **+**
+5. Click **Done**
+
+### Manual install
+
+```bash
+git clone https://github.com/keithdriscoll/bing-daily.git
+cd bing-daily
+bash install.sh
+```
+
+Then add the applet to your panel:
+
+1. Right-click your Cinnamon panel โ **Applets**
+2. Find **Bing Daily** โ click **+**
+3. Click **Done**
+
+---
+
+## Applet Menu
+
+| Item | Action |
+|------|--------|
+| Refresh Now | Fetch today's image immediately |
+| โ Previous Image | Switch to a newer image in history |
+| โถ Next Image | Switch to an older image in history |
+| Open Current Image | Open the image in your default viewer |
+| Image Info | Show title and copyright of the current image |
+| Populate History | Bulk-download the last ~8 days of images |
+| Clear All Images | Remove all cached wallpapers from disk |
+| Settings | Open the applet settings panel |
+| About | Show version info and open project page |
+
+---
+
+## Settings
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| Auto-update | On | Enable/disable automatic refresh |
+| Frequency | Daily | How often to fetch a new image (Daily / Weekly / Monthly) |
+| Update time | 08:00 | Time of day for automatic refresh |
+| Region | Global | Region for image selection โ affects local holidays and cultural imagery |
+| History limit | 30 | Maximum number of wallpapers to keep on disk |
+
+### About Region
+
+Bing curates daily images around local events, national holidays, and cultural moments. Selecting your region means you'll see imagery that's actually relevant to where you live:
+
+- ๐บ๐ธ **United States** โ Independence Day, Thanksgiving, national parks
+- ๐ฏ๐ต **Japan** โ Cherry blossom season, national holidays
+- ๐ฎ๐ณ **India** โ Diwali, Holi, local landmarks
+- ๐ **Global** โ Bing's worldwide pick, no regional bias
+
+---
+
+## Privacy
+
+- **No connection to Microsoft** โ images are fetched exclusively via [Peapix](https://peapix.com/api), never from `bing.com`
+- **No telemetry** โ nothing phoned home, ever
+- **URL parameters stripped before logging** โ no identifying data in `log.txt`
+- **All data stored locally** โ `~/.cache/bing-daily/` only
+
+---
+
+## File Locations
+
+| Path | Contents |
+|------|----------|
+| `~/.local/share/cinnamon/applets/bing-daily@keithdriscoll.nyc/` | Applet code |
+| `~/.cache/bing-daily/` | Downloaded images and history |
+| `~/.cache/bing-daily/log.txt` | All events and errors |
+| `~/.cache/bing-daily/history.json` | Image metadata history |
+| `~/.config/bing-daily/config.json` | User settings |
+| `~/.config/systemd/user/bing-daily.{service,timer}` | systemd units |
+
+---
+
+## Troubleshooting
+
+**Read the log:**
+```bash
+tail -f ~/.cache/bing-daily/log.txt
+```
+
+**Check the timer:**
+```bash
+systemctl --user status bing-daily.timer
+```
+
+**Manual refresh:**
+```bash
+python3 ~/.local/share/cinnamon/applets/bing-daily@keithdriscoll.nyc/engine/bing_engine.py refresh
+```
+
+**Wallpaper not changing after refresh:**
+```bash
+gsettings get org.cinnamon.desktop.background picture-uri
+```
+
+**Applet not loading:**
+```bash
+grep -i bing ~/.xsession-errors
+```
+Then reload Cinnamon: `Alt+F2` โ type `r` โ Enter.
+
+**Note:** The systemd service waits 30 seconds after boot before running, to allow the network to come up. This is normal โ the wallpaper will update shortly after login.
+
+---
+
+## Uninstall
+
+```bash
+systemctl --user disable --now bing-daily.timer
+rm ~/.config/systemd/user/bing-daily.{service,timer}
+systemctl --user daemon-reload
+rm -rf ~/.local/share/cinnamon/applets/bing-daily@keithdriscoll.nyc
+
+# Optional: remove cached images
+rm -rf ~/.cache/bing-daily
+```
+
+---
+
+## License
+
+MIT โ ยฉ 2026 Keith Driscoll ยท [keithdriscoll.nyc](https://keithdriscoll.nyc)
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/applet.js b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/applet.js
new file mode 100644
index 00000000000..29b3f3e8e48
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/applet.js
@@ -0,0 +1,442 @@
+// Bing-Daily
+// Copyright (c) 2026 Keith Driscoll
+// https://keithdriscoll.nyc/projects/bing-daily
+// Licensed under the MIT License. See LICENSE file for details.
+
+// Bing Daily Cinnamon Applet
+// Works on Cinnamon 5.x (Mint 21.x) and Cinnamon 6.x (Mint 22.x).
+// All network operations are delegated to the Python engine via subprocess โ
+// no Soup import is needed and there are no Soup2/Soup3 compatibility issues.
+
+const Applet = imports.ui.applet;
+const GLib = imports.gi.GLib;
+const Gio = imports.gi.Gio;
+const St = imports.gi.St;
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const Settings = imports.ui.settings;
+const Util = imports.misc.util;
+const Gettext = imports.gettext;
+
+// Cinnamon version (informational โ no Soup-related branching needed)
+const CINNAMON_VERSION = imports.misc.config.PACKAGE_VERSION;
+const CINNAMON_MAJOR = parseInt(CINNAMON_VERSION.split('.')[0]);
+
+// Translations
+const UUID = "bing-daily@keithdriscoll.nyc";
+Gettext.bindtextdomain(UUID, GLib.get_user_data_dir() + "/locale");
+function _(text) {
+ return Gettext.dgettext(UUID, text);
+}
+
+// ---------------------------------------------------------------------------
+// Applet class
+// ---------------------------------------------------------------------------
+
+class BingWallpaperApplet extends Applet.IconApplet {
+
+ constructor(metadata, orientation, panel_height, instance_id) {
+ super(orientation, panel_height, instance_id);
+
+ this.metadata = metadata;
+ this._enginePath = metadata.path + '/engine/bing_engine.py';
+
+ // Panel icon โ symbolic SVG follows panel text colour on supporting themes
+ try {
+ this.set_applet_icon_path(metadata.path + '/icons/bing-daily-symbolic.svg');
+ } catch (e) {
+ // Fall back to a named icon if the SVG is missing
+ this.set_applet_icon_name('photo');
+ }
+ this.set_applet_tooltip(_("Bing Daily"));
+
+ // Settings
+ this.settings = new Settings.AppletSettings(
+ this, 'bing-daily@keithdriscoll.nyc', this.instance_id
+ );
+ this.settings.bindProperty(
+ Settings.BindingDirection.IN,
+ 'auto_update', 'auto_update',
+ this._onSettingsChanged.bind(this), null
+ );
+ this.settings.bindProperty(
+ Settings.BindingDirection.IN,
+ 'update_time', 'update_time',
+ this._onSettingsChanged.bind(this), null
+ );
+ this.settings.bindProperty(
+ Settings.BindingDirection.IN,
+ 'frequency', 'frequency',
+ this._onSettingsChanged.bind(this), null
+ );
+ this.settings.bindProperty(
+ Settings.BindingDirection.IN,
+ 'region', 'region',
+ this._onSettingsChanged.bind(this), null
+ );
+ this.settings.bindProperty(
+ Settings.BindingDirection.IN,
+ 'history_limit', 'history_limit',
+ this._onSettingsChanged.bind(this), null
+ );
+
+ // Build menu
+ this.menuManager = new PopupMenu.PopupMenuManager(this);
+ this.menu = new Applet.AppletPopupMenu(this, orientation);
+ this.menuManager.addMenu(this.menu);
+ this._buildMenu();
+
+ // Silently refresh on startup โ only notify if a new image is downloaded
+ this._runEngine(['refresh'], (exitCode, stdout, stderr) => {
+ this._handleRefreshResult(exitCode, stdout, stderr, /*silent=*/true);
+ });
+
+ // Re-refresh silently on any network connect event (including reconnecting to the same network)
+ this._networkMonitor = Gio.NetworkMonitor.get_default();
+ this._networkRefreshTimeout = null;
+ this._networkChangedId = this._networkMonitor.connect('network-changed', (monitor, available) => {
+ if (available) {
+ // Debounce: cancel any pending refresh so rapid signals don't stack up
+ if (this._networkRefreshTimeout) {
+ GLib.source_remove(this._networkRefreshTimeout);
+ this._networkRefreshTimeout = null;
+ }
+ // Wait 5s for connection to stabilize before refreshing
+ this._networkRefreshTimeout = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 5, () => {
+ this._networkRefreshTimeout = null;
+ this._runEngine(['refresh'], (exitCode, stdout, stderr) => {
+ this._handleRefreshResult(exitCode, stdout, stderr, /*silent=*/true);
+ });
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ });
+ }
+
+ // -----------------------------------------------------------------------
+ // Menu
+ // -----------------------------------------------------------------------
+
+ _buildMenu() {
+ this.menu.removeAll();
+
+ // Refresh Now
+ let refreshItem = new PopupMenu.PopupMenuItem(_("Refresh Now"));
+ refreshItem.connect('activate', () => {
+ this._runEngine(['refresh'], (exitCode, stdout, stderr) => {
+ this._handleRefreshResult(exitCode, stdout, stderr, /*silent=*/false);
+ });
+ });
+ this.menu.addMenuItem(refreshItem);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ // Previous Image (older)
+ let prevItem = new PopupMenu.PopupMenuItem(_("\u25C0 Previous Image"));
+ prevItem.connect('activate', () => {
+ this._runEngine(['prev'], (exitCode, stdout, stderr) => {
+ this._handleNavResult(exitCode, stdout, stderr);
+ });
+ });
+ this.menu.addMenuItem(prevItem);
+
+ // Next Image (newer)
+ let nextItem = new PopupMenu.PopupMenuItem(_("\u25B6 Next Image"));
+ nextItem.connect('activate', () => {
+ this._runEngine(['next'], (exitCode, stdout, stderr) => {
+ this._handleNavResult(exitCode, stdout, stderr);
+ });
+ });
+ this.menu.addMenuItem(nextItem);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ // Open Current Image
+ let openItem = new PopupMenu.PopupMenuItem(_("Open Current Image"));
+ openItem.connect('activate', () => {
+ this._runEngine(['open'], (exitCode, stdout, stderr) => {
+ if (exitCode !== 0) {
+ this._notify(_("Error opening image โ see log for details"));
+ }
+ });
+ });
+ this.menu.addMenuItem(openItem);
+
+ // Image Info
+ let infoItem = new PopupMenu.PopupMenuItem(_("Image Info"));
+ infoItem.connect('activate', () => {
+ this._runEngine(['info'], (exitCode, stdout, stderr) => {
+ if (exitCode === 0 && stdout) {
+ try {
+ let info = JSON.parse(stdout);
+ let msg = info.title || _("No title");
+ if (info.copyright) msg += "\n" + info.copyright;
+ this._notify(msg);
+ } catch (e) {
+ this._notify(stdout.substring(0, 200));
+ }
+ } else {
+ this._notify(_("Could not retrieve image info"));
+ }
+ });
+ });
+ this.menu.addMenuItem(infoItem);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ // Populate History
+ let populateItem = new PopupMenu.PopupMenuItem(_("Populate History"));
+ populateItem.connect('activate', () => {
+ this._notify(_("Downloading wallpaper history..."));
+ this._runEngine(['populate'], (exitCode, stdout, stderr) => {
+ if (exitCode !== 0) {
+ this._notify(_("Bing Daily: Error during populate โ see log for details"));
+ } else if (stdout.startsWith('populated:')) {
+ let parts = stdout.split(':');
+ let dl = parseInt(parts[1]) || 0;
+ let failed = parseInt(parts[2]) || 0;
+ let msg = _("%d image(s) downloaded").format(dl);
+ if (failed > 0) msg += _(", %d failed").format(failed);
+ this._notify(msg);
+ }
+ });
+ });
+ this.menu.addMenuItem(populateItem);
+
+ // Clear All Images
+ let clearItem = new PopupMenu.PopupMenuItem(_("Clear All Images"));
+ clearItem.connect('activate', () => {
+ this._runEngine(['clear'], (exitCode, stdout, stderr) => {
+ if (exitCode !== 0) {
+ this._notify(_("Bing Daily: Error during clear โ see log for details"));
+ } else {
+ this._notify(_("All images cleared"));
+ }
+ });
+ });
+ this.menu.addMenuItem(clearItem);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ // Settings
+ let settingsItem = new PopupMenu.PopupMenuItem(_("Settings"));
+ settingsItem.connect('activate', () => {
+ this._openSettings();
+ });
+ this.menu.addMenuItem(settingsItem);
+
+ // About
+ let aboutItem = new PopupMenu.PopupMenuItem(_("About"));
+ aboutItem.connect('activate', () => {
+ this._notify(
+ _("Bing Daily v1.0.1\nBy Keith Driscoll\nSets your desktop to the Bing Image of the Day.\nWorks on Cinnamon 5.x and 6.x.")
+ );
+ });
+ this.menu.addMenuItem(aboutItem);
+ }
+
+ // -----------------------------------------------------------------------
+ // Engine runner
+ // -----------------------------------------------------------------------
+
+ _runEngine(args, callback) {
+ try {
+ let proc = new Gio.Subprocess({
+ argv: ['/usr/bin/python3', this._enginePath].concat(args),
+ flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
+ });
+ proc.init(null);
+ proc.communicate_utf8_async(null, null, (proc, res) => {
+ try {
+ let [, stdout, stderr] = proc.communicate_utf8_finish(res);
+ let exitCode = proc.get_exit_status();
+ if (callback) {
+ callback(
+ exitCode,
+ stdout ? stdout.trim() : '',
+ stderr ? stderr.trim() : ''
+ );
+ }
+ } catch (e) {
+ global.logError('BingWallpaper: subprocess result error: ' + e);
+ }
+ });
+ } catch (e) {
+ global.logError('BingWallpaper: failed to launch engine: ' + e);
+ if (callback) callback(1, '', String(e));
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Result handlers
+ // -----------------------------------------------------------------------
+
+ _handleRefreshResult(exitCode, stdout, stderr, silent) {
+ if (exitCode !== 0) {
+ // Always notify on error โ never silent
+ if (stderr && stderr.toLowerCase().includes('network')) {
+ this._notify(_("Bing Daily: Network error โ check your connection"));
+ } else {
+ this._notify(
+ _("Bing Daily: Error โ see ~/.cache/bing-daily/log.txt")
+ );
+ }
+ return;
+ }
+
+ if (stdout.includes("Wallpaper set to today's image")) {
+ if (!silent) {
+ this._notify(_("Wallpaper set to today's image"));
+ }
+ // silent=true on startup: do not notify when already up to date
+ } else {
+ // New image was downloaded โ always notify
+ this._notify(_("Wallpaper updated"));
+ }
+ }
+
+ _handleNavResult(exitCode, stdout, stderr) {
+ if (exitCode !== 0) {
+ if (stderr && stderr.toLowerCase().includes('network')) {
+ this._notify(_("Bing Daily: Network error โ check your connection"));
+ } else {
+ this._notify(
+ _("Bing Daily: Error โ see ~/.cache/bing-daily/log.txt")
+ );
+ }
+ return;
+ }
+
+ if (stdout === 'BOUNDARY:oldest') {
+ this._notify(_("Already at oldest image"));
+ } else if (stdout === 'BOUNDARY:newest') {
+ this._notify(_("Already at newest image"));
+ }
+ // Otherwise wallpaper changed silently โ no notification needed
+ }
+
+ // -----------------------------------------------------------------------
+ // Notifications
+ // -----------------------------------------------------------------------
+
+ _notify(message) {
+ Main.notify(_("Bing Daily"), message);
+ }
+
+ // -----------------------------------------------------------------------
+ // Settings change handler
+ // -----------------------------------------------------------------------
+
+ _onSettingsChanged() {
+ let config = {
+ region: (this.region !== undefined && this.region !== null) ? this.region : 'us',
+ history_limit: this.history_limit || 30,
+ frequency: this.frequency || 'daily'
+ };
+ let configDir = GLib.get_user_config_dir() + '/bing-daily';
+ GLib.mkdir_with_parents(configDir, 0o755);
+ let configPath = configDir + '/config.json';
+ let file = Gio.File.new_for_path(configPath);
+ let bytes = GLib.Bytes.new(JSON.stringify(config, null, 2));
+ file.replace_contents_async(bytes.get_data(), null, false,
+ Gio.FileCreateFlags.REPLACE_DESTINATION, null, (f, res) => {
+ try {
+ f.replace_contents_finish(res);
+ f.set_attribute_uint32('unix::mode', 0o600,
+ Gio.FileQueryInfoFlags.NONE, null);
+ } catch (e) {
+ global.logError('BingWallpaper: failed to write config: ' + e);
+ }
+ });
+
+ // Compute time string from update_time (seconds since midnight)
+ let hours = Math.floor(this.update_time / 3600).toString().padStart(2, '0');
+ let minutes = Math.floor((this.update_time % 3600) / 60).toString().padStart(2, '0');
+ let timeString = hours + ':' + minutes;
+
+ // Build OnCalendar expression based on frequency
+ let onCalendar;
+ if (this.frequency === 'weekly') {
+ onCalendar = 'Mon *-*-* ' + timeString + ':00';
+ } else if (this.frequency === 'monthly') {
+ onCalendar = '*-*-01 ' + timeString + ':00';
+ } else {
+ onCalendar = '*-*-* ' + timeString + ':00';
+ }
+
+ // Write the systemd timer file dynamically
+ let timerContent = '[Unit]\nDescription=Bing Daily Wallpaper Refresh Timer\n\n[Timer]\nOnCalendar=' + onCalendar + '\nPersistent=true\nUnit=bing-daily.service\n\n[Install]\nWantedBy=timers.target\n';
+ let timerPath = GLib.get_user_config_dir() + '/systemd/user/bing-daily.timer';
+ let timerFile = Gio.File.new_for_path(timerPath);
+ let timerBytes = GLib.Bytes.new(timerContent);
+ timerFile.replace_contents_async(timerBytes.get_data(), null, false,
+ Gio.FileCreateFlags.REPLACE_DESTINATION, null, (f, res) => {
+ try {
+ f.replace_contents_finish(res);
+ } catch (e) {
+ global.logError('BingWallpaper: failed to write timer: ' + e);
+ }
+ // Reload systemd timer after file is written
+ Util.spawn(['systemctl', '--user', 'daemon-reload']);
+ if (this.auto_update) {
+ Util.spawn(['systemctl', '--user', 'enable', '--now', 'bing-daily.timer']);
+ } else {
+ Util.spawn(['systemctl', '--user', 'disable', '--now', 'bing-daily.timer']);
+ }
+ });
+ }
+
+ // -----------------------------------------------------------------------
+ // Settings launcher
+ // -----------------------------------------------------------------------
+
+ _openSettings() {
+ Util.spawn(['cinnamon-settings', 'applets']);
+ }
+
+ // -----------------------------------------------------------------------
+ // Applet click
+ // -----------------------------------------------------------------------
+
+ on_applet_clicked() {
+ this.menu.toggle();
+ }
+
+ // -----------------------------------------------------------------------
+ // Cleanup
+ // -----------------------------------------------------------------------
+
+ on_applet_removed_from_panel() {
+ Util.spawn(['systemctl', '--user', 'disable', '--now', 'bing-daily.timer']);
+ if (this._networkRefreshTimeout) {
+ GLib.source_remove(this._networkRefreshTimeout);
+ this._networkRefreshTimeout = null;
+ }
+ if (this._networkChangedId) {
+ this._networkMonitor.disconnect(this._networkChangedId);
+ this._networkChangedId = null;
+ }
+ if (this.settings) {
+ this.settings.finalize();
+ }
+
+ // Clean up systemd unit files left on disk
+ let systemdDir = GLib.get_user_config_dir() + '/systemd/user/';
+ for (let name of ['bing-daily.service', 'bing-daily.timer']) {
+ try {
+ Gio.File.new_for_path(systemdDir + name).delete(null);
+ } catch (e) {
+ // File may not exist โ not an error
+ }
+ }
+ Util.spawn(['systemctl', '--user', 'daemon-reload']);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Factory function required by Cinnamon
+// ---------------------------------------------------------------------------
+
+function main(metadata, orientation, panel_height, instance_id) {
+ return new BingWallpaperApplet(metadata, orientation, panel_height, instance_id);
+}
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/engine/bing_engine.py b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/engine/bing_engine.py
new file mode 100644
index 00000000000..39b01f67895
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/engine/bing_engine.py
@@ -0,0 +1,583 @@
+#!/usr/bin/env python3
+
+# Bing-Daily
+# Copyright (c) 2026 Keith Driscoll
+# https://keithdriscoll.nyc/projects/bing-daily
+# Licensed under the MIT License. See LICENSE file for details.
+
+"""
+Bing Daily Engine
+Fetches and manages Bing Image of the Day wallpapers.
+Images fetched exclusively via the Peapix API.
+"""
+
+import hashlib
+import json
+import logging
+import os
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+from urllib import request
+from urllib.error import URLError, HTTPError
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+CACHE_DIR = Path.home() / ".cache" / "bing-daily"
+HISTORY_FILE = CACHE_DIR / "history.json"
+INDEX_FILE = CACHE_DIR / "current_index"
+LOG_FILE = CACHE_DIR / "log.txt"
+
+# ---------------------------------------------------------------------------
+# Config defaults
+# ---------------------------------------------------------------------------
+DEFAULT_HISTORY_LIMIT = 30
+CONFIG_FILE = Path.home() / ".config" / "bing-daily" / "config.json"
+
+# ---------------------------------------------------------------------------
+# API endpoints
+# ---------------------------------------------------------------------------
+PEAPIX_BASE = "https://peapix.com/bing/feed"
+
+# ---------------------------------------------------------------------------
+# Logging setup
+# ---------------------------------------------------------------------------
+
+def setup_logging():
+ """Configure logging to both file and stderr."""
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ logger = logging.getLogger("bing_wallpaper")
+ logger.setLevel(logging.DEBUG)
+ if not logger.handlers:
+ # File handler โ append
+ fh = logging.FileHandler(LOG_FILE, mode="a", encoding="utf-8")
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
+ logger.addHandler(fh)
+ # Stderr handler โ only WARNING and above
+ sh = logging.StreamHandler(sys.stderr)
+ sh.setLevel(logging.WARNING)
+ sh.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
+ logger.addHandler(sh)
+ return logger
+
+
+log = setup_logging()
+
+
+def _safe_url(url: str) -> str:
+ """Strip query string from a URL before logging (no identifying params in log)."""
+ return url.split("?")[0]
+
+
+def md5_file(path: Path) -> str:
+ """Return MD5 hex digest of a file's contents."""
+ return hashlib.md5(path.read_bytes()).hexdigest()
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def get_history_limit() -> int:
+ """Read history_limit from config file, fall back to default."""
+ try:
+ if CONFIG_FILE.exists():
+ data = json.loads(CONFIG_FILE.read_text())
+ return int(data.get("history_limit", DEFAULT_HISTORY_LIMIT))
+ except Exception:
+ pass
+ return DEFAULT_HISTORY_LIMIT
+
+
+def get_region() -> str:
+ """Read region from config file, fall back to '' (Global). Empty string = Global feed."""
+ try:
+ if CONFIG_FILE.exists():
+ data = json.loads(CONFIG_FILE.read_text())
+ if "region" in data:
+ return str(data["region"])
+ except Exception:
+ pass
+ return ""
+
+
+def get_frequency() -> str:
+ """Read frequency from config file, fall back to 'daily'."""
+ try:
+ if CONFIG_FILE.exists():
+ data = json.loads(CONFIG_FILE.read_text())
+ return str(data.get("frequency", "daily"))
+ except Exception:
+ pass
+ return "daily"
+
+
+def load_history() -> list:
+ """Load history.json; return empty list on any error."""
+ if not HISTORY_FILE.exists():
+ log.info("history.json not found โ starting fresh")
+ return []
+ try:
+ data = json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
+ if not isinstance(data, list):
+ log.warning("history.json is not a list โ resetting")
+ return []
+ return data
+ except json.JSONDecodeError as e:
+ log.error("Failed to parse history.json: %s", e)
+ return []
+
+
+def save_history(history: list):
+ """Write history list to disk."""
+ HISTORY_FILE.write_text(json.dumps(history, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def load_index() -> int:
+ """Load current_index; default to 0 on any error."""
+ try:
+ if INDEX_FILE.exists():
+ return int(INDEX_FILE.read_text().strip())
+ except (ValueError, OSError):
+ pass
+ return 0
+
+
+def save_index(idx: int):
+ INDEX_FILE.write_text(str(idx))
+
+
+def set_wallpaper(local_path: str):
+ """Set Cinnamon desktop wallpaper via gsettings (both light and dark keys)."""
+ uri = f"file://{local_path}"
+ # picture-uri โ required key, log error on failure
+ result = subprocess.run(
+ ["gsettings", "set", "org.cinnamon.desktop.background", "picture-uri", uri],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ log.error(
+ "gsettings set picture-uri failed (exit %d): %s",
+ result.returncode,
+ result.stderr.strip(),
+ )
+ else:
+ log.info("Set wallpaper picture-uri โ %s", uri)
+ # picture-uri-dark โ optional (Cinnamon 6.2+), silently skip if unsupported
+ result = subprocess.run(
+ ["gsettings", "set", "org.cinnamon.desktop.background", "picture-uri-dark", uri],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ log.info("picture-uri-dark not supported on this Cinnamon version (skipping)")
+ else:
+ log.info("Set wallpaper picture-uri-dark โ %s", uri)
+
+
+def http_get(url: str, timeout: int = 15) -> bytes:
+ """
+ Fetch a URL; retry once after 5 s on network errors.
+ Raises URLError / HTTPError on persistent failure.
+ """
+ # Intentional: Chrome UA is standard practice for image API access.
+ # Without it, some CDNs return 403. No personal data is included.
+ headers = {
+ "User-Agent": (
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ )
+ }
+ req = request.Request(url, headers=headers)
+ for attempt in range(2):
+ try:
+ with request.urlopen(req, timeout=timeout) as resp:
+ if resp.status != 200:
+ raise HTTPError(url, resp.status, "Non-200 response", {}, None)
+ return resp.read()
+ except (URLError, OSError) as e:
+ if attempt == 0:
+ log.warning("Network error on %s: %s โ retrying in 5 s", _safe_url(url), e)
+ time.sleep(5)
+ else:
+ raise
+
+
+def download_image(url: str, dest: Path) -> bool:
+ """Download image to dest. Returns True on success."""
+ try:
+ data = http_get(url)
+ dest.write_bytes(data)
+ log.info("Downloaded image from %s โ %s (%d bytes)", _safe_url(url), dest, len(data))
+ return True
+ except Exception as e:
+ log.error("Failed to download image from %s: %s", _safe_url(url), e)
+ return False
+
+
+# ---------------------------------------------------------------------------
+# API fetchers
+# ---------------------------------------------------------------------------
+
+def fetch_peapix() -> dict:
+ """
+ Fetch from Peapix. Returns a normalised entry dict or raises.
+ Normalised keys: date, title, copyright, url
+ """
+ region = get_region()
+ if region:
+ url = PEAPIX_BASE + '?country=' + region
+ else:
+ url = PEAPIX_BASE
+ log.info("Trying Peapix API: %s", _safe_url(url))
+ raw = http_get(url)
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as e:
+ snippet = raw[:500].decode("utf-8", errors="replace")
+ log.error("Peapix returned invalid JSON: %s | Raw: %s", e, snippet)
+ raise
+
+ if not isinstance(data, list) or len(data) == 0:
+ raise ValueError("Peapix response is empty or not a list")
+
+ item = data[0]
+ url = item.get("fullUrl") or item.get("imageUrl") or item.get("thumbUrl")
+ if not url:
+ raise ValueError(f"Peapix item missing image URL keys: {list(item.keys())}")
+
+ entry = {
+ "date": datetime.now().strftime("%Y%m%d"),
+ "title": item.get("title", "Bing Image of the Day"),
+ "copyright": item.get("copyright", ""),
+ "url": url,
+ }
+ log.info("Peapix entry: title=%r url=%s", entry["title"], _safe_url(entry["url"]))
+ return entry
+
+
+def fetch_today() -> dict:
+ """
+ Fetch today's image from Peapix.
+ Raises RuntimeError on failure.
+ """
+ try:
+ return fetch_peapix()
+ except Exception as e:
+ log.error("Peapix failed: %s", e)
+ raise RuntimeError(
+ f"Failed to fetch image from Peapix: {e}\n"
+ f"Check your network connection or see {LOG_FILE}"
+ ) from e
+
+
+def fetch_peapix_all() -> list:
+ """
+ Fetch all items from the Peapix feed (typically 8 days, newest first).
+ Returns a list of normalised entry dicts: {date, title, copyright, url}.
+ Dates are inferred as today, yesterday, etc. (Peapix doesn't include them).
+ """
+ from datetime import timedelta
+ region = get_region()
+ url = PEAPIX_BASE + '?country=' + region if region else PEAPIX_BASE
+ log.info("Fetching full Peapix feed: %s", _safe_url(url))
+ raw = http_get(url)
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as e:
+ snippet = raw[:500].decode("utf-8", errors="replace")
+ log.error("Peapix returned invalid JSON: %s | Raw: %s", e, snippet)
+ raise
+ if not isinstance(data, list) or len(data) == 0:
+ raise ValueError("Peapix response is empty or not a list")
+
+ today = datetime.now()
+ entries = []
+ for i, item in enumerate(data):
+ img_url = item.get("fullUrl") or item.get("imageUrl") or item.get("thumbUrl")
+ if not img_url:
+ log.warning("Peapix item %d missing image URL โ skipping", i)
+ continue
+ date_str = (today - timedelta(days=i)).strftime("%Y%m%d")
+ entries.append({
+ "date": date_str,
+ "title": item.get("title", "Bing Image of the Day"),
+ "copyright": item.get("copyright", ""),
+ "url": img_url,
+ })
+ log.info("Peapix feed: %d items", len(entries))
+ return entries
+
+
+# ---------------------------------------------------------------------------
+# Commands
+# ---------------------------------------------------------------------------
+
+def cmd_refresh():
+ """Fetch today's image for the current region and set as wallpaper."""
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ # Use LOCAL date โ UTC can roll to tomorrow while locally it's still today
+ today = datetime.now().strftime("%Y%m%d")
+ dest = CACHE_DIR / f"{today}.jpg"
+ current_region = get_region()
+
+ history = load_history()
+
+ # Fast path: already have today's image AND it was fetched for this exact region
+ today_entry = history[0] if history and history[0].get("date") == today else None
+ if dest.exists() and today_entry and today_entry.get("region") == current_region:
+ log.info("Image for %s (%s) already cached โ skipping", today, current_region or "global")
+ print("Wallpaper set to today's image")
+ save_index(0)
+ set_wallpaper(str(dest))
+ return
+
+ # Need to fetch โ either no file yet, or region changed
+ try:
+ entry = fetch_today()
+ except RuntimeError as e:
+ print(str(e), file=sys.stderr)
+ sys.exit(1)
+
+ if dest.exists():
+ # Region changed โ download to a temp file and compare MD5 before replacing
+ tmp = dest.with_suffix(".tmp")
+ if not download_image(entry["url"], tmp):
+ print(f"Failed to download image. Check {LOG_FILE} for details.", file=sys.stderr)
+ sys.exit(1)
+
+ if md5_file(tmp) == md5_file(dest):
+ # Same image for both regions (e.g. Global and US share today's pick)
+ tmp.unlink()
+ log.info("Region changed to %r but image is identical โ updating stored region", current_region or "global")
+ if today_entry:
+ today_entry["region"] = current_region
+ save_history(history)
+ print("Wallpaper set to today's image")
+ save_index(0)
+ set_wallpaper(str(dest))
+ return
+ else:
+ # Different image โ replace atomically
+ tmp.replace(dest)
+ log.info("Region changed to %r โ new image installed", current_region or "global")
+ else:
+ if not download_image(entry["url"], dest):
+ print(f"Failed to download image. Check {LOG_FILE} for details.", file=sys.stderr)
+ sys.exit(1)
+
+ # Build history entry (includes region so future refreshes can detect changes)
+ history_entry = {
+ "date": today,
+ "title": entry["title"],
+ "copyright": entry["copyright"],
+ "url": entry["url"],
+ "local_path": str(dest),
+ "region": current_region,
+ }
+
+ # Remove any existing entry for today (idempotent) and prepend
+ history = [h for h in history if h.get("date") != today]
+ history.insert(0, history_entry)
+
+ # Trim to limit
+ limit = get_history_limit()
+ if len(history) > limit:
+ removed = history[limit:]
+ history = history[:limit]
+ for old in removed:
+ old_path = Path(old.get("local_path", ""))
+ if old_path.exists():
+ try:
+ old_path.unlink()
+ log.info("Trimmed old image: %s", old_path)
+ except OSError as e:
+ log.warning("Could not delete old image %s: %s", old_path, e)
+
+ save_history(history)
+ save_index(0)
+ set_wallpaper(str(dest))
+ log.info("Refresh complete: %s", history_entry["title"])
+
+
+def cmd_next():
+ """Move to a newer image (decrement index)."""
+ history = load_history()
+ if not history:
+ print("BOUNDARY:newest")
+ return
+
+ idx = load_index()
+ if idx <= 0:
+ print("BOUNDARY:newest")
+ log.info("next: already at newest (index 0)")
+ return
+
+ idx -= 1
+ save_index(idx)
+ entry = history[idx]
+ local_path = entry.get("local_path", "")
+ if not Path(local_path).exists():
+ log.error("Image file missing: %s", local_path)
+ print(f"Image file not found: {local_path}", file=sys.stderr)
+ sys.exit(1)
+ set_wallpaper(local_path)
+ log.info("next: moved to index %d (newer)", idx)
+
+
+def cmd_prev():
+ """Move to an older image (increment index)."""
+ history = load_history()
+ if not history:
+ print("BOUNDARY:oldest")
+ return
+
+ idx = load_index()
+ if idx >= len(history) - 1:
+ print("BOUNDARY:oldest")
+ log.info("prev: already at oldest (index %d)", idx)
+ return
+
+ idx += 1
+ save_index(idx)
+ entry = history[idx]
+ local_path = entry.get("local_path", "")
+ if not Path(local_path).exists():
+ log.error("Image file missing: %s", local_path)
+ print(f"Image file not found: {local_path}", file=sys.stderr)
+ sys.exit(1)
+ set_wallpaper(local_path)
+ log.info("prev: moved to index %d (older)", idx)
+
+
+def cmd_open():
+ """Open the current image with xdg-open."""
+ history = load_history()
+ if not history:
+ print("No images in history.", file=sys.stderr)
+ sys.exit(1)
+
+ idx = load_index()
+ idx = max(0, min(idx, len(history) - 1))
+ local_path = history[idx].get("local_path", "")
+ if not Path(local_path).exists():
+ print(f"Image file not found: {local_path}", file=sys.stderr)
+ sys.exit(1)
+
+ result = subprocess.run(["xdg-open", local_path])
+ if result.returncode != 0:
+ log.error("xdg-open failed for %s", local_path)
+ sys.exit(1)
+
+
+def cmd_info():
+ """Print current image entry as formatted JSON."""
+ history = load_history()
+ if not history:
+ print("No images in history.", file=sys.stderr)
+ sys.exit(1)
+
+ idx = load_index()
+ idx = max(0, min(idx, len(history) - 1))
+ print(json.dumps(history[idx], indent=2, ensure_ascii=False))
+
+
+def cmd_populate():
+ """Download all images from the Peapix feed (typically the last 8 days)."""
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ limit = get_history_limit()
+ history = load_history()
+ existing_dates = {h.get("date") for h in history}
+
+ try:
+ feed = fetch_peapix_all()
+ except Exception as e:
+ print(f"Failed to fetch image feed: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ downloaded = 0
+ failed = 0
+
+ for entry in feed:
+ date_str = entry["date"]
+ dest = CACHE_DIR / f"{date_str}.jpg"
+
+ if dest.exists() and date_str in existing_dates:
+ log.info("populate: %s already cached โ skipping", date_str)
+ continue
+
+ if not download_image(entry["url"], dest):
+ failed += 1
+ continue
+
+ history_entry = {
+ "date": date_str,
+ "title": entry["title"],
+ "copyright": entry["copyright"],
+ "url": entry["url"],
+ "local_path": str(dest),
+ }
+ history = [h for h in history if h.get("date") != date_str]
+ history.insert(0, history_entry)
+ existing_dates.add(date_str)
+ downloaded += 1
+
+ # Sort newest first and trim to limit
+ history.sort(key=lambda h: h.get("date", ""), reverse=True)
+ history = history[:limit]
+ save_history(history)
+ save_index(0)
+ if history:
+ set_wallpaper(history[0].get("local_path", ""))
+ print(f"populated:{downloaded}:{failed}")
+ log.info("Populate complete: %d downloaded, %d failed", downloaded, failed)
+
+
+def cmd_clear():
+ """Delete all cached images and reset history."""
+ history = load_history()
+ deleted = 0
+ for entry in history:
+ path = Path(entry.get("local_path", ""))
+ if path.exists():
+ try:
+ path.unlink()
+ deleted += 1
+ log.info("Deleted: %s", path)
+ except OSError as e:
+ log.warning("Could not delete %s: %s", path, e)
+ save_history([])
+ if INDEX_FILE.exists():
+ try:
+ INDEX_FILE.unlink()
+ except OSError:
+ pass
+ print(f"cleared:{deleted}")
+ log.info("Clear complete: %d images deleted", deleted)
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+COMMANDS = {
+ "refresh": cmd_refresh,
+ "next": cmd_next,
+ "prev": cmd_prev,
+ "open": cmd_open,
+ "info": cmd_info,
+ "populate": cmd_populate,
+ "clear": cmd_clear,
+}
+
+if __name__ == "__main__":
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
+ print(f"Usage: {sys.argv[0]} <{'|'.join(COMMANDS)}>" , file=sys.stderr)
+ sys.exit(1)
+ COMMANDS[sys.argv[1]]()
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icon.png b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icon.png
new file mode 100644
index 00000000000..997d1ecf636
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icon.png differ
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/bing-daily-symbolic.svg b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/bing-daily-symbolic.svg
new file mode 100644
index 00000000000..2888e0ab374
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/bing-daily-symbolic.svg
@@ -0,0 +1,12 @@
+
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/icon-symbolic.svg b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/icon-symbolic.svg
new file mode 100644
index 00000000000..7ee66d8afc1
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/icon-symbolic.svg
@@ -0,0 +1,13 @@
+
+
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/icon.png b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/icon.png
new file mode 100644
index 00000000000..997d1ecf636
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/icons/icon.png differ
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/metadata.json b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/metadata.json
new file mode 100644
index 00000000000..91f3f17c059
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/metadata.json
@@ -0,0 +1,11 @@
+{
+ "uuid": "bing-daily@keithdriscoll.nyc",
+ "name": "Bing Daily",
+ "description": "Sets your desktop wallpaper to the Bing Image of the Day. Supports history navigation, automatic daily updates via systemd, and works on Cinnamon 5.x and 6.x.",
+ "version": "1.0.1",
+ "author": "Keith Driscoll",
+ "website": "https://github.com/keithdriscoll/bing-daily",
+ "max-instances": -1,
+ "has-settings": true,
+ "cinnamon-version": ["5.4", "5.6", "6.0", "6.2", "6.4", "6.6"]
+}
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/settings-schema.json b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/settings-schema.json
new file mode 100644
index 00000000000..b01c1c4328a
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/settings-schema.json
@@ -0,0 +1,68 @@
+{
+ "layout": {
+ "type": "layout",
+ "pages": ["page-general"],
+ "page-general": {
+ "type": "page",
+ "title": "General",
+ "sections": ["section-update", "section-display"]
+ },
+ "section-update": {
+ "type": "section",
+ "title": "Automatic Updates",
+ "keys": ["auto_update", "frequency", "update_time"]
+ },
+ "section-display": {
+ "type": "section",
+ "title": "Display",
+ "keys": ["region", "history_limit"]
+ }
+ },
+ "auto_update": {
+ "type": "switch",
+ "default": true,
+ "description": "Automatically update wallpaper daily"
+ },
+ "frequency": {
+ "type": "combobox",
+ "default": "daily",
+ "options": {
+ "Daily": "daily",
+ "Weekly": "weekly",
+ "Monthly": "monthly"
+ },
+ "description": "How often to fetch a new wallpaper"
+ },
+ "update_time": {
+ "type": "timechooser",
+ "default": {"h": 8, "m": 0, "s": 0},
+ "description": "Time to fetch new wallpaper each day"
+ },
+ "region": {
+ "type": "combobox",
+ "default": "",
+ "options": {
+ "Global": "",
+ "United States": "us",
+ "United Kingdom": "gb",
+ "Canada": "ca",
+ "Australia": "au",
+ "Germany": "de",
+ "France": "fr",
+ "Japan": "ja",
+ "India": "in",
+ "China": "cn",
+ "Brazil": "br"
+ },
+ "description": "Region for Bing Image of the Day"
+ },
+ "history_limit": {
+ "type": "spinbutton",
+ "default": 30,
+ "min": 7,
+ "max": 90,
+ "step": 7,
+ "units": "images",
+ "description": "How many wallpapers to keep on disk"
+ }
+}
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/systemd/bing-daily.service b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/systemd/bing-daily.service
new file mode 100644
index 00000000000..06a996e9344
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/systemd/bing-daily.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Bing Daily Wallpaper Refresh
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStartPre=/bin/sleep 30
+ExecStart=/usr/bin/python3 %h/.local/share/cinnamon/applets/bing-daily@keithdriscoll.nyc/engine/bing_engine.py refresh
+Environment=DISPLAY=:0
+Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
+StandardOutput=append:%h/.cache/bing-daily/log.txt
+StandardError=append:%h/.cache/bing-daily/log.txt
diff --git a/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/systemd/bing-daily.timer b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/systemd/bing-daily.timer
new file mode 100644
index 00000000000..05c0c9c37e2
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/files/bing-daily@keithdriscoll.nyc/systemd/bing-daily.timer
@@ -0,0 +1,13 @@
+# This file is managed by the Bing Daily applet.
+# Manual edits will be overwritten when settings change.
+
+[Unit]
+Description=Bing Daily Wallpaper Refresh Timer
+
+[Timer]
+OnCalendar=*-*-* 08:00:00
+Persistent=true
+Unit=bing-daily.service
+
+[Install]
+WantedBy=timers.target
diff --git a/bing-daily@keithdriscoll.nyc/info.json b/bing-daily@keithdriscoll.nyc/info.json
new file mode 100644
index 00000000000..3be97fdd280
--- /dev/null
+++ b/bing-daily@keithdriscoll.nyc/info.json
@@ -0,0 +1,3 @@
+{
+ "author": "keithdriscoll"
+}
diff --git a/bing-daily@keithdriscoll.nyc/screenshot.png b/bing-daily@keithdriscoll.nyc/screenshot.png
new file mode 100644
index 00000000000..71bfa0ca2a5
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/screenshot.png differ
diff --git a/bing-daily@keithdriscoll.nyc/screenshots/applets.png b/bing-daily@keithdriscoll.nyc/screenshots/applets.png
new file mode 100644
index 00000000000..bb60da6b236
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/screenshots/applets.png differ
diff --git a/bing-daily@keithdriscoll.nyc/screenshots/features.png b/bing-daily@keithdriscoll.nyc/screenshots/features.png
new file mode 100644
index 00000000000..f2a6039918a
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/screenshots/features.png differ
diff --git a/bing-daily@keithdriscoll.nyc/screenshots/settings.png b/bing-daily@keithdriscoll.nyc/screenshots/settings.png
new file mode 100644
index 00000000000..71bfa0ca2a5
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/screenshots/settings.png differ
diff --git a/bing-daily@keithdriscoll.nyc/screenshots/wallpaper.png b/bing-daily@keithdriscoll.nyc/screenshots/wallpaper.png
new file mode 100644
index 00000000000..1a21b57d529
Binary files /dev/null and b/bing-daily@keithdriscoll.nyc/screenshots/wallpaper.png differ