Skip to content

Windows readiness: Fluent redesign, platform fixes, packaging, and CI - #1

Merged
msk-one merged 18 commits into
mainfrom
codex/multiplatform-wallpaper-switcher
Jul 29, 2026
Merged

Windows readiness: Fluent redesign, platform fixes, packaging, and CI#1
msk-one merged 18 commits into
mainfrom
codex/multiplatform-wallpaper-switcher

Conversation

@msk-one

@msk-one msk-one commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Takes the app from "builds on Linux and macOS" to a Windows-tested, publishable v0.7.0: a Fluent two-pane redesign, the Windows-specific defects found by testing on real hardware, packaging, CI, and the docs an open-source release needs.

Why

Windows had never been exercised. Testing on Windows 11 surfaced a set of defects that only appear on that platform — an autostart entry that could never read itself back, no single-instance guard, a schedule that did not arm unless a window was shown — plus repo-level gaps that block publishing at all: no licence, no CI, no packaging, no install instructions.

What changed

Correctness and robustness

  • The wallpaper schedule now arms from the application, not from MainWindow.Opened. It previously did not run at all unless a window was shown, which made a tray-only start silently do nothing.
  • Single-instance guard (Local\ mutex + activation pipe), so autostart plus a manual launch no longer produces two instances fighting over the wallpaper.
  • Launch-at-login round-trips. The Windows registry value was written with .desktop-style backslash escaping, so IsEnabled() never matched and the checkbox always read unchecked. It now parses the stored value and self-heals a stale path.
  • Settings are written atomically and a corrupt file is set aside with a message rather than silently replaced by defaults.
  • A wallpaper that fails to apply no longer stalls the cycle. The cycle key is deterministic, so the previous code retried the same unusable file every minute until the next boundary — up to 24 hours on daily cadence.
  • Assignment paths are stored relative to the wallpaper folder, so renaming or moving that folder no longer discards every Day/Night assignment.
  • Junction and symlink loops are skipped during the folder scan.
  • Windows wallpaper fit (Fill/Fit/Stretch/Center/Tile/Span) is now the app's setting rather than inherited from whatever the machine was left on.
  • Logging to %LOCALAPPDATA%\WallpaperSwitcher\logs plus global exception handlers. A tray-resident app that dies with no window and no log is unsupportable.
  • Windows power/time/display hooks reclaim the wallpaper when something else (Spotlight, a theme apply, a display reset) changes it.

UI — rebuilt against the Claude Design mockup

  • Two-pane shell: a 200px nav rail with Wallpapers and Settings, custom 48px title bar, status bar docked to the window.
  • The image list is now a virtualized 16:9 tile grid where each tile is a real Button that cycles Day → Night → Ignore. Previously every interactive element was a Border with pointer handlers: no keyboard focus, no tab order, no screen-reader semantics. That gap is closed.
  • New TwentyFourHourBar control replaces the two time text boxes, with 15-minute snap, a 60-minute minimum gap, and full keyboard operation. The three time-parse error messages are gone because they are no longer reachable.
  • ThemePalette.cs and the ActualThemeVariantChanged full-tree rebuild are deleted. Every colour is a Fluent theme resource, so light/dark follows the OS for free.
  • Autosave throughout; no Save button.

Performance

  • Thumbnails decode off the UI thread. They ran synchronously inside a value converter, so every realized tile blocked the UI. Measured against 6–9 MB wallpapers: window appeared after 5.2s before, 1.8s after.
  • The wallpaper apply runs off the UI thread. On Windows it ends in SystemParametersInfo with SPIF_SENDWININICHANGE, which broadcasts to every top-level window and blocks until they answer — that is what made the tray menu unresponsive.
  • Autosave no longer forces a reapply, and changing the shuffle cadence no longer swaps the wallpaper on the spot.

Packaging and release

  • Per-user Inno Setup installer (no UAC, Start-menu entry, proper uninstall that removes the Run value) plus a portable zip.
  • Application icon, hardened app.manifest (supportedOS, PerMonitorV2 DPI, longPathAware, UTF-8), version stamping from Directory.Build.props.
  • CI workflow (build + test) and a tag-triggered release workflow. The release matrix builds macOS artifacts on a macOS runner — cross-publishing osx-arm64 from Windows produces a binary the kernel refuses to launch, so no single machine can produce a valid release set.
  • MIT LICENSE, rewritten install-first README, CHANGELOG.md, CONTRIBUTING.md, issue templates.
  • Legacy WPF project deleted (not in the solution, no tests, 7 files byte-identical to WallpaperSwitcher.Core).

Close behaviour

  • Clicking X now asks whether to keep running in the tray or quit, with a "remember my choice" option, changeable afterwards from a Settings row.

Verification

  • CI is green on windows-latest, ubuntu-latest and macos-14. 50 tests; the 5 WindowsRunCommand parsing tests are skipped off Windows by design, and the symlink test is skipped on Windows where it needs elevation.
  • Windows 11 26200, x64: manual matrix covering first run, folder picking, all four cadences forced by moving the system clock, the night window crossing midnight, tray menu, close-to-tray, autostart verified against the actual HKCU\...\Run value, DPI change, light/dark switch, non-ASCII paths (this machine's profile path contains one), junction loops, and corrupt settings.
  • Layout numbers verified by instrumenting the running app rather than by eye.

The first CI run failed on Linux and macOS, and fixing it was worth more than the green tick: the settings tests hard-coded C:\Wallpapers\..., which is rooted on Windows but an ordinary filename elsewhere, so the relative-path logic under test never engaged off Windows and the assertions that depended on it failed. Paths now go through a per-platform helper, with a test asserting the fixture really is rooted so it cannot regress quietly. That work turned up two things a green Windows run had hidden:

  • AppSettings.CloseAction was never copied in SettingsStore.ToPersistedForm, so answering the new close prompt with "remember my choice" wrote a file that did not contain the answer — the prompt came back on the next launch. Fixed, and the round-trip test now covers every persisted setting.
  • The doc comment on WallpaperAssignment.Path claimed paths are "always absolute in memory" and that SettingsStore converts both ways. It only converts on save. Corrected.

msk-one and others added 16 commits May 4, 2026 02:16
…F project

The WallpaperSwitcher/ WPF project was not referenced by the solution, had
no test coverage, and seven of its files were byte-identical duplicates of
WallpaperSwitcher.Core. Git history preserves it.

Adds global.json so local builds and CI resolve the same SDK feature band,
a MIT LICENSE (the repo was previously unlicensed), and Directory.Build.props
as the single source of truth for version and package identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lish

CI builds and tests on Windows, Linux, and macOS, plus a win-x64 publish
smoke that asserts the executable is large enough to still have the Skia
and HarfBuzz native libraries embedded. This repo has never had CI, and the
next change set touches app startup on all three platforms.

.gitattributes pins scripts/*.sh to LF. The committed blobs are correct
today only because core.autocrlf happens to be on; a contributor without it
would commit CRLF and break the shebang on macOS and Linux.

SkiaSharp and HarfBuzzSharp ship ~100 MB of native .pdb files as runtime
assets that DebugType does not control, so every published artifact carried
them. Removing them takes the win-x64 publish output from 195 MB to 95 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… handling

Startup no longer depends on a window existing. Tray creation and the wallpaper
scheduler moved from MainWindow.Opened into App, because the schedule
previously never armed unless a window was shown, which made a start-minimized
mode impossible. TrayMenuController now takes an action instead of a Window so
it can outlive and predate the window it opens. Adds a command-line flag and a
persisted setting for starting into the tray.

The launch-at-login entry was written with .desktop-style backslash escaping,
so reading it back never matched the executable path and the checkbox always
displayed as unchecked. Windows Run values now use plain quoting, the parsing
is a tested pure helper in Core, and a stale entry pointing at a moved
executable is repointed instead of reported as off.

Adds a per-user single-instance guard. With autostart enabled, launching from
the Start menu produced a second process with its own tray icon and its own
scheduler, both overwriting the wallpaper.

Settings are now written atomically and a file that fails to parse is moved
aside with a message rather than silently replaced by defaults, which used to
discard the folder and every Day/Night assignment without telling anyone.

SystemParametersInfo reports success for an empty or corrupt file and then
paints the desktop black, so images are now header-checked before use and a
file that cannot be applied is skipped in favour of the next candidate. A
deterministic cycle key meant the old code retried the same unusable file every
minute for the rest of the cycle.

Also: honours a Fill/Fit/Stretch/Center/Tile/Span setting on Windows instead of
inheriting whatever the machine was left on; logs to the per-user app data
folder with a tray shortcut to open it; installs global exception handlers so a
tray-resident crash is diagnosable; broadens the thumbnail decoder catch, which
could take down the UI thread from inside a value converter; and replaces
macOS-specific status text that was shown on all platforms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…claim the wallpaper

Day/Night assignments are now matched by path relative to the wallpaper folder
instead of by absolute path. Renaming or moving the folder used to make every
stored assignment stop matching, so the user's choices were silently replaced by
filename guesses. Absolute paths still match, so existing settings files keep
working, and entries outside the folder stay absolute. Enums are written by name
rather than as ordinals, and Ignore entries are no longer persisted so newly
added images pick up name inference.

Saving no longer refuses when no images are loaded. The tray cadence radios and
"Swap day/night hours" both save, so in the first-run state those clicks
appeared to work and then reverted on restart. An empty list also no longer
overwrites a good set of assignments.

The image list is virtualized and the thumbnail cache is bounded to the most
recent 300 entries and actually disposed on exit. Previously every row was
materialized and every thumbnail decoded up front, and because an OS light/dark
switch rebuilds the whole visual tree, changing theme with a large folder loaded
froze the app. A 520-image folder now reaches its first wallpaper in under two
seconds at around 100 MB.

Directory junctions and symlinks are no longer followed during the folder scan.
Pointing the app at a tree containing a self-referential junction collected the
same files repeatedly until the paths grew too long to open.

On Windows the wallpaper is reapplied on resume from sleep, on a system clock
change, and on a display settings change. The one-minute watchdog only asks for
a non-forced apply, which short-circuits when nothing in the schedule changed,
so anything else that took over the wallpaper -- Spotlight, a theme, a display
driver reset -- kept it until the next cycle boundary, up to a week away on the
weekly cadence.

Also starts draining the child process stdout and stderr pipes before waiting
for exit. A command producing more than the pipe buffer would block until the
five-second timeout and then be reported as timed out; xfconf-query listing
xfce4-desktop properties can produce that much.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lish script

The executable carried the stock .NET icon in the taskbar, Explorer, Add/Remove
Programs and the SmartScreen dialog, because no ApplicationIcon was set and the
only icon generator in the repo is a macOS-only Swift script. AppIcon.ico is now
a committed asset generated by scripts/build-windows-icon.ps1, so contributors
do not need an image toolchain. Sizes up to 128px are written as BMP entries and
256px as PNG: PNG entries at small sizes are legal but GDI+ cannot decode them,
which would leave the icon blank anywhere that renders it through System.Drawing.

app.manifest was a bare assemblyIdentity. It now declares supportedOS (without
which Windows applies compatibility shims and reports itself as Windows 8),
PerMonitorV2 DPI awareness ahead of the first window, longPathAware, UTF-8 as
the active code page for non-ASCII profile paths, and asInvoker.

Distribution is a per-user Inno Setup installer plus a portable zip. The
installer needs no elevation, adds Start menu and Add/Remove Programs entries,
and removes the autostart registry value on uninstall so uninstalling cannot
leave an entry pointing at an executable that no longer exists. It does not
create that value -- the app owns it, and two writers to one value is how the
round-trip bug happened. Settings and logs are kept on uninstall unless the user
asks for them to be removed.

Verified on this machine: silent install with no UAC prompt, correct Add/Remove
Programs metadata and icon, the Start at login round trip through the real
checkbox, self-healing of a stale autostart path, and a clean uninstall.

Also drops the Windows branch of the native library copy in publish-all.sh.
IncludeNativeLibrariesForSelfExtract embeds those DLLs in the single file --
confirmed by running the published executable from an empty directory -- so the
copies only shadowed them, and the hard failure when they were absent was a
release-blocking footgun. Publishes now also strip debug symbols, which were
adding 100 MB of native .pdb files to every artifact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release workflow builds each target on its own runner and drafts a GitHub
release with SHA-256 checksums and the matching changelog section. Running each
platform on its own OS is a requirement, not a preference: arm64 macOS binaries
need at least an ad-hoc signature to execute, and dotnet publish only applies one
when it runs on macOS, so a cross-published build is killed on launch.

The macOS packaging script now takes a version, puts it in the bundle and the DMG
filename, and stamps it into the binary. Successive releases previously produced
identically named DMGs that overwrote each other.

The README documented how to build the app and never how to install it, which is
backwards for something aimed at end users. It now leads with per-OS install
instructions, explains the SmartScreen and Gatekeeper warnings plainly rather
than leaving people to guess, and carries a format support table reflecting what
was actually measured on Windows rather than what the code accepts.

Adds CHANGELOG.md in the format the release workflow parses, CONTRIBUTING.md
covering the two things that surprise every new contributor -- the UI is C# with
no XAML, and the app must work with no window open -- and a bug report template
that asks for the version and log up front.

Replaces the two working documents in docs/ with design-notes.md, which records
the reasoning behind the scheduling model, the deliberate daylight saving
behaviour, why image files are header-checked, and the known limitations
including the accessibility gaps.

The app version is now shown in the status bar, so a bug report can quote it
without the reporter having to find a log file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the single scrolling page with a two-destination shell: a 200-wide
navigation rail, a content pane inset with a rounded corner and hairline
dividers, and a status bar docked to the window rather than the pane. The grid
is the only thing anyone opens the window for, so it gets a page to itself and
setup moves out of the way.

Wallpapers is a hero strip plus a virtualized tile grid. The strip answers "is it
working" with the current image, its set and the next change; it collapses
entirely when no folder is chosen rather than showing dead controls, and turns
into an inline error with a route to Settings when the folder goes missing. Tiles
are 16:9 thumbnails carrying a badge and a filename, and one click cycles
Ignore, Day, Night.

Settings is Source, Schedule and App. The path text box is gone -- the picker is
the only way in -- and the two time fields are replaced by a new
TwentyFourHourBar, which shows a night window crossing midnight at a glance
instead of leaving it to be inferred from two numbers. Because there is no typed
entry left the control is fully keyboard operable: arrows move 15 minutes, Page
keys an hour, Home and End run to the limit the 60-minute minimum gap allows.
That also makes three validation errors structurally impossible, so they are
deleted.

There is no Save button. Every change writes settings and reapplies through the
existing paths, and the status bar confirms it.

Closes the accessibility gap the design notes recorded. Nothing interactive is a
Border with pointer handlers any more. Verified through the UI Automation tree:
the nav exposes two tab items, each tile is a keyboard-focusable button named
"beach-day.jpg, currently day. Activate to change.", the schedule handles are
thumbs named "Day starts" and "Night starts" carrying their value, and the status
line is a polite live region. Colour is never the only signal -- every badge
carries the word.

ThemePalette and the ActualThemeVariantChanged handler that rebuilt the whole
visual tree on a theme switch are both gone. Every colour now resolves through a
theme resource that swaps with ThemeVariant.

Two substitutions were needed because the design targets a stack this project is
not on. It assumes Avalonia 11 and names WinUI tokens; Avalonia's Fluent theme
defines those in neither 11.3 nor 12.0 -- confirmed by enumerating every key it
exposes -- and FluentAvalonia, which does define them, is net10.0-only at 3.x and
Avalonia-11-only at the net9.0-compatible 2.4.1. Theming/FluentTokens.cs supplies
that layer using WinUI's published values instead. Separately, ItemsRepeater and
UniformGridLayout left Avalonia.Controls in 12 and now come from
Avalonia.Controls.ItemsRepeater.

Icons are vector geometry rather than Segoe Fluent Icons glyphs, which exist only
on Windows and would have rendered as empty boxes on the other two platforms.
The 48px title bar is Windows-only; macOS and Linux keep native chrome and inset
the content by WindowDecorationMargin.

Nothing in WallpaperSwitcher.Core or the services changed. The 49 tests pass
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flips settings.json into a known state and relaunches, so the hero states, the
empty first run, a 520-image grid and the tray-only start can each be reached
without hand-editing JSON. Development helper only; nothing in the build or the
release references it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six issues from manual testing on Windows 11.

Chrome and layout:
- Drop the full-screen caption button. Avalonia 12 tags caption buttons with
  WindowDecorationProperties.ElementRole rather than template part names, so it
  is matched on that.
- Size the window 960x760 (min 780x560). At the mockup's 900x620 the four-column
  grid reached exactly to the pane padding, so the last column sat under the
  overlay scrollbar, and the Settings page's last row was clipped by the status
  bar with no visible scrollbar to suggest it could scroll.
- Switch the grid to UniformGridLayoutItemsStretch.Uniform. Fill stretched width
  only, so tiles drifted off 16:9 as the window widened.
- Remove the fixed 16px LineHeight from the status bar. It is below what Segoe UI
  Variable Text needs at 12px, which pushed the text above centre and clipped
  descenders.

Nav rail selection was never visible in either theme: the template assigned
Background and IsVisible as local values, which outrank Style-priority setters,
so the :selected fill and accent pill could not apply. Both states are now
expressed as styles.

Responsiveness:
- Decode thumbnails off the UI thread. Decoding ran synchronously inside a value
  converter, so every realized tile blocked the UI thread for as long as one
  image took to read and downscale. With a folder of 6-9 MB wallpapers the window
  took 5.2s to appear; it now takes 1.8s.
- Apply the wallpaper off the UI thread. On Windows the apply ends in
  SystemParametersInfo with SPIF_SENDWININICHANGE, which broadcasts to every
  top-level window and blocks until they answer. Overlapping requests coalesce.
- Stop forcing a reapply on every autosave. Every toggle and schedule drag paid
  for a full broadcast even when the right wallpaper was already showing.
- Changing the shuffle cadence no longer swaps the wallpaper. Cadence governs how
  often the image changes, not that it should change now, so the current image is
  re-anchored to the new cycle.

Restoring the window from the tray now raises it to the front. Windows only
honours SetForegroundWindow from the foreground process and the tray click does
not transfer that right, so Activate() alone was a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second pass on the Windows audit.

Layout:
- The pane inset was Padding on the ScrollViewer, which was not subtracted from
  the width handed to the content, so every card was laid out ~27px wider than
  the pane. At 780x560 that clipped the right-hand control of each settings row
  and the grid's last column. It is now a Margin on the content presenter, which
  also gives the last row breathing room when scrolled.
- The pane scrollbar is always drawn instead of Auto. Fluent scrollbars float
  over the content and fade in on hover, so the Settings page read as truncated
  with nothing on screen to say it could scroll, and Auto made the viewport width
  depend on content height, which re-measured the pane mid-layout.
- Default height 820 so the Settings page fits without scrolling.

The status bar's divider was a top border on the bar itself, so its 34px box was
split 1 + 33 and centred content sat low. The divider is now its own strip and
the bar is an exact 34px box.

Version is 0.7.0 and moved from the status bar into the title bar, which is also
what the taskbar and alt-tab show.

The tray menu never re-synced from the view model: NativeMenu.NeedsUpdate does
not fire on Windows, so changing the shuffle cadence on the Settings page left
the tray radio on the old value. It now follows PropertyChanged.

Restoring the window from a tray icon click still came up behind the foreground
window. Toggling Topmost reorders without activating, and worked only for the
menu item because opening the menu happens to transfer foreground rights.
Replaced with the documented AttachThreadInput approach.

Closing the window now asks whether to keep running in the tray or quit, with a
remember-my-choice option. The answer is persisted as AppSettings.CloseAction
and can be changed back from a new row on the Settings page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@msk-one msk-one self-assigned this Jul 29, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbee7c2de1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread WallpaperSwitcher.Desktop/ViewModels/MainWindowViewModel.cs Outdated
Comment thread WallpaperSwitcher.Tests/SettingsSchemaTests.cs Outdated
Comment thread WallpaperSwitcher.Core/SettingsStore.cs
Comment thread WallpaperSwitcher.Desktop/ViewModels/MainWindowViewModel.cs
Michał Szklarski and others added 2 commits July 28, 2026 18:23
CI passed on Windows and failed on ubuntu-latest and macos-14.

The settings tests hard-coded paths like C:\Wallpapers\a.jpg. On Linux and macOS
a backslash is an ordinary filename character, so that string is not rooted, and
BuildAssignmentKey only rewrites a path as relative when Path.IsPathRooted says
it is. Three tests therefore failed off Windows, and the ones that passed did so
without exercising the relative-path logic at all. Paths are now built through a
TestPaths.Rooted helper that roots them per platform, plus a test that asserts
the fixture really is rooted so this cannot regress quietly.

WindowsRunCommand.ParseExecutablePath ends in Path.GetFullPath, which is Windows
path semantics: on macOS it prepended the working directory to the Windows path
and the round-trip test failed. The parsing tests are now skipped off Windows.
Formatting is plain string work and stays covered everywhere.

Two things the portability work turned up:

- AppSettings.CloseAction was never copied in SettingsStore.ToPersistedForm, so
  answering the close prompt with "remember my choice" wrote a file that did not
  contain the answer and the prompt returned on the next launch. The round-trip
  test now covers every persisted setting.
- The doc comment on WallpaperAssignment.Path claimed paths are "always absolute
  in memory" and that SettingsStore converts both ways. It only converts on save;
  loading leaves the stored form and ResolveCategory reconciles it. Corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the Codex review of bbee7c2.

Choosing a folder only refreshed the in-memory list. It neither persisted the
path nor armed the schedule, and the status line still read "Review the Day/Night
assignments, then save" from before autosave replaced the Save button. A folder
whose inferred assignments needed no edits was therefore never written to disk
and its schedule stayed cancelled, so the choice vanished on the next launch
unless the user happened to change some other setting. Both picker paths had the
omission. Selecting a folder now persists and applies immediately; the macOS
bookmark restore passes persist: false, since reopening a folder the app already
knows about is not a new choice, but it still arms the scheduler because Start()
ran before the bookmark resolved.

The apply coalescing dropped the "forced" part of a request that arrived while an
apply was in flight: the follow-up was always unforced, so it short-circuited on
an unchanged path and cycle key. Fit is the case that matters, because it changes
no file choice — it is written to the registry as part of applying — so changing
Fit during the startup broadcast left the dropdown disagreeing with the desktop
until some later forced event. The coalesced request now remembers whether any of
the requests it stands in for was forced.

The review's other two findings, the Windows-only paths in the cross-platform
schema tests and the unpersisted CloseAction, were already fixed in bd421f9.

Verified on Windows from a deleted settings file: picking C:\WPTest\Small through
the folder dialog wrote WallpaperDirectory and 4 assignments immediately, and
applied city-night.jpg without any further interaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@msk-one
msk-one merged commit b7183b6 into main Jul 29, 2026
8 checks passed
@msk-one
msk-one deleted the codex/multiplatform-wallpaper-switcher branch July 29, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant