The UI
How the window (src/) is built: where its state lives, how it talks to the native side, and how it is styled and tested. The commands and types it uses are in CONTRACTS.md, and the settings they save are in SETTINGS.md. The shell came with #29, as a port of V1's (PARITY.md).
The parts
| Folder | What it holds |
|---|---|
src/app/ | The window as a whole. App handles starting up (with StartupLoader, the full logo while it loads, #363), Shell the window once it is set up, plus TitleBar (with the Help menu and the Settings gear, #363), Sidebar (the navigation, icons only when collapsed), StatusBar, the theme hook and the error screen |
src/features/ | One folder for each screen: onboarding/ (the first-run setup), categories/ (the Categories screen and its editor), add/ (the Add download dialog and the SFTP host-key question, #60), and downloads/ (the download list, its menus and the login dialog, #59) |
src/components/ | Shared pieces, one per file: Button, Dialog, ConfirmDialog and confirmAction, Field, Input, Textarea, Select, Checkbox (the form controls share fieldStyles.ts), Splitter, EmptyState, Spinner, Badge (with a status tone), ProgressBar, Menu (ContextMenu and DropdownMenu), BrandIcon and CategoryGlyph |
src/layout/ | The panel rules (layoutLogic.ts, V1's), and saving the layout (useLayout.ts) |
src/lib/ | The bridge to Rust (api.ts, events.ts, errors.ts), the queries (queries.ts), and format.ts and cn.ts |
src/stores/ | Presentation state (ui.ts), and the latest snapshot of the downloads with the list's selection, search and sort (downloads.ts) |
src/downloads/ | Rules about downloads that more than one part uses: the counts for each list (views.ts), and searching, sorting, the actions each status allows and the status labels (list.ts) |
src/contract.ts | The strict schemas for everything the native side sends (CONTRACTS.md) |
Who owns which state
The native side owns the data: settings, categories and downloads. The window reads it and asks for changes, but never keeps its own copy as the truth:
Settings and categories are TanStack Query queries (
useShellSettings,useCategories).The downloads come from
get_snapshot, then from everydownloads-snapshotevent (useSnapshotFeed), into thedownloadsstore.
The window owns its presentation state: the current view, the category filter and the panel layout (the
uistore, zustand). The layout is also saved, as below.Questions to the user go through one place:
confirmAction(options)returns a promise, andConfirmHostshows the dialog.
Talking to Rust
Commands go through
api(src/lib/api.ts), neverinvokedirectly:Every answer is checked with its schema. An answer outside the contract is an
INTERNALerror that says which command sent it.A failed command becomes an
ApiErrorwith its code and the sentence for the user (ERROR_MESSAGES). A test keeps those sentences the same as Rust's (error-codes.json).
Events go through
events.ts:Each payload is checked too. One that doesn't match is dropped, because the next snapshot brings the state again.
The window listens to three events:
downloads-snapshot,quit-requestedandtorrents-dropped.
Permissions: the web layer gets only Tauri's core app, event and window permissions (
src-tauri/capabilities/default.json), plus minimise, maximise, close and drag for the window's own title bar (#347), and a test keeps it that way. The folder,.torrentand SSH key choosers and notifications are Rust's, through the app's own commands, so the page can't open a dialog or send a notification by itself. Files dropped on the window reach Rust too, which sends the page only the.torrentfiles among them.
Starting up
App goes through these states, each shown explicitly:
Not inside the desktop app (no Tauri IPC): it says the window needs the desktop app.
app_info: a native side with another contract version is refused, saying so.get_shell_settings: a failure shows its code, with "Try again".Setup not finished: the first-run setup shows the transfer engine's state and asks for the download folder.
complete_onboardingchecks the folder: it must exist and can't be a link.Unlike V1, the setup doesn't wait for the engine, because Apollo Download Manager's window works without one and says why (#58).
The browser extension step comes with #64.
The shell.
The shell
The layout:
The navigation, a splitter, the current view and the status bar.
The navigation's width is a CSS variable, so a drag moves it without a React render. The splitter commits once, when the drag ends (
Splitter).useLayoutPersistencesaves the layout 250 ms after the last change, throughset_appearance. A failure shows as a toast.The navigation's width is written to its CSS variable only when the saved or fitted width changes, so a render for something else during a drag, such as the details panel opening, doesn't put the old width back over the preview (#433).
A window too narrow for the navigation shows it as icons, without changing what is saved (
resolveLayout). The list keeps at leastLAYOUT.TABLE_MIN(440 px) after the window's frame (FRAME, 4 px each side) and a 4 px splitter beside each shown panel (SPLITTER), which #347's floating panels added (#433).
The theme:
useThemeputs thedarkclass on<html>fordark, or forsystemwhile the system is dark, and follows the system as it changes.The colours are V1's design tokens, the PO's brand colours, in
src/index.css. Tailwind reads them through@theme.
The views:
The six lists show their counts in the navigation, and the download list below.
History (#32) and Settings (#30) are in the navigation's Library. Ctrl+, (⌘, on macOS) opens Settings, as in V1.
Quitting:
When quitting would stop active downloads and the setting asks for it, Rust shows the window and sends
quit-requested.The window asks "Quit Apollo Download Manager?". Only "Quit" calls
quit_app, and "Keep downloading" changes nothing.
Adding downloads
The Add dialog (#60, V1's) is in src/features/add/. Its state is in the ui store (addDialog), so anything can open it with openAdd(prefill).
It opens from the "Add download" button at the top of the navigation, Ctrl+N (⌘N on macOS),
.torrentfiles dropped on the window (useAddEntryPoints), and a link the browser sends for the user to check (thebrowser-requestevent, thentake_browser_requests, #65, #415).Metalink files (#222): "Choose Metalink files…" (
choose_metalinks) puts them in the request'smetalinkFiles, in place of links, as.torrentfiles do. It is disabled, with the reason, while the engine can't read Metalink (availability); the manager refuses one while Metalink is off (PROTOCOL_DISABLED). Each file's result ismetalinkAdded, with every download it added.What it checks as the user types (
addLogic.ts, V1's): each link's scheme and form, a login typed into a link, and a protocol the engine can't download (get_capabilities, withavailability's reason). Headers and the checksum are checked too. The native side checks everything again.What it shows before adding: the name the file gets (
naming.ts), the automatic category and folder, a name already taken, and a missing chosen folder, which it offers to create (check_destination, 300 ms after the last change). A plainftp://link gets the cleartext warning and "Require encryption (FTPS)".What it shows after:
add_downloadsanswers for each link. All added closes the dialog with a toast. Otherwise it lists taken names ("Keep both (rename)"; a torrent's are only explained), duplicates ("Add anyway"; a torrent still in the list is only explained), and the links that failed with why (outcome.ts).The SFTP host key (
HostKeyPrompt): when a download in the snapshot has failed witherror.hostKey, the window asks once for that download and key. "Trust and continue" callstrust_host_key, thenretry_downloads. A changed key is only explained.
The download list
The list (#59, V1's) is in src/features/downloads/. It shows the snapshot as it is: what a download becomes after a command comes from the next snapshot, never from the window.
The table (
DownloadTable) is virtualised with TanStack Virtual, so only the rows in view are drawn. Its 13 columns are V1's (columns.tsx); the column chooser shows and hides them, always with Name.Column widths: dragging a column's edge previews the width and saves it once, when the drag ends; arrow keys change it by 16 px, and Home or a double-click restores it. The list's columns save through
set_appearanceover the latest saved appearance, so they never overwrite the layout (SETTINGS.md).Selection (the
downloadsstore, V1's rules): a click selects one, Ctrl or ⌘ toggles, Shift selects a range, and the checkboxes and select-all cover the rows shown. Arrow keys, Home, End, Ctrl+A and Escape move and select; Space pauses or resumes; Delete removes and Shift+Delete deletes the files. A download that leaves the snapshot leaves the selection.Actions (
useDownloadActions): the toolbar and the right-click menu (menus.tsx) offer whatactionsForallows for the selection, as the manager allows it. Removing a running download and deleting files ask first. A command over several downloads reports what it couldn't do, from itsBatchResult, and the buttons are disabled while a command is being sent. That a late status from the engine can't undo a pause or a cancel is the manager's rule (ENGINE_CONTRACT.md).The toolbar in a narrow list (#442, PO's choice): the labels of Resume all and Pause all hide first (a container query). Below 540 px (
TOOLBAR_FITS, measured: the toolbar needs about 536 px with its labels hidden), Cancel, Retry and Remove selected move to the top of the More menu, where they act on the selection as their buttons do.useNarrowerThan(src/lib/) follows the toolbar's width with a ResizeObserver and re-renders only when the answer changes.Statuses (
statusLabel): Retrying counts down to the engine'sretryAtMs, and says "Waiting to retry" without one; a stalled direct download says "No data received"; one that came back without its login says "Needs its login". Speed and time left show only while bytes come, and the time left is the engine's speed over the bytes left. A notice says when the transfer engine isn't running, and when saved downloads are from a newer Apollo Download Manager.Entering a login again (
LoginDialog): resuming or retrying a download in the snapshot'sneedsLoginasks for its login, custom headers or SSH key, sends them withset_download_login, then resumes or retries it.Show download: the Add dialog's "Show download", for a duplicate already in the list, shows the whole list with that download selected (
showDownload).Hover help (#424): a cell whose text is cut off (name, category, folder) shows it in full in the window's tooltip (
CutOffText, which measures only when the tooltip would open), and the status badge says what the status means and what to do (statusHelpinsrc/downloads/list.ts, with a test for every status). The same help describes the row (aria-describedby) for screen readers. The list uses no browsertitletooltips.Opening: a double-click on a finished download opens its file, and on any other shows its details. "Open file" and "Open folder" are in the right-click menu and the details panel. Opening a program asks first: the native side answers
CONFIRMATION_REQUIREDuntil the user confirms (PO decision on #61).
Torrents
The torrent screens (#63, V1's src/features/torrents/) are in src/features/torrents/.
The Add dialog previews a single
.torrentfile (preview_torrent_file): its name, version, hash, a warning when it repeats a download, and its files as a tree (FileTree), each chosen by default. Several files are each added whole. For any torrent it offers the seeding policy (SeedingFields) and an upload limit.Choose files (
ChooseFilesDialog) opens for a torrent waiting in Choose files: double-click it, or use its menu or its details.Pasting magnet or
.torrentlinks outside a text field opens the Add dialog with them (useAddEntryPoints).The rules (
torrentLogic.ts): the file tree, folder selection, the file choice to send (none when every file is chosen), the seeding policy in words, and a tracker's line.
The details panel
The panel (#61, V1's DetailsPanel) is src/features/downloads/DetailsPanel.tsx.
What it shows: everything comes from the live snapshot, so switching rows shows the new download at once, and no late answer can show another (PO decision on #61). There is no details command.
Which download: while open, the panel follows the focused row (a click or the arrow keys). Without one it shows the download it was opened for, even while the search or a filter hides it, and says so. When its download leaves the list, it says that instead.
Opening it: double-click a row, press Enter, use "Details" in the right-click menu, or the toolbar's panel button. Whether it is open, and its width, are saved with the layout (
appearance.layout.detailsVisibleanddetailsWidth, SETTINGS.md). The shell fits it into the window (resolveLayout) and passes the result throughShellLayoutContext.Sections:
Transfer: the speed, the average speed and the time left; a direct download's connections in use, how many it may use and what holds it back (
ConnectionLine, #47); the next try while retrying; the verification state.Torrent (
src/features/torrents/TorrentSection.tsx, #61, #63): the peers and seeds connected, the bytes uploaded, the upload speed and the time seeding, as the torrent helper reports them ("Not reported yet" until it does); the info hashes, version and seeding policy (get_torrent_details); the trackers and the connected peers, asked for every second only while shown; re-announce, the upload limit, "Stop seeding", "Leave seeding queue", and changing its files, which keeps the files dropped unless the user asks to delete them.Source and destination, and Timeline.
Options for this download: its category, its own speed limit, and a direct download's connections, which change while it runs (#47), so there is no restart to confirm.
Errors show the engine's message, the HTTP status when there is one, a hint about retrying that follows the failure's class (#250), and Retry when the download allows it.
Only what the engines report: there are no per-connection or per-segment figures, because the engine doesn't report them.
The history
The history (#32, V1's HistoryView) is src/features/history/HistoryView.tsx.
What it shows: every finished download, newest first, 100 a page, with how it ended, why it failed, and whether it is still in the list.
Search and filter: the search waits 250 ms after typing and goes back to the first page; the status filter is completed, failed or cancelled.
Removing: "Remove selected" and "Clear history" (which asks first) take entries out of the history. Files are never touched, and a download still in the list stays there.
Again: opens the Add dialog with the entry's link, so adding it goes through the usual questions (#60).
The user guide
The user guide (#354) is src/features/help/. It opens from Help, User guide, from each Settings section's "… in the user guide", and with openGuide(page, anchor) from anywhere, in a window over the app like Settings (the ui store's guide).
Its pages are the user guides in docs/user/, which
pnpm guidebuilds intoguide.generated.jsonwith markdown-it (scripts/guide-lib.mjs). A Vitest test fails when that file is older than the guides, so edit the guides and runpnpm guide; never edit the JSON.No HTML from the guides reaches the window: the data is typed blocks and inlines, drawn as React elements. Raw HTML and images in a guide fail the build. A link to another guide page opens it in the guide; a web address is shown as text, since the window opens no web page.
Search runs over every section's text in memory, with no network call: a section matches when it holds every word typed, with heading matches first.
Layout A (PO decision on #354): search and the contents on the left, the page on the right, previous and next at the bottom; on a narrow window the contents go above the page.
Settings
The Settings screen (#30) is src/features/settings/: what each section holds and when it applies is in SETTINGS.md.
One query (
useSettings) holds what is saved. A change sends every section throughset_settingsand shows the answer; a refusal shows why, and the saved values again.Numbers are saved when the field is left or Enter is pressed, brought within their bounds (V1's).
Appearance saves through
set_appearance, like the panel widths.About and diagnostics is #28's
DiagnosticsPanel.
The native side of the window
These are in src-tauri/src/shell.rs and tray.rs:
The tray:
"Show Apollo Download Manager", "Pause all", "Resume all" and "Quit". A left click shows the window.
Its tooltip says how many downloads are active, and their total speed.
On Linux the tray needs an AppIndicator library at runtime. Without a tray, closing the window quits and the app never starts hidden.
Closing and starting:
Closing hides the window in the tray while
general.closeToTrayis on, as it is by default.The window starts hidden (
tauri.conf.json) and is shown at once, unlessgeneral.startMinimizedis on and there is a tray.A second launch shows the window.
Notifications:
velocity_core::noticescompares each snapshot with the one before it:a download completed, or started seeding;
a torrent waits for its files to be chosen;
a download failed, with why;
a download started;
the transfer engine or the torrent helper became unavailable.
Each follows its setting (SETTINGS.md). The first snapshot after starting notifies nothing.
Snap layouts (#359): Windows 11 offers its layouts over a window that answers HTMAXBUTTON, and the web view covers the whole window. So src-tauri/src/snap_layouts.rs puts a small native child window over the maximise button. TitleBar.tsx tells it where the button is with set_maximize_button_rect. It takes the mouse there, so it does the click itself and sends maximize-button so the button still shows hover and press. Keyboard and screen-reader users use the page's button, under it. If the overlay can't be made, the page's button keeps working, without the layouts. Windows closes the layouts when a simulated pointer moves, on a plain window too, so choosing a layout can only be tested by hand.
Testing
UI tests run in jsdom (
vite.config.ts, theuiproject) with Testing Library. Only the Tauri boundary is mocked:src/test/fakeBackend.tsanswers the commands over Tauri's ownmockIPC, with the generated contract fixtures, and records every call.Events are sent with Tauri's
emit.
Pure rules (
layoutLogic,views,list,format,parseExtensions) have unit tests.The test setup gives elements a size (
src/test/setup.ts, as V1's), because jsdom does no layout and the virtualised list would draw no rows.The layout in a real browser is
pnpm test:e2e:layout(L4, TESTING.md).The tray and notifications are checked by hand on each platform, and the PR records it.
Not here yet
Owned by later issues:
The file checks for opening, hardened further (#72).
The browser extension's steps (#64).
Keyboard shortcuts: Ctrl+, opens Settings (#30), and Ctrl+N the Add dialog (#60).
Accessibility: what is checked, and how, is in ACCESSIBILITY.md (#66).