Windows readiness: Fluent redesign, platform fixes, packaging, and CI - #1
Merged
Conversation
…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>
There was a problem hiding this comment.
💡 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".
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
MainWindow.Opened. It previously did not run at all unless a window was shown, which made a tray-only start silently do nothing.Local\mutex + activation pipe), so autostart plus a manual launch no longer produces two instances fighting over the wallpaper..desktop-style backslash escaping, soIsEnabled()never matched and the checkbox always read unchecked. It now parses the stored value and self-heals a stale path.%LOCALAPPDATA%\WallpaperSwitcher\logsplus global exception handlers. A tray-resident app that dies with no window and no log is unsupportable.UI — rebuilt against the Claude Design mockup
Buttonthat cycles Day → Night → Ignore. Previously every interactive element was aBorderwith pointer handlers: no keyboard focus, no tab order, no screen-reader semantics. That gap is closed.TwentyFourHourBarcontrol 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.csand theActualThemeVariantChangedfull-tree rebuild are deleted. Every colour is a Fluent theme resource, so light/dark follows the OS for free.Performance
SystemParametersInfowithSPIF_SENDWININICHANGE, which broadcasts to every top-level window and blocks until they answer — that is what made the tray menu unresponsive.Packaging and release
app.manifest(supportedOS, PerMonitorV2 DPI, longPathAware, UTF-8), version stamping fromDirectory.Build.props.osx-arm64from Windows produces a binary the kernel refuses to launch, so no single machine can produce a valid release set.LICENSE, rewritten install-first README,CHANGELOG.md,CONTRIBUTING.md, issue templates.WallpaperSwitcher.Core).Close behaviour
Verification
WindowsRunCommandparsing tests are skipped off Windows by design, and the symlink test is skipped on Windows where it needs elevation.HKCU\...\Runvalue, DPI change, light/dark switch, non-ASCII paths (this machine's profile path contains one), junction loops, and corrupt settings.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.CloseActionwas never copied inSettingsStore.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.WallpaperAssignment.Pathclaimed paths are "always absolute in memory" and thatSettingsStoreconverts both ways. It only converts on save. Corrected.