A high-performance download manager for Windows, built on WinUI 3 (Windows App SDK 1.5) and .NET 8. o-down bundles three sidecar tools to give you the most feature-rich, optimized download experience possible on Windows:
- aria2c — multi-connection, multi-source, segmented HTTP/HTTPS/FTP downloads
- yt-dlp + ffmpeg — 1000+ media sites, format selection, transcoding
- MonoTorrent — BitTorrent (magnet links, .torrent files, DHT, PEX)
- ✅ Pause / resume (per-task and global)
- ✅ Automatic error recovery (exponential backoff, mirror failover)
- ✅ Checksum verification (MD5, SHA-1, SHA-256, SHA-384, SHA-512) — streamed during write, no re-read
- ✅ Crash-safe resume state persisted in SQLite
- ✅ Segmented / multi-threaded downloading (default 16 connections, configurable to 64)
- ✅ Multi-source / mirror support (paste comma- or newline-separated mirrors)
- ✅ Bandwidth throttling (per-task and global, hot-tunable)
- ✅ Connection pool, disk cache (64 MB), pipelining
- ✅ Queue with priority levels and drag-reorder
- ✅ Cron-based schedules (e.g. "start at 03:00 daily")
- ✅ Auto-sort into folders by extension or regex rule
- ✅ Bulk / batch link processing — paste any text, URLs auto-extracted and classified
- ✅ Default rules pre-seeded: Video, Audio, Images, Docs, Archives, Installers
- ✅ Browser extension: Chrome / Edge / Brave / Opera (MV3) and Firefox (MV2 + MV3)
- ✅ Post-download actions: run a script, shutdown, hibernate, sleep, lock, logout, open folder
- ✅ Media extraction and format conversion (pick from yt-dlp's format list, ffmpeg remux)
- ✅ Clipboard monitoring (off by default; consent prompt on first run)
- ✅ Native messaging host (separate small EXE, no dependencies on the browser)
- ✅ Magnet links and
.torrentfiles - ✅ DHT, PEX, μTP, Local Peer Discovery
- ✅ Per-torrent sequential download
- ✅ Persistent piece-level resume (auto-saved by MonoTorrent)
- Windows 10 1809+ or Windows 11 (x64; arm64 build requires arm64 Windows)
- ~50-80 MB of disk space for the install
- Internet connection for downloads
- .NET 8 Desktop Runtime — only needed if you want the browser extension (one-click "send to o-down" from web pages). Preinstalled on Windows 11 22H2 and later. If you don't have it, the Inno Setup installer detects this on launch and offers to open the download page; the main app still works without it.
- Chrome / Edge / Brave / Opera or Firefox — for the browser extension
- ~100 MB free RAM while running
aria2c.exe— multi-connection HTTP/HTTPS/FTPyt-dlp.exe— 1000+ media sitesffmpeg.exe(essentials build) — video/audio remuxo-down.App.exe— main WinUI 3 app (self-contained, no runtime needed)o-down.NativeMessaging.exe— small browser-extension host- Browser extension source folders (for manual load in developer mode)
Settings, resume state, logs, and the SQLite database live under %LOCALAPPDATA%\o-down\. The app and download folder need normal user write access.
o-down.sln
├── src/
│ ├── o-down.App/ # WinUI 3 host (unpackaged)
│ ├── o-down.Core/ # Domain models, interfaces, pipeline
│ ├── o-down.Data/ # EF Core SQLite context
│ ├── o-down.Infrastructure/ # Clipboard monitor, registry, paths
│ ├── o-down.Update/ # Self-updater
│ ├── o-down.Engines.Aria2/ # JSON-RPC client + host process
│ ├── o-down.Engines.Torrent/ # MonoTorrent wrapper
│ ├── o-down.Engines.Media/ # yt-dlp + ffmpeg invocation
│ └── o-down.NativeMessaging/ # Small EXE for browser host
├── tools/ # Bundled sidecars (copied at build)
├── extensions/ # Browser extension manifests
├── tests/ # xUnit tests
└── build/ # Build scripts (to be added)
- Windows 10 1809+ (or Windows 11)
- Visual Studio 2022 with the Windows App SDK workload and .NET 8 SDK
- Standalone
.NET 8 SDKworks for non-WinUI projects (Core, Data, Infrastructure, engines, tests) - WinUI 3 XAML compilation requires the
Microsoft.WindowsAppSDKworkload from VS 2022
- Standalone
- Sidecar binaries placed in
tools/(see Sidecars)
dotnet build o-down.sln -c Releasedotnet run --project src/o-down.App -c Release.\build.ps1 -Version 1.2.0 -DownloadUrl https://updates.example.com/o-down-1.2.0.zip
# or, with the cmd shim:
.\build.cmd -Version 1.2.0 -DownloadUrl https://updates.example.com/o-down-1.2.0.zipbuild.ps1 publishes the App (win-x64, self-contained) and the native-messaging host (framework-dependent on the .NET 8 Desktop Runtime, see Runtime Requirements), bundles aria2c.exe / yt-dlp.exe / ffmpeg.exe from tools/, copies the native-messaging host into the App output, zips the result, and writes dist\latest.json with the SHA-256 + size of the zip (via o-down.Update.UpdateManifestBuilder).
Output:
dist\o-down-<Version>.zip— portable, drop-in install (extract anywhere)dist\latest.json— feed manifest, ready to upload next to the zip
For an ARM64 build: .\build.ps1 -Runtime win-arm64 -Version 1.2.0 .... Sidecar binaries are picked from tools\aria2c\arm64\ and tools\ffmpeg\arm64\ automatically.
dotnet test o-down.sln -c ReleaseMost unit tests are pure xUnit and have no external dependencies. The Aria2 engine has a small set of integration tests that spawn a real aria2c.exe subprocess and an in-process HTTP file server. They are tagged [Trait("Category", "Integration")] and excluded from the default dotnet test run by default.
To run them:
- Download a Windows build of aria2 and drop the binary at one of:
tools/aria2c/x64/aria2c.exe(preferred)tools/aria2c/aria2c.exe(alt, flat)C:\Program Files\aria2\aria2c.exe(system install fallback)
- Run with the explicit filter:
dotnet test tests/o-down.Engines.Aria2.Tests -c Release --filter "Category=Integration"
If the binary is missing, the integration tests will fail with a clear message: aria2c.exe not available. Drop a binary at tools/aria2c/x64/aria2c.exe to run integration tests.
What the integration tests cover:
EndToEnd_DownloadsFile_ViaRealAria2— full download of a 4 MB seed file, verifies the engine'sProgressChangedandCompletedevents fire, the file lands on disk, the gid is purged from aria2's stopped list, andQueryAsyncreturnsnullafterwards.ForceRemoveAsync_StopsInFlightDownload— throttled in-progress download, callsForceRemoveAsyncwhile the gid is still active, verifies state transitions toRemoved, the partial file (and.aria2control file) is cleaned up on disk, and the gid is purged from aria2's cache.ChangeOptionAsync_AcceptsMidFlightChanges— callschangeOptionmid-flight to updateuser-agentand a custom header, verifies the RPC succeeds and the download remains inRunningstate.PurgeCompletedResultsAsync_RemovesStoppedResults— force-removes an active download, confirms the gid shows up intellStopped, callsPurgeCompletedResultsAsync, and verifies the gid is no longer in the stopped list.
The in-process HTTP file server in the tests is intentionally throttled (16 KB chunks with a 20 ms delay between writes) so that the test can reliably observe the in-flight state before completion. The default Windows test discovery filters out the integration category so dotnet test remains fast and offline.
Diagnostic environment variables:
ODOWN_KEEP_TEST_DIR=1— preserves the per-test work directory under%TEMP%\odown-aria2-it-*for postmortem. Otherwise it is cleaned up onDisposeAsync.ODOWN_RPC_LOG=1— (diagnostic, off by default) writes the raw JSON-RPC bodies to%TEMP%\odown-rpc.log.
Place the following binaries under tools/ (or in the directory indicated) before first run:
| Tool | Path (preferred) | Path (alt, flat) | License |
|---|---|---|---|
aria2c |
`tools/aria2c/{x64 | arm64}/aria2c.exe` | tools/aria2c/aria2c.exe |
yt-dlp |
tools/yt-dlp/yt-dlp.exe |
— | Unlicense |
ffmpeg |
`tools/ffmpeg/{x64 | arm64}/ffmpeg.exe` | tools/ffmpeg/ffmpeg.exe |
Use the essentials build of ffmpeg (not "full shared") to keep the installer small — ffprobe.exe is not bundled (yt-dlp does its own probing; MediaDownloadEngine only invokes ffmpeg.exe for remux).
Downloads:
- aria2: https://github.com/aria2/aria2/releases (build:
aria2-*-win-64bit-build1.zipfor x64,aria2-*-win-arm64-build1.zipfor arm64) - yt-dlp: https://github.com/yt-dlp/yt-dlp/releases (download
yt-dlp.exe) - ffmpeg (x64): https://www.gyan.dev/ffmpeg/builds/ — pick the essentials build
- ffmpeg (arm64): https://github.com/BtbN/FFmpeg-Builds — pick the gpl-shared (smallest arm64 with the codecs we need)
The SidecarManager falls back to whatever it finds in PATH if the bundled binaries are missing.
+----------------------------+ Named Pipe
| o-down.App (WinUI 3) | <--------------------+
| - NavigationView shell | |
| - ViewModels | |
| - DownloadOrchestrator | |
+-----------+----------------+ |
| |
| JSON-RPC (HTTP 127.0.0.1:6800) |
v |
+----------------+ stdio +----------------+------------+
| aria2c.exe | | o-down.NativeMessaging |
+----------------+ | (browser host EXE) |
+----------------+---------+
+----------------+ ^
| yt-dlp.exe | | Native Messaging
| ffmpeg.exe | | (stdin/stdout JSON)
+----------------+ +----------+----------+
| Chrome/Edge/FF ext |
+--------------------+
- Process model: aria2, yt-dlp, and ffmpeg are spawned and supervised by o-down. Communication with the browser is via a separate small host EXE that talks Native Messaging (stdio) and forwards to o-down over a named pipe (
\\.\pipe\o-down-link). - State: SQLite (WAL) at
%LOCALAPPDATA%\o-down\odown.db. Resume data is in the same DB plus per-engine files (.aria2for HTTP, MonoTorrent's own cache dir for torrents). - Threading: single
DownloadOrchestratorloop with a priority queue and a configurable concurrency cap (default 5).
- Unpackaged by default (portable
.exe+ sidecars). Settings live in%LOCALAPPDATA%\o-down\, so the binary can be moved freely. - Single-instance enforced at startup (re-activation focuses the running window).
- Auto-update via
UpdateService(M6 milestone) — checks a JSON manifest athttps://updates.example.com/o-down/{channel}/latest.json.
Source-available, closed-source. See LICENSE.md (TBD) for terms. Bundled sidecars retain their own licenses (GPL, Unlicense, LGPL — see Settings → About).
Milestone 6 (polish: update flow, settings persistence, single-instance, scheduled update checks, build/publish script) — in progress. 210 tests pass: 118 Core unit + 11 Media engine unit + 10 Aria2 unit + 17 Torrent engine unit + 4 Infrastructure unit + 35 Update unit + 7 Infrastructure unit + 4 Aria2 integration (real aria2c.exe + in-process HTTP file server) + 1 Infrastructure integration (spawns the real native-messaging host EXE) + 4 Torrent integration (in-process MonoTorrent seeder + leecher). The remaining M6 work is tray icon (needs H.NotifyIcon.WinUI, blocked by the sandbox XAML compiler).
AppSettingsmodel +JsonAppSettingsStore(src/o-down.Core/Models/AppSettings.cs,src/o-down.Core/Pipeline/JsonAppSettingsStore.cs): pure JSON-on-disk settings (default download dir, max concurrent downloads, clipboard-monitor toggle, update channel, minimize-to-tray, theme, etc.). Atomic writes via.tmp+File.Move(overwrite: true). Semaphore-guarded for concurrent save. Falls back to defaults when the file is missing or corrupt. Path defaults to%LOCALAPPDATA%\o-down\settings.json.UpdateServicerewrite (src/o-down.Update/UpdateService.cs): adds a realIsNewerhelper (handles invalid/empty versions),VerifySha256Async(accepts uppercase, lowercase, and dashed hex; skips when expected is empty), andApplyAsync(zipPath, currentExePath)— extracts the update zip to a staging dir next to the current app dir, renames the live app dir to.old-{timestamp}(rollback), moves the staging dir in, then deletes the backup. ThrowsFileNotFoundExceptionif the current exe path is invalid.DownloadAsyncnow validates theContent-Lengthagainst the manifest'sSizeBytesand detects truncated downloads. NewCurrentVersionandAppDirectoryproperties for callers that need them.UpdateCheckScheduler(src/o-down.Update/UpdateCheckScheduler.cs): background loop that periodically callsUpdateService.CheckAsyncusing the liveAppSettings.UpdateChannelandAppSettings.AutoUpdateEnabled. Re-reads settings on every check so a mid-loop user toggle is honoured. RaisesCheckCompletedon every result andUpdateAvailableonly whenHasUpdate. Default interval is 6 hours; configurable via constructor.Start()is idempotent;StopAsync()cancels cleanly.SingleInstanceGuard(src/o-down.Infrastructure/SingleInstanceGuard.cs): named-mutex single-instance check + named-pipe focus-signal server.TryAcquire()returns true only for the first instance. The first instance callsStartFocusServer(pipeName, onFocusRequested)to listen forSendFocusMessageAsynccalls. Subsequent instances send the focus message (with their command-line args) and exit, so launching the .exe again just brings the existing window to the front.- App startup wiring (
App.xaml.cs): pre-DI single-instance check at the very start ofOnLaunched; if a previous instance exists, sends the focus signal and exits.UpdateService,UpdateCheckScheduler,IAppSettingsStore, andSingleInstanceGuardare now properly resolved through DI (the previousAddSingleton<UpdateService>()was a no-op because the constructor needsIUpdateFeed/appDir/Version). After the orchestrator starts, the update scheduler is started and itsUpdateAvailableevent is logged. UpdateManifestBuilder(src/o-down.Update/UpdateManifestBuilder.cs): pure helper that turns a zip into anUpdateManifest(computes SHA-256 + size, defaults channel/release-date) and writeslatest.jsonatomically. Used bybuild.ps1to generate the feed manifest.- Build/publish script (
build.ps1+build.cmd): one-shot release pipeline. Restores, publishes the App (win-x64, self-contained, x64), publishes the native-messaging host (framework-dependent on the .NET 8 Desktop Runtime), bundlesaria2c/yt-dlp/ffmpegfromtools/, copies the native-messaging host into the App output, zips everything, and writesdist\latest.jsonviaUpdateManifestBuilder. ARM64 supported via-Runtime win-arm64(sidecars picked fromtools\aria2c\arm64\andtools\ffmpeg\arm64\).
What the M6 tests cover (17 UpdateService + 9 AppSettings + 9 UpdateCheckScheduler + 9 UpdateManifestBuilder + 7 SingleInstanceGuard = 51 new tests):
UpdateServiceTests— version compare (newer/equal/lesser, invalid/empty/null), SHA-256 verify (match/mismatch/dashed/empty),StageAsync(extract + overwrite),ApplyAsync(replace contents, delete backup+staging, throw on missing exe),CheckAsync(newer/equal/missing manifest).JsonAppSettingsStoreTests— default-when-missing, round-trip,Currentupdates, atomic no-tmp, overwrite, corrupt-JSON fallback, nested-dir creation,Reload, concurrent saves.UpdateCheckSchedulerTests— skip when auto-update disabled, call feed with current channel, raiseUpdateAvailableon has-update, raiseCheckCompletedalways, run at least once onStart, idempotentStart,LastResultis set, reads latest settings on each call, mid-loop auto-update toggle is honoured.UpdateManifestBuilderTests— SHA-256 + size from zip, channel default to stable, release-date default to now, throw on missing zip, reject blank version, create parent directory, atomic overwrite, JSON round-trip, end-to-end build-then-write-then-read.SingleInstanceGuardTests— first/second acquire, release allows re-acquire, focus message round-trip, timeout-when-no-server, throws when not first instance,MutexNameaccessor.
TorrentDownloadEngine : IDownloadEngine(Kind=Torrent) (src/o-down.Engines.Torrent/TorrentDownloadEngine.cs): wraps aMonoTorrent.Client.ClientEngineand surfaces realProgressChanged/Completedevents.Completedfires once onSeeding(deduped via_completionFired) and once onError. State mapping covers all 10TorrentStatevalues:Downloading → Running,Seeding → Completed,Paused/HashingPaused/Stopped/Stopping → Paused,Error → Failed,Hashing → Verifying,Metadata → FetchingMetadata,Starting → Running.- O(1) manager lookup:
_byManagerreverse map (TorrentManager → DownloadId) so the state-changed handler resolves the download id in O(1) instead of scanning_byDownloadId. - Per-torrent settings (
BuildTorrentSettings(item)):MaximumConnections,MaximumDownloadSpeed(Int32),UploadSlots,AllowDht,AllowPeerExchange,AllowInitialSeeding,CreateContainingDirectory. - File priorities (
ApplyFilePrioritiesAsync): usesmanager.SetFilePriorityAsync(file, priority)to mark excluded files asPriority.DoNotDownloadand included files asPriority.Normal. The engine'sTorrentFilemodel is the canonical input;TorrentWantedFilesonDownloadItemis resolved throughTorrentFileSelector. - Progress: byte counts derived from
m.Bitfield.Length×PieceLength(countingBitfield[i]=true), clamped to torrent size. Speed fromm.Monitor?.DownloadSpeed. Peer count fromm.Peers?.Available, connection count fromm.OpenConnections. - Magnet link parser (
src/o-down.Core/Pipeline/MagnetLinkParser.cs): pure parser forxt=urn:btih:...,urn:btmh:...,dn,tr,ws,xs,kt,as,xl. Returns a structuredMagnetLinkInfo. - Torrent file selector (
src/o-down.Core/Pipeline/TorrentFileSelector.cs): pure spec parser supportingall,video,audio,images,subs,regex:...,ext:jpg,srt,size>500MB,size<1MB, comma-separated indices, and a single index. DownloadItemtorrent options:TorrentSequential(no-op in MonoTorrent 2.0 — no public sequential picker),TorrentFirstLastPieceFirst=true,TorrentMaxConnections?,TorrentMaxDownloadSpeed?,TorrentWantedFiles?,TorrentUploadSlots=8.- DI wiring (
App.xaml.cs):TorrentDownloadEngineregistered as both a concrete singleton and asIDownloadEngine, soDownloadRouterroutesKind=Torrentitems to it.
What the torrent tests cover (17 unit + 4 integration):
TorrentDownloadEngineUnitTests— constructor, name, kind, availability, empty torrents, query, pause/resume, remove unknown handles, bandwidth/sequential/purge no-throw, event subscription,ProbeAsync/AddAsyncerror paths.TorrentTestBuilder— builds BitTorrent v1 torrents (single-file and multi-file) by emitting a hand-rolledBEncodedDictionarysince MonoTorrent 2.0 has no publicTorrent.Createmethod. UsesSHA1for piece hashes; piece hashes are computed over the byte stream with padding so multi-file torrents match MonoTorrent's on-disk piece alignment.TorrentRoundTripIntegrationTests:Engine_CanDownloadFromInProcessSeeder— runs an in-process seeder (ClientEngineon a random TCP port) + our leecher (TorrentDownloadEngine). Verifies that the leecher'sCompletedevent fires, the file lands on disk, and its SHA-256 matches the original payload.Engine_RespectsWantedFiles_ExcludesExcluded— multi-file torrent, marks one file as wanted viaTorrentWantedFiles, verifies the wanted file is written and the unwanted file is not.TorrentTestBuilder_ProducesValidSingleFileTorrent/TorrentTestBuilder_ProducesValidMultiFileTorrent— builder validation: the produced.torrentround-trips throughMTorrent.Loadand exposes the correct name, size, piece length, and file entries.
- The integration tests use a
FindFreeTcpPorthelper, aTryConnectToListenerAsyncprobe to confirm the seeder is actually listening before peer injection, and reflection to access the leecher's private_enginefield forAddPeersAsync(new[] { new Peer(new BEncodedString(20 bytes), new Uri("tcp://127.0.0.1:port")) }). Tests honourODOWN_KEEP_TEST_DIR=1to preserve the per-test work dir under%TEMP%\odown-m5-roundtrip-*for postmortem.
M5 caveats / deferred:
SetSequentialAsyncis a documented no-op — MonoTorrent 2.0 has no publicSequentialPicker. Sequential download would require a customIPieceRequesterswap.SetBandwidthLimitAsyncis a documented no-op for in-flight torrents — MonoTorrent 2.0EngineSettingsare read-only at runtime, so the limit only takes effect on newly-added torrents.
MediaDownloadEngine : IDownloadEngine(src/o-down.Engines.Media/MediaDownloadEngine.cs): probes the URL viaIMediaExtractor, picks a format from the probe, spawns yt-dlp, and reportsProgressChanged/Completedevents. Handles per-download cancellation, pause (kills process), resume (no-op for media — re-add required), and remove (with optional file delete).IMediaExtractor.DownloadAsyncnow takes anAction<DownloadProgress>? progressparameter. TheYtDlpMediaExtractorreads yt-dlp's--newlinestderr output and forwards eachYtDlpEvent(progress, completed, destination, error) to the callback. The download respectsMediaAudioOnly(adds-x --audio-format),MediaWriteSubtitles(adds--write-subsand--sub-langs),MediaEmbedSubtitles(adds--embed-subs), andMediaSponsorblockRemove(adds--sponsorblock-remove).- Pure helpers in
o-down.Core/Pipeline/:FormatSelector— picks aMediaFormatfrom aMediaProbeperMediaFormatPreference(Best, Worst, BestVideoOnly, BestAudioOnly, Smallest, Largest, Custom) and generates the corresponding yt-dlp-fexpression.YtDlpProgressParser— parses yt-dlp's stderr lines (progress[download] 42% of 10MiB at 1MiB/s ETA 00:04, completed100%, destination, merger, error) into structuredYtDlpEventrecords. Also has unit-testedTryParseSizeandTryParseSpeedfor1.23MiB,500KiB,2.0TiB, etc.OutputTemplateResolver— resolves yt-dlp output templates (%(title)s.%(ext)s,%(uploader)s/%(title)s.%(ext)s) against aMediaTemplateContext(probe data) up-front so the App can register the expected final path with the engine. Sanitises invalid filename chars.
DownloadItemmedia options:MediaFormatId,MediaFormatPreference,MediaAudioOnly,MediaAudioFormat,MediaWriteSubtitles,MediaEmbedSubtitles,MediaSubtitleLanguages,MediaOutputTemplate,MediaSponsorblockRemove,MediaChapterStart,MediaChapterEnd.DownloadRouternow routesMediaitems to a media engine when one is registered (falling back to an HTTP engine if not). Engines are registered asIDownloadEngineviasp.GetRequiredService<X>()so the router sees bothKind == Http(aria2) andKind == Media(yt-dlp) instances.- DI wiring (
App.xaml.cs):MediaDownloadEngineis registered as both a concrete singleton and asIDownloadEngine.FfmpegTranscodernow uses theILoggerfrom DI.
What the media tests cover (11 unit, 0 integration — integration tests require real yt-dlp.exe and ffmpeg.exe binaries in tools/yt-dlp/ and tools/ffmpeg/x64/):
FormatSelectorTests— empty list, custom ID, best/worst, video-only/audio-only, smallest/largest, null-size handling, expression generationYtDlpProgressParserTests— progress lines with/without speed/ETA/fragments, completion, destination, merger, error, info, garbage, size parsing (KiB/MiB/GiB/TiB), speed parsingOutputTemplateResolverTests— title+ext, invalid char sanitisation, uploader, empty template fallback, unknown token, trailing-dot trim, resolution+fpsMediaDownloadEngineTests(usesFakeMediaExtractor): progress+completed events, explicit format ID, audio-only fallback, output template resolution, failure propagation, unavailable extractor rejection, cancel viaRemoveAsync,QueryAllAsyncsnapshot
- Wire protocol (
src/o-down.Core/Protocol/NativeMessageCodec.cs): canonical 4-byte little-endian length prefix + UTF-8 JSON, used by both halves (browser host ↔ named pipe). Requests carryurl,referrer,cookies,filenameHint,source,capturedAt; responses carryok,downloadId,error,version. - Native-messaging host EXE (
src/o-down.NativeMessaging/): smallWinExethat reads one request per loop iteration from stdin, forwards to the named pipe\\.\pipe\o-down-link, writes the response to stdout, then loops. HonorsODOWN_PIPE_NAME(test/CI override) andODOWN_HOST_LOG(diagnostic file log). - Named-pipe server (
src/o-down.Infrastructure/NamedPipeLinkServer.cs): 4-listenerListenLoop, bidirectionalPipeDirection.InOut, pluggableFunc<CapturedLink, NativeResponse>responder (so the App can return aDownloadItem.Idto the browser). When no responder is wired, the server still firesLinkCapturedfor in-process consumers. - Native-messaging registrar (
src/o-down.Infrastructure/NativeMessagingRegistrar.cs): writes per-browser manifest JSON to%LOCALAPPDATA%\o-down\native-messaging\{chrome,firefox}\o_down_native_messaging.jsonand registers HKCU keys for Chrome, Edge, and Firefox. Chrome manifest usesallowed_origins; Firefox usesallowed_extensions(the two browsers expect different fields). - Clipboard monitor (
src/o-down.Infrastructure/WindowsClipboardMonitor.cs): hidden message-only window registered withAddClipboardFormatListener, 2 s debounce,UrlClassifier.IsUrlfilter, raisesTextCapturedonly for URLs. - Consent gate (
src/o-down.Core/Abstractions/IConsentStore.cs+FileConsentStore): per-feature opt-in stored at%LOCALAPPDATA%\o-down\consent.json. The App only starts the clipboard monitor when the user has granted consent; the default is off. - Browser extensions (
extensions/): three manifests — Chrome/Edge MV3, Firefox MV3, Firefox MV2 — with context-menu items, toolbar action, and an "extract all links on page" helper that requires thescriptingpermission.
- Build the host:
dotnet publish src/o-down.NativeMessaging -c Release -r win-x64 --self-contained false(the host needs to live in a stable path so the manifest can point to it). - Place the published
o-down.NativeMessaging.exesomewhere stable (e.g., next too-down.App.exe). - Run the App once; the Settings page exposes a "Register native messaging host" button that calls
NativeMessagingRegistrar.Register(hostExePath). This writes the manifest JSON files and the registry keys. Unregister with the matching button. - Chrome/Edge: load
extensions/chrome/as an unpacked extension (chrome://extensions→ Developer mode → "Load unpacked"). For an installed extension, the host manifest'sallowed_originsmust include the real extension ID — updateNativeMessagingRegistrar.ChromeExtensionIdbefore publishing to the Chrome Web Store. - Firefox MV3:
about:debugging#/runtime/this-firefox→ "Load Temporary Add-on" → pickextensions/firefox-mv3/manifest.json. For a permanent install, sign via AMO and updateNativeMessagingRegistrar.FirefoxExtensionId. - Firefox MV2: same as above, pointing at
extensions/firefox-mv2/.
If o-down is not running when the browser sends a message, the host returns {"ok":false,"error":"o-down is not running","version":"0.1.0"} after a 2 s connect timeout.