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. + +![Bing Daily in panel](screenshots/applets.png) + +--- + +## 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