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.
+

-Runs on Windows, macOS, and Linux. Built with .NET 9 and Avalonia.
+
+ Download
+ ·
+ Features
+ ·
+ Install
+ ·
+ How it works
+ ·
+ FAQ
+ ·
+ Changelog
+ ·
+ Contribute
+ ·
+ Report a bug
+
-[](https://github.com/msk-one/WallpaperSwitcher/actions/workflows/ci.yml)
-[](LICENSE)
+
+
+
+
+
+
-
+
-## 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
+
-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 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"
+