Build, run, test and develop the Downloader.Desktop app (Avalonia/.NET download manager). Use for any task in this repo — launching the GUI, running the test suite, regenerating screenshots, or implementing features against the architecture below.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Build, run, test and develop the Downloader.Desktop app (Avalonia/.NET download manager). Use for any task in this repo — launching the GUI, running the test suite, regenerating screenshots, or implementing features against the architecture below.
Downloader.Desktop
Cross-platform (Windows/Linux/macOS) Avalonia + .NET 10 GUI for the Downloader multipart-download engine. MVVM with ReactiveUI. Original modern-minimal design (ocean-blue/teal, light+dark). End-user focused: simple, stable, sensible defaults.
All commands run from the src/ folder (where Downloader.Desktop.sln lives).
Maintaining this skill (read first, every session)
Treat this file as a living cache. Whenever you discover something non-obvious that a future session would otherwise have to re-derive (an engine API shape, a gotcha, a settled design choice), append a concise boilerplate note here. The goal is steadily fewer tokens per session: each future run should read the answer here instead of re-grepping the codebase or the sibling ../Downloader engine. Keep additions short and factual — a few lines, not essays. Prune notes that become wrong. This is an explicit standing instruction from the author.
Commit policy: follow root CLAUDE.md → "Workflow & progress tracking" — commit frequently directly to develop and push, including skill-file notes, as part of routine work. (Superseded the older "never commit automatically" rule once the cross-machine PLAN.md/TASKS.md workflow was set up.)
Code map (read this before grepping — it's where things live)
Skip the discovery grep; jump straight to the file. src/Downloader.Desktop/:
Download lifecycle / queues / scheduler: Services/DownloadManager.cs (+ IDownloadManager.cs). Owns Items, all state transitions (Start/Pause/Cancel/Resume/Retry/StopAll), PumpQueue (the ONLY capped start path — Start is the uncapped primitive), EvaluateSchedules, ApplyGlobalSpeedLimit. Start builds the DownloadConfiguration from Settings.ToConfiguration()synchronously before its first await, so vm.Status=Running + vm.Configuration are observable right after Add(autoStart:true) — that's how the headless tests assert without real I/O (unreachable IP 10.255.255.1).
Per-row state/UI: ViewModels/DownloadItemViewModel.cs. Model-backed props write through to _item (pattern: get => _item.X; set { _item.X = value; RaisePropertyChanged(); } — see Status, HasCustomSpeedLimit). Exposes GetItem(), Manager, live Configuration.
Persisted record: Models/DownloadItem.cs. Global settings (mirrors the engine DownloadConfiguration): Models/DownloadSettings.cs (+ ToConfiguration()). Root persisted state: Models/Config.cs (Settings/Queues/Schedules/Downloads, DefaultQueue).
Settings screen: ViewModels/SettingViewModel.cs — S = the DownloadSettings; setters that must "bite" live also call the manager (e.g. MaxConcurrentDownloads→PumpQueue, MaxSpeedKbPerSecond→ApplyGlobalSpeedLimit).
Details dialog: ViewModels/DownloadDetailsViewModel.cs + Views/DownloadDetailsView.axaml (per-connection strip, mirror editor, speed-limit box). Reaches global config via Item.Manager.Config.
Tests (src/Downloader.Desktop.Tests/): foldered by kind — Unit/ (pure), Integration/ (manager+engine, headless via [AvaloniaFact]), UI/, Plugins/. Build manager tests with new DownloadManager(); Initialize(Config.New()); Add(item, autoStart).
Token discipline in this repo (the author flagged over-spend on small fixes)
Use the Code map above instead of grepping for where a thing lives. Only grep for a specific symbol you can't place from it.
Read the method, not the file — use Read with offset/limit (or grep -n the symbol first) rather than dumping 400-line files. VMs/DownloadManager are large.
Batch independent reads/greps into one message (multiple tool calls per turn) — don't serialize discovery.
Build once per logical chunk, not after every edit; Edit already fails loudly on a bad match, so don't re-Read a file just to confirm an edit landed.
For a small, well-scoped fix, target the one file the Code map names, edit, then one build+filtered-test — that's the whole loop.
Engine (Downloader 5.9.5) quick reference
DownloadBuilder is single-URL only (WithUrl(string)) and its IDownloadcannot take a logger (no AddLogger on IDownload). For mirrors and logging, use DownloadService directly instead of the builder.
DownloadService(DownloadConfiguration cfg, ILoggerFactory factory = null) — implements IDownloadService: same events (DownloadStarted/DownloadProgressChanged/ChunkDownloadProgressChanged/DownloadFileCompleted), plus Package, Pause(), Resume(), CancelAsync()/CancelTaskAsync(), Clear(), and AddLogger(ILogger).
Multi-URL / mirrors are first-class: DownloadFileTaskAsync(string[] urls, DirectoryInfo folder, ct) (auto-resolves name), (string[] urls, string fileName, ct), and package overloads. DownloadPackage.Urls is string[]. So the data model should carry List<string> Urls (first = primary, rest = mirrors), not a separate Url + Mirrors.
Filename still auto-resolves from URL/Content-Disposition; read it from DownloadStartedEventArgs.FileName (full path).
Localization (i18n) — how it works here
Services/Localizer (singleton) loads Assets/i18n/{lang}.json (en, fa, es, fr, ar, eo) via AssetLoader; English is the fallback. Active language persists in DownloadSettings.Language; load it at startup in MainViewModel and switch it from Settings (SelectedLanguage).
XAML usage:Text="{i18n:Tr Some_Key}" (xmlns i18n="clr-namespace:Downloader.Desktop.Markup"). VM strings: Localizer.Instance["Key"]. Format strings use {0} + string.Format.
Live-switch gotcha (important): Avalonia indexer-change notifications ("Item[]"/empty PropertyChanged) do NOT reliably refresh already-rendered [key] bindings. Instead {i18n:Tr} binds to Localizer.Tick (a normal int bumped each Load) through TrConverter, which DOES refresh. Don't revert to a raw indexer binding.
RTL:Localizer.FlowDirection is RightToLeft for fa/ar; each Window binds FlowDirection="{Binding FlowDirection, Source={x:Static services:Localizer.Instance}}" (UserControls inherit it).
VM-computed localized strings (row StatusText/DisplayName/Group, details headers) subscribe to Localizer.PropertyChanged and re-raise; DownloadItemViewModel.Detach() unsubscribes on removal (called by the manager) to avoid leaks.
Adding a key: add to en.json first (it's the fallback), then translate into the other 5. Missing keys fall back to English gracefully.
Engine/behavior gotchas worth caching
Plan-part completion must NOT gate on DownloadPart.ExpectedSize (DownloadManager.Plans.cs): for extracted streams (progressive/video/audio via yt-dlp) ExpectedSize is filesize_approx — an estimate (e.g. x.com reported 5.36 MB, real file 3.66 MB). An exact len==ExpectedSize gate made a finished part look unfinished → "Part 1/1 did not finish downloading" and an infinite re-download. IsPartComplete/PartDownloadedOk/MarkPartDone now rely on the .done marker + non-empty file (the engine already validates the full download and reports errors via the completion event). ExpectedSize is kept only for progress display. x.com/YouTube page-URL downloads work directly — the HLS plugin (com.bezzad.hls, now in-repo at src/Downloader.Desktop.Plugins/Downloader.Desktop.Plugins.Hls, an optional/catalog-tier plugin — was the separate ../Downloader.Plugins repo, consolidated in; see the "Plugin consolidation" note near the end) runs yt-dlp, no browser-extension .m3u8 hunting needed. yt-dlp runs bare (no cookies) so public content works but login-gated/age-restricted media would need --cookies-from-browser (future work). Two plugin bugs fixed in v1.1.1 (root-relative segments becoming file:// on Unix; codecless progressive MP4s skipped — see Downloader.Plugins issue #2). YouTube (plugin v1.1.2): needs BOTH browser cookies (bot check — plugin retries --cookies-from-browser per installed browser) AND a deno JS runtime (--js-runtimes deno:<path>, auto-provisioned like yt-dlp/ffmpeg) — without deno yt-dlp can't solve the "n challenge" and returns ONLY storyboard images (no formats). Node ≤20 is "unsupported" by yt-dlp's EJS solver — don't bother with it; deno is the supported default (see Downloader.Plugins issue #3).
HttpClientTimeout is the WHOLE-request timeout (HttpClient.Timeout), incl. reading a chunk's body — keep it large (default 100 s). Setting it small (e.g. 10 s) makes longer chunks fail with "Operation Cancelled" after retries (~1 min). Per-block stalls are handled by BlockTimeout, not this.
Cancellation vs failure status: the engine raises with for BOTH a user pause/stop and an internal abort (e.g. timeout). Disambiguate by the status we set calling the engine: if it's already Paused/Stopped it was the user; a cancel while still Running = real failure → mark Failed.
Tray (Services/TrayService, static): create TrayIcon in code and register via TrayIcon.SetIcons(Application.Current, new TrayIcons { icon }). NativeMenuItemToggleType does NOT exist in this Avalonia 12 — don't use ToggleType/IsChecked on NativeMenuItem; reflect state by swapping the item's Header ("Disable/Enable notifications"). Wrap creation in try/catch — headless/no-session platforms throw; on failure leave _tray=null so close-to-tray fails soft. Linux tray (Ubuntu GNOME/AppIndicator) — DO NOT make speculative changes here. Ever. This code has flip-flopped three times (2654f9a removed the Clicked handler, 3aac545 re-added it, fa6b925 gated it off for Linux) and each theory about the TrayIcon.Clicked → ShowWindow handler was later contradicted by on-device behavior: after fa6b925 gated the handler off, the author reported the tray icon stopped appearing at all (previously it appeared and only the right-click menu was broken) — so the handler was reverted to unconditional, which is the configuration of every build where the icon did show. Conclusions that ARE settled: (a) keep the unconditional _tray.Clicked += → ShowWindow subscription; (b) use a SMALL tray icon (downscale to 64×64 via Bitmap.CreateScaledBitmap — the 1080×1080 PNG is a ~4.6 MB pixmap over DBus and can make the SNI item render while its menu fails to attach). The right-click-menu-doesn't-open bug is still OPEN and cannot be diagnosed from this headless box (no desktop session, no DBus StatusNotifierWatcher): any further change requires on-device evidence first — e.g. run the app on the Ubuntu box with logging enabled, dbus-monitor the org.kde.StatusNotifierItem traffic, or test a minimal Avalonia tray repro — never another code-only guess.
Close-to-tray: handle window.Closing, e.Cancel=true; window.Hide() — but gate on TrayService.IsActive, NOT the setting, or a failed tray strands the window with no way back. Real quit sets a _quitting flag then window.Close() (ShutdownMode is OnMainWindowClose → App.ShutdownRequested still saves). Wired in MainViewModel.SetupAppShell() after config loads (needs ).
Avalonia 12 UI patterns (Round 11)
Rounded window corners (now 10px, all three windows): set Window Background="Transparent" + TransparencyLevelHint="Transparent", move the real background onto the root Border with CornerRadius="10" ClipToBounds="True". The root border's Background MUST be an opaque resource — ThemeBackgroundColor is NOT defined by the Fluent theme here, so it resolved to nothing and the transparent window showed the desktop/window behind through dialogs. Use {DynamicResource SystemRegionColor} (what MainWindow uses). Caveat: on Linux WMs without a compositor the corners/shadow vary — accepted.
Per-fragment colors in the details strip (#7): give ChunkProgressViewModel a stable IBrush Brush from a curated palette indexed by Index (no reshuffle on update), and bind the segment ProgressBar Foreground="{Binding Brush}" (the plain-track+fill ProgressBar's fill is its Foreground). Palette stays within one blue→teal family (deep blue → sky → cyan → teal), not a rainbow — author preference.
Theme-aware README images: GitHub honors <picture><source media="(prefers-color-scheme: dark)" srcset="…dark.png"><img src="…light.png"></picture> — dark shot in dark mode, light otherwise. Needs both light+dark captures (added a settings-light.png capture alongside the dark one). GitHub renders .svg images referenced from markdown, so the README banner is a hand-authored docs/banner.svg (no PNG rasterizer needed; use web-safe font-family so text renders through camo).
Notification success icon: Linux notify-send -i emblem-default (green check) for success, dialog-error (red) for failure — don't use dialog-information (blue "i") for a completed download (#3).
Icon inside a TextBox: <TextBox.InnerLeftContent><PathIcon .../></TextBox.InnerLeftContent> (search = SearchRegular, link = LinkRegular, both added to Icons.axaml).
Nav count pill on a selected (accent-filled) item: the selected nav sets descendant text white via TextElement.Foreground, which made the pill number invisible on the light pill. Fix with a more-specific style: → opaque white bg, → accent foreground (a style setter on the TextBlock beats the inherited attached value). Pills also get .
Dynamic MenuFlyout of runtime items: bind MenuFlyout ItemsSource="{Binding Targets}" + an ItemContainerThemeControlTheme TargetType=MenuItem x:DataType=<wrapper>BasedOn="{StaticResource {x:Type MenuItem}}" with Header/Command setters. The wrapper is a tiny { string Name; ICommand Command } built in the VM. Live examples: QueueActionTarget (Start/Stop queue buttons in DownloadsView) and QueueMoveTarget (move-to-queue in QueuesView). Don't try to bind a generated MenuItem's Command back to the page VM — its DataContext is the item.
All-downloads-complete + shutdown-on-completion: DownloadManager raises AllDownloadsCompleted once when a completion drains the list (ActiveCount==0 && QueuedCount==0 && CompletedCount>0), guarded by _allCompleteFired (re-armed in Start). CRITICAL: the trigger must only fire when a download actually COMPLETED, never on a stop/cancel/fail — else "Stop All" (which cancels rows → Stopped) would arm a shutdown whenever a finished item sits in the list. The terminal handler routes through FinishTerminal(vm) which calls MaybeAllCompleted() only if (vm.Status == DownloadStatus.Completed). Test seams RaiseCompletedForTest/RaiseStoppedForTest both go through FinishTerminal. MainViewModel.OnAllDownloadsCompleted does the UI/OS parts (all-complete notification + ShutdownService.Schedule) so the manager stays UI/OS-free + testable.
ShutdownService (the cancel UX matters): shows a Topmost standalone ShutdownView countdown dialog (ShutdownViewModel, 30 s, "Cancel" + "Shut down now" + Esc=cancel) — a top-level window so it's visible even when the app is minimized to the tray (an in-app WindowNotificationManager toast is NOT, since its host is the hidden main window — don't use it here). It ALSO fires a native OS notification (NotificationService.Notify, prefers notify-send/osascript) as a heads-up when is on. The dialog is always shown (it's the safety/cancel mechanism); only gates the extra native alert. Power-off ( / / ) has a test seam.
About dialog: Views/AboutView + ViewModels/AboutViewModel, opened via DialogHelper.ShowAbout() (own Window, transparent+rounded like DownloadDetailsView, Esc closes). Left = logo/title/version/donate/website; right = three clickable Button.about section cards + GitHub/Telegram/Email contact Button.icons. All links open with Process.Start(UseShellExecute=true). Canonical links are consts on AboutViewModel (RepoUrl/EngineRepoUrl/DonateUrl/TelegramUrl/Email…) so they're testable without constructing the VM (its VersionText touches Localizer → needs headless). Original layout — never name or clone another download app (repo design rule).
Top-bar Donate(♥)/About(i): small Button.icons in MainWindow top bar; MainViewModel.DonateCommand opens AboutViewModel.DonateUrl (repo Donate.md), ShowAboutCommand → DialogHelper.ShowAbout. Tether/Liberapay addresses live in repo-root Donate.md (sourced from the sibling ../Downloader README).
Square toolbar buttons (Button.tool in App.axaml): icon-on-top/label-below (vertical StackPanel), :disabled → Opacity .4. Per-row bulk actions (Start/Pause/Stop/Remove) pass an IObservable<bool> canExecute (this.WhenAnyValue(x => x.HasSelection)) so they grey out when nothing is checked; Stop-All / Start-Queue / Stop-Queue take no canExecute (always enabled).
Tri-state select-all in the grid header: bool? SelectAllState on DownloadsViewModel (true=all / false=none / null=some). GOTCHA: this Avalonia 12 DataGrid does NOT render a control placed in a column header — only string Header="..." shows; a <DataGridTemplateColumn.Header><CheckBox/></...> (any control, even a plain TextBlock) renders blank, regardless of compiled-vs-ReflectionBinding. So the select-all CheckBox is overlaid over the first column's header band: it's a sibling of the DataGrid inside the wrapping Panel, HorizontalAlignment=Left VerticalAlignment=Top Margin="14 9 0 0", with the DataGrid pinned to and the checkbox column so the overlay stays aligned. Bound normally (page DataContext) since it's outside the column scope. Keep selection state live by subscribing to each row's (IsChecked) + . There is NO toolbar select-all checkbox.
Tests that read Localizer MUST be [AvaloniaFact] (in AppTests), not plain [Fact] (in LogicTests). The i18n maps only load under the Avalonia headless runtime (AssetLoader); a plain Fact gets the raw key back (e.g. "State_Pending" instead of "Pending") and is order-dependent → flaky on CI (it passed only if an AvaloniaFact had loaded assets first). Start such a test with Localizer.Instance.Load("en"). This was the cause of the intermittent macOS CI failures.
Single instance + IPC (Services/SingleInstanceService, called first thing in Program.Main): a fixed loopback lock-port (SingleInstanceService.LockPort = 15150 — moved from 15152, see next note) doubles as the mutex.
The single-instance lock port MUST stay OUTSIDE LocalApiService.PortRange (15151–15155). It was 15152 — inside the API range — so the app's own single-instance lock permanently held 15152 and the API's fallback silently skipped it (verified live: with 15151 blocked the API landed on 15153, not 15152). Moved LockPort to 15150. Invariant guarded by test SingleInstance_lock_port_is_outside_the_api_range. If you ever change either the range or the lock port, keep them disjoint. LockPort is a public const (exposed for that test). First instance binds it (primary) + runs an accept loop; a later launch fails to bind → forwards its args (the first http(s) URL) to the primary and returns from Main (exits). MainViewModel.SetupAppShell calls SetMessageHandler(...) → BringToFront() + CaptureUrl(msg). Only SocketError.AddressAlreadyInUse means "secondary"; other bind errors fail open (run normally). This is the cross-platform replacement for the Windows named-mutex + WM_COPYDATA trick.
Telegram-style auto-update (Services/UpdateFlow, stateful): check → auto-download in background → on ready, show a persistent "Update Downloader" button at the bottom of the nav rail (MainViewModel.IsUpdateReady + ApplyUpdateCommand) AND a native system notification (NotificationService.Notify, not an in-app toast). The swap is applied via , so clicking the button (which quits) OR just closing the app both install it; close-to-tray is bypassed when . Settings shows live download progress then a "Restart to update" button. — the old bug was calling quit from a background continuation, which hung the window + close button. Disabled under snap ( ⇐ env).
Packaging / publish
macOS must NOT use single-file/compression (PublishSingleFile/EnableCompressionInSingleFile/IncludeNativeLibrariesForSelfExtract). On Apple Silicon the compressed bundle crashes the first time a managed assembly is loaded — inflate hits a "(Data Abort) byte write Translation fault" → FailFastIfCorruptingStateException → abort()/SIGABRT (seen as a crash on Start download, when the Downloader engine assembly is first bound). Fix: macOS publishes plain --self-contained true (loose assemblies + dylibs); make-macos-app.sh already copies the whole publish dir into Downloader.app/Contents/MacOS, so the apphost rename (Downloader.Desktop→Downloader) still works. Windows/Linux keep single-file+compression (they work). Applied in both release.yml and scripts/publish.sh (gated on osx-*).
Self-contained, dependency-free single file: dotnet publish -r <rid> --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true (the last flag is required so Skia/native libs are bundled). Validated ~49 MB ELF.
The output binary is Downloader.Desktop[.exe] (= project name). Renaming the file to Downloader is safe — avares://Downloader.Desktop/... uses the embedded assembly name, not the file name, so don't change AssemblyName (that WOULD break every avares URI).
CI/release live in .github/workflows/ (dotnet-desktop.yml = build+test on push/PR, release.yml = matrix publish on v* tag → creates the GitHub Release for the tag and attaches zip/tar.gz). Local: scripts/publish.sh [rid ...].
winget: the package identifier MUST be Publisher.Package (winget rejects a bare name), so it stays bezzad.Downloader. To let users run the short winget install downloader, add Moniker: downloader to the locale manifest — don't try to rename the identifier. Submitting to microsoft/winget-pkgs (no wingetcreate on Linux): fork (gh repo fork), sync the fork's (), create a branch, PUT the 3 manifests under via the Contents API, then . The PR auto-validates (Azure pipeline) then to merge; the CLA is already signed for . — ALWAYS check for an existing open PR before opening another (we once stacked a 1.1.0 + 1.3.3 dup; close the older one). Installer manifest = + (the win zip ships at its root).
Avalonia 12 gotchas worth caching
DataGrid cell focus/current border: there is NO named FocusVisual/CurrencyVisual element in v12. The current/focus outline is on an unnamed template Border; kill it with DataGridCell:current /template/ Border + DataGridCell:focus /template/ Border → BorderThickness=0/BorderBrush=Transparent (and Rectangle Stroke for safety). Focusable=False does NOT remove it. Full-row selection highlight comes from the Fluent theme's :selected default.
DataGrid grouping kills row virtualization → janky scroll/UI past ~10 rows. Keep the DataGridCollectionView flat (no GroupDescriptions) for performance.
DataGrid.FontSize does NOT cascade to cell/header content — setting it on the <DataGrid> has no visible effect on rows. To resize cell text: set FontSize on each template-column TextBlock, set FontSize on DataGridTextColumn (it has its own), and/or add scoped styles DataGrid.Styles → Selector="DataGridCell TextBlock" and "DataGridColumnHeader TextBlock".
Single-line TextBox strips newlines on paste (AcceptsReturn=false), merging pasted multi-line input. For multi-URL paste, set AcceptsReturn="True" + a KeyDown handler that fires the action on Enter (Shift+Enter = newline).
To intercept Enter on an AcceptsReturn="True" TextBox you MUST use the TUNNEL phase, not a bubble KeyDown=. The TextBox inserts the newline in its own bubble-phase key handler which runs first and marks the event Handled, so a bubble-phase XAML KeyDown="…" handler never fires (bug: "Enter just adds a new line" in the Add dialog's clipboard-suggestion accept). Fix = code-behind box.AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel) (tunnel runs root→target before the TextBox's bubble handler; set e.Handled=true there). Headless-testable: view.Show() + focus the box + view.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, "\r") (from ); find named controls in tests via — is not available cross-assembly in Avalonia 12.
Build / run / test
dotnet build Downloader.Desktop.sln # 0 warnings / 0 errors expected
dotnet run --project Downloader.Desktop/Downloader.Desktop.csproj # launch the GUI (needs a desktop session)# ALWAYS run the suite bounded (standing rule — see note below):timeout -k 30 900 dotnet test Downloader.Desktop.Tests/Downloader.Desktop.Tests.csproj -v q --nologo \
--blame-hang --blame-hang-timeout 180s --blame-crash # all tests (Unit/ Integration/ UI/ Plugins/ + Plugins/Hls/)
Test runs MUST be bounded (learned the hard way, 2026-07-16): a dotnet test host can hang/die silently
right after "A total of 1 test files matched" and sit alive for HOURS; every subsequent dotnet test then
contends with it (same bin/obj + MSBuild node locks) and freezes at the same spot — looking like "tests
hang" when it's really a stale sibling process. Three layers, all standing: (1) every test attribute carries
Timeout = TestTimeouts.DefaultMs (60s; TestTimeouts.SlowMs=180s for genuinely slow ones — the port-range
binder needs it on macOS CI ~1m17s), see TestSupport/TimedAttributes.cs; (2) --blame-hang --blame-hang-timeout 180s --blame-crash makes VSTest kill+dump a stuck host and NAME the culprit test;
(3) timeout -k 30 900 hard-caps the whole command. Before re-running a "hung" suite, first
pkill -f "dotnet test"; pkill -f testhost — a leftover host is the usual cause of the next freeze.
Root cause of the in-host hang itself (diagnosed from the hang dump, 2026-07-17): xunit.v3 ran test
COLLECTIONS in parallel; 8 workers from 8 classes sat blocked in AvaloniaTestCase.Run awaiting the shared
headless dispatcher while NO dispatcher thread existed anymore — a parallel-collection race on the suite's
shared statics killed the session thread, and per-test Timeout can't fire when the dispatcher that would
run the test is dead. Fixed by [assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)] in
TestSupport/TestAppBuilder.cs — parallelism bought nothing (AvaloniaFacts serialize through the one
dispatcher; the suite runs in seconds). Don't re-enable it. Analyze future hang dumps with
dotnet-dump analyze <dmp> -c pstacks; the in-flight tests are the Completed="False" rows in the blame
Sequence_*.xml.
Headless smoke check (no display interaction): timeout 10 dotnet run --project Downloader.Desktop/Downloader.Desktop.csproj — a clean 10s run (SIGTERM/143) with no exceptions means it launched OK. Note: empty-list startup does NOT exercise row/file-kind icons.
Regenerate README screenshots
A gated headless test renders real PNGs to docs/screenshots/ (home-dark, home-light, settings-dark):
DLDESKTOP_CAPTURE=1 dotnet test Downloader.Desktop.Tests/Downloader.Desktop.Tests.csproj --filter FullyQualifiedName~CaptureScreenshots
Then verify the PNGs by viewing them. Capture uses the real App with .UseSkia().UseHeadless(UseHeadlessDrawing=false).
Architecture (where things live)
Services/DownloadManager (DI singleton IDownloadManager): owns the master ObservableCollection<DownloadItemViewModel>, builds engine DownloadService instances, coalesces engine progress onto the UI via the shared EnsureUiPumpDispatcherTimer (see perf note above), enforces queue concurrency through PumpQueue, runs the DispatcherTimer scheduler, raises StatsChanged/ListChanged.
Views/ axaml + Converters/FileKindToIconConverter. App-wide styles/theme palettes in App.axaml; icon geometries in Assets/Icons.axaml.
Persistence: Services/FileService → JSON at %AppData%/Downloader/config.json (Linux ~/.config/Downloader). Saved on shutdown + autosaved every 20s.
Conventions / gotchas
Filename auto-resolve: pass only URL+folder to the engine when the user gives no name; read the resolved name from DownloadStartedEventArgs.FileName (NOT IDownload.Filename, which stays empty).
DataGrid bindings: DataGridTextColumn.Binding must use {ReflectionBinding ...} (compiled bindings resolve against the page VM); template columns set x:DataType.
New icon geometries are parsed at runtime — validate by adding to the converter/icons and relying on the headless geometry tests, or by viewing a screenshot.
Tests: xUnit v3 (don't add v2); [assembly: AvaloniaTestApplication] is in namespace Avalonia.Headless; the test csproj must be SelfContained=true with RuntimeIdentifier=$(NETCoreSdkPortableRuntimeIdentifier) because the app project is self-contained.
Shutdown save uses .Wait() on the UI thread — keep ConfigureAwait(false) on the save path to avoid deadlock.
Keep each commit green (build + tests). Commit messages end with the Co-Authored-By line. Don't reference other download-manager apps anywhere — this is an original design.
See CLAUDE.md at the repo root for product vision, locked decisions, and the full roadmap.
Snap publishing (done — downloader is live on the Snap Store)
Store name downloader is registered to bezzad (public); latest/stable carries the release. Publisher login: snapcraft whoami (token expires 2027-06). Verify: snapcraft status downloader / snap info downloader.
Do NOT build the snap locally on this dev box with --destructive-mode: the host is Ubuntu 26.04 (resolute) but the snap targets base: core22 (22.04). Destructive mode fetches stage-packages (libicu70, libssl3) from the host archive, which 26.04 doesn't have → "Stage package not found: libicu70". A real build needs an isolated core22 env (LXD/multipass, both need sudo) or just use CI.
Easiest publish path = reuse the CI-built .snap: the Snap workflow (.github/workflows/snap.yml) builds correctly via snapcore/action-build (clean core22) and uploads a downloader-snap artifact on every v* tag. To publish: gh run download <id> -n downloader-snap then snapcraft upload --release=stable downloader_<ver>_amd64.snap (uses the local login; ~processing 1-2 min → "released to 'stable'").
CI auto-publish caveat (fixed): the publish step runs only on refs/tags/. The v1.3.1 run built+uploaded the artifact but died at "Attach snap to the GitHub Release" (Resource not accessible by integration — default GITHUB_TOKEN can't update a Release another workflow created), which skipped "Publish to the Snap Store". Fixed by continue-on-error: true on the attach step + always() && … on the publish step. Re-running an OLD tag run won't pick up the fix (tag runs use the workflow at the tag commit) — the fix applies to the next tag. The SNAPCRAFT_STORE_CREDENTIALS repo secret is already set (from snapcraft export-login).
Never commit snap-creds.txt (the export-login token) — gitignored, along with *.snap and parts/ stage/ prime/.
In-app updater self-disables under snap (SNAP env) — the Store handles updates.
Accent picker (Light/Dark + accent): Services/ThemeService holds Accents (Teal/Blue/Purple/Green/Amber) and ApplyAccent(key) overrides the Fluent accent color resources at the Application level — SystemAccentColor + SystemAccentColorLight1/2/3 + Dark1/2/3 (shades computed by mixing toward white/black) — which beats the per-theme palette Accent and recolors every {DynamicResource SystemAccentColor} consumer (nav selection, accent buttons, links, pill) in both themes. ThemeService.Apply(config) sets the variant + accent together; call it at startup (MainViewModel) and on Reset. Persisted as DownloadSettings.AccentColor. Status colors stay semantic (NOT accent-driven). Selected accent VM: SettingViewModel.SelectedAccent/Accents with a swatch+name ComboBox (AccentOption.Brush).
Selected-row contrast (#row-select): the Fluent default fills a selected DataGrid row with the SOLID accent → dark text becomes unreadable. Fix in App.axamlDataGridCell:selected: Background={DynamicResource RowSelectionBrush} (a translucent accent, alpha ~0.28, kept in sync by ThemeService.ApplyAccent) + Foreground={DynamicResource SystemBaseHighColor} (normal text). Verify by programmatically selecting grid.SelectedIndex=1 in CaptureScreenshots (a headless click only reads as hover) → home-selected-{dark,light}.png.
Tray/relaunch "does nothing" = wrong thread: SingleInstanceService.Dispatch runs on the background TCP accept thread and TrayIcon.Clicked can fire on a DBus thread; calling window.Show()/Activate() off the UI thread silently no-ops (and the off-thread throw can wedge the tray event pipeline, plausibly killing the right-click menu too). Both now marshal via Dispatcher.UIThread.Post (Dispatch wraps _onMessage; TrayService.ShowWindow wraps its body + a topmost flip). Linux tray menu behavior is DE-specific and NOT verifiable headlessly — keep both _tray.Menu (right-click) and _tray.Clicked→ShowWindow (left-click); the menu's "Open" is the reliable restore.
"Start just queues, never downloads" (1.3.2/1.3.3 regression): DownloadQueue.IsRunning is persisted and PumpQueue early-returns when !IsRunning. Since every per-item start (Resume/Retry, row button, bulk) funnels through PumpQueue, a queue saved with IsRunning=false (after Stop-queue / Pause-queue) silently swallows all starts — items sit as Created ("Queued") until the scheduler's StartQueue flips IsRunning=true. Fix: an explicit user start calls EnsureQueueRunning(queueId) (sets IsRunning=true) before PumpQueue. Don't remove the IsRunning gate from PumpQueue itself — completion's TryStartNextInQueue still needs it so a paused queue doesn't auto-advance. Regression test: Start_runs_item_even_when_its_queue_was_paused.
Removing a queue deactivates its schedules: RemoveQueue now disables (Enabled=false) + unbinds (TargetQueueId=null) any Schedules pointing at it, so the scheduler can't act on a deleted target. Test: Removing_a_queue_deactivates_its_schedules.
Auto-update is now user-initiated (was Telegram-style silent auto-download): UpdateFlow gained an Available state + PromptUpdate callback (wired in MainViewModel → DialogHelper.ShowUpdatePrompt). CheckAsync no longer auto-downloads — it raises Available and shows the in-app UpdatePromptView (Download / Later, modeled on ShutdownView: Topmost, Esc=Later). StartDownloadAsync() runs only on Download (so the Settings progress bar is actually seen). Settings button flows Check → Download update (Available) → Restart to update (Ready). The "invisible progress" was because the old flow downloaded silently at startup.
Update restart relaunch: the Unix swap script (UpdateService.WriteUnixScript) now trap '' HUP + relaunches via (fallback ) so the NEW app runs in its own session and isn't torn down with the old process group — that detachment is what makes "restart to update" actually relaunch. (Self-swap still can't be tested headlessly.)
Plugin system (Phase 1 — foundation) — patterns
SDK assembly:src/Downloader.Desktop.Plugins.Abstractions (net10.0, nullable on) holds ONLY interfaces + POCO types — the stable surface external plugins reference. App + tests reference it via ProjectReference. Design: docs/plugins-architecture.md. Pipeline = Resolve (ILinkResolver) → Transfer (ITransferProvider/ITransfer) → Post-process (IPostProcessor); a plugin implements only the phases it needs (IDownloaderPlugin.Initialize(IPluginContext) registers contributions).
Loader:Services/PluginManager (DI singleton, UI-free → unit-testable). Loads each plugin DLL in a collectible AssemblyLoadContext + AssemblyDependencyResolver. Critical: the load context MUST return null for the Downloader.Desktop.Plugins.Abstractions assembly name so it resolves from the host → shared type identity (else IsAssignableFrom/is IDownloaderPlugin fails). AssemblyDependencyResolver method is ResolveAssemblyToPath (not ResolveAssemblyPath). Only ENABLED plugins' contributions are returned by FindResolver/FindPostProcessor/FindTransferProvider/ResolveAsync. Disabled ids persist in Config.DisabledPlugins. Plugins live in PluginManager.PluginsRoot (~/.config/Downloader/plugins).
Plugin projects set <EnableDynamicLoading>true</EnableDynamicLoading> (emits the deps.json the ADR needs) and reference Abstractions with <Private>false</Private><ExcludeAssets>runtime</ExcludeAssets> (don't ship a 2nd SDK copy). Example: samples/Downloader.Desktop.SamplePlugin.
TDD:PluginTests.cs (plain [Fact], in-process fakes) covers register/route/enable-disable/idempotency/safe-missing-dir, PLUS a real external-DLL load — the test csproj builds the sample plugin and stages its DLL into <testout>/plugins-sample (MSBuild StageSamplePlugin target + ReferenceOutputAssembly=false ProjectReference), and the test asserts LoadFromDirectory loads it. This validates the ALC + shared-SDK identity end-to-end.
NO left nav rail; pages open IN the main window (2026-07-10, superseding the earlier page-dialog model).MainWindow's central ContentControl binds MainViewModel.CurrentPage; the three Show*Commands + ShowDownloadsCommand call Navigate(NavSection.X) (no dialogs — PageDialogView + DialogHelper.ShowPage + PageDialogWindowKey were DELETED). The action toolbar lives in MainWindow (docked under the top bar, visible on every page): bulk buttons bind through Downloads.* (e.g. {Binding Downloads.StartSelectedCommand} — null-safe until init, refreshed by RaisePropertyChanged(nameof(Downloads))), nav buttons bind directly and highlight the current page via Classes.selected="{Binding Is*Selected}" + the Button.tool.selected style in App.axaml (translucent RowSelectionBrush fill + accent icon/text). The toolbar is a Grid "Auto,*,Auto": page nav is pinned LEFT (Grid.Column=0) — Home (icon-only HomeRegular, tooltip Nav_Downloads — key in all 16 packs), then Settings, Scheduler, Queues (icon+label); the downloads-list action cluster (Start/Pause/Stop/Remove + queue buttons) is pinned RIGHT (Grid.Column=2) in a StackPanel IsVisible="{Binding IsDownloadsSelected}" so it only shows on the Downloads page. The * middle column is the spacer. RTL mirrors both sides correctly. The Add-link dialog closes on Esc (same OnKeyDown override as DownloadDetailsView; the inline queue-name editor's own Esc handler wins because it marks the event handled first). Only Add-link, Details, About, Update-prompt remain separate windows. Plugins stay a collapsible Expander in SettingView.
Capture gotcha: management pages render inside MainWindow — in CaptureScreenshots just vm.ShowSettingViewCommand.Execute(null) (etc.) then Save(window, …); return with vm.ShowDownloadsCommand. No page dialog to Show/Close anymore.
Auto-update macOS restart loop: the old swap extracted Downloader.app INTO Contents/MacOS (nesting) and relaunched the OLD binary → re-detect → loop. now replaces the whole bundle (= → bundle = ) and relaunches via . NOTE: an update FROM a buggy build still uses that build's broken swap — the fix only helps updates initiated from a fixed build (tell users to manually install once to break the loop). Unverifiable headless.
Plugin SDK refinements (naming + logging)
IMediaResolver → ILinkResolver, MediaPart → DownloadPart (author: the app downloads any file, not just media). PartKind (Combined/Video/Audio/Segment/Subtitle) stays — it's a post-processing hint, default Combined.
Logging is Microsoft.Extensions.Logging.ILogger, NOT a custom Log(string).IPluginContext.Logger is an ILogger (the SDK references Microsoft.Extensions.Logging.Abstractions). PluginManager's context builds it via AppLog.Factory.CreateLogger($"plugin:{id}"). CLAUDE.md Conventions now mandates ILogger everywhere (engine/app/plugins → one log via AppLog.Factory).
Footer status pills double as the list filter. The orphaned StatusFilter enum + Show{All,Active,Queued,Completed,Failed}Command + Is*Selected flags + *FilterCount (left over from the removed nav rail) are now reused by clickable footer buttons in MainWindow.axaml (Button.filterpill style + Classes.active="{Binding Is*Selected}"; accent fill when active). Filters are disjoint: Active=Running/Paused, Queued=Created/None, Completed, Failed=Failed/Stopped, All. Counts (ActiveFilterCount etc.) match each bucket exactly and are re-raised in OnStatsChanged/RaiseNavFlags. To add a filter: enum value + DownloadsViewModel.Matches case + command/flag/count + footer button.
File pickers must parent to DialogHelper.ActiveWindow, not MainWindow. Management pages (Settings/Queues) are modal dialogs; opening a picker from the background MainWindow opens behind the modal / fails on some Linux WMs. ActiveWindow = AppLifetime.Windows.LastOrDefault(w => w.IsActive) ?? MainWindow. OpenFilePicker now uses it.
Plugin Install must give feedback (it silently swallowed failures → "nothing happened"). PluginsViewModel.InstallAsync now diffs _manager.Plugins before/after and shows an always-on in-app toast: installed plugin name / "no plugin in that file" / the exception. Use NotificationService.Inform(title,msg,isError) (added) — it always shows the in-app toast regardless of the notifications on/off switch (direct action feedback). Also copies the .deps.json sidecar next to the DLL so plugins with their own deps resolve (the sample has none, so a bare DLL still loads — proven by Loads_a_real_external_plugin_DLL_from_disk).
Settings double-border: wrap nothing extra around an Expander — the Plugins section was Border.card > Expander (two borders); a bare <Expander> matches the Advanced section. Right-hand inputs: ComboBox.ctrl/TextBox.ctrl set Width=148 Height=34 MinHeight=34 to line up with the global (, Width 148). Proxy is a single-line (label left, box fills) not a label-over-box StackPanel.
Drag-to-reorder rows (main grid)
6-dot grip = first column of DownloadsView (left of the select-all checkbox). Dragging a row reorders the master Items list = queue pump priority (same ordering the Queues-page chevrons drive). Dropping onto a row in another queue moves the dragged item into that queue (adopts the target's QueueId).
Manager: DownloadManager.ReorderTo(vm, target, placeAfter) (on IDownloadManager) — Items.Move to the drop index (decrement when source was above target), adopt target's QueueId if different (+ vm.RaiseQueueNameChanged()), NotifyList(), then PumpQueue the old and new queues. Keep MovePriority (±1) for the Queues page. DownloadsViewModel.Reorder(...) just forwards to the manager (code-behind calls it).
Row VM: DownloadItemViewModel.QueueName is computed live from _manager.Queues by _item.QueueId (no manual sync); RaiseQueueNameChanged() re-raises it after a cross-queue move.
Queue column: shown only when DownloadsViewModel.ShowQueue (Queues.Count > 1, re-raised in Refresh()). An x:Name on a DataGridColumn does NOT generate a code-behind field (CS0103) — find it instead via Root.Columns.FirstOrDefault(c => c.SortMemberPath == "QueueName") and set IsVisible from code-behind (subscribe to the VM's PropertyChanged for ShowQueue).
Avalonia 12 drag-drop API changed: DataObject/DragDrop.DoDragDrop/DragEventArgs.Data are obsolete. DataTransfer/DoDragDropAsync exist but the OS drag session renders NO moving visual on Linux/X11 — the row never appears to follow the cursor. We no longer use OS DragDrop here (see "Sticky drag ghost" below); only ReorderTo/Reorder/placeAfter = e.GetPosition(row).Y > row.Bounds.Height/2 are kept.
Sticky drag ghost (manual pointer drag — replaces OS DragDrop)
Why: Avalonia's OS DragDrop.DoDragDropAsync shows no moving adorner on X11, so the picked-up row didn't visibly follow the pointer. The fix is a hand-rolled pointer-capture drag with a floating ghost — this is the only way to get "row sticks under the cursor" here.
Overlay: a <Canvas x:Name="DragOverlay" IsHitTestVisible="False" ClipToBounds="False" /> is the last child of the Panel wrapping the DataGrid (so the ghost paints above rows).
Ghost = a Border (opaque SystemRegionColor bg, accent border, BoxShadows.Parse("0 6 18 0 #50000000"), Opacity ~0.92, IsHitTestVisible=False) whose Child is a Border{ Background = new VisualBrush(sourceRow){ Stretch=None, AlignmentX=Left, AlignmentY=Top } } — i.e. a live snapshot of the dragged DataGridRow. Size it to sourceRow.Bounds.
This SKILL.md is very large, so SkillsMP previews the first section here.View on GitHub
DownloadFileCompleted
Cancelled=true
before
"File already exists" is NOT a failure (FileExistPolicy=IgnoreDownload, the app default): when the target already exists the engine skips the download — it SendDownloadCompletionSignal(Stopped) (so DownloadFileCompleted arrives Cancelled=true, Error=null) and never fires DownloadStarted. That used to be misread as a timeout failure. The manager now calls TryMarkAlreadyExists in the cancelled branch: if DownloadManager.LooksAlreadyDownloaded(policy, path) (policy==IgnoreDownload && the resolved file exists on disk), it backfills name/folder/size from that file and marks the row Completed with DownloadItemViewModel.AlreadyExisted=true (StatusText → State_Exists "Already downloaded"; still IsCompleted, green bar). The final path comes from (e.UserState as DownloadPackage)?.FileName (the event's userState IS the DownloadPackage) or vm.Download.Package.FileName. AlreadyExisted is reset in Start. No new enum state was added (would ripple through filters/converters) — it's a Completed row with a display flag.
Queued-item file names: the engine only resolves the name once a download starts, so queue-capped items show no name. UrlResolver.ResolveFileNameAsync (Content-Disposition → URL path) fills a VM-only PreviewName in the background; don't write it to DownloadItem.FileName or it gets forced on the engine.
Integration test pattern: spin up a loopback HttpListener with Range/206 support and download through a real DownloadService — no external network, CI-safe (see IntegrationTests).
UI progress coalescing (perf — main-thread budget): do NOT marshal each engine DownloadProgressChanged to the UI (with N downloads × M connections that floods the dispatcher and makes the grid lag). Handlers call vm.StageProgress(...) (plain fields, any thread, no UI touch); a single DispatcherTimer in DownloadManager (EnsureUiPump, 250 ms) flushes all rows via vm.FlushProgress() and fires StatsChanged once per tick. The pump self-stops when no row is Running. FlushProgress drops staged values unless Status==Running, so a paused row keeps its last fill. This bounds main-thread work regardless of download count — keep it; don't re-add per-event Dispatcher.UIThread.Post.
Queue concurrency cap — single choke point: Start(vm) is the uncapped primitive and must only be reached via PumpQueue. Every user-facing start path (Resume, Retry, StartAll, bulk StartSelected → Resume, Add(autoStart), completion's TryStartNextInQueue) must re-queue the item (set Status=Created) and call PumpQueue, which starts/resumes only while running < MaxConcurrent. PumpQueue handles both Paused (resume in place) and Created/None (start fresh), paused first. Regression to watch: making Resume/StartAll call Start directly bypasses the cap (e.g. select 10 with cap 2 → all 10 ran). Start sets Status=Running synchronously before its first await, so PumpQueue's running recount is correct mid-loop.
Cap value lives on DownloadQueue.MaxConcurrent, but the user sets it via Settings (DownloadSettings.MaxConcurrentDownloads). These are TWO fields — MaxConcurrentDownloads historically only seeded new queues, so changing it never limited anything (the real bug behind "I set max 2 but 10 ran"). They're now kept in lockstep for the primary/default queue (Config.DefaultQueue = Queues[0]): SettingViewModel.MaxConcurrentDownloads setter writes it through to DefaultQueue.MaxConcurrent + PumpQueue; DownloadManager.Initialize re-syncs the default queue from the setting on load (fixes stale saved configs); and the Queues page (QueueRowViewModel.MaxConcurrent) mirrors edits of the default queue back into the setting. Extra (non-default) queues keep their own caps. So enforcement reads queue.MaxConcurrent, but the default queue's value always equals the Settings number.
State transitions must be guarded in the manager, not just the buttons: per-row buttons gate via IsVisible/Can*, but bulk actions (StopSelected→Cancel, StartSelected→Resume) apply to every selected row regardless of state. So the guards live in DownloadManager (the single choke point that all callers — buttons, bulk, scheduler, pump — go through):
Pause no-ops unless Running.
Cancel (= "Stop") acts on Running/Paused and queued (Created/None) → all become Stopped; it no-ops only for terminal/idle states (Completed/Failed/already-Stopped). Stopping the queued rows too is essential: otherwise stopping the running rows fires DownloadFileCompleted→TryStartNextInQueue and the pump immediately starts the next queued rows ("select all → Stop: 3 stop, 3 start"). The StopSelected loop runs synchronously before any completion callback is posted, so by the time the pump runs there are no queued rows left to start. (Do not restrict Cancel to Running/Paused only — that reintroduces the bug.)
Resume/Start no-op if Running/Completed; Retry only acts on Failed/Stopped. Prevents re-running a completed download from 0% and stray double-Start (a second engine reporting from 0% → "100% then begins again from 0").
Keep state-machine rules in the manager methods, not scattered across VMs/views.
StartQueue must re-queue Stopped/Failed: PumpQueue only picks up Paused/Created/None, so StartQueue/StartAll must first flip Stopped (and Failed, for StartQueue) rows to Created — otherwise "Start queue → " after a Stop/Stop-all does nothing (rows are Stopped). StartQueue does this in a RunBatch then pumps.
Completed rows always show 100%: don't compute a completed row's bar from Downloaded/Size — a file that already existed on disk is Completed with Downloaded=0, and that read 0% (esp. after restart). The DownloadItemViewModel ctor forces _progress=100 when Status==Completed, the Status setter sets Progress=100 on transition to Completed, and the already-exists handler persists Downloaded=fileLength.
Status badge colors live in Converters/StatusToBrushConverter: Running teal, Completed green, Failed red, Paused amber, Stopped neutral-gray, Queued steel-blue (#4F6D9C — deliberately distinct from Stopped's gray so a waiting vs stopped row is tellable apart). Badge shows for every state except Running (which shows live %); DownloadItemViewModel.ShowStatusBadge = Status != Running.
Queues page = a real queue manager (Views/QueuesView.axaml, ViewModels/QueuesViewModel.cs): per-queue card shows live aggregate stats (RunningCount/WaitingCount/DoneCount/FailedCount, TotalSpeedText, SummaryText) + a combined OverallProgress bar (average of item Progress), a run/pause ToggleSwitch, the concurrency cap, and the queue's downloads with per-item progress + pause/resume/retry/cancel/remove + reorder (ChevronUp/Down → manager.MovePriority(vm, ±1)) + move-between-queues (a MenuFlyout of QueueMoveTargets → manager.MoveToQueue(vm, queueId), hidden when only one queue). Rows are wrapped in QueueItemViewModel (holds the real DownloadItemViewModel as Item + the reorder/move commands); the card's Items is an ObservableCollection<QueueItemViewModel> rebuilt on ListChanged (order = master Items order = pump priority), and aggregates refresh on both ListChanged and StatsChanged (live). QueueRowViewModel.Detach() unsubscribes both events. Pump order follows master-list order, so MovePriority just Items.Moves past the same-queue neighbour. Initialize now backfills QueueId=DefaultQueue.Id for items saved without one (older configs) so they always appear on a queue. DownloadItemViewModel.FormatBytes is now public static for reuse.
View as Window
Run-at-startup (Services/StartupService): no extra deps — Windows via reg.exe add/query/delete HKCU\...\Run (avoids Microsoft.Win32.Registry package on the non-windows TFM), Linux ~/.config/autostart/downloader.desktop, macOS ~/Library/LaunchAgents/*.plist. Launches with --minimized; MainViewModel hides the window at startup if that arg is present AND tray active. Coupling lives in SettingViewModel: disabling tray disables startup; enabling startup enables tray.
Auto-update (Services/UpdateService + UpdateFlow): version compare uses Assembly.GetName().Version (CurrentVersion = Major.Minor.Build), NOT InformationalVersion (that has the date-derived revision and would never compare sensibly to a v1.1.0 tag). Versioning fix (#update-false-alarm, 2026-06-19):VersionPrefix is now the FULL 3-part semver (e.g. 1.1.2) and AssemblyVersion=$(VersionPrefix).0 so the app reports its real patch — the old major.minor.0.0 pin made it always report x.y.0, so every patch release (e.g. v1.1.1) looked "newer" forever → false "update available". release.yml stamps -p:VersionPrefix=<tag-without-v> from the tag so a released build reports exactly the tag; About card (SettingViewModel.AppVersion) shows UpdateService.CurrentVersion so About + update status + release tag all agree. Keep VersionPrefix three-part.UpdateService.IsNewer(tag, current) + Normalize(tag) are pure/tested. Flow: GitHub releases/latest → if newer, the in-app UpdateFlow.PromptUpdate dialog (Download/Later) + a passive OS notification (NOT a clickable toast — see the OS-only note below) → download the per-RID asset (ExpectedAssetName() matches release.yml names) via a throwaway DownloadService → ApplyDownloadedArchive spawns a detached unix .sh/win .cmd that waits for the PID to exit, extracts over the app dir, relaunches → UpdateFlow.RequestShutdown (= MainViewModel.Quit). The self-swap is untestable here; only the version logic has tests.
Notifications are OS-only (2026-07-10): NotificationService now shows EVERY message as a native OS notification (Linux notify-send, macOS MacNotifier in-process banner, Windows WindowsNotifier toast) on all platforms, regardless of window focus. There are NO in-app toasts and NO focus tracking — the whole focus-aware routing model (Attach/SetFocused/AppFocused/PreferOsChannel, WindowNotificationManager, ShowAction + its pending-action replay queue) was removed (it caused the macOS "in-app toast from the tray" bug: hide-to-tray doesn't fire Deactivated). Surface is just Notify(title,msg,isError) (gated by the on/off switch) + Inform(...) (always). Native channels can't carry a click callback, so any actionable prompt keeps its action IN THE WINDOW: app update = UpdateFlow.PromptUpdate dialog + "Update Downloader" nav button; plugin update = Settings→Plugins row "Update" button (PluginRowViewModel.UpdateAvailable); post-download action = the completed-row action button (PostDownloadActionLabel). On native failure (no notify daemon / blocked toast API) the message is just skipped + logged — no fallback. Don't reintroduce in-app toasts or ShowAction.
Button.nav.selected Border.pill
Button.nav.selected Border.pill > TextBlock
Margin="8 0 0 0"
Linux taskbar icon: set X11PlatformOptions.WmClass = "Downloader" in Program.cs and make the installed .desktopStartupWMClass=Downloader match — that's what makes the DE use our icon instead of a generic/host (e.g. IDE) one. A raw ELF can't carry a file-manager icon; the .desktop from scripts/install.sh provides it.
NotifyOnShutdown
notify
shutdown /s
osascript … shut down
systemctl poweroff
PowerOffOverride
Granular notifications: master EnableNotifications gates inside NotificationService.Notify; per-event toggles (NotifyOnComplete/Failed/AllComplete/Shutdown) are checked in the manager / MainViewModel before calling NotificationService (they read _config.Settings live). Settings UI: a "NOTIFICATIONS" card with sub-toggles IsEnabled="{Binding EnableNotifications}".
StopAll now cancels every Running/Paused/queued item (→ Stopped) via Cancel (was: pause running). Cancel already guards terminal states, so completed/failed rows are untouched.
Browser integration (Services/BrowserIntegrationService, opt-in, default off): HttpListener on http://127.0.0.1:15151/, permissive CORS, reads ?url=; OnUrlCaptured → MainViewModel.CaptureUrl surfaces the window + opens Add pre-filled. Parse the query manually (don't pull in System.Web). App side only — the extension ships separately.
Test seams for OS/engine side-effects: give production code an override so tests don't hit the network/OS — ShutdownService.PowerOffOverride (Action), DownloadManager.RaiseCompletedForTest(vm) (post-completion bookkeeping without a real download). In completion tests set config.DefaultQueue.IsRunning=false so PumpQueue doesn't kick off background (network) starts.
Adding a UI language: add new LanguageOption(code,name) to Localizer.Languages + create Assets/i18n/{code}.json (auto-embedded by AvaloniaResource Include="Assets\**"). Only en.json strictly needs every key (fallback); full packs carry all keys. Bulk-add new keys to existing locales with a small python3 json script (object_pairs_hook=OrderedDict, json.dump(…, ensure_ascii=False, indent=2)). Shipped locales (16): en, fa, es, fr, ar, eo, tr, az, de, it, pt, ru, hi, zh, ja, ko.
Linux app icon (a raw ELF can't carry one): scripts/install.sh fetches downloader.png from the repo raw URL when the tarball lacks it, installs to hicolor/{512,256,128}x*/apps + ~/.local/share/pixmaps, then gtk-update-icon-cache -f. publish.sh + release.yml also copy Assets/downloader.png into the linux tarball so future installs find it locally. .desktop keeps Icon=downloader + StartupWMClass=Downloader.
ColumnHeaderHeight="38"
Width=44 CanUserResize=False
PropertyChanged
manager.Items.CollectionChanged
Toolbar acts on selected rows = checked OR highlighted: the bulk buttons enable when a row is either checked or highlighted in the DataGrid. The view's SelectionChanged pushes grid.SelectedItems into DownloadsViewModel.SetGridSelection(...); SelectedTargets() = Items.Where(IsChecked || _gridSelection.Contains(i)); HasSelection drives the commands' WhenAnyValue canExecute. (Headless screenshot capture can't reproduce real row-selection — a click reads as hover — so verify this via a unit test calling SetGridSelection, not a screenshot.)
Time-left column: DownloadItemViewModel.TimeLeftText = remaining ÷ Speed (only while Running), formatted by public static FormatDuration(double seconds) ("45s"/"1m 23s"/"2h 5m"; "—" for non-finite/idle). Re-raised from the Speed and Status setters.
State → only the progress bar recolors: bind ProgressBar.Foreground="{Binding Status, Converter=StatusToBrushConverter}" and show StatusText ("62% · Paused") under it. The row name keeps one consistent style across states (removed the old TextBlock.failed/.pending name classes + the colored badge) — author preference.
Browser extension lives at src/browser-extension/ (NOT a .NET project, excluded from build/tests): cross-browser MV3, manifest.json (Chrome/Edge, background.service_worker) + manifest.firefox.json (background.scripts + gecko id). background.js guards importScripts (if (typeof importScripts === "function")) so the same file works as a Chrome SW (imports common.js) and a Firefox event page (manifest loads common.js first). It does context menus + webRequest.onHeadersReceived media sniffing (video/audio/HLS .m3u8; YouTube/DRM unsupported) + forwards to the app via GET http://127.0.0.1:15151/add?url=…. The app listener answers /ping (200) for the popup's status dot. Resize icons with Pillow (Image.LANCZOS) — no ImageMagick on this box. Store deploy is the author's (needs dev accounts); Safari intentionally skipped.
All UpdateFlow state changes marshal to the UI thread
ConfigureAwait(false)
UpdateFlow.IsManagedExternally
SNAP
Snap (snap/snapcraft.yaml, core22 + extensions: [gnome], strict confinement): the part is plugin: dump over the pre-publishedpublish/linux-x64 self-contained single-file (built by scripts/build-snap.sh / .github/workflows/snap.yml), organize: { Downloader: bin/Downloader }; stage-packages: libicu70, libssl3 (gnome ext provides the rest). Desktop+icon live in snap/gui/. The Store / snap info icon needs a top-level icon: snap/gui/downloader.png key (or a file literally named snap/gui/icon.png) — the .desktopIcon= line only sets the launcher icon, not the Store icon (fixed in b5d00a1). The Store icon must be ≤512×512 — snap/gui/downloader.png is the 512×512 Assets/downloader512.png (the 1080×1080 Assets/downloader.png was rejected by the Store for size; fixed in fb53047). Version stamped from snap/local/VERSION via an adopt-infonil part. Single-file extraction under strict confinement: the single-file build self-extracts native libs (libSkiaSharp.so …) to ~/.cache/dotnet_bundle_extract by default, but the home interface DENIES hidden dot-dirs like ~/.cache → Failure processing application bundle … Error code: 13 (EACCES) and the app never opens. Fix = apps.downloader.environment.DOTNET_BUNDLE_EXTRACT_BASE_DIR: $SNAP_USER_COMMON/.dotnet_bundle_extract (snapd expands $SNAP_USER_COMMON; always writable). Fixed in b289fd8. Snaps auto-update via the Store, so the in-app updater self-disables under SNAP. Publishing needs the author's snapcraft login (or a SNAPCRAFT_STORE_CREDENTIALS repo secret for CI auto-publish).
Browser-extension store packaging: scripts/build-extension.sh makes two zips from src/browser-extension — Chrome/Edge (uses manifest.json) and Firefox (swaps in manifest.firefox.json as manifest.json). Listing copy + step-by-step submission in PUBLISHING.md; PRIVACY.md is the required privacy-policy URL. Store submission needs the author's dev accounts.
Queue page ScrollViewer: set HorizontalScrollBarVisibility="Disabled" so cards stay within the viewport (they overflowed behind the window when narrow), and put the page padding on the inner content's Margin (NOT ScrollViewer.Padding) so the bottom gap is part of the scroll extent and the last row is reachable at scroll end.
Stop vs Pause a queue: PauseQueue only pauses running items (the Queues-page Run/Pause toggle); StopQueue (toolbar "Stop queue") cancels every running/paused/queued item → Stopped. Two distinct manager methods.
master
gh api -X POST repos/<you>/winget-pkgs/merge-upstream -f branch=master
manifests/b/bezzad/Downloader/<ver>/
gh pr create --repo microsoft/winget-pkgs
waits on a community moderator
bezzad
scripts/release.sh does this automatically (submit_winget) each release and is dedup-safe
gh pr list --repo microsoft/winget-pkgs --author @me
InstallerType: zip
NestedInstallerType: portable
Downloader.exe
winget install bezzad.Downloader "fails" on some machines — it's NOT the package. Symptom: Failed when searching source: msstore + SSL Error: WINHTTP_CALLBACK_STATUS_FLAG_CERT_REV_FAILED / INVALID_CA, then winget LISTS bezzad.Downloader (source winget) and says "Please specify one of them using the --source option". winget found our package fine; it refuses to auto-pick because one source errored, so the match isn't provably unambiguous. The msstore SSL failure is the user's network/machine (corporate TLS inspection, VPN MITM, blocked CRL/OCSP endpoint). Answer: winget install bezzad.Downloader --source winget (skips msstore entirely). Confirmed working by the author, 2026-07-22. README + packaging/winget/README.md now document --source winget as the recommended form. Don't chase this as a manifest bug.
packaging/winget/*.yaml is a MIRROR of manifests/b/bezzad/Downloader/<ver>/ in winget-pkgs, not the source of truth; release.sh submit_winget bumps both. To check the mirror against what's actually published: gh api "repos/microsoft/winget-pkgs/contents/manifests/b/bezzad/Downloader/<ver>/bezzad.Downloader.installer.yaml" --jq '.content' | base64 -d.
Session gotcha: git status in the CLAUDE.md preamble is a SNAPSHOT taken at session start and develop may already be far ahead of it (a session opened at ab797a1 found origin/develop 31 commits later, incl. the whole v2.2.0 release). A fresh worktree branches from that stale base, so files can look out of date when they aren't. Always git fetch origin develop && git rev-list --left-right --count HEAD...origin/develop before concluding something is stale or unreleased, and rebase the worktree onto origin/develop before editing.
Release matrix RIDs: win-x64, linux-x64, osx-x64, osx-arm64 (only macOS ships both arches; Windows/Linux are x64-only). Versioning — VersionPrefix is the full 3-part semver in the csproj (currently 1.1.2); release.yml overrides it from the tag (-p:VersionPrefix=<tag-without-v>). To release: bump VersionPrefix to the new 3-part version, ensure green + pushed, then git tag vMAJOR.MINOR.PATCH && git push origin <tag> (tag must equal VersionPrefix). NOTE this repo currently releases off develop (the working branch), not main. softprops/action-gh-release creates the Release if absent; re-running needs the tag + Release deleted first.
Reveal-a-file-in-folder cross-platform: Windows explorer /select,"path", macOS open -R path, Linux dbus-send … org.freedesktop.FileManager1.ShowItems array:string:file://path string: (fallback: open the directory). For an in-progress row the final file doesn't exist yet — the engine writes <name>.download — so OpenContainingFolder reveals the final file if present, else <final>.download, else just opens the folder (completed rows already selected correctly).
Only ONE modal on screen — a dialog opened from another dialog appears UNDERNEATH it. Every modal here is shown with view.ShowDialog(MainWindow), so a dialog opened from inside another one (Donate from About) is the first one's sibling, not its child; the shared owner raises the earlier dialog back on top and the new one looks like it opened behind. Do NOT "fix" this by re-parenting to ActiveWindow (that nests windows and makes Esc/close order confusing) — instead every modal entry point in DialogHelper calls BeginModal(view) before ShowDialog, which closes whatever modal is still open and tracks the new one (CloseOpenModals()/OpenModals). Confirm deliberately SKIPS BeginModal — a confirmation must sit on top of whatever asked for it, not close its caller. Any new modal added to DialogHelper must call BeginModal. Regression tests: UI/DialogHelperTests.Opening_a_modal_closes_the_modal_that_was_already_open (+2). Note headless can't exercise ShowAbout/ShowDonate themselves (no classic desktop lifetime ⇒ MainWindow is null ⇒ they early-return), so the tests target the BeginModal seam they all funnel through.
Custom window chrome: ExtendClientAreaChromeHints was removed in Avalonia 12 (compile error AVLN2000). Use only ExtendClientAreaToDecorationsHint="True" + ExtendClientAreaTitleBarHeightHint="-1", then draw your own bar (see Views/TitleBar). OS resize/snap still works. Drag = host.BeginMoveDrag(e) on left-button PointerPressed; get the window via TopLevel.GetTopLevel(this) as Window.
All three windows (MainWindow, AddDownloadItemView, DownloadDetailsView) use TitleBar; dialogs set ShowMinMax="False".
CanResize="True" is NOT enough to edge-drag-resize these windows — they set WindowDecorations="None" (+ transparent rounded chrome), which removes the OS resize border, so CanResize only drives maximize/restore. To get edge/corner dragging you MUST wrap the root border in a <Panel> and add <v:ResizeGrips /> as the last child (an 8-zone transparent overlay in Views/ResizeGrips; it resizes manually via pointer-capture + Window.Position/Width/Height because Window.BeginResizeDrag is a no-op on macOS for borderless windows). MainWindow + DownloadDetailsView already had it; a resizable dialog WITHOUT ResizeGrips silently only maximizes (the resizable-persisted-dialog-sizes regression: Add-link + PageDialog were missing it). Any new custom-chrome resizable window needs this overlay.
Esc-to-close dialogs: with WindowDecorations="None" there's no native close-on-Esc. Override OnKeyDown on the dialog window and Close() on Key.Escape (see DownloadDetailsView). A focused TextBox doesn't swallow Esc, so the window-level override is enough.
{Binding #ElementName.Bounds.Width} is unreliable inside an ItemsControl/DataTemplate — the element-name reference silently fails to resolve per-item, so a control bound to it (e.g. Width="{Binding #Sibling.Bounds.Width}") gets no width and stretches to fill instead of matching the sibling (bit the PluginsView catalog-row busy pill: the progress bar rendered full-width, not button-width). To make sibling controls share a width in a template, give them the same explicitWidth, OR (see next) fix the parent panel width and let children stretch.
ProgressBar ignores an explicit Width that's smaller than the Fluent template's own minimum — setting Width="90" on a <ProgressBar> did NOT shrink it (it still rendered ~200px wide) while the same Width="90" on a sibling Button worked. Fix: don't size the ProgressBar itself — put it in a fixed-width parent (e.g. the enclosing StackPanel Width="90") and set the bar HorizontalAlignment="Stretch" MinWidth="0" so it fills exactly the parent width. (PluginsView busy panel: parent panel Width="90", bar stretches to match the Add button.)
NumericUpDown empty → null crash: clearing a NumericUpDown sets Value=null, which a binding to a non-nullable int/long setting can't convert → a "value cannot be null" validation error in the view. Behaviors/NumericCoerce.EmptyToMinimum (attached prop, enabled globally by a NumericUpDown style in App.axaml) snaps an empty box back to its Minimum. Covers Settings/Queues/Details numerics at once.
Interrupted downloads load as Stopped: DownloadManager.Initialize normalizes BOTH saved Running AND Paused → Stopped (a live/paused server connection can't survive a restart). Terminal states (Completed/Failed/Stopped) are kept.
Language flags: Assets/flags/{code}.png (auto-embedded by AvaloniaResource Include="Assets\**"), generated by a one-off PIL script (no SVG rasterizer on this box). LanguageOption.Flag lazy-loads the bitmap via AssetLoader (null if missing). Picker ComboBox shows <Image Source="{Binding Flag}">+name. Mapping: en→US, pt→Brazil, ar→UAE, eo→Esperanto star, fa→green/white/red Iran tricolor WITH a simplified gold Lion & Sun emblem (this line previously said "no emblem" — that was stale/wrong; the shipped asset has always carried the emblem, see the "Round 19" flag note below — never the official Sun&Lion/takbir version, just a crude sun-disc+lion silhouette). New i18n keys only strictly need en.json (others fall back to English).
setsid
nohup
Flag rendering (PIL, ~30×20): real 5-point star() polygons (not dots) for the US canton; Korea taeguk = full red circle → blue bottom-semicircle → left small circle red + right small circle blue (the S-curve) + 4 corner trigram bars — without the two small circles it looks like Japan's disc. Iran (per author's reversal) = green/white/red + a simplified gold Lion & Sun emblem on the white stripe (sun disc+rays + crude lion silhouette); a detailed emblem isn't legible at this size.
Flags are SVG now (Assets/flags/{code}.svg), NOT PNG (author request 2026-07-04, superseding the PIL/PNG notes above): hand-authored vector SVGs (viewBox 90×60), rasterized at load time by Localizer.RenderSvg via the Svg.Skia 5.1.1 package — the Avalonia-independent core (Avalonia.Svg.Skia has NO Avalonia-12 build, but plain Svg.Skia only needs SkiaSharp ≥3.119.2 and Avalonia.Skia 12.0.4 ships 3.119.4). LanguageOption.Flag stays a Bitmap so the XAML is unchanged: SKSvg.Load → scale to 45px height (3x the 22×15 display) → SKSurface → PNG-encode → Avalonia Bitmap. fa.svg embeds the author's provided Lion & Sun (Naval flag of Iran) JPEG as a base64 <image> data URI (resized 360×240) — do NOT redraw it procedurally; SKSvg renders embedded raster images fine. eo now has the correct GREEN Esperanto star (the old PNG had red); az the real 8-point star. Preview SVGs on macOS with qlmanage -t -s 300 -o <dir> <files>. Test Every_language_has_a_loadable_flag asserts non-null + 45px height for all 16.
README docs/banner.svg: embed the real app icon as a base64 data:image/png<image> (SVG2 plain href, NOT xlink:href) so GitHub renders it — don't hand-draw a stand-in logo. Source PNG = Assets/downloader512.png.
Release notes MUST be pretty Markdown (CLAUDE.md release routine): one-line summary + emoji section headers (### ✨ New / ### 🐛 Fixes / ### 🔧 Under the hood), short end-user bullets, no commit hashes. Backfill empty/plain releases with gh release edit <tag> --notes-file. Good examples on GitHub: v1.0.0 / v1.1.0 / v1.2.0. CI gotcha: never put generate_release_notes: true on release.yml's matrix action-gh-release steps — 4 concurrent creates race → tag_name already_exists and one asset (osx-x64) fails to upload (bit v1.4.0). A single post-build notes job fills auto-notes only if the body is empty instead.
Views/PluginsView
ViewModels/PluginsViewModel
DialogHelper.OpenFilePicker(title,filterName,ext)
NOT YET (Phase 2): the download-pipeline integration (JobCoordinator + multi-part download + the ITransfer refactor so the queue/UI drive torrent/HLS uniformly) and the official HLS (yt-dlp+ffmpeg)/torrent plugins. PluginManager.ResolveAsync is the ready hook.
UpdateService.WriteMacScript
.app
appDir
<bundle>.app/Contents/MacOS
appDir/../..
open
Update cancel + version:UpdateFlow.CancelDownload() (CancellationTokenSource in DownloadAsync; cancel → back to Available). Settings shows a × (DismissRegular) on the progress bar + AvailableVersionText ("vX.Y.Z available", from UpdateFlow.AvailableTag).
Expired-link detection:DownloadManager.Start captures vm.PreAttemptSize = item.Downloaded>0 ? item.Size : null (the known size BEFORE progress events overwrite vm.Size, which writes through to _item.Size). On a successful completion, ExpiredLinkHeuristic(known, finalBytes) (pure, tested) flags a RESUME that finished at <half the known size as Failed ("re-add with a fresh link") instead of Completed. finalBytes from (e.UserState as DownloadPackage)?.ReceivedBytesSize ?? vm.Download?.Package?.ReceivedBytesSize.
Stop All vs Stop icon: were identical (StopRegular square). Stop All now uses StopAllRegular (a solid octagon stop-sign). Added DismissRegular (×) too.
Plugin developer docs:docs/writing-plugins.md (how to write/build/install a plugin). The sample samples/Downloader.Desktop.SamplePlugin is now GitHub Releases (com.bezzad.github-releases) implementing ALL interfaces (resolver = repo→latest asset; IPostProcessor = .sha256 sidecar; ITransferProvider = file:// copier). The integration test loads it and checks the GitHub resolver + file:// transfer.
NumericUpDown
MinHeight=34
.ctrl
Grid ColumnDefinitions="Auto,*"
ExpiredLinkHeuristic → LooksCorruptedAfterResume (author spec): a first-time download finishing small is FINE (never flagged — PreAttemptSize is null when item.Downloaded==0); only a RESUME (PreAttemptSize>0) that finishes smaller than the known size is flagged → Failed with a "file looks corrupted / incomplete" message (threshold is < knownSize, not < knownSize/2). Same PreAttemptSize capture + ReceivedBytesSize read as before.
Adding the grip column shifted the overlaid select-all checkbox: its Margin went 14 9 0 0 → 42 9 0 0 (28px grip + 14px).