diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 283fb6d..1ae0617 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ name: Release # # Every job here runs the same script a developer would run locally, so a # released artifact and a locally built one are produced the same way. +# +# Pushing a tag builds everything and drafts the release. Running it by hand is a +# rehearsal by default: it builds and checks all six artifacts and publishes +# nothing, which is how you find out that Inno Setup or hdiutil is unhappy +# without burning a tag. Tick "publish" to draft the release from a manual run. on: push: @@ -16,10 +21,18 @@ on: version: description: "Version to build (without the leading v)" required: true + publish: + description: "Draft the GitHub release as well, instead of only building" + type: boolean + default: false permissions: contents: write +concurrency: + group: release-${{ github.event.inputs.version || github.ref_name }} + cancel-in-progress: false + env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -135,13 +148,13 @@ jobs: name: linux-${{ matrix.rid }} path: artifacts/tar/*.tar.gz - release: - name: Draft release + bundle: + name: Collect and check artifacts needs: [version, windows, macos, linux] runs-on: ubuntu-latest + env: + VERSION: ${{ needs.version.outputs.version }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 with: path: downloaded @@ -154,19 +167,58 @@ jobs: -exec cp {} release/ \; cd release sha256sum * > SHA256SUMS.txt - cat SHA256SUMS.txt - # Six artifacts: two Windows, two macOS, two Linux. A missing one means a - # platform job uploaded nothing, which is worth failing on. + # Named rather than counted, so a rename or a platform that quietly + # published the wrong runtime fails here instead of in someone's download. - name: Check every platform produced its artifacts shell: bash run: | - count=$(find release -type f ! -name 'SHA256SUMS.txt' | wc -l) - find release -type f | sort - if [ "$count" -ne 6 ]; then - echo "Expected 6 release files, found $count." >&2 - exit 1 - fi + missing=0 + for suffix in win-x64.zip win-x64-Setup.exe osx-arm64.dmg osx-x64.dmg linux-x64.tar.gz linux-arm64.tar.gz; do + if [ ! -f "release/WallpaperSwitcher-$VERSION-$suffix" ]; then + echo "Missing WallpaperSwitcher-$VERSION-$suffix" >&2 + missing=1 + fi + done + exit $missing + + - name: Summarise + shell: bash + run: | + { + echo "## Wallpaper Switcher $VERSION" + echo + echo '| Artifact | Size |' + echo '|---|---|' + for f in release/*; do + [ "$(basename "$f")" = SHA256SUMS.txt ] && continue + printf '| `%s` | %s |\n' "$(basename "$f")" "$(du -h "$f" | cut -f1)" + done + echo + echo '```' + cat release/SHA256SUMS.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: release-bundle + path: release/* + + publish: + name: Draft release + needs: [version, bundle] + # A tag publishes. A manual run only publishes when asked to, so the default + # manual run is a rehearsal that cannot create a release named after a branch. + if: startsWith(github.ref, 'refs/tags/') || github.event.inputs.publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: release-bundle + path: release - name: Extract release notes from the changelog shell: bash @@ -174,6 +226,10 @@ jobs: - uses: softprops/action-gh-release@v2 with: + # Explicit, because the default is github.ref: on a manual run that + # would be a branch name, and the release would be tagged with it. + tag_name: v${{ needs.version.outputs.version }} + name: Wallpaper Switcher ${{ needs.version.outputs.version }} files: release/* body_path: release-notes.md draft: true diff --git a/README.md b/README.md index ee132d1..9a15cf3 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,103 @@ -# Wallpaper Switcher +
-Switches your desktop wallpaper between **Day** and **Night** image sets based on -your local time, from a folder you choose. Open source, no account, no server, no -telemetry — it only ever reads the folder you point it at. +Wallpaper Switcher — day and night wallpapers, from a folder you own -Runs on Windows, macOS, and Linux. Built with .NET 9 and Avalonia. +

+ Download + · + Features + · + Install + · + How it works + · + FAQ + · + Changelog + · + Contribute + · + Report a bug +

-[![CI](https://github.com/msk-one/WallpaperSwitcher/actions/workflows/ci.yml/badge.svg)](https://github.com/msk-one/WallpaperSwitcher/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +

+ CI + MIT license + Windows, macOS and Linux + No account, no server +

-![The Wallpapers page on Windows](docs/screenshot-windows.png) +
-## What it does +
-- Point it at one folder. Subfolders are scanned too. -- Click a tile to tag each image **Day**, **Night**, or **Ignore**. Files with - `day` or `night` in the name are tagged for you. -- Drag the bar to set when day and night begin. A night window that crosses - midnight works. -- Shuffle within the current set **every hour**, **every 6 hours**, **each day**, - or **each week**. -- Lives in the tray. Closing the window asks whether to keep it running or quit, - and can remember your answer. -- Optionally starts when you sign in, and can start straight into the tray. +Wallpaper Switcher gives your desktop a **day set** and a **night set**, and swaps +between them on your computer's own clock. Point it at a folder you already have, +tag each picture as Day or Night, and forget about it — bright photos while you +work, dark ones in the evening. -Changes are saved as you make them. The image chosen for a given period is -deterministic, so restarting the app does not reshuffle your wallpaper. +It is free and open source, it has no account and no server, and it makes no +network connections at all. The only thing it ever touches is the folder you +choose. -## Install +
-Download from the [latest release](https://github.com/msk-one/WallpaperSwitcher/releases/latest). +# Why this exists + +Every operating system can shuffle a folder of wallpapers. None of them know what +time it is. So the beach photo you love turns up at 11pm, and the moody city +skyline greets you at breakfast. + +The apps that do solve this usually come with strings attached: an account, a +subscription, a single photo service you have to take your pictures from, and a +background process quietly talking to somebody's server. + +Wallpaper Switcher does the one job and stops there. Your pictures, your folder, +your machine's clock. Nothing is uploaded, nothing is downloaded, and if you +uninstall it your files are exactly where you left them. + +
+ +# What it does + +| | | +|---|---| +| **Day and night sets** | Tag each image Day, Night, or Ignore. Files with `day` or `night` in the name are tagged for you the moment you pick the folder. | +| **Your own folder** | One folder, subfolders included. Vacation photos, a pack you bought, a shared drive — it does not care where the pictures came from. | +| **Shuffle on your terms** | A different picture every hour, every 6 hours, each day, or each week. Or never: leave one image in a set and it stays put. | +| **You set the hours** | Drag a 24-hour bar to say when day and night begin. A night that runs from 21:00 to 07:00 works fine. | +| **Lives in the tray** | Change wallpaper, swap day and night, or switch cadence from the tray menu without opening the window. | +| **Starts with your computer** | Optional, and it can start straight into the tray with no window at all. | +| **Fits the screen properly** | On Windows, choose Fill, Fit, Stretch, Center, Tile, or Span across monitors. | +| **Looks like your desktop** | Follows your light or dark theme automatically, on all three operating systems. | + +
+ +# Install it + +**[⬇ Download the latest release](https://github.com/msk-one/WallpaperSwitcher/releases/latest)** ### Windows | | | |---|---| -| **Installer** (recommended) | `WallpaperSwitcher--win-x64-Setup.exe` | -| **Portable** | `WallpaperSwitcher--win-x64.zip` | +| **Installer** — easiest | `WallpaperSwitcher--win-x64-Setup.exe` | +| **Portable** — no install | `WallpaperSwitcher--win-x64.zip` | -Windows 10 or 11, 64-bit. Nothing else to install — .NET is bundled. +Windows 10 or 11, 64-bit. Nothing else to install; everything it needs is inside. -The installer is per-user: it installs to `%LOCALAPPDATA%\Programs\WallpaperSwitcher`, -never prompts for administrator rights, and adds Start menu and Add/Remove -Programs entries. The portable zip is a single executable you can run from -anywhere, including a USB stick. +The installer is per-user: it never asks for administrator rights, it adds the +usual Start menu and Add/Remove Programs entries, and uninstalling cleans up +after itself. The portable zip is a single file you can run from anywhere, +including a USB stick. ### macOS -macOS 11 or later. Download `WallpaperSwitcher--osx-arm64.dmg` (Apple -silicon) or `-osx-x64.dmg` (Intel) and drag the app to Applications, then clear -the quarantine flag once, because the app is not notarised: +macOS 11 or later. Download `WallpaperSwitcher--osx-arm64.dmg` for Apple +silicon or `-osx-x64.dmg` for Intel, and drag the app into Applications. + +The first launch will be refused, because the app is not notarised by Apple. Run +this once in Terminal and it will open normally afterwards: ```bash xattr -dr com.apple.quarantine /Applications/WallpaperSwitcher.app @@ -63,63 +111,105 @@ chmod +x WallpaperSwitcher ./WallpaperSwitcher ``` -`linux-arm64` is published too. Works with GNOME, KDE Plasma, XFCE, and any -compositor with `swww` or `feh` available — the app tries each in turn. +`linux-arm64` is published too. Works on GNOME, KDE Plasma and XFCE, and on any +compositor with `swww` or `feh` available — it tries each in turn. + +### Why does my computer warn me about it? + +Because the app is **not code-signed**. Windows will say "Windows protected your +PC" (click **More info** → **Run anyway**) and macOS will say the developer +cannot be verified (use the `xattr` command above). + +That warning is not a judgement about this app — it is what every unsigned +program gets. A signing certificate costs several hundred dollars a year, which is +hard to justify for something given away for free. + +What you get instead: every release is built in public by +[GitHub Actions](.github/workflows/release.yml) straight from a tagged commit, and +SHA-256 checksums are attached to each release so you can check that the file you +downloaded is the file that was built. And if you would rather trust nothing at +all, [build it yourself](#build-it-yourself) — it is one command. -### Why your OS warns about it +
-**Wallpaper Switcher is not code-signed**, so Windows shows "Windows protected -your PC" (**More info** → **Run anyway**) and macOS says the developer cannot be -verified (use the `xattr` command above). That is what every unsigned application -gets: a signing certificate costs several hundred dollars a year, which is hard -to justify for a free tool. +# Using it -Every release is built by [GitHub Actions](.github/workflows/release.yml) from a -public tag, and SHA-256 checksums are attached to each release so you can verify -what you downloaded. If you would rather not trust the binaries, -[build from source](#build-from-source) — it is one command. +Pick your folder on **Settings**, then tag your pictures on **Wallpapers**. +Clicking a tile cycles it **Day → Night → Ignore**, and anything left on Ignore is +never used. There is no Save button — every change is written straight away. -## Using it +![The Wallpapers page on Windows, showing a grid of tagged images](docs/screenshot-windows.png) -1. On **Settings**, choose your wallpaper folder. -2. On **Wallpapers**, click any tile to cycle it **Day → Night → Ignore**. - Anything left as Ignore is never used. -3. Back on **Settings**, drag the bar to set when day and night begin, and pick a - **Shuffle** cadence. On Windows you can also set the **Fit** mode. +Back on **Settings**, drag the bar to set when day and night begin, and choose how +often to shuffle. The same window on macOS, following the system light theme: -There is no Save button — every change is written immediately and takes effect at -once. +![The Settings page on macOS](docs/screenshot-macos.png) -The tray menu has the things you want without opening the window: cycle to the -next wallpaper now, swap the day and night hours, change cadence, toggle start at -login, and open the log folder. +Closing the window asks whether to keep running in the tray or quit properly, and +can remember your answer — that row on Settings changes it back if you want the +question again. -### File formats +
-| Format | Preview | Applies as wallpaper | +# How it works + +A few decisions worth knowing about, because they are what make it feel reliable: + +- **The picture for a given period is fixed, not random.** It is worked out from + the date and the period rather than drawn from a hat, so restarting the app or + rebooting gives you the same wallpaper back instead of a fresh shuffle. +- **A "day" ends when your night starts, not at midnight.** An evening that runs + past midnight is still the same evening. +- **It wakes up when it should.** A timer fires on the next boundary, a + once-a-minute check catches a machine that was asleep, and on Windows it also + listens for the clock changing, the screen resolution changing, and other apps + taking the wallpaper over. +- **A broken picture never leaves you with a blank desktop.** Images are checked + before use, and an unusable one is skipped in favour of the next and noted in + the log. +- **Your tags survive a move.** They are stored relative to the wallpaper folder, + so renaming or moving it does not throw them away. + +[`docs/design-notes.md`](docs/design-notes.md) goes into the details, including +daylight saving and the known limitations. + +
+ +# Questions + +**Does it upload my photos anywhere?** No. The app makes no network connections +whatsoever. There is no account, no server and no telemetry. + +**Does it need to be running?** Yes — it lives in the tray and changes the +wallpaper when the time comes. Turn on "Start when I sign in" and you can forget +it exists. + +**What image formats work?** + +| Format | Preview thumbnail | Works as wallpaper | |---|---|---| | `.jpg` `.jpeg` `.png` `.bmp` `.gif` | Yes | Yes | | `.tif` `.tiff` | No | Yes | | `.heic` `.heif` `.webp` | `.webp` only | Only with the matching Windows codec installed | -Verified on Windows 11. `.tif`/`.tiff` apply correctly but have no preview -thumbnail, because the renderer the app uses cannot decode them. Files that -cannot be used are skipped in favour of the next image and noted in the log, -rather than leaving you with a blank desktop. +**Where does it keep my settings?** In plain, readable JSON: +`%LOCALAPPDATA%\WallpaperSwitcher\settings.json` on Windows, or +`~/.local/share/WallpaperSwitcher/settings.json` on macOS and Linux. Logs sit +beside it and are kept for 7 days. Uninstalling leaves both alone unless you ask +for them to be removed. -### Where your data lives +**Can I have more than two sets?** Not today. Day and night, and that is the whole +idea. -| | | -|---|---| -| Settings | `%LOCALAPPDATA%\WallpaperSwitcher\settings.json` (Windows)
`~/.local/share/WallpaperSwitcher/settings.json` (macOS, Linux) | -| Logs | the `logs` folder beside it, 7 days retained | +**Something is wrong.** [Open an issue](https://github.com/msk-one/WallpaperSwitcher/issues/new/choose) +— the log folder is one click away on the Settings page, and attaching it helps a +lot. -Settings are plain JSON you can read and edit. Uninstalling leaves them in place -unless you ask for them to be removed. +
-## Build from source +# Build it yourself -Requires the [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0). +You need the [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0). ```bash git clone https://github.com/msk-one/WallpaperSwitcher.git @@ -129,23 +219,23 @@ dotnet test WallpaperSwitcher.Tests/WallpaperSwitcher.Tests.csproj -c Release dotnet run --project WallpaperSwitcher.Desktop ``` -To build the release artifacts yourself: +To produce the same artifacts a release does: ```powershell -./scripts/publish-windows.ps1 # zip + Inno Setup installer +./scripts/publish-windows.ps1 # portable zip + Inno Setup installer ``` ```bash -./scripts/package-macos-dmg.sh osx-arm64 # .app bundle + DMG, macOS only -./scripts/publish-linux.sh linux-x64 # tarball +./scripts/package-macos-dmg.sh osx-arm64 # .app bundle + DMG, macOS only +./scripts/publish-linux.sh linux-x64 # tarball ``` -Each target is built on its own operating system, which is also what -`.github/workflows/release.yml` does. A macOS build produced on a non-macOS host -will not run: arm64 macOS binaries need a code signature that `dotnet publish` -only applies on macOS. +Each target is built on its own operating system, which is exactly what +[`.github/workflows/release.yml`](.github/workflows/release.yml) does. A macOS +build produced anywhere but macOS will not run: arm64 binaries need a signature +that `dotnet publish` only applies on a Mac. -## Project layout +### Project layout | | | |---|---| @@ -154,9 +244,21 @@ only applies on macOS. | `WallpaperSwitcher.Tests/` | Unit tests for the core. | | `docs/design-notes.md` | How scheduling works and why, plus known limitations. | -The UI is built in C# rather than XAML. See [CONTRIBUTING.md](CONTRIBUTING.md) -before sending a patch. +Built with [.NET 9](https://dotnet.microsoft.com/) and +[Avalonia](https://avaloniaui.net/). The UI is written in C# rather than XAML — +[CONTRIBUTING.md](CONTRIBUTING.md) explains why, and covers the pull request +process. + +
+ +# License + +[MIT](LICENSE). Do what you like with it. + +
+ +
-## License +**If this is useful to you, a star helps other people find it.** ⭐ -[MIT](LICENSE). +
diff --git a/docs/banner.png b/docs/banner.png new file mode 100644 index 0000000..2db6641 Binary files /dev/null and b/docs/banner.png differ diff --git a/scripts/render-readme-banner.ps1 b/scripts/render-readme-banner.ps1 new file mode 100644 index 0000000..ceafd69 --- /dev/null +++ b/scripts/render-readme-banner.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Renders docs/banner.png, the image at the top of the README. + +.DESCRIPTION + The banner is a day-to-night sky with the sun, moon and hills from the + application icon. Generated rather than hand-drawn so it can be re-rendered + when the wording or the size changes. Windows only: it uses System.Drawing + and the Segoe UI font. +#> +param([string]$Out = 'docs\banner.png') + +Add-Type -AssemblyName System.Drawing +$ErrorActionPreference = 'Stop' + +$W = 1280; $H = 340 +$bmp = New-Object System.Drawing.Bitmap $W, $H +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.SmoothingMode = 'AntiAlias' +$g.TextRenderingHint = 'ClearTypeGridFit' +$g.InterpolationMode = 'HighQualityBicubic' + +function C([string]$hex) { [System.Drawing.ColorTranslator]::FromHtml($hex) } +function A([System.Drawing.Color]$c, [int]$a) { [System.Drawing.Color]::FromArgb($a, $c.R, $c.G, $c.B) } + +# Day on the left, night on the right. +$rect = New-Object System.Drawing.Rectangle 0, 0, $W, $H +$grad = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, (C '#7FC4F2'), (C '#070B18'), 0.0) +$blend = New-Object System.Drawing.Drawing2D.ColorBlend 4 +$blend.Colors = @((C '#8ACDF5'), (C '#4E86C8'), (C '#1B2B4D'), (C '#070B18')) +$blend.Positions = @(0.0, 0.38, 0.72, 1.0) +$grad.InterpolationColors = $blend +$g.FillRectangle($grad, $rect) +$grad.Dispose() + +function Glow([int]$cx, [int]$cy, [int]$r, [System.Drawing.Color]$col, [int]$alpha) { + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + $path.AddEllipse(($cx - $r), ($cy - $r), ($r * 2), ($r * 2)) + $pgb = New-Object System.Drawing.Drawing2D.PathGradientBrush $path + $pgb.CenterColor = (A $col $alpha) + $pgb.SurroundColors = @((A $col 0)) + $g.FillPath($pgb, $path) + $pgb.Dispose(); $path.Dispose() +} + +# Sun, low on the day side. +Glow 128 118 130 (C '#FFF2C0') 150 +$sun = New-Object System.Drawing.SolidBrush (A (C '#FFF6DA') 245) +$g.FillEllipse($sun, 90, 80, 76, 76) +$sun.Dispose() + +# Stars, then the moon, on the night side. +$rand = New-Object System.Random 7 +$star = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::White) +for ($i = 0; $i -lt 70; $i++) { + $x = $rand.Next(760, $W - 10) + $y = $rand.Next(10, 210) + # Fade the field out towards the day side so it does not look pasted on. + $strength = [Math]::Min(1.0, ($x - 760) / 380.0) + $alpha = [int](($rand.Next(70, 230)) * $strength) + if ($alpha -lt 20) { continue } + $size = if ($rand.Next(0, 10) -gt 7) { 3 } else { 2 } + $star.Color = [System.Drawing.Color]::FromArgb($alpha, 255, 255, 255) + $g.FillEllipse($star, $x, $y, $size, $size) +} +$star.Dispose() + +Glow 1152 112 110 (C '#CFE0FF') 90 +$moon = New-Object System.Drawing.SolidBrush (A (C '#EAF1FF') 240) +$g.FillEllipse($moon, 1114, 78, 72, 72) +$moon.Dispose() + +# Hills, echoing the application icon. Filled left-to-right so they follow the +# sky from day into night instead of cutting a green band across it. +function Hill([int]$baseY, [int]$amp, [string]$dayHex, [string]$nightHex, [double]$phase) { + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + $points = New-Object System.Collections.Generic.List[System.Drawing.PointF] + for ($x = 0; $x -le $W; $x += 8) { + $y = $baseY + [Math]::Sin(($x / 210.0) + $phase) * $amp + $points.Add((New-Object System.Drawing.PointF $x, $y)) + } + $points.Add((New-Object System.Drawing.PointF $W, $H)) + $points.Add((New-Object System.Drawing.PointF 0, $H)) + $path.AddPolygon($points.ToArray()) + $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush( + (New-Object System.Drawing.Rectangle 0, 0, $W, $H), (C $dayHex), (C $nightHex), 0.0) + $g.FillPath($brush, $path) + $brush.Dispose(); $path.Dispose() +} +Hill 270 14 '#5A9A57' '#132436' 1.2 +Hill 296 18 '#3D7A45' '#0A141F' 2.6 + +$center = New-Object System.Drawing.StringFormat +$center.Alignment = 'Center' +$center.LineAlignment = 'Center' + +function Text([string]$s, [string]$family, [single]$size, [System.Drawing.FontStyle]$style, [int]$y, [System.Drawing.Color]$col, [int]$alpha, [int]$shadow) { + $font = New-Object System.Drawing.Font $family, $size, $style, ([System.Drawing.GraphicsUnit]::Pixel) + # The layout rectangle clips, so it has to be tall enough for the glyphs and + # centred on $y rather than starting there. + $boxHeight = $size * 2.4 + $box = New-Object System.Drawing.RectangleF 0, ($y - $boxHeight / 2), $W, $boxHeight + if ($shadow -gt 0) { + $sb = New-Object System.Drawing.SolidBrush (A ([System.Drawing.Color]::Black) $shadow) + $shifted = New-Object System.Drawing.RectangleF 2, ($y - $boxHeight / 2 + 3), $W, $boxHeight + $g.DrawString($s, $font, $sb, $shifted, $center) + $sb.Dispose() + } + $brush = New-Object System.Drawing.SolidBrush (A $col $alpha) + $g.DrawString($s, $font, $brush, $box, $center) + $brush.Dispose(); $font.Dispose() +} + +Text 'Wallpaper Switcher' 'Segoe UI' 74 ([System.Drawing.FontStyle]::Bold) 128 ([System.Drawing.Color]::White) 255 90 +Text 'Day and night wallpapers, from a folder you own' 'Segoe UI' 30 ([System.Drawing.FontStyle]::Regular) 200 ([System.Drawing.Color]::White) 225 70 +Text 'Windows · macOS · Linux' 'Segoe UI' 22 ([System.Drawing.FontStyle]::Regular) 296 ([System.Drawing.Color]::White) 165 60 + +$g.Dispose() +$bmp.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +Write-Host "Wrote $Out" +