The UI contract
The UI (src/) and the native side (src-tauri/, crates/) talk only through typed Tauri commands and events. This page describes the shared types, how they're kept in step, and how to change them (#25).
The shared types
They're defined once in Rust, in crates/velocity-core/src/domain.rs (plus AppInfo in lib.rs), and mirrored by strict Zod schemas in src/contract.ts.
| Type | What it is | Notes |
|---|---|---|
DownloadStatus | Where a download is: queued, connecting, downloading, paused, verifying, retrying, stalled, completed, failed, removed, fetchingMetadata, choosingFiles, seeding, unknown, waitingForNetwork | V1's fourteen values, and waitingForNetwork (ADR-0019). A new status needs an ADR, so the UI stays consistent. completed, failed and removed are terminal |
DownloadKind | http, ftp, sftp, metalink, torrent, magnet | — |
DownloadRecord | One download as the UI sees it: a stable app id, kind, source URL (credentials removed), file name, folder, category, status, progress, error, verification, timestamps (milliseconds since the Unix epoch), retryAtMs: while retrying, when the engine tries again, startedOver: why it last dropped the bytes it had and started again, null while it never did (since contract version 23, #257; the engine's, not saved), startedAtMs: the first time the engine reported it running, averageSpeedBps: the bytes per second it received while it ran, null until it ran for a second, speedLimitBps: its own speed limit (null for none), requestedConnections: the connections a direct download asks for (null for a torrent), and torrent: a TorrentActivity, and fileCount: how many files a torrent has, padding left out, once its metadata is known (null for a direct download, and before; since contract version 27, #313), and waitsUntil: why a queued download waits and until when, schedule or chosenTime with a local YYYY-MM-DDTHH:MM (#350) | Never carries an engine's own id. Since contract version 12 (#59): retryAtMs comes only from the engine, is null in every other status, and isn't saved; startedAtMs and averageSpeedBps are the manager's, and survive a restart (DATA_AND_MIGRATIONS.md). Time waiting, paused, or before the engine's first report doesn't count, nor do bytes a download resumed with. Since contract version 13 (#61): speedLimitBps and requestedConnections are the download's stored options; torrent is null for a direct download, and for a torrent until the helper reports after a start |
TorrentActivity | A torrent's peers and seeds connected now, its uploadedBytes and seedingSecs over its life, and seedQueued: completed and waiting for a seeding slot, as the torrent helper reports them | Since contract version 13 (#61); seedQueued since version 14 (#63). The fixture downloads-snapshot.json has a seeding torrent |
TorrentDetails, TorrentPreview, TorrentContents, TrackerStatus, PeerStatus | A torrent's seeding policy, uploadLimitBps, contents (null before its metadata) and the files chosen (null for every file); a .torrent file's contents and the duplicate it would repeat; its name, info hashes, version (v1, v2, hybrid), whether it is private, its size and its files (index, /-separated path, length, padding left out); a tracker, its passkey redacted; a peer | Since contract version 14 (#63). Asked for while shown, never in the snapshot (PO decision on #63). The fixture torrent.json has one of each |
Verification | What was proven about a completed download's bytes: verified (a checksum or trusted piece hashes matched), sizeVerified (complete, with no hash checked) or notVerified (resumed without a strong validator, ADR-0010). The fixture verifications.json lists them | null until the download completes. Show "Verified" only for verified. Added in contract version 3 (#46) |
Progress | Completed and total bytes (totalBytes is null while unknown), download and upload speeds in bytes per second, the connections in use now, and connectionDetail | — |
ConnectionDetail | A direct download's connections: requested, allowed (at least 1, never above requested) and limitedBy, what holds allowed below requested: perHostCap, sharedBudget, serverThrottling, rampingUp (adaptive connections are still finding how many help, by trying fewer; #221), noGain (more connections stopped making it faster) fileSize, noRanges (the server sends the file in one piece, without byte ranges, so one connection is all that helps; since contract version 24, #274), or speedLimit (the download's speed limit is reached with fewer connections; since contract version 26, #278), or null (#47). The fixture connection-limits.json lists them | null for torrents and while no engine reports it. Added in contract version 2 |
TransferError | { class, httpStatus, message, retryable, hostKey, confirm, noNetwork, detail }. class is transient, throttled or permanent. hostKey is { host, port, fingerprint, pinned } when an SFTP server's host key isn't trusted, else null | Engine-neutral. A permanent failure is never retryable. With hostKey, the UI shows the SHA-256 fingerprint for the user to confirm; with pinned set, the key changed and is always refused (SECURITY.md). hostKey since contract version 6 (#53). confirm is { kind: "insecureRedirect", host } or { kind: "localAddress", host, address } when the download waits for the user to allow a redirect from https to http, or a link from the browser that reaches a local address (SECURITY.md), else null; since contract version 18 (#67). noNetwork: no connection could be made, so the download can wait for the network; since version 28 (#318). detail: the engine's technical text behind the plain message, such as libcurl error 60: …, shown only behind "Technical details" and kept in the logs, else null; since version 30 (#252) |
Capabilities | What an engine really supports: the protocols, each as a ProtocolCapabilities (kind, segmented, resume, validatorResume, http2, and encryption: whether the connection can be encrypted with its certificate checked, https:// or FTPS; a protocol not listed isn't supported), live connection changes, Metalink, and the most connections per host now (the performance profile's cap) | Reported, never assumed (research report section 4.2). Per protocol since contract version 4 (#57), with encryption since version 5 (#51). cleartextWarning in src/capabilities.ts holds the warning for a plain ftp:// download (PO decision on #51); SETTINGS.md says how the UI uses it libcurl ({ version, tls }, or null for an engine without one) is in it since contract version 16 (#28) |
ErrorCode | Stable codes for command failures, in SCREAMING_SNAKE_CASE (23 codes: V1's, without the four aria2 RPC_* codes, plus NAME_TAKEN, since contract version 9, for a category name another category has). Each has a user-facing sentence (user_message) | The code is the contract; the message is for people |
AppInfo | { name, version, commandApiVersion } | Returned by app_info |
DownloadsSnapshot | What the UI is sent about the downloads (#58): downloads (records, the unfinished ones in queue order, then the finished ones, newest first), unavailable (stored downloads this build can't act on: id, sourceUrl, fileName, directory, createdAtMs), needsLogin (ids of downloads that came back after a restart without their login), engine, torrentEngine (the torrent helper's health, since version 8) and storageError (the last failure to save, or null), and schedule: the schedule's window open now or next, { opens, closes, open } in local time, null while it is off (#350) | Sent as the downloads-snapshot event and returned by get_snapshot |
EngineHealth | { state: "running" }, { state: "restarting", attempt } or { state: "unavailable", code } | The fixture engine-health.json lists one of each |
Category | A download category (#31): id, name, color (#rrggbb), icon, extensions (lowercase, without the dot), directory (its own folder, or null) and builtin. The fixture category-icons.json lists the icons | Since contract version 9. Created and edited as a CategoryInput (the same fields without id and builtin). The rules are in FILE_NAMING.md |
DestinationCheck | What adding a download would do (#31): directory, directoryExists, automaticDirectory, categoryId, fileName (null when the server will name it), path (the folder and the name joined in the platform's separator, for the UI to show; null with fileName; since contract version 25, #277), fileExists, partialDownloadExists (Apollo DM's or V1's) and suggestedName (the first free name when it is taken) | Since contract version 9. Free disk space isn't reported yet |
OnConflict | What adding does when the name is taken (#31): ask or rename. The fixture on-conflict.json lists them | Never replace (PO decision on #31). Used by add_downloads |
AddRequest | What the Add dialog asks for (#60): links (direct and magnet links, and links to .torrent files), torrentFiles (absolute paths), metalinkFiles (absolute paths of .metalink or .meta4 files; since contract version 22, #222), directory, fileName (one direct link only), categoryId (each null for automatic), createDirectory, onConflict, allowDuplicate ("Add anyway"), startPaused, connections (null for the performance profile's), speedLimitKib (0 for none), headers, referrer, credentials ({ userName, password }), sshKey ({ path, passphrase }, for sftp:// links), checksum ({ algorithm, hex }), requireEncryption (for ftp:// links), chooseFiles (torrents wait in Choosing files) and fromBrowser (the browser extension sent the links: a local address needs the user's confirmation; since contract version 18, #67) | Since contract version 11. The fixture add-request.json sets every field. The login, headers and passphrase stay in memory |
HistoryEntry, HistoryPage, HistoryQuery, StorageSettings | A finished download as the history shows it: id, kind (null for one a later Apollo DM stored), file name, link, folder, category, status, size, when it was added and finished, why it failed, and whether it is still in the list (inList); a page of them with the total; what is asked for; and the retention | Since contract version 15 (#32). The fixture history.json has one of each |
DownloadLogin | What set_download_login gives a download again (#59): credentials ({ userName, password } or null), sshKey ({ path, passphrase } or null) and headers. The fixture download-login.json sets every part | Since contract version 12. Kept in memory only, never saved, and redacted in diagnostics |
AddResult | { source, outcome } for each link, .torrent file and Metalink file, in the request's order. outcome.kind is added (id), metalinkAdded (ids: every download a Metalink file added, in its order; since contract version 22, #222), conflict (check, a DestinationCheck), duplicate (duplicate: existingId, name, status, inList, canAddAnyway), failed (code) or notFetched (error, a TransferError: a .torrent link couldn't be fetched) | Since contract version 11. The fixture add-results.json has one of each |
ShellSettings | What the window starts with (#29): appearance (theme, density, layout, and the download list's visibleColumns and columnWidths), onboardingCompleted, and defaultDirectory, the folder downloads go to now (null when the system names none). The fixtures themes.json and densities.json list the themes and densities, and columns.json the column ids, the default columns and the width bounds | Since contract version 10; the columns since version 12 (#59). The settings are in SETTINGS.md. set_appearance takes and returns an AppearanceSettings |
CategoryMove | What changing a download's category did with its file (#366): { kind: "moved", path }, { kind: "unchanged" } (already there, or no file yet), { kind: "moving" } (running: it pauses, moves and carries on) or { kind: "stayed", path, code }. The category changes in every case | Since contract version 31. The fixture category-move.json lists one of each |
BatchResult | What a command over several downloads did: done (ids), failed ({ id, code }), and kept ({ path, reason, code }, files a removal with files left in place). reason is notPlain, tooLarge, behindLink, notEmpty or failed; code is set only for failed | The fixture kept-reasons.json lists the reasons |
Conventions:
Field names are camelCase on the wire (
#[serde(rename_all = "camelCase")]).Optional values are
null, never omitted.No aria2 terms: no aria2 shapes or option names appear in any of these types.
The contract version
COMMAND_API_VERSION exists in both velocity_core and src/contract.ts, and app_info reports the native side's version. At startup the UI validates the response. If the versions differ, the window refuses to start and explains that the two parts don't match, rather than misreading messages. Raise the version, on both sides in the same PR, whenever a command or shared type changes incompatibly.
The downloads commands
The commands of src-tauri/src/downloads.rs (#58), V1's direct-download commands. A command that fails returns its ErrorCode. Commands that take ids accept at most 10,000 ids, each at most 64 characters, and return a BatchResult.
| Command | Arguments | Returns |
|---|---|---|
get_snapshot | — | DownloadsSnapshot |
pause_downloads, resume_downloads, cancel_downloads, retry_downloads | ids | BatchResult |
remove_downloads | ids, deleteFiles | BatchResult, with the files kept |
retry_failed, pause_all, resume_all | — | nothing |
clear_completed | — | how many were cleared |
move_download | id, direction (top, up, down, bottom) | nothing |
set_start_at | id, startAt: local YYYY-MM-DDTHH:MM, or null to take it away (#350) | nothing; INVALID_STATE for a download running, finished or waiting for the network |
set_download_login | id, login (a DownloadLogin) | nothing; INVALID_STATE for a download that doesn't need its login, and the Add path's codes for an invalid one. The window then resumes or retries it (#59) |
set_download_connections | id, connections | the connections that apply |
set_download_speed_limit | id, kib (KiB/s; 0 for no limit, otherwise at least 1) | nothing |
set_download_category | id, categoryId (null for none) | a CategoryMove: the download's file moves to that category's folder (FILE_NAMING.md). Since contract version 31 (#366); nothing moved before |
open_download_file | id, allowProgram | nothing; opens the finished file, or a torrent's folder, with the system's program. INVALID_STATE before it finished, NOT_FOUND when the file is gone, INVALID_PATH for a link, CONFIRMATION_REQUIRED for a program until allowProgram, and IO when the program can't start (#61) |
open_feedback_page | none | nothing; opens the repository's "new issue" chooser in the default browser, a fixed address the window can't change. Sends nothing itself (#363) |
open_download_folder | id | nothing; shows the file selected in its folder (on Linux, the folder), or the folder when the file is gone. DESTINATION_MISSING when the folder is gone (#61) |
restart_engine | — | EngineHealth |
trust_host_key | host, port, fingerprint | nothing; the UI then retries the download |
allow_download | id, kind: insecureRedirect or localAddress | nothing. Allows what the download's error.confirm asked for, for that download only, stores it, and retries the download. INVALID_STATE unless it failed waiting for that confirmation. Since contract version 18 (#67) |
remember_login | id | nothing. Saves the download's login, custom headers and SSH key in the system keychain, at the user's request; INVALID_STATE for one without any, KEYCHAIN_UNAVAILABLE when there is no keychain or it refused. Since contract version 20 (#70) |
choose_torrent_files | id, files (indexes, at least one), deleteDropped | the files kept, each a KeptFile. With deleteDropped, the torrent's own files no longer chosen are deleted, by the rules torrents use; otherwise they stay (#63) |
get_torrent_details | id | TorrentDetails; UNSUPPORTED for a direct download (#63) |
preview_torrent_file | path (absolute) | TorrentPreview, read as adding reads the file: never through a link, at most the size limit (#63) |
get_torrent_trackers, get_torrent_peers | id | the helper's TrackerStatus or PeerStatus list; INVALID_STATE while the helper doesn't have the torrent (#63) |
reannounce_torrent | id | nothing; INVALID_STATE while the helper doesn't have the torrent (#63) |
set_torrent_upload_limit | id, kib (KiB/s; 0 for no limit) | nothing (#63) |
set_torrent_seeding | id, seeding (a seeding policy; { "mode": "never" } stops seeding) | nothing |
restart_torrent_helper | — | EngineHealth |
The history's commands (#32), in the same file:
| Command | Arguments | Returns |
|---|---|---|
get_history | query, a HistoryQuery: search (at most 1024 bytes; a name, a link, or a torrent's info hash), status (completed, failed, cancelled), limit (at most 1000; 200 when null), offset | HistoryPage: entries, newest first, and the total that match |
clear_history | — | how many entries left the history |
delete_history_entries | ids (at most 10,000) | nothing; an id that isn't a finished download is left alone |
get_storage_settings, set_storage_settings | —, or settings | StorageSettings (SETTINGS.md); INVALID_INPUT outside its bounds |
An entry still in the list only leaves the history; one out of it is forgotten. Neither touches a file.
Adding downloads is #60's. A download in the snapshot's needsLogin came back after a restart without its login, custom headers or SSH key, which are never saved; set_download_login gives them again, checked as when it was added, and refused whole if one isn't valid (PO decision on #59).
The category commands
The commands of src-tauri/src/categories.rs (#31), V1's category commands and check_destination. A command that fails returns its ErrorCode. The rules are in FILE_NAMING.md.
| Command | Arguments | Returns |
|---|---|---|
list_categories | — | Category[], in the order the automatic category is chosen in |
create_category | input (a CategoryInput) | the Category; INVALID_INPUT, INVALID_PATH or NAME_TAKEN |
update_category | id, input | the Category; also NOT_FOUND |
delete_category | id | nothing; NOT_FOUND, or INVALID_STATE for a built-in one. Its downloads are left without one |
set_download_category | id, categoryId (null for none) | a CategoryMove (#366) |
check_destination | url, directory, fileName, categoryId (each null for automatic) | DestinationCheck |
Diagnostics
The commands of src-tauri/src/diagnostics.rs (#28). What they show and how secrets are kept out is in DIAGNOSTICS.md.
| Command | Arguments | Returns |
|---|---|---|
get_diagnostics | — | DiagnosticReport: generatedAtMs, app, os, arch, transferEngine, capabilities, torrentEngine, libtorrent, downloads (counts by status), storageError, appLog (LogLines: atMs, level, target, message), transferEngineLog and torrentEngineLog, every line redacted. The fixture diagnostics.json has one. Since contract version 13 |
export_diagnostics | — | where the report was saved, after the system's chooser, or null when the user cancels; IO when it can't be written |
restart_engine, restart_torrent_helper | — | EngineHealth after asking (the downloads commands) |
The window's commands
The commands of src-tauri/src/shell.rs (#29). A command that fails returns its ErrorCode. How the window uses them is in UI_ARCHITECTURE.md.
| Command | Arguments | Returns |
|---|---|---|
get_shell_settings | — | ShellSettings; DATABASE when a section can't be read |
set_appearance | appearance | the AppearanceSettings saved; INVALID_INPUT for a panel or column width outside its bounds, or a column list without name or with a column twice |
complete_onboarding | directory: an existing folder, not a link | ShellSettings; INVALID_PATH, or DESTINATION_MISSING when it doesn't exist |
choose_directory | initial (null, or a folder to start in) | the folder chosen with the system's chooser, or null when the user cancels |
quit_app | — | nothing: the app quits. The window calls it only after the user confirms |
set_maximize_button_rect | rect (x, y, width, height in CSS pixels) | nothing: on Windows the snap layouts' overlay moves there (#359), and elsewhere nothing happens; INVALID_INPUT for numbers no button could have |
get_browser_integration | — | BrowserIntegration: hostPath (the native host beside the app, null when missing), nativeHostName, chromeExtensionIds, firefoxExtensionId, and registrations, one { browser, registered, manifestPath, hostMatches, found, storeUrl } per browser (chrome, chromium, edge, brave, firefox). found is the browser installed here, { program, sandboxed } (sandboxed: a Snap or Flatpak, which may not reach the native host), or null; storeUrl is the extension's store page for that browser, null until it is published (#240). The fixture browser-integration.json. Since contract version 19 (#65); found and storeUrl since version 33 (#241) |
register_browser, unregister_browser | browser | BrowserIntegration as it is now. Registering needs the host beside the app (NOT_FOUND); unregistering removes only Apollo DM's registration (BROWSER_EXTENSION.md). Since contract version 19 (#65) |
open_extension_page | browser | null. Starts that browser on the extension's store page from branding.json, never a link from the window, with an argument list and no shell. NOT_FOUND when the browser isn't installed, UNSUPPORTED while it has no store page, IO when it didn't start (the window then shows the link to copy). Since contract version 33 (#241) |
take_browser_requests | — | The links from the browser waiting for the Add dialog, oldest first, each as in the browser-request event below; afterwards none waits. The app keeps at most 64, dropping the oldest. Since contract version 34 (#415) |
get_settings | — | Settings: every section the Settings screen edits (SETTINGS.md). The fixture settings.json has the defaults. Since contract version 17 (#30); the browser section since version 19 (#65), the proxy section since version 21 (#192) |
set_settings | settings | the Settings saved; INVALID_INPUT, with nothing saved or applied, when a section is out of bounds |
reset_settings | — | the defaults saved, keeping the download folder |
forget_learned_servers | — | nothing; forgets what downloads learned about servers, in the database and in the engine (#325). Since contract version 32 |
get_proxy_password_set | — | whether the proxy has a password, typed this session or remembered; the password never comes to the window. Since contract version 21 (#192) |
set_proxy_password | password (null clears it) | nothing. Gives both engines the proxy with it, and keeps it in the system keychain when proxy.rememberPassword is on; INVALID_INPUT for one a login can't have, KEYCHAIN_UNAVAILABLE when the keychain refused (it is still used). Since contract version 21 (#192) |
get_torrent_network | — | the torrent helper's NetworkStatus: listenPort, listenError and portMapping |
The maximize-button event carries idle, hover or pressed: on Windows a native window has the mouse over the maximise button, so it says what the button should show (#359).
The quit-requested event carries { active }: quitting would stop that many downloads, and general.confirmExitWithActive is on. The window asks the user, and calls quit_app only if they confirm.
Adding downloads
The commands of src-tauri/src/adding.rs (#60). A command that fails returns its ErrorCode. The rules are in the module docs of velocity_core::manager::adding and torrent_links.
| Command | Arguments | Returns |
|---|---|---|
add_downloads | request: an AddRequest | an AddResult for each link and file, in order. The whole request is refused with INVALID_INPUT, adding nothing, when it is empty, has more than 500 links and files, names a file for more than one, or has a header, referrer, login, checksum or speed limit outside its rules. .torrent links are fetched first, for up to 60 seconds |
get_capabilities | — | the transfer engine's Capabilities, so the dialog offers only what it can do |
choose_torrents | — | the .torrent files chosen with the system's chooser; empty when the user cancels |
choose_metalinks | — | the Metalink files (.metalink, .meta4) chosen with the system's chooser; empty when the user cancels. Since contract version 22 (#222) |
choose_ssh_key | — | the SSH private key chosen with the system's chooser, starting in ~/.ssh; null when the user cancels |
What each link gets:
Duplicates (PO decision on #60, V1's rules): a direct link is the same download when its link is, as typed; a torrent when it shares an info hash, from a magnet link or a
.torrentfile. A torrent still in the list isduplicatewithcanAddAnyway: false, even withallowDuplicate, because two copies can't run. Anything else in the list or the history isduplicateuntilallowDuplicateis set.Taken names:
conflictwithonConflict: ask, or the first free name withrename. A torrent's names come from its metadata, so a taken one is alwaysconflict, with nosuggestedName.The chosen folder is created only with
createDirectory, one level at a time and never through a link (INVALID_PATH).A torrent's category comes from its largest file, or from a magnet link's display name.
.torrentlinks (anhttporhttpslink whose path ends in.torrent, as V1 told them apart) are fetched through the transfer engine into the app'storrent-linksfolder, at most 16 MiB, then added as the file. The file is kept without its trackers, which stay in memory as every torrent's do. A fetch that fails isnotFetchedwith the server's or network's reason; one larger than 16 MiB, or that isn't a torrent, isfailedwithINVALID_INPUT.
The browser-request event (#65) says a link the browser sent for the user to check is waiting. It carries nothing: the window takes the waiting links with take_browser_requests, once it listens and again on each event (#415). A link that starts the app arrives before the window listens, and an event then would be lost. Each link is { url, fileName, referrer, fileSize, mime, source } (source is intercept, contextMenu or popup; the fixture browser-request.json). The window opens the Add dialog with it, marked as from the browser while its link is unchanged (AddRequest.fromBrowser). Since contract version 19; the event carries nothing since version 34.
The torrents-dropped event carries { paths, ignored }: the .torrent files dropped on the window (plain files, never through a link, at most 500), and how many other things were dropped. The window opens the Add dialog with the files, or says only .torrent files can be dropped.
The SFTP host key has no command of its own here: an SFTP download stops before logging in with error.hostKey set (#53). The window then asks, and "Trust and continue" calls trust_host_key and then retry_downloads.
How the two sides stay in step
Rust generates contract fixtures in
tests/fixtures/contract/from the real types:statuses.json,error-codes.json,download-record.json,capabilities.json,app-info.json,downloads-snapshot.json,engine-health.json,batch-result.json,kept-reasons.json,category.json,category-icons.json,destination-check.json,on-conflict.json,shell-settings.json,themes.json,densities.json,add-request.jsonandadd-results.json.naming-cases.jsonholds the namessrc/naming.tsmust give (FILE_NAMING.md). A Rust test fails when a fixture no longer matches the types: "fixture … is out of date; regenerate it withVELOCITY_UPDATE_FIXTURES=1".Vitest validates the same fixtures with the UI's strict schemas (
src/contract.test.ts). An unknown field, a missing field, an unknown enum value or a wrong type fails the test.So a change on one side alone always fails a test:
Change Rust without regenerating, and the Rust contract test fails.
Regenerate without updating the schemas, and the Vitest contract test fails.
Change only the schemas, and they no longer match the fixtures.
This was shown in #25 by renaming a field.
Changing the contract
Change the Rust type, test first in
domain.rs.Regenerate the fixtures:
VELOCITY_UPDATE_FIXTURES=1 cargo test -p velocity-core contract.Update
src/contract.tsuntilpnpm test:focused -- frontendpasses.If the change is incompatible, raise
COMMAND_API_VERSIONon both sides.Update this page if a type's meaning changed.
Status values and error codes are part of the contract: adding a status needs an ADR, and every new error code needs a user-facing message, which a test checks.