Skip to content

feat: categorized settings, new options, and consistent download settings across HTTP, FTP and torrent - #263

Merged
linroid merged 21 commits into
mainfrom
feat/settings-categories
Sep 26, 2026
Merged

linroid merged 21 commits into
mainfrom
feat/settings-categories

Conversation

@linroid

@linroid linroid commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Summary

The settings page was one long scroll of cards, each with its own Save button. It's now organised into categories, applies changes as they're made, and gains the options that were missing. An audit of the engine showed that most global download settings didn't reach FTP or torrent downloads, and weren't applied after a change at runtime. This PR fixes that too, so each setting behaves the way its Settings page describes.

App: categorized settings

  • Categories: General, Downloads, Network, Remote access, AI discovery, About.
    • Wide windows show the category list beside the selected page.
    • Phones show the list first and open a page on tap, with system back support.
  • No Save buttons. Rows sit on grouped cards with compact controls: switches, drop-downs, a segmented control and colour swatches.
    • Choices apply immediately.
    • Text fields save when typing pauses, on Enter, on blur, or when the page closes, and only while the value is valid.
  • New options:
    • Theme: system, light or dark (new AppearanceConfig.theme).
    • Retry attempts, plus a custom speed limit in KB/s or MB/s (decimals allowed).
    • Network: pick the interfaces HTTP downloads are spread across. Until now this was only available through the API.
    • Remote access:
      • Start automatically (new ServerConfig.autoStart).
      • Allow other devices, or only this device (the bind host).
      • Generate an access token, with a warning when the server is open to other devices without one.
      • A Restart prompt when the saved settings differ from the running server's.
    • About: version, build and project links.
  • Download and network settings belong to the active instance.
    • For a remote instance, its own values are loaded and changed on the server. Before, this device's values were shown and pushed to it.
    • Only the embedded instance's settings are written to config.toml.
    • Rapid changes are applied one at a time, so the instance ends up on the last value.
  • Server fixes:
    • The server starts with the saved config and records it in ServerState.Running. The instance picker used to report port 8642 regardless of the setting.
    • A failed start, e.g. port in use, now shows as ServerState.Failed instead of crashing the app.

Engine: settings apply to every source

  • updateConfig: the speed limit and queue limits still apply immediately. Directory, connections, retries and intervals now apply to downloads that start or resume afterwards; before, they only took effect after a restart. status() reports the current directory.
  • Per-task settings: speed limit and connections now persist for queued, paused and scheduled tasks; before, they were lost. Queued downloads can now be paused.
  • Queue:
    • Raising a limit starts queued tasks.
    • Per-host keys strip userinfo and port, are lowercased and handle IPv6.
    • Magnet and local sources aren't counted per host.
  • Servers without range or REST support stay on one connection instead of failing when connections are raised.
  • Reschedules are saved, so they survive a restart.
  • FTP:
    • Uses the global connections and progress interval; FtpDownloadSource() no longer takes arguments.
    • Retries continue from the bytes already written instead of starting over.
    • Reads with bufferSize.
  • Torrent:
    • Torrents beyond maxActiveTorrents (default 5) wait for a slot instead of failing: highest priority first, then arrival order. Pause and cancel work while waiting.
    • Engine errors pass through unchanged, so network failures are retried.
    • A task's connections setting maps to its peer cap, clamped to the allowed range.
  • KDoc: DownloadRequest.speedLimit now states that the lower of the task and global limits wins.

Reviewer notes

  • Public API:
    • DownloadSource.resolve(url, properties, config) overload, with a default implementation.
    • DownloadContext.config, set from the current global config when a download starts or resumes.
    • DownloadContext.effectiveConnections().
    • FtpDownloadSource() takes no arguments.
    • Ketch's default dispatchers now use fixed-size pools.
  • Torrent peer cap: torrent reads DownloadContext.maxConnections as its peer cap. It still starts at request.connections, so the global HTTP connection count never caps torrent peers.
  • Not applied to HTTP: bufferSize, since that would change the HttpEngine interface. Its KDoc now says it applies to FTP.
  • Dependency: adds org.jetbrains.androidx.navigationevent:navigationevent-compose. Compose's BackHandler is deprecated in 1.12.
  • Config compatibility: config.toml files without the new fields decode to the previous behaviour; tests cover this.

Testing

  • :library:api:jvmTest (94), :library:core:jvmTest (263), :library:ftp:jvmTest (86), :library:ktor:jvmTest (27), :library:torrent:jvmTest (656), :library:remote:jvmTest (6), :library:server:test (46), :config:jvmTest (21), :app:shared:jvmTest (131): all passing.
  • Compiled: CLI, desktop, Android, web (wasmJs) and the iOS simulator target for app/shared.
  • New tests:
    • Settings choices and parsing.
    • The per-instance settings controller: persistence, remote loading, serialized updates, network rollback.
    • Server start, failure and auto start.
    • Config backward compatibility.
    • Live config, per-task persistence, queue promotion, host keys.
    • Clamping connections on servers without range support.
    • Queued pause.
    • FTP retry and connection defaults, using a new FakeFtpClient.
    • Torrent slot queueing, cancel while waiting, error classification, peer-cap mapping.
  • UI was checked with rendered screenshots of every category at desktop and phone widths, in light and dark themes.
  • :library:core:wasmWasiTest has 2 failures that also occur on main. CI doesn't run that task.

AppearanceConfig gains a ThemeMode (system, light, dark) and ServerConfig
an autoStart flag plus loopback helpers, so the apps can offer a theme
switch, start the server on launch and a this-device-only toggle. Configs
written before these fields existed decode to the old behaviour.
The settings page was one long scroll of cards, each with its own Save
button. It is now split into categories — General, Downloads, Network,
Remote access, AI discovery and About — shown beside the selected page on
wide windows and as a list that opens each page on phones (with system
back support).

Rows are grouped on cards with a compact control each: switches,
drop-downs, a segmented theme picker and accent swatches. Changes apply
as they are made; text fields save when typing pauses, on Enter, when
focus leaves or when the page closes, and only while valid.

New options:
- Theme: system, light or dark
- Retry attempts for failed downloads, and a custom speed limit in KB/s
  or MB/s with decimals
- Network: pick the interfaces HTTP downloads are spread across
- Remote access: start automatically, allow other devices or only this
  one, generate an access token, and restart when saved settings differ
  from the running server
- About: version, build and project links

Download and network settings now belong to the active instance
(InstanceSettingsController): a remote instance's own values are loaded
and changed on the server, instead of this device's values being shown
and pushed to it; only the embedded instance's are saved to config.toml.
Rapid changes are serialised so the instance ends on the last value.

The server now starts with the saved config and keeps it in
ServerState.Running, so the status shows the real port (the instance
picker assumed 8642) and Settings knows when a restart is needed. A start
that throws, e.g. port in use, becomes ServerState.Failed instead of
crashing the app.
With unlimited (or more than TorrentConfig.maxActiveTorrents) simultaneous
downloads, the extra torrent tasks failed with a non-retryable SourceError
("Too many active torrents") when no seeding session could be evicted.

Torrent tasks now wait for an engine slot through TorrentActiveSlots:
higher priority first, then arrival order, without polling. Waiting is
cancellable, so pause and cancel return at once, and every exit path
(completion, failure, pause, cancel) hands the slot to the next waiter.
The oldest seeding session still yields its slot first, and a finished
download does not start seeding while another download is waiting.
While waiting, a task reports as downloading at 0 bytes/s with its
restored progress, which also keeps it pausable.

Magnet metadata fetches beyond the runtime's 16 pending fetches now wait
for a permit instead of failing with "Too many pending metadata requests".
Torrent downloads wrapped every failure, including KetchError subtypes such
as Disk or Network, in a non-retryable SourceError, so DownloadConfig.retryCount
never applied to torrents.

Failures are now reported in Ketch's terms: KetchError passes through
unchanged, cancellation is never wrapped, internal timeouts (such as magnet
metadata resolution) become retryable Network errors, and a busy peer listen
port or a raw socket failure while fetching remote metainfo is Network too.
Storage failures become Disk, and verification, protocol and admission
failures stay SourceError; neither is retried. Peer, tracker and DHT errors
were already retried inside the swarm and still do not fail the task.
…tions

For torrents, a task's connections setting is its peer cap. v1 and v2 each
had their own inline mapping; both now use torrentPeerLimit, which clamps an
explicit value to the session's accepted range (1..512 for v1, 1..500 for
v2) instead of risking a rejected value, and uses
TorrentConfig.connectionsPerTorrent when the task sets none. Ketch's
maxConnectionsPerDownload (HTTP segments) is never used as a peer cap.
Describe simultaneous downloads and the engine's active-torrent limit,
global and per-task speed limits, per-task connections as a clamped peer
cap, priority, and which failures Ketch retries.
Ketch.updateConfig only applied the speed limit and queue limits; the
coordinator, executions and HTTP source kept the constructor config.

- DownloadCoordinator takes a config provider backed by Ketch's current
  config and snapshots it for each start or resume.
- DownloadContext carries that snapshot (config) and exposes
  effectiveConnections(): live override > request.connections >
  maxConnectionsPerDownload.
- DownloadSource gains resolve(url, properties, config); Ketch calls it so
  ResolvedSource.maxSegments reflects the current default.
- HttpDownloadSource reads connections and progress interval from the
  context instead of constructor ints.
- status() reports the current default directory.
- Default dispatcher pools no longer scale with the constructor's
  maxConnectionsPerDownload; network I/O suspends, so pool size does not
  cap connections raised later.
…tasks

DownloadTask.setSpeedLimit and setConnections were dropped unless the task
had a running execution, so the value snapped back for queued, scheduled,
paused and failed tasks. They are now persisted in the task record (like
setPriority) before being applied to a running execution, and a new
execution initializes its task limiter from the persisted request.
setConnections validates its argument before persisting.
- DownloadQueue.updateLimits replaces both limits under the queue mutex
  and promotes queued tasks that now fit; Ketch.updateConfig uses it.
  Lowering a limit never interrupts running downloads.
- extractHost strips user info (ftp://user:pass@host keyed on the host),
  lowercases, handles bracketed IPv6 and ports, and returns null for
  URIs without a network host (magnet:, torrent:, file:, local paths),
  which are no longer subject to the per-host limit.
A live setConnections(>1) resegmented the transfer even when the server
does not support byte ranges, and the next Range request failed the task
with Unsupported. HttpDownloadSource now clamps the effective connection
count to 1 for such servers on fresh starts, retries and resumes, and
SegmentedDownloadHelper.downloadAll takes supportsRanges so sources can
ignore live connection changes that would need unsupported offsets.

FakeHttpEngine now rejects non-zero range offsets when the server does not
accept ranges, like KtorHttpEngine's response validation.
DownloadTask.reschedule only changed in-memory state, so a restart
restored the previous schedule or paused state. DownloadScheduler now
saves the new schedule (and in-memory conditions) in the task request and
marks the record SCHEDULED. Scheduled tasks are enqueued with
preferResume, so a task rescheduled after it started resumes its saved
segments after a restart instead of starting over; tasks without segments
still start fresh. Conditions remain unpersisted by design.
FtpDownloadSource ignored the global config: it used its own constructor
defaults (4 connections, 200 ms progress) and the apps construct it
without arguments. It also restarted a failed fresh download from byte
zero on every retry.

- Remove the maxConnections/progressIntervalMs constructor knobs. The
  connection count follows DownloadContext.effectiveConnections() (live
  override > request.connections > maxConnectionsPerDownload), progress
  uses the context's progressIntervalMs and the FTP client reads with
  DownloadConfig.bufferSize.
- A retry reuses the segment progress recorded in the context, like HTTP.
- Servers without REST always use one connection: live connection changes
  are ignored and a resume restarts from zero instead of failing on REST.
- resolve(url, properties, config) reports maxConnectionsPerDownload as
  maxSegments.

Adds FakeFtpServer/FakeFtpClient (via the internal clientFactory hook)
and an in-memory FileAccessor for source-level tests.
DownloadTask.pause() ignored tasks in the Queued state, so a download
waiting for a slot could not be held back. Pausing a queued task now
removes it from the queue, persists PAUSED and publishes Paused with the
saved progress; resume() queues it again. A task paused before it saved
any segments is also persisted as PAUSED so it does not restart on its
own after a restart.
- KetchApi.updateConfig lists which fields apply immediately and which
  apply to downloads that start or resume later.
- DownloadRequest.speedLimit applies in addition to the global limit
  (the lower rate wins) instead of overriding it.
- DownloadConfig documents per-source connection behavior, the per-host
  key (host-less URIs are not counted), queue-limit changes and that
  bufferSize applies to FTP reads.
- DownloadTask documents persistence of speed limit, connections and
  reschedule for inactive tasks, and pausing queued tasks.
- AGENTS.md and docs replace the removed setGlobalSpeedLimit API with
  updateConfig.
Only magnet links skip the per-server limit (.torrent files count against
the site serving them), and lowering a queue limit lets running downloads
finish rather than stopping them.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-26T13:46:48.232328Z 20b6f1b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20b6f1b8f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

networkLock.withLock {
attempt(onError = {
networkError = it
networks = current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back network updates to the last confirmed state

When multiple interface changes are queued, current may already contain an optimistic, unconfirmed selection. For example, starting from no interfaces, selecting A and then A+B before the first request finishes captures A as the second rollback value; if both API calls fail, this line leaves A checked even though the server applied neither request. Track the last server-confirmed configuration or coalesce pending selections instead of rolling back to the per-call optimistic snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a7df78: the controller now keeps the last server-confirmed interfaces and falls back to them on failure, and selections queued behind an in-flight request are coalesced into the newest. A regression test covers the A then A+B case with both requests failing.

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

  867 files  + 22    867 suites  +22   6m 11s ⏱️ +28s
3 825 tests +161  3 825 ✅ +161  0 💤 ±0  0 ❌ ±0 
5 155 runs  +183  5 155 ✅ +183  0 💤 ±0  0 ❌ ±0 

Results for commit 4054999. ± Comparison against base commit 9f9a1f7.

This pull request removes 39 and adds 200 tests. Note that renamed tests count towards both.
com.linroid.ketch.app.DownloadSettingsInputTest ‑ a blank directory falls back to the platform default[jvm]
com.linroid.ketch.app.DownloadSettingsInputTest ‑ a saved config round trips through the form[jvm]
com.linroid.ketch.app.DownloadSettingsInputTest ‑ non-numeric and negative values are rejected[jvm]
com.linroid.ketch.app.DownloadSettingsInputTest ‑ valid values build a config[jvm]
com.linroid.ketch.app.DownloadSettingsInputTest ‑ zero connections per download is rejected[jvm]
com.linroid.ketch.app.DownloadSettingsInputTest ‑ zero means unlimited for queue limits[jvm]
com.linroid.ketch.app.SettingsEditsTest ‑ discard_forgetsEditsAndConfirmation[jvm]
com.linroid.ketch.app.SettingsEditsTest ‑ keepEditing_keepsEditsForTheNextClose[jvm]
com.linroid.ketch.app.SettingsEditsTest ‑ report_savedAfterEditing_allowsClosing[jvm]
com.linroid.ketch.app.SettingsEditsTest ‑ requestClose_nothingUnsaved_closesRightAway[jvm]
…
com.linroid.ketch.app.AppSettingsControllerTest ‑ accent and theme mode are saved without overwriting each other[jvm]
com.linroid.ketch.app.InstanceSettingsControllerTest ‑ a network selection shows at once and rolls back when rejected[jvm]
com.linroid.ketch.app.InstanceSettingsControllerTest ‑ a rejected change is reported[jvm]
com.linroid.ketch.app.InstanceSettingsControllerTest ‑ embedded changes are saved to the config file and applied[jvm]
com.linroid.ketch.app.InstanceSettingsControllerTest ‑ failed selections fall back to what the instance last confirmed[jvm]
com.linroid.ketch.app.InstanceSettingsControllerTest ‑ rapid changes leave the instance on the last one[jvm]
com.linroid.ketch.app.InstanceSettingsControllerTest ‑ remote settings come from the instance and stay off the local file[jvm]
com.linroid.ketch.app.SettingsCategoryTest ‑ every category is offered where the server can run[jvm]
com.linroid.ketch.app.SettingsCategoryTest ‑ remote access is hidden where no local server can run[jvm]
com.linroid.ketch.app.SettingsChoicesTest ‑ a hand-edited value joins the presets in order[jvm]
…

♻️ This comment has been updated with latest results.

Brings in the settings overlay dialog from #257 and keeps its
presentation: wide windows open Settings as a panel over the current
destination, narrow ones as a page, and ⌘, / Ctrl+, plus the macOS app
menu open it. The panel and page now show the categorized settings
from this branch.

With changes applied as they are made there are no drafts to lose, so
the unsaved-section tracking and discard confirmation (SettingsEdits)
and the per-section cards are dropped. Back on the phone category list
now closes Settings.
The iOS test build failed with 'Name contains illegal characters' on a
backtick test name containing a comma.
A failed network interface update restored the snapshot taken before
that call, which could be another unconfirmed selection: selecting A,
then A+B, with both rejected left A ticked. The controller now keeps what
the instance last reported and falls back to it, and selections made
while one is in flight are coalesced into the newest.
Brings in drag-to-add torrents (#261), extra torrent trackers and DHT
state (#256) and the Dock icon fix (#262).

- AppShell: keeps the new FileDropTarget wrapper and uses the
  categorized SettingsPage inside it.
- DownloadSource: keeps both new members, the config-aware resolve
  overload and canHandleContent/resolveContent.
- AGENTS.md: keeps the TorrentSettings entry alongside the settings
  notes.
Brings in opening .torrent files from the system file manager (#260).
App passes the new IncomingDownloads through alongside the theme mode;
AppShell keeps its import next to the settings controller and categories.
@linroid
linroid merged commit 6f66473 into main Sep 26, 2026
9 of 10 checks passed
@linroid
linroid deleted the feat/settings-categories branch September 26, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant