diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a464579..8ecd44b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,16 @@ # Contributing +## Implementations + +There are two parallel implementations that must be kept in feature parity: + +| File | Platform | Language | +| ------------------- | -------- | ---------- | +| `src/backdrop.sh` | Linux | Bash | +| `src/backdrop.psm1` | Windows | PowerShell | + +Changes to commands, sources, config keys, or behaviour should be applied to both files. + ## Setup After cloning, install the Node dev dependencies: @@ -10,10 +21,10 @@ npm install This registers the git hooks via `simple-git-hooks`: -- **pre-commit** - runs Prettier, shfmt, ShellCheck, and the BATS test suite +- **pre-commit** - runs Prettier, shfmt, ShellCheck, and BATS against the bash implementation, and PSScriptAnalyzer and Pester against the PowerShell implementation - **prepare-commit-msg** - launches an interactive conventional commit prompt (`czg`) -You also need [**shellcheck**](https://github.com/koalaman/shellcheck) and [**shfmt**](https://github.com/mvdan/sh) installed locally for the hooks and `npm run lint` / `npm test` to work: +You also need [**shellcheck**](https://github.com/koalaman/shellcheck) and [**shfmt**](https://github.com/mvdan/sh) installed locally for `npm run lint:sh` / `npm run test:sh` to work: ```bash # Ubuntu/Debian @@ -23,6 +34,14 @@ sudo apt install shellcheck shfmt brew install shellcheck shfmt ``` +For the PowerShell side, you need [**PowerShell 7+**](https://github.com/PowerShell/PowerShell) (`pwsh`) installed. Then install PSScriptAnalyzer and Pester: + +```bash +npm run install:ps +``` + +This is required for `npm run lint:ps` / `npm run test:ps` to work. Since the pre-commit hook runs `npm run lint` and `npm run test` (which cover both implementations), it won't pass without these installed. + ## Commits Commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) spec. The pre-commit hook will guide you through it. You can also run the prompt manually: @@ -33,17 +52,35 @@ npm run commit ## Testing -To run the full test suite (ShellCheck + BATS): +To run the full test suite (ShellCheck + BATS, plus Pester for PowerShell): ```bash npm test ``` +To run just one implementation's tests: + +```bash +npm run test:sh +``` + +or + +```bash +npm run test:ps +``` + Tests live in `test/backdrop.bats` and cover the pure and file-I/O functions in `src/backdrop.sh`; things like config read/write, source validation, image dimension detection, wallpaper option selection, metadata read/write, version comparison, and source rotation. Source resolver functions are tested using a stub `curl` script injected via `PATH`. The `_rotation_index` helper takes a unix timestamp as a parameter rather than calling `date`, so rotation tests run without needing to stub `date`; `get_active_source` integration tests stub `date` via `PATH` in the same way as `curl`. +The PowerShell module (`src/backdrop.psm1`) is tested with [Pester 5](https://pester.dev/). Tests live in `test/backdrop.tests.ps1` and run via `Invoke-Pester test/backdrop.tests.ps1`. The `Get-UnixTimestamp` helper is extracted so rotation tests can mock it without stubbing `[DateTimeOffset]::UtcNow` directly. + ## Image metadata -Each `resolve_()` function emits `META_TITLE:`, `META_DESC:`, and `META_URL:` prefixed lines before the image URLs it returns. `apply_wallpaper` strips these out to get the candidate URL list, then stores the values in the `META_TITLE`/`META_DESC`/`META_URL` globals. After a successful download, `_write_meta` saves those values to a `-.meta` file alongside the `.jpg`. The `status` command reads this file via `_meta_get` to display image title, description, and URL. Old `.meta` files are pruned on the same 14-day schedule as wallpaper images. +**Bash (`backdrop.sh`):** each `resolve_()` function emits `META_TITLE:`, `META_DESC:`, and `META_URL:` prefixed lines before the image URLs it returns. `apply_wallpaper` strips these out to get the candidate URL list, then stores the values in the `META_TITLE`/`META_DESC`/`META_URL` globals. + +**PowerShell (`backdrop.psm1`):** each `Resolve-` function returns a hashtable with `Title`, `Desc`, `Url`, and `ImageUrls` keys directly, avoiding the stdout multiplexing used by the bash version. + +In both implementations, after a successful download the metadata is saved to a `-.meta` file (key = value format) alongside the `.jpg`. The `status` command reads this file to display image title, description, and URL. Old `.meta` files are pruned on the same 14-day schedule as wallpaper images. ## Linting @@ -53,8 +90,22 @@ To check formatting: npm run lint ``` +To check just one implementation: + +```bash +npm run lint:sh +``` + +or + +```bash +npm run lint:ps +``` + To auto-fix formatting: ```bash npm run lint:fix ``` + +`lint:ps:fix` uses `Invoke-ScriptAnalyzer -Fix`, which only corrects rules that support automatic fixes (mostly formatting/whitespace). Violations like `PSAvoidUsingCmdletAliases` still need to be fixed by hand; re-run `npm run lint:ps` afterward to see what's left. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8fcc71..34e0f79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: matrix: node-version: [22] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: token: ${{ secrets.GHA_SEMANTIC_RELEASE_TOKEN }} - name: Use Node.js ${{ matrix.node-version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ace44b7..52948dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: name: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -25,7 +25,7 @@ jobs: run: go install mvdan.cc/sh/v3/cmd/shfmt@latest - name: Lint - run: npm run lint + run: npm run lint:sh - name: Install ShellCheck v0.11.0 run: | @@ -33,4 +33,19 @@ jobs: sudo mv shellcheck-v0.11.0/shellcheck /usr/local/bin/shellcheck - name: Test - run: npm test + run: npm run test:sh + + test-ps: + name: test (powershell) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install PSScriptAnalyzer and Pester + run: npm run install:ps + + - name: Lint + run: npm run lint:ps + + - name: Test + run: npm run test:ps diff --git a/README.md b/README.md index b540fbe..8e9b322 100644 --- a/README.md +++ b/README.md @@ -14,23 +14,40 @@ Set a new desktop wallpaper every day from various sources. backdrop fetches a daily image from one of several curated sources and sets it as your desktop wallpaper. It automatically picks the best display mode based on the image dimensions and your screen aspect ratio. -Run it manually or let a systemd timer handle it on a schedule. +Run it manually or let a background timer handle it on a schedule. ## Quick Install +### Linux + ```bash curl -fsSL https://ensl.ee/backdrop | bash ``` +### Windows + +```powershell +iex (iwr 'https://ensl.ee/backdrop-w' -UseBasicParsing).Content +``` + ## Requirements -- A supported Linux desktop environment (see below) +### Linux + +- A supported desktop environment (see [Desktop Environments](#desktop-environments) below) - `curl`, `python3` (standard on most distros) - systemd (for the daily timer) +### Windows + +- Windows 10 or later +- PowerShell 5.1 or later (built-in on Windows 10+) + ## Desktop Environments -backdrop supports the following Desktop Environments. +**Windows** uses `SystemParametersInfo` (user32.dll) to set the wallpaper, with fill mode stored in the registry under `HKCU:\Control Panel\Desktop`. No additional tools required. + +**Linux** supports the following desktop environments: | Desktop | Method | Notes | | --------------------- | ----------------------------- | ------------------------------------------------------------------------------- | @@ -46,7 +63,9 @@ backdrop supports the following Desktop Environments. ## Installation -Use the [quick install script](#quick-install) or clone the repo and run the installer locally: +Use the [quick install script](#quick-install) or clone the repo and run the installer locally. + +### Linux ```bash git clone https://github.com/aensley/backdrop.git \ @@ -59,15 +78,24 @@ The installer: 2. Copies `backdrop` to `/usr/local/bin/` 3. Runs `backdrop enable`, which downloads and installs the systemd unit files and starts the daily timer -### Additional users +**Additional users:** with `backdrop` already installed system-wide, additional users can enable it for their own login with `backdrop enable`. This downloads the matching systemd unit files from GitHub and installs them into `~/.config/systemd/user/`. -With `backdrop` already installed, additional users can enable it for their login with: +### Windows -```bash -backdrop enable +Clone the repo and run the installer from PowerShell: + +```powershell +git clone https://github.com/aensley/backdrop.git +cd backdrop/src +.\install.ps1 ``` -This will download the matching systemd unit files from the matching GitHub release and install them into their own `~/.config/systemd/user/` directory. +The installer: + +1. Copies `backdrop.psm1` and `backdrop.psd1` to the per-user PowerShell modules directory +2. Runs `backdrop enable`, which registers the scheduled task and applies the wallpaper immediately + +The module is auto-imported in every new PowerShell session; no `$PROFILE` changes needed. ## Usage @@ -75,19 +103,19 @@ This will download the matching systemd unit files from the matching GitHub rele backdrop ``` -| Command | Description | -| ------------------------------- | ---------------------------------------------------------------------------- | -| `status` | Show version, active source, last image, and image metadata (default) | -| `update [--force]` | Refresh wallpaper from the active source | -| `set [--force]` | Switch active source(s) and refresh now; use `all` for all sources | -| `set-time ` | Set the daily run time (24-hour); restarts timer if active | -| `set-rotate-interval ` | Set rotation interval in minutes; 0 to disable | -| `random [--force]` | Refresh from a randomly chosen source (does not change active) | -| `enable` | Enable the systemd --user timer; downloads unit files from GitHub if missing | -| `disable` | Disable the systemd --user timer | -| `upgrade` | Check for and install the latest version from GitHub | -| `uninstall` | Remove backdrop from this system | -| `help` | Show help | +| Command | Description | +| ------------------------------- | ---------------------------------------------------------------------------------- | +| `status` | Show version, active source, last image, and image metadata (default) | +| `update [--force]` | Refresh wallpaper from the active source | +| `set [--force]` | Switch active source(s) and refresh now; use `all` for all sources | +| `set-time ` | Set the daily run time (24-hour); restarts timer if active | +| `set-rotate-interval ` | Set rotation interval in minutes; 0 to disable | +| `random [--force]` | Refresh from a randomly chosen source (does not change active) | +| `enable` | Enable the background timer (Linux: systemd --user timer; Windows: Task Scheduler) | +| `disable` | Disable the background timer | +| `upgrade` | Check for and install the latest version from GitHub | +| `uninstall` | Remove backdrop from this system | +| `help` | Show help | ## Sources @@ -147,7 +175,12 @@ When rotation is active, the systemd timer fires at the rotation interval instea ## Configuration -The config file lives at `~/.config/backdrop/config` and is created on first run. You can edit it directly or use the `set` / `set-time` commands. +The config file is created on first run. You can edit it directly or use the `set` / `set-time` commands. + +| Platform | Config file | Cached images | +| -------- | --------------------------- | -------------------------- | +| Linux | `~/.config/backdrop/config` | `~/.local/share/backdrop/` | +| Windows | `%APPDATA%\backdrop\config` | `%LOCALAPPDATA%\backdrop\` | | Key | Default | Description | | --------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -160,12 +193,14 @@ The config file lives at `~/.config/backdrop/config` and is created on first run ## Uninstallation -```bash +``` backdrop uninstall ``` To also remove your config and cached wallpapers: -```bash +``` backdrop uninstall --purge ``` + +On Linux this removes the script from `/usr/local/bin` and disables the systemd timer. On Windows it unregisters the scheduled task and removes the PowerShell module. diff --git a/package-lock.json b/package-lock.json index 5976c2a..4927df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "backdrop", - "version": "1.6.1", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backdrop", - "version": "1.6.1", + "version": "1.6.2", "license": "MIT", "devDependencies": { "@semantic-release/commit-analyzer": "^13.0.1", @@ -16,9 +16,9 @@ "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "bats": "^1.13.0", - "conventional-changelog-conventionalcommits": "^9.3.1", + "conventional-changelog-conventionalcommits": "^10.2.0", "czg": "^1.13.1", - "prettier": "^3.8.4", + "prettier": "^3.9.3", "semantic-release": "^25.0.5", "simple-git-hooks": "^2.13.1" } @@ -108,6 +108,16 @@ "node": ">=0.1.90" } }, + "node_modules/@conventional-changelog/template": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.0.tgz", + "integrity": "sha512-12qHxvlKjHmP0PQ+17EREgC7lWyLwbph1RKcZQZ7k7ZWGmrxfxC9gadHGfvzr0g0u8BhiBGg3tks93txodlyRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -225,9 +235,9 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", - "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.11.tgz", + "integrity": "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -977,9 +987,9 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1147,16 +1157,16 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz", + "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==", "dev": true, "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.2.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/conventional-changelog-writer": { @@ -1709,9 +1719,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "dev": true, "license": "MIT", "dependencies": { @@ -2120,9 +2130,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -2487,9 +2497,9 @@ } }, "node_modules/npm": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.17.0.tgz", - "integrity": "sha512-PurxiZexEHDTE4SSaLI3ZrnbAGiZfeyUcQcxcP5D+hfytNAze/D1IzDuInTn9XVLIbAQUnQuSPXJx02LHjLvQw==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", + "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2568,8 +2578,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.8.0", - "@npmcli/config": "^10.11.0", + "@npmcli/arborist": "^9.9.0", + "@npmcli/config": "^10.12.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -2593,11 +2603,11 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.10", - "libnpmexec": "^10.3.0", - "libnpmfund": "^7.0.24", + "libnpmdiff": "^8.1.11", + "libnpmexec": "^10.3.1", + "libnpmfund": "^7.0.25", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.10", + "libnpmpack": "^9.1.11", "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -2613,7 +2623,7 @@ "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", + "npm-profile": "^12.0.2", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", @@ -2622,11 +2632,11 @@ "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.8.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.16", + "tar": "^7.5.19", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -2715,7 +2725,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.8.0", + "version": "9.9.0", "dev": true, "inBundle": true, "license": "ISC", @@ -2763,7 +2773,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.11.0", + "version": "10.12.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3108,7 +3118,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.6", + "version": "5.0.7", "dev": true, "inBundle": true, "license": "MIT", @@ -3482,12 +3492,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.10", + "version": "8.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -3501,13 +3511,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.0", + "version": "10.3.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -3524,12 +3534,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.24", + "version": "7.0.25", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0" + "@npmcli/arborist": "^9.9.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3549,12 +3559,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.10", + "version": "9.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -3923,13 +3933,13 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4134,7 +4144,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.8.4", + "version": "7.8.5", "dev": true, "inBundle": true, "license": "ISC", @@ -4259,7 +4269,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.16", + "version": "7.5.19", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -4355,7 +4365,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.26.0", + "version": "6.27.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4521,9 +4531,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", "dev": true, "license": "MIT", "engines": { @@ -4709,9 +4719,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz", + "integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==", "dev": true, "license": "MIT", "bin": { @@ -5129,9 +5139,9 @@ } }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index 9666a4a..a8f5a05 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,16 @@ "license": "MIT", "scripts": { "commit": "czg", - "test": "shellcheck src/backdrop.sh src/install.sh test/backdrop.bats && bats test/", - "lint": "prettier --check . && ~/go/bin/shfmt -d -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", - "lint:fix": "prettier --write . && ~/go/bin/shfmt -w -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", + "test": "npm run test:sh && npm run test:ps", + "test:sh": "shellcheck src/backdrop.sh src/install.sh test/backdrop.bats && bats test/", + "test:ps": "pwsh -Command \"\\$result = Invoke-Pester test/backdrop.tests.ps1 -Output Detailed -PassThru; if (\\$result.FailedCount -gt 0) { exit 1 }\"", + "lint": "npm run lint:sh && npm run lint:ps", + "lint:sh": "prettier --check . && ~/go/bin/shfmt -d -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", + "lint:ps": "pwsh -Command \"\\$exclude = @('PSAvoidUsingWriteHost', 'PSUseShouldProcessForStateChangingFunctions'); \\$results = 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path \\$_ -Severity @('Error', 'Warning') -ExcludeRule \\$exclude }; if (\\$results) { \\$results | Format-Table RuleName, Severity, Line, Message -AutoSize; exit 1 }\"", + "lint:fix": "npm run lint:sh:fix && npm run lint:ps:fix", + "lint:sh:fix": "prettier --write . && ~/go/bin/shfmt -w -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", + "lint:ps:fix": "pwsh -Command \"\\$exclude = @('PSAvoidUsingWriteHost', 'PSUseShouldProcessForStateChangingFunctions'); 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path \\$_ -Severity @('Error', 'Warning') -ExcludeRule \\$exclude -Fix }\"", + "install:ps": "pwsh -Command \"Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop; Install-Module -Name Pester -Scope CurrentUser -Force -MinimumVersion 5.0 -ErrorAction Stop\"", "prepare": "npx simple-git-hooks", "update": "npx npm-check-updates -u && npm update" }, @@ -27,9 +34,9 @@ "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "bats": "^1.13.0", - "conventional-changelog-conventionalcommits": "^9.3.1", + "conventional-changelog-conventionalcommits": "^10.2.0", "czg": "^1.13.1", - "prettier": "^3.8.4", + "prettier": "^3.9.3", "semantic-release": "^25.0.5", "simple-git-hooks": "^2.13.1" }, @@ -62,7 +69,7 @@ [ "@semantic-release/exec", { - "prepareCmd": "sed -i 's/^VERSION=.*/VERSION=\"${nextRelease.version}\"/' src/backdrop.sh" + "prepareCmd": "sed -i 's/^VERSION=.*/VERSION=\"${nextRelease.version}\"/' src/backdrop.sh && sed -i 's/^\\$script:Version[[:space:]]*=.*/\\$script:Version = '\"'\"'${nextRelease.version}'\"'\"'/' src/backdrop.psm1 && sed -i 's/ModuleVersion[[:space:]]*=.*/ModuleVersion = '\"'\"'${nextRelease.version}'\"'\"'/' src/backdrop.psd1" } ], [ @@ -70,7 +77,9 @@ { "assets": [ "package.json", - "src/backdrop.sh" + "src/backdrop.sh", + "src/backdrop.psm1", + "src/backdrop.psd1" ] } ], diff --git a/src/backdrop.psd1 b/src/backdrop.psd1 new file mode 100644 index 0000000..56bb2e6 --- /dev/null +++ b/src/backdrop.psd1 @@ -0,0 +1,16 @@ +@{ + ModuleVersion = '1.6.1' + GUID = '3D6C8A5B-1F2E-4A0D-9B7C-E6F5D4C3B2A1' + Author = 'Andrew Ensley' + Description = 'Set a new desktop wallpaper every day from various sources (apod, bing, earth, eo, iotd, natgeo, wmc)' + PowerShellVersion = '5.1' + RootModule = 'backdrop.psm1' + FunctionsToExport = @('backdrop') + PrivateData = @{ + PSData = @{ + Tags = @('wallpaper', 'desktop', 'background', 'Windows') + ProjectUri = 'https://github.com/aensley/backdrop' + LicenseUri = 'https://github.com/aensley/backdrop/blob/main/LICENSE' + } + } +} diff --git a/src/backdrop.psm1 b/src/backdrop.psm1 new file mode 100644 index 0000000..2c4cd3e --- /dev/null +++ b/src/backdrop.psm1 @@ -0,0 +1,801 @@ +# backdrop.psm1 - set a new desktop wallpaper every day from various sources +# Sources: apod bing earth eo iotd natgeo wmc + +#region Module state + +$script:Version = '1.6.1' +$script:StateDir = Join-Path $env:LOCALAPPDATA 'backdrop' +$script:ConfigDir = Join-Path $env:APPDATA 'backdrop' +$script:ConfigFile = Join-Path $script:ConfigDir 'config' +$script:ValidSources = @('apod', 'bing', 'earth', 'iotd', 'natgeo', 'eo', 'wmc') +$script:TaskName = 'backdrop' + +# Built-in defaults (overridden by config file) +$script:Source = 'iotd' +$script:RotateInterval = 0 +$script:ScreenAspectRatio = 1.7778 +$script:ZoomMinCoverage = 0.55 +$script:UserAgent = "backdrop/$(($script:Version -split '\.')[0..1] -join '.') (personal daily wallpaper script)" +$script:TimerTime = '08:00' + +$null = New-Item -ItemType Directory -Force -Path $script:StateDir, $script:ConfigDir + +#endregion + +#region Helpers + +function Invoke-BackdropRequest { + param([string]$Uri, [int]$TimeoutSec = 30) + $ProgressPreference = 'SilentlyContinue' + (Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` + -TimeoutSec $TimeoutSec -ErrorAction Stop).Content +} + +function Save-BackdropFile { + param([string]$Uri, [string]$OutFile, [int]$TimeoutSec = 120) + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` + -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop | Out-Null +} + +function Remove-HtmlMarkup { + param($s) + if ($s -is [System.Xml.XmlNode]) { $s = $s.InnerText } + if (-not $s) { return '' } + $s = [string]$s + $s = $s -replace '<[^>]+>', '' + try { + Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue + $s = [System.Web.HttpUtility]::HtmlDecode($s) + } + catch { $null = $_ } + ($s -replace '\s+', ' ').Trim() +} + +#endregion + +#region Config + +function Get-BackdropConfigValue { + param([string]$Key) + if (-not (Test-Path $script:ConfigFile)) { return $null } + $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue + if (-not $content) { return $null } + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { + $v = $Matches[1] + if ($v -match '^"(.+)"$' -or $v -match "^'(.+)'$") { return $Matches[1] } + return $v + } + return $null +} + +function Set-BackdropConfigValue { + param([string]$Key, [string]$Value) + Initialize-BackdropConfig + $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue + if (-not $content) { $content = '' } + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=") { + $content = $content -replace "(?m)^(\s*$([regex]::Escape($Key))\s*=\s*).*", "`${1}$Value" + } + else { + $content = $content.TrimEnd() + "`n$Key = $Value`n" + } + Set-Content -Path $script:ConfigFile -Value $content -NoNewline +} + +function Initialize-BackdropConfig { + if (Test-Path $script:ConfigFile) { return } + Set-Content -Path $script:ConfigFile -Value @" +# backdrop configuration (key = value; lines starting with # are ignored) + +# Active wallpaper source(s): iotd | apod | bing | wmc | eo | earth | natgeo +# Single source: source = iotd +# Multiple sources: source = iotd apod bing +# All sources: source = all +# Also settable with: backdrop set [source...] +source = $($script:Source) + +# How often to rotate between sources, in minutes (0 = disabled). +# Only applies when multiple sources are configured. +# Also settable with: backdrop set-rotate-interval +rotate_interval = $($script:RotateInterval) + +# Screen aspect ratio used only if auto-detection fails. +# 16:9 = 1.7778 16:10 = 1.6 21:9 = 2.3333 4:3 = 1.3333 +screen_aspect_ratio = $($script:ScreenAspectRatio) + +# Crop tolerance for choosing zoom vs scaled. +zoom_min_coverage = $($script:ZoomMinCoverage) + +# HTTP User-Agent string sent with all requests. +# user_agent = backdrop/1.6 (personal daily wallpaper script) + +# Time of day to run the daily wallpaper update (HH:MM, 24-hour format). +# Also settable with: backdrop set-time HH:MM +timer_time = $($script:TimerTime) +"@ +} + +function Import-BackdropConfig { + Initialize-BackdropConfig + $v = Get-BackdropConfigValue 'screen_aspect_ratio' + if ($v) { $script:ScreenAspectRatio = [double]$v } + $v = Get-BackdropConfigValue 'zoom_min_coverage' + if ($v) { $script:ZoomMinCoverage = [double]$v } + $v = Get-BackdropConfigValue 'user_agent' + if ($v) { $script:UserAgent = $v } + $v = Get-BackdropConfigValue 'timer_time' + if ($v) { $script:TimerTime = $v } + $v = Get-BackdropConfigValue 'rotate_interval' + if ($v -match '^\d+$') { $script:RotateInterval = [int]$v } +} + +#endregion + +#region Source resolvers +# Each returns a hashtable: Title, Desc, Url (source page), ImageUrls (array, best first). +# Returns $null when the source has no image today (e.g. APOD video day). +# Throws on network failure. + +function Resolve-Iotd { + $feed = Invoke-BackdropRequest 'https://www.nasa.gov/feeds/iotd-feed/' + [xml]$xml = $feed + $item = @($xml.rss.channel.item)[0] + $imageUrl = $item.enclosure.url + if (-not $imageUrl) { return $null } + @{ + Title = Remove-HtmlMarkup $item.title + Desc = Remove-HtmlMarkup $item.description + Url = if ($item.link) { $item.link } else { 'https://www.nasa.gov/image-of-the-day/' } + ImageUrls = @($imageUrl) + } +} + +function Resolve-Apod { + $page = Invoke-BackdropRequest 'https://apod.nasa.gov/apod/astropix.html' + $rel = $null + if ($page -match '(?i)href="(image/[^"]+\.(jpg|jpeg|png|gif))"') { $rel = $Matches[1] } + if (-not $rel) { return $null } + $title = '' + $m = [regex]::Match($page, '(?i)
\s*([^<]+)\s*\s*Explanation:\s*(.*?)(?=

|)') + if ($m.Success) { + $desc = ($m.Groups[1].Value -replace '<[^>]+>', '') -replace '\s+', ' ' + $desc = $desc.Trim() + if ($desc.Length -gt 400) { $desc = $desc.Substring(0, 400) } + } + @{ + Title = $title + Desc = $desc + Url = 'https://apod.nasa.gov/apod/astropix.html' + ImageUrls = @("https://apod.nasa.gov/apod/$rel") + } +} + +function Resolve-Bing { + $json = Invoke-BackdropRequest 'https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US' + $img = ($json | ConvertFrom-Json).images[0] + $urls = @() + if ($img.urlbase) { $urls += "https://www.bing.com$($img.urlbase)_UHD.jpg" } + if ($img.url) { $urls += "https://www.bing.com$($img.url)" } + if (-not $urls) { return $null } + @{ + Title = $img.title + Desc = $img.copyright + Url = 'https://www.bing.com/' + ImageUrls = $urls + } +} + +function Resolve-Wmc { + $date = Get-Date -Format 'yyyy-MM-dd' + $resp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$date%7D%7D" + $file = ($resp | ConvertFrom-Json).expandtemplates.wikitext + if (-not $file) { return $null } + $title = ($file -replace '^File:', '') -replace '\.[^.]+$', '' -replace '_', ' ' + $desc = '' + try { + $dresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$($date)%20(en)%7D%7D" + $rawDesc = ($dresp | ConvertFrom-Json).expandtemplates.wikitext + $rawDesc = $rawDesc -replace '\[\[(?:[^|\]]*\|)?([^\]]*)\]\]', '$1' -replace '\{\{[^}]*\}\}', '' + $desc = ($rawDesc -replace '\s+', ' ').Trim() + if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) } + } + catch { $null = $_ } + $enc = [Uri]::EscapeDataString($file) + $iresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=query&format=json&prop=imageinfo&iiprop=url&iiurlwidth=3840&titles=File:$enc" + $info = (($iresp | ConvertFrom-Json).query.pages.PSObject.Properties | Select-Object -First 1).Value.imageinfo[0] + $urls = @() + if ($info.thumburl) { $urls += $info.thumburl } + if ($info.url) { $urls += $info.url } + if (-not $urls) { return $null } + @{ + Title = Remove-HtmlMarkup $title + Desc = Remove-HtmlMarkup $desc + Url = "https://commons.wikimedia.org/wiki/File:$([Uri]::EscapeDataString(($file -replace '^File:', '')))" + ImageUrls = $urls + } +} + +function Resolve-Eo { + $feed = Invoke-BackdropRequest 'https://earthobservatory.nasa.gov/feeds/image-of-the-day.rss' + $m = [regex]::Match($feed, '(?is)(.*?)') + if (-not $m.Success) { return $null } + $item = $m.Groups[1].Value + $imgUrl = $null + if ($item -match '(?i)(https://assets\.science\.nasa\.gov/dynamicimage/[^"?]+\.(jpg|jpeg|png))') { + $imgUrl = $Matches[1] + } + if (-not $imgUrl) { return $null } + $title = '' + if ($item -match '(?i)]*>') { $title = $Matches[1] } + elseif ($item -match '(?i)]*>([^<]+)') { $title = $Matches[1] } + $link = '' + if ($item -match '(?i)([^<]+)') { $link = $Matches[1] } + elseif ($item -match '(?i)(https://earthobservatory\.nasa\.gov/images/\d+[^"< ]*)') { $link = $Matches[1] } + @{ + Title = Remove-HtmlMarkup $title + Desc = '' + Url = if ($link) { $link.Trim() } else { 'https://earthobservatory.nasa.gov/' } + ImageUrls = @("${imgUrl}?w=3840", $imgUrl) + } +} + +function Resolve-Natgeo { + $page = Invoke-BackdropRequest 'https://www.nationalgeographic.com/photo-of-the-day/' + $url = $null + if ($page -match '(?i)property="og:image"\s+content="(https://i\.natgeofe\.com/[^"]+)"') { $url = $Matches[1] } + if (-not $url) { return $null } + $ogTitle = '' + $ogDesc = '' + $ogUrl = '' + if ($page -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] -replace ' \|.*', '' } + if ($page -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } + if ($page -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } + @{ + Title = Remove-HtmlMarkup $ogTitle + Desc = Remove-HtmlMarkup $ogDesc + Url = if ($ogUrl) { $ogUrl } else { 'https://www.nationalgeographic.com/photo-of-the-day/' } + ImageUrls = @("${url}?w=5120", $url) + } +} + +function Resolve-Earth { + $page = Invoke-BackdropRequest 'https://www.earth.com/gallery/images-of-the-day/' + $articleUrl = $null + if ($page -match '(?i)href="(https://www\.earth\.com/image/[^"]+)"') { $articleUrl = $Matches[1] } + if (-not $articleUrl) { return $null } + $article = Invoke-BackdropRequest $articleUrl + $url = $null + if ($article -match '(?i)(https://cff2\.earth\.com/uploads/[^"]+\.(jpg|jpeg|png))') { $url = $Matches[1] } + if (-not $url) { return $null } + $ogTitle = '' + $ogDesc = '' + $ogUrl = '' + if ($article -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] } + if ($article -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } + if ($article -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } + @{ + Title = Remove-HtmlMarkup $ogTitle + Desc = Remove-HtmlMarkup $ogDesc + Url = if ($ogUrl) { $ogUrl } else { $articleUrl } + ImageUrls = @($url) + } +} + +#endregion + +#region Geometry + +function Get-ImageDimension { + param([string]$Path) + try { + Add-Type -AssemblyName System.Drawing -ErrorAction Stop + $img = [System.Drawing.Image]::FromFile($Path) + $dims = @{ Width = $img.Width; Height = $img.Height } + $img.Dispose() + return $dims + } + catch { + return $null + } +} + +function Get-ScreenAspectRatio { + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + $s = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + if ($s.Height -gt 0) { return [double]$s.Width / $s.Height } + } + catch { $null = $_ } + return [double]$script:ScreenAspectRatio +} + +function Get-PictureOption { + param([string]$Path) + $dims = Get-ImageDimension $Path + if (-not $dims -or $dims.Width -le 0 -or $dims.Height -le 0) { return 'zoom' } + $iar = [double]$dims.Width / $dims.Height + $sar = Get-ScreenAspectRatio + $cov = if ($iar -lt $sar) { $iar / $sar } else { $sar / $iar } + if ($cov -ge $script:ZoomMinCoverage) { 'zoom' } else { 'scaled' } +} + +#endregion + +#region Wallpaper + +if (-not ([System.Management.Automation.PSTypeName]'BackdropWallpaper').Type) { + Add-Type @' +using System.Runtime.InteropServices; +public class BackdropWallpaper { + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni); +} +'@ +} + +function Set-Wallpaper { + param([string]$Path, [string]$Option) + # WallpaperStyle: 10=Fill (zoom/crop), 6=Fit (scaled/letterbox) + $style = if ($Option -eq 'zoom') { '10' } else { '6' } + Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallpaperStyle -Value $style + Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name TileWallpaper -Value '0' + [BackdropWallpaper]::SystemParametersInfo(20, 0, $Path, 3) | Out-Null +} + +#endregion + +#region Scheduling + +function Invoke-TimerConfig { + $action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NonInteractive -WindowStyle Hidden -Command `"Import-Module backdrop; backdrop update`"" + + if ($script:RotateInterval -gt 0) { + $timeTrigger = New-ScheduledTaskTrigger -Daily -At '00:00' + $timeTrigger.Repetition.Interval = "PT$($script:RotateInterval)M" + $timeTrigger.Repetition.Duration = 'P1D' + } + else { + $timeTrigger = New-ScheduledTaskTrigger -Daily -At $script:TimerTime + } + + $logon = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME + $logon.Delay = 'PT2M' + + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` + -StartWhenAvailable -MultipleInstances IgnoreNew + + $existing = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($existing) { + Set-ScheduledTask -TaskName $script:TaskName -Action $action ` + -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null + } + else { + Register-ScheduledTask -TaskName $script:TaskName -Action $action ` + -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null + } +} + +#endregion + +#region Source and meta helpers + +function Get-BackdropSource { + $s = Get-BackdropConfigValue 'source' + if ($s) { + if ($s -eq 'all') { return $script:ValidSources } + return @($s -split '\s+') + } + return @($script:Source) +} + +function Get-UnixTimestamp { [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } + +function Get-ActiveSource { + $srcs = @(Get-BackdropSource) + if ($srcs.Count -le 1 -or $script:RotateInterval -le 0) { + if ($srcs.Count -gt 0) { return $srcs[0] } else { return $script:Source } + } + $idx = [Math]::Floor((Get-UnixTimestamp) / 60 / $script:RotateInterval) % $srcs.Count + return $srcs[$idx] +} + +function Test-ValidSource { + param([string]$s) + $script:ValidSources -contains $s +} + +function Write-MetaFile { + param([string]$Dest, [hashtable]$Meta) + $metaPath = [System.IO.Path]::ChangeExtension($Dest, 'meta') + $lines = @() + if ($Meta.Title) { $lines += "title = $($Meta.Title)" } + if ($Meta.Desc) { + $d = $Meta.Desc + if ($d.Length -gt 200) { $d = $d.Substring(0, 200) } + $lines += "desc = $d" + } + if ($Meta.Url) { $lines += "url = $($Meta.Url)" } + Set-Content -Path $metaPath -Value $lines +} + +function Get-MetaValue { + param([string]$MetaFile, [string]$Key) + if (-not (Test-Path $MetaFile)) { return $null } + $content = Get-Content $MetaFile -Raw -ErrorAction SilentlyContinue + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { return $Matches[1] } + return $null +} + +#endregion + +#region Core + +function Invoke-ApplyWallpaper { + param([string]$Src, [bool]$Force = $false) + if (-not (Test-ValidSource $Src)) { + throw "backdrop: unknown source '$Src' (valid: $($script:ValidSources -join ', '))" + } + + $dest = Join-Path $script:StateDir "$Src-$(Get-Date -Format 'yyyy-MM-dd').jpg" + + if ((Test-Path $dest) -and -not $Force) { + $opt = Get-PictureOption $dest + Set-Wallpaper $dest $opt + Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest + $dims = Get-ImageDimension $dest + $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } + Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest (cached)" + return + } + + $result = switch ($Src) { + 'iotd' { Resolve-Iotd } + 'apod' { Resolve-Apod } + 'bing' { Resolve-Bing } + 'wmc' { Resolve-Wmc } + 'eo' { Resolve-Eo } + 'natgeo' { Resolve-Natgeo } + 'earth' { Resolve-Earth } + } + + if ($null -eq $result) { + Write-Host "backdrop: $Src has no image today (e.g. APOD video day); wallpaper unchanged." + return + } + + $ok = $false + foreach ($url in $result.ImageUrls) { + try { Save-BackdropFile $url $dest; $ok = $true; break } catch { $null = $_ } + } + if (-not $ok) { throw "backdrop: could not download any image for $Src" } + + $opt = Get-PictureOption $dest + Set-Wallpaper $dest $opt + Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest + Write-MetaFile $dest $result + + Get-ChildItem $script:StateDir -File | + Where-Object { $_.Extension -in @('.jpg', '.meta') -and $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | + Remove-Item -Force -ErrorAction SilentlyContinue + + $dims = Get-ImageDimension $dest + $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } + Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest" +} + +#endregion + +#region Commands + +function Invoke-BackdropUpdate { + param([switch]$Force) + Invoke-ApplyWallpaper (Get-ActiveSource) $Force.IsPresent +} + +function Invoke-BackdropRandom { + param([switch]$Force) + Invoke-ApplyWallpaper ($script:ValidSources | Get-Random) $Force.IsPresent +} + +function Invoke-BackdropEnable { + Invoke-TimerConfig + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -eq 'Disabled') { + Enable-ScheduledTask -TaskName $script:TaskName | Out-Null + } + if ($script:RotateInterval -gt 0) { + Write-Host "backdrop: timer enabled (rotating every $($script:RotateInterval) min)." + } + else { + Write-Host "backdrop: daily timer enabled (runs at $($script:TimerTime))." + } + Invoke-BackdropUpdate +} + +function Invoke-BackdropDisable { + Disable-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue | Out-Null + Write-Host "backdrop: daily timer disabled." +} + +function Invoke-BackdropSetTime { + param([string]$Time) + if ($Time -notmatch '^([01]\d|2[0-3]):[0-5]\d$') { + throw "backdrop: set-time: expected HH:MM (24-hour), e.g. 08:00" + } + $script:TimerTime = $Time + Set-BackdropConfigValue 'timer_time' $Time + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-TimerConfig + Write-Host "backdrop: timer time set to $Time and task updated." + } + else { + Write-Host "backdrop: timer time set to $Time (run 'backdrop enable' to start the timer)." + } +} + +function Invoke-BackdropSetRotateInterval { + param([string]$Minutes) + if ($Minutes -notmatch '^\d+$') { + throw "backdrop: set-rotate-interval: expected number of minutes (0 to disable), e.g. 60" + } + $script:RotateInterval = [int]$Minutes + Set-BackdropConfigValue 'rotate_interval' $Minutes + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-TimerConfig + if ([int]$Minutes -gt 0) { + Write-Host "backdrop: rotate interval set to $Minutes min and task updated." + } + else { + Write-Host "backdrop: rotation disabled, task reset to daily at $($script:TimerTime)." + } + } + else { + if ([int]$Minutes -gt 0) { + Write-Host "backdrop: rotate interval set to $Minutes min (run 'backdrop enable' to start the timer)." + } + else { + Write-Host "backdrop: rotation disabled (run 'backdrop enable' to start the daily timer)." + } + } +} + +function Invoke-BackdropSet { + param([string[]]$Sources, [bool]$Force = $false) + if (-not $Sources -or $Sources.Count -eq 0) { + throw "backdrop: set: choose one or more sources ($($script:ValidSources -join ', ')) or 'all'" + } + if ($Sources.Count -eq 1 -and $Sources[0] -eq 'all') { $Sources = $script:ValidSources } + foreach ($s in $Sources) { + if (-not (Test-ValidSource $s)) { + throw "backdrop: set: unknown source '$s' (valid: $($script:ValidSources -join ', '))" + } + } + Set-BackdropConfigValue 'source' ($Sources -join ' ') + $timerChanged = $false + if ($Sources.Count -gt 1) { + if ($script:RotateInterval -le 0) { + $script:RotateInterval = 30 + Set-BackdropConfigValue 'rotate_interval' '30' + $timerChanged = $true + } + Write-Host "backdrop: active sources: $($Sources -join ' ') (rotating every $($script:RotateInterval) min)" + } + else { + if ($script:RotateInterval -gt 0) { + $script:RotateInterval = 0 + Set-BackdropConfigValue 'rotate_interval' '0' + $timerChanged = $true + } + Write-Host "backdrop: active source is now '$($Sources[0])'" + } + if ($timerChanged) { + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { Invoke-TimerConfig } + } + Invoke-ApplyWallpaper (Get-ActiveSource) $Force +} + +function Invoke-BackdropStatus { + Write-Host "backdrop v$($script:Version)" + Write-Host '' + + $activeSrcs = @(Get-BackdropSource) + $activeSrc = Get-ActiveSource + $latest = $null + + $currentFile = Join-Path $script:StateDir 'current' + if (Test-Path $currentFile) { + $p = (Get-Content $currentFile -Raw).Trim() + if (Test-Path $p) { $latest = $p } + } + if (-not $latest) { + $latest = Get-ChildItem $script:StateDir -Filter "$activeSrc-*.jpg" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + } + + $displayedSrc = $activeSrc + if ($latest) { + $candidate = [System.IO.Path]::GetFileNameWithoutExtension($latest) -replace '-\d{4}-\d{2}-\d{2}$', '' + if (Test-ValidSource $candidate) { $displayedSrc = $candidate } + } + + if ($activeSrcs.Count -gt 1) { + $labeled = ($activeSrcs | ForEach-Object { if ($_ -eq $displayedSrc) { "[$_]" } else { $_ } }) -join ' ' + Write-Host "Active sources: $labeled" + } + else { + Write-Host "Active source: $activeSrc" + } + + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -ne 'Disabled') { + if ($script:RotateInterval -gt 0) { + Write-Host "Timer: enabled (rotating every $($script:RotateInterval) min)" + } + else { + Write-Host "Timer: enabled (runs at $($script:TimerTime))" + } + } + else { + Write-Host "Timer: disabled" + } + + Write-Host '' + if ($latest) { + Write-Host "Current image: $latest" + $metaFile = [System.IO.Path]::ChangeExtension($latest, 'meta') + $mv = Get-MetaValue $metaFile 'title' + if ($mv) { + if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } + Write-Host "Title: $mv" + } + $mv = Get-MetaValue $metaFile 'desc' + if ($mv) { + if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } + Write-Host "Description: $mv" + } + $mv = Get-MetaValue $metaFile 'url' + if ($mv) { Write-Host "URL: $mv" } + } + + $styleNum = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -ErrorAction SilentlyContinue).WallpaperStyle + $styleStr = switch ($styleNum) { '10' { 'zoom' } '6' { 'scaled' } default { "style=$styleNum" } } + Write-Host '' + Write-Host "Display method: windows, $styleStr" + Write-Host "Aspect ratio: $(Get-ScreenAspectRatio), $($script:ZoomMinCoverage) min coverage" + Write-Host "Config file: $($script:ConfigFile)" + Write-Host '' + Write-Host "Use 'backdrop help' for usage information." + Write-Host '' +} + +function Invoke-BackdropUpgrade { + Write-Host "backdrop: checking for updates (current: v$($script:Version))..." + $apiResp = Invoke-BackdropRequest 'https://api.github.com/repos/aensley/backdrop/releases/latest' -TimeoutSec 15 + $release = $apiResp | ConvertFrom-Json + $latestTag = $release.tag_name + $latestVer = $latestTag.TrimStart('v') + if ([version]$latestVer -le [version]$script:Version) { + Write-Host "backdrop: already up to date (v$($script:Version))." + return + } + Write-Host "backdrop: upgrading v$($script:Version) -> v$latestVer..." + $rawUrl = "https://raw.githubusercontent.com/aensley/backdrop/$latestTag/src/backdrop.psm1" + $modPath = (Get-Module backdrop -ErrorAction SilentlyContinue).Path + if (-not $modPath) { throw 'backdrop: upgrade: could not locate installed module path' } + $tmp = [System.IO.Path]::GetTempFileName() + try { + Save-BackdropFile $rawUrl $tmp -TimeoutSec 60 + Copy-Item $tmp $modPath -Force + Write-Host "backdrop: upgraded to v$latestVer. Restart PowerShell to use the new version." + } + finally { + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-BackdropUninstall { + param([switch]$Purge) + Disable-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue + $modDir = Split-Path -Parent (Get-Module backdrop -ErrorAction SilentlyContinue).Path + if ($modDir -and (Test-Path $modDir)) { Remove-Item $modDir -Recurse -Force } + if ($Purge) { + Remove-Item $script:ConfigDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $script:StateDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host 'backdrop: uninstalled. Config and cached wallpapers removed.' + } + else { + Write-Host 'backdrop: uninstalled.' + Write-Host "Note: config and cached wallpapers were not removed. Run 'backdrop uninstall --purge' to delete them." + } +} + +function Invoke-BackdropHelp { + Write-Host @" +backdrop v$($script:Version) + +Usage: backdrop + +Commands: + status Show the active source and last image (default command) + update [--force] Refresh wallpaper from the active source + set [--force] Switch active source(s) and refresh now; use 'all' for all sources + set-time Set the daily run time (24-hour); updates task if active + set-rotate-interval Set rotation interval in minutes; 0 to disable rotation + random [--force] Refresh from a randomly chosen source (does not change active source) + enable Enable the scheduled task + disable Disable the scheduled task + upgrade Check for and install the latest version from GitHub + uninstall [--purge] Remove backdrop and (with --purge) delete config and cached wallpapers + help Show this help + +Sources: + bing Bing image of the day + earth Earth.com Image of the Day + apod NASA Astronomy Picture of the Day + eo NASA Earth Observatory Image of the Day + iotd NASA Image of the Day (default) + natgeo National Geographic Photo of the Day + wmc Wikimedia Commons Picture of the Day +"@ +} + +#endregion + +#region Dispatch + +function backdrop { + <# + .SYNOPSIS + Set a new desktop wallpaper every day from various sources. + .DESCRIPTION + Downloads the image of the day from a configurable source and sets it as the desktop wallpaper. + Run 'backdrop help' for available commands and sources. + #> + param( + [Parameter(Position = 0)] + [string]$Command = 'status', + [Parameter(ValueFromRemainingArguments)] + [string[]]$Rest + ) + + if ($Command -ne 'uninstall') { Import-BackdropConfig } + + $force = $Rest -contains '--force' + $filteredArgs = @($Rest | Where-Object { $_ -ne '--force' }) + + try { + switch ($Command) { + { $_ -in 'update', 'refresh' } { Invoke-BackdropUpdate -Force:$force } + { $_ -in 'set', 'use' } { Invoke-BackdropSet -Sources $filteredArgs -Force $force } + 'status' { Invoke-BackdropStatus } + 'random' { Invoke-BackdropRandom -Force:$force } + 'enable' { Invoke-BackdropEnable } + 'disable' { Invoke-BackdropDisable } + 'set-time' { Invoke-BackdropSetTime ($filteredArgs | Select-Object -First 1) } + 'set-rotate-interval' { Invoke-BackdropSetRotateInterval ($filteredArgs | Select-Object -First 1) } + 'upgrade' { Invoke-BackdropUpgrade } + 'uninstall' { Invoke-BackdropUninstall -Purge:($Rest -contains '--purge') } + { $_ -in '-h', '--help', 'help' } { Invoke-BackdropHelp } + default { throw "backdrop: unknown command '$Command' (try: backdrop help)" } + } + } + catch { + Write-Host $_.Exception.Message -ForegroundColor Red + exit 1 + } +} + +Export-ModuleMember -Function 'backdrop' + +#endregion diff --git a/src/install.ps1 b/src/install.ps1 new file mode 100644 index 0000000..fb98902 --- /dev/null +++ b/src/install.ps1 @@ -0,0 +1,60 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$REPO = 'aensley/backdrop' + +# Resolve install source: local repo checkout or remote download. +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null } +$repoRaw = $null + +if (-not $scriptDir) { + Write-Host 'Fetching latest release info from GitHub...' + $apiResp = (Invoke-WebRequest -Uri "https://api.github.com/repos/$REPO/releases/latest" ` + -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop).Content + $latestTag = ($apiResp | ConvertFrom-Json).tag_name + $repoRaw = "https://raw.githubusercontent.com/$REPO/$latestTag/src" + Write-Host "Installing backdrop $latestTag..." +} else { + Write-Host 'Installing backdrop...' +} + +# Determine the per-user PowerShell modules directory for the running PS version. +$modulesRoot = if ($PSVersionTable.PSVersion.Major -ge 6) { + Join-Path $env:USERPROFILE 'Documents\PowerShell\Modules' +} else { + Join-Path $env:USERPROFILE 'Documents\WindowsPowerShell\Modules' +} +$destDir = Join-Path $modulesRoot 'backdrop' +$null = New-Item -ItemType Directory -Force -Path $destDir + +$tmp = $null +if ($repoRaw) { + # Remote install: download both files to a temp directory first. + $tmp = Join-Path $env:TEMP "backdrop-install-$(Get-Random)" + $null = New-Item -ItemType Directory -Force -Path $tmp + foreach ($file in 'backdrop.psm1', 'backdrop.psd1') { + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "$repoRaw/$file" -UseBasicParsing -OutFile (Join-Path $tmp $file) ` + -TimeoutSec 30 -ErrorAction Stop + } + $srcDir = $tmp +} else { + $srcDir = $scriptDir +} + +try { + Copy-Item (Join-Path $srcDir 'backdrop.psm1') $destDir -Force + Copy-Item (Join-Path $srcDir 'backdrop.psd1') $destDir -Force +} finally { + if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host "backdrop: installed to $destDir" + +# Load the module and enable the scheduled task. +Import-Module (Join-Path $destDir 'backdrop.psm1') -Force +backdrop enable + +Write-Host 'Done.' diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 new file mode 100644 index 0000000..4fbec62 --- /dev/null +++ b/test/backdrop.tests.ps1 @@ -0,0 +1,512 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +# Script-scope import: runs during Pester discovery so InModuleScope can find the module. +# APPDATA/LOCALAPPDATA don't exist on Linux but the module reads them at load time. +if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } +if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } +Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force + +BeforeAll { + # Pester does not re-execute the script body during the run phase, so we must re-import here to + # ensure the module is present when InModuleScope enters it for test execution. + if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } + if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } + Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force +} + +# All tests run inside the module scope so private functions are directly callable. +InModuleScope backdrop { + + # Pester v5 does not allow BeforeEach directly at the InModuleScope root level; wrap in Describe. + Describe 'backdrop' { + + BeforeEach { + # $TestDrive is shared across all tests in a file, so use a unique subdirectory per test. + $testId = [System.Guid]::NewGuid().ToString('N') + $script:ConfigDir = Join-Path $TestDrive "config-$testId" + $script:ConfigFile = Join-Path $script:ConfigDir 'config' + $script:StateDir = Join-Path $TestDrive "state-$testId" + $script:Source = 'iotd' + $script:RotateInterval = 0 + $script:ScreenAspectRatio = 1.7778 + $script:ZoomMinCoverage = 0.55 + $script:TimerTime = '08:00' + $script:UserAgent = "backdrop/$(($script:Version -split '\.')[0..1] -join '.') (personal daily wallpaper script)" + New-Item -ItemType Directory -Force -Path $script:ConfigDir, $script:StateDir | Out-Null + } + + # ------------------------------------------------------------------------- + # Test-ValidSource + # ------------------------------------------------------------------------- + + Describe 'Test-ValidSource' { + It 'accepts built-in source: <_>' -ForEach @('iotd', 'apod', 'bing', 'wmc', 'eo', 'earth', 'natgeo') { + Test-ValidSource $_ | Should -BeTrue + } + It 'rejects an unknown source' { + Test-ValidSource 'unknown' | Should -BeFalse + } + It 'rejects an empty string' { + Test-ValidSource '' | Should -BeFalse + } + } + + # ------------------------------------------------------------------------- + # Get-BackdropConfigValue + # ------------------------------------------------------------------------- + + Describe 'Get-BackdropConfigValue' { + It 'returns null when config does not exist' { + Get-BackdropConfigValue 'source' | Should -BeNullOrEmpty + } + It 'reads a simple key = value' { + Set-Content $script:ConfigFile 'source = bing' + Get-BackdropConfigValue 'source' | Should -Be 'bing' + } + It 'strips surrounding double quotes' { + Set-Content $script:ConfigFile 'user_agent = "my agent"' + Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' + } + It 'strips surrounding single quotes' { + Set-Content $script:ConfigFile "user_agent = 'my agent'" + Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' + } + It 'ignores comment lines' { + Set-Content $script:ConfigFile "# source = apod`nsource = wmc" + Get-BackdropConfigValue 'source' | Should -Be 'wmc' + } + } + + # ------------------------------------------------------------------------- + # Initialize-BackdropConfig / Set-BackdropConfigValue + # ------------------------------------------------------------------------- + + Describe 'Initialize-BackdropConfig / Set-BackdropConfigValue' { + It 'creates the config file with built-in defaults' { + Initialize-BackdropConfig + $script:ConfigFile | Should -Exist + Get-BackdropConfigValue 'source' | Should -Be 'iotd' + Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + } + It 'writes a new key' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'apod' + Get-BackdropConfigValue 'source' | Should -Be 'apod' + } + It 'overwrites an existing key' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'bing' + Set-BackdropConfigValue 'source' 'wmc' + Get-BackdropConfigValue 'source' | Should -Be 'wmc' + } + It 'preserves other keys when updating one' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'eo' + Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + } + } + + # ------------------------------------------------------------------------- + # Import-BackdropConfig + # ------------------------------------------------------------------------- + + Describe 'Import-BackdropConfig' { + It 'reads config values into module globals' { + Set-Content $script:ConfigFile @" +screen_aspect_ratio = 2.3333 +zoom_min_coverage = 0.75 +timer_time = 10:30 +rotate_interval = 15 +user_agent = test-agent/1.0 +"@ + Import-BackdropConfig + $script:ScreenAspectRatio | Should -Be 2.3333 + $script:ZoomMinCoverage | Should -Be 0.75 + $script:TimerTime | Should -Be '10:30' + $script:RotateInterval | Should -Be 15 + $script:UserAgent | Should -Be 'test-agent/1.0' + } + It 'leaves globals at defaults when config is missing' { + Import-BackdropConfig + $script:ScreenAspectRatio | Should -Be 1.7778 + $script:ZoomMinCoverage | Should -Be 0.55 + $script:TimerTime | Should -Be '08:00' + $script:RotateInterval | Should -Be 0 + } + } + + # ------------------------------------------------------------------------- + # Get-ScreenAspectRatio + # ------------------------------------------------------------------------- + + Describe 'Get-ScreenAspectRatio' { + It 'returns a positive numeric aspect ratio' { + $ar = Get-ScreenAspectRatio + $ar | Should -BeOfType [double] + $ar | Should -BeGreaterThan 0 + } + } + + # ------------------------------------------------------------------------- + # Get-PictureOption + # ------------------------------------------------------------------------- + + Describe 'Get-PictureOption' { + It 'returns zoom for a wide image on a 16:9 screen' { + Mock Get-ImageDimension { @{ Width = 1920; Height = 1080 } } + Mock Get-ScreenAspectRatio { 1.7778 } + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + It 'returns scaled for a tall image on a 16:9 screen' { + Mock Get-ImageDimension { @{ Width = 1080; Height = 1920 } } + Mock Get-ScreenAspectRatio { 1.7778 } + Get-PictureOption 'dummy.jpg' | Should -Be 'scaled' + } + It 'returns zoom for a square image (coverage 0.5625 >= threshold 0.55)' { + Mock Get-ImageDimension { @{ Width = 1000; Height = 1000 } } + Mock Get-ScreenAspectRatio { 1.7778 } + $script:ZoomMinCoverage = 0.55 + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + It 'returns zoom when image dimensions cannot be read' { + Mock Get-ImageDimension { $null } + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + } + + # ------------------------------------------------------------------------- + # Remove-HtmlMarkup + # ------------------------------------------------------------------------- + + Describe 'Remove-HtmlMarkup' { + It 'removes HTML tags' { + Remove-HtmlMarkup 'Hello World' | Should -Be 'Hello World' + } + It 'decodes common HTML entities' { + Remove-HtmlMarkup '& < > " '' | Should -Be '& < > " ''' + } + It 'decodes numeric entities with leading zeros' { + Remove-HtmlMarkup "San Francisco's Streets" | Should -Be "San Francisco's Streets" + } + It 'collapses whitespace' { + Remove-HtmlMarkup ' foo bar ' | Should -Be 'foo bar' + } + } + + # ------------------------------------------------------------------------- + # Write-MetaFile / Get-MetaValue + # ------------------------------------------------------------------------- + + Describe 'Write-MetaFile / Get-MetaValue' { + It 'reads a key from a meta file' { + $f = Join-Path $script:StateDir 'test.meta' + Set-Content $f "title = My Image`ndesc = Some desc`nurl = https://example.com/" + Get-MetaValue $f 'title' | Should -Be 'My Image' + } + It 'returns null for a missing file' { + Get-MetaValue (Join-Path $script:StateDir 'nonexistent.meta') 'title' | Should -BeNullOrEmpty + } + It 'returns null for a missing key' { + $f = Join-Path $script:StateDir 'test.meta' + Set-Content $f 'url = https://example.com/' + Get-MetaValue $f 'title' | Should -BeNullOrEmpty + } + It 'writes non-empty fields to a .meta file' { + $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' + $null = New-Item $dest -ItemType File -Force + Write-MetaFile $dest @{ Title = 'Test Image'; Desc = 'A test description'; Url = 'https://example.com/' } + $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') + Get-MetaValue $meta 'title' | Should -Be 'Test Image' + Get-MetaValue $meta 'desc' | Should -Be 'A test description' + Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + } + It 'omits empty fields from the .meta file' { + $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' + $null = New-Item $dest -ItemType File -Force + Write-MetaFile $dest @{ Title = ''; Desc = ''; Url = 'https://example.com/' } + $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') + Get-MetaValue $meta 'title' | Should -BeNullOrEmpty + Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + } + } + + # ------------------------------------------------------------------------- + # Get-BackdropSource + # ------------------------------------------------------------------------- + + Describe 'Get-BackdropSource' { + It 'returns default source when config does not exist' { + Get-BackdropSource | Should -Be @('iotd') + } + It 'returns a single configured source' { + Set-Content $script:ConfigFile 'source = bing' + Get-BackdropSource | Should -Be @('bing') + } + It 'returns a list for multiple configured sources' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + Get-BackdropSource | Should -Be @('iotd', 'apod', 'bing') + } + It "expands 'all' to every valid source" { + Set-Content $script:ConfigFile 'source = all' + Get-BackdropSource | Should -Be $script:ValidSources + } + } + + # ------------------------------------------------------------------------- + # Get-ActiveSource + # ------------------------------------------------------------------------- + + Describe 'Get-ActiveSource' { + It 'returns the single configured source' { + Set-Content $script:ConfigFile 'source = apod' + $script:RotateInterval = 0 + Get-ActiveSource | Should -Be 'apod' + } + It 'returns the first source when rotation is disabled' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 0 + Get-ActiveSource | Should -Be 'iotd' + } + It 'returns index 0 at epoch (timestamp=0)' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 0 } + Get-ActiveSource | Should -Be 'iotd' + } + It 'advances to the next source after one full interval' { + # 60 minutes elapsed, interval=60min, 3 sources -> slot 1 -> apod + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 3600 } + Get-ActiveSource | Should -Be 'apod' + } + It 'wraps around after all sources are used' { + # 180 minutes elapsed, interval=60min, 3 sources -> slot 3 % 3 = 0 -> iotd + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 10800 } + Get-ActiveSource | Should -Be 'iotd' + } + It 'floors partial intervals' { + # 90 minutes elapsed -> floor(90/60)=1 -> apod + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 5400 } + Get-ActiveSource | Should -Be 'apod' + } + } + + # ------------------------------------------------------------------------- + # Resolve-Iotd + # ------------------------------------------------------------------------- + + Describe 'Resolve-Iotd' { + It 'returns image URL and metadata from RSS feed' { + Mock Invoke-BackdropRequest { + @' + + <![CDATA[Test IOTD Image]]> + A test NASA image. + https://www.nasa.gov/image-of-the-day/test/ + + +'@ + } + $r = Resolve-Iotd + $r.ImageUrls | Should -Be @('https://www.nasa.gov/wp-content/uploads/2025/01/test.jpg') + $r.Title | Should -Be 'Test IOTD Image' + $r.Url | Should -Be 'https://www.nasa.gov/image-of-the-day/test/' + } + It 'returns null when no enclosure URL is present' { + Mock Invoke-BackdropRequest { + '<![CDATA[No Image Today]]>' + } + Resolve-Iotd | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Iotd } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Apod + # ------------------------------------------------------------------------- + + Describe 'Resolve-Apod' { + It 'returns image URL and metadata from HTML page' { + Mock Invoke-BackdropRequest { + @' + +

Starry Night +image +Explanation: A wonderful view of stars. + +'@ + } + $r = Resolve-Apod + $r.ImageUrls | Should -Be @('https://apod.nasa.gov/apod/image/2025/starry_night.jpg') + $r.Title | Should -Be 'Starry Night' + } + It 'returns null when no image link is found' { + Mock Invoke-BackdropRequest { 'No image today.' } + Resolve-Apod | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Apod } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Bing + # ------------------------------------------------------------------------- + + Describe 'Resolve-Bing' { + It 'returns 4K and fallback URLs with metadata' { + Mock Invoke-BackdropRequest { + '{"images":[{"urlbase":"/th/id/OHR.TestImage","url":"/th/id/OHR.TestImage_1920x1080.jpg","title":"Test Bing Image","copyright":"Test copyright 2025"}]}' + } + $r = Resolve-Bing + $r.ImageUrls[0] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_UHD.jpg' + $r.ImageUrls[1] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_1920x1080.jpg' + $r.Title | Should -Be 'Test Bing Image' + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Bing } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Eo + # ------------------------------------------------------------------------- + + Describe 'Resolve-Eo' { + It 'returns 4K and base URLs with metadata from RSS feed' { + Mock Invoke-BackdropRequest { + @' + + <![CDATA[Earth at Night]]> + https://earthobservatory.nasa.gov/images/12345/earth-at-night +

https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg

+
+'@ + } + $r = Resolve-Eo + $r.ImageUrls[0] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg?w=3840' + $r.ImageUrls[1] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg' + $r.Title | Should -Be 'Earth at Night' + } + It 'returns null when no asset URL found' { + Mock Invoke-BackdropRequest { + '<![CDATA[No Image]]>' + } + Resolve-Eo | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Eo } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Natgeo + # ------------------------------------------------------------------------- + + Describe 'Resolve-Natgeo' { + It 'returns high-res and base URLs from og:image' { + Mock Invoke-BackdropRequest { + '' + } + $r = Resolve-Natgeo + $r.ImageUrls[0] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg?w=5120' + $r.ImageUrls[1] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg' + } + It 'strips the site suffix from og:title' { + Mock Invoke-BackdropRequest { + @' + + +'@ + } + (Resolve-Natgeo).Title | Should -Be 'Forever in Motion' + } + It 'returns null when no natgeofe og:image is found' { + Mock Invoke-BackdropRequest { + '' + } + Resolve-Natgeo | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Natgeo } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Earth + # ------------------------------------------------------------------------- + + Describe 'Resolve-Earth' { + It 'returns URL and metadata from the article page' { + Mock Invoke-BackdropRequest { + param($Uri) + if ($Uri -match 'gallery') { + return '' + } + @' + + + +"https://cff2.earth.com/uploads/2025/10/01/photo.jpg" +'@ + } + $r = Resolve-Earth + $r.ImageUrls | Should -Be @('https://cff2.earth.com/uploads/2025/10/01/photo.jpg') + $r.Title | Should -Be 'Test Title' + } + It 'returns null when no article link is found' { + Mock Invoke-BackdropRequest { 'no image here' } + Resolve-Earth | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Earth } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Wmc + # ------------------------------------------------------------------------- + + Describe 'Resolve-Wmc' { + It 'returns thumbnail and base URLs with metadata' { + Mock Invoke-BackdropRequest { + param($Uri) + if ($Uri -match 'imageinfo') { + return '{"query":{"pages":{"-1":{"imageinfo":[{"thumburl":"https://upload.wikimedia.org/thumb/photo.jpg","url":"https://upload.wikimedia.org/photo.jpg"}]}}}}' + } + if ($Uri -match '\(en\)') { + return '{"expandtemplates":{"wikitext":"A beautiful photograph of the day."}}' + } + return '{"expandtemplates":{"wikitext":"File:Test Photo.jpg"}}' + } + $r = Resolve-Wmc + $r.ImageUrls[0] | Should -Be 'https://upload.wikimedia.org/thumb/photo.jpg' + $r.ImageUrls[1] | Should -Be 'https://upload.wikimedia.org/photo.jpg' + $r.Title | Should -Be 'Test Photo' + } + It 'returns null when no file is found' { + Mock Invoke-BackdropRequest { '{"expandtemplates":{"wikitext":""}}' } + Resolve-Wmc | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Wmc } | Should -Throw + } + } + + } # Describe 'backdrop' + +} # InModuleScope backdrop