Commit Graph
8 Commits
Author SHA1 Message Date
Michel de Graaf a91d35b672 feat(daemon): expose watching over the RPC surface and the CLI
The watch subscription was reachable only from the library. It is most
useful against a daemon -- that is the session that stays connected long
enough to receive the server's status pushes -- so it needs the wire.

- proto: user.watch / user.unwatch / user.watched, with a Watched response
- SessionApi gains watch_user / unwatch_user / watched_users, so a local and
  a remote session are still interchangeable above the seam
- CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md
  including the caveat that a one-shot run's watch ends with the run
- docs/openrpc.json regenerated from the wire types
2026-08-28 13:22:50 +00:00
Michel de Graaf 1551fbbf1b perf(daemon): answer search polls with counts instead of copies
A CLI one-off search leaves its result set in the daemon until someone
forgets it, and an attached TUI made that permanent state expensive:
every 100ms tick asked for the search list twice, and each answer
deep-cloned every result of every search under the context lock just to
sum file counts - stalling the thread that stores incoming results.
Worse, any active or selected search pulled its entire result set over
the socket again each tick, megabytes of JSON repeating what the window
already held.

search.list is now backed by search_file_counts, a walk of the cache
instead of a copy of it; the TUI syncs once per tick and re-fetches a
result set only when the session's file count differs from what it
holds. get_search_results_count reads the same summary, so get --pick
first against a daemon polls a count instead of the full set, and both
session kinds now count files where the local one counted peer
responses.
2026-08-09 22:16:27 +02:00
Michel de Graaf cd56fb3b39 feat(daemon): make the download folder a live setting in both modes
The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.

- `download.set_dir`: a session-wide move, published in the contract;
  the per-transfer destination stays refused. `daemon.status` reports
  it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
  live daemon the way `shares add` does, through one shared helper;
  the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
  pushes both folders over the socket as typed, reports a partial
  refusal as exactly that, and creates no client-side directories. It
  reads the daemon token from the resolved config — with the token only
  in config.toml it could not attach over TCP — and settings
  persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
  renders it as running rather than instantly finished.

The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
2026-07-28 10:44:06 +02:00
Michel de Graaf 011eafaa7f feat(daemon): make every window a view of one shared session
Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.

The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.

Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.

Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.

A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
2026-07-27 20:58:49 +02:00
Michel de Graaf 41307d0510 fix(daemon): correct the published contract and harden the control socket
A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.

Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
  arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
  was briefly connectable by any local user between bind and chmod, and on
  that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
  connections are capped, so an unauthenticated peer cannot exhaust memory or
  threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
  subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
  dropped from the fan-out and left connected, working on but blind to every
  event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
  waiting out its full timeout and reporting a misleading "gave up".

Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.

Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
2026-07-27 11:38:46 +02:00
Michel de Graaf a69f52c657 test(daemon): drive the whole CLI suite against a daemon, and fix what it caught
SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session
from a daemon instead of the command's own login, so the same 83 tests assert
the same records and exit codes both ways. CI runs the suite twice.

Two defects it found:

serve judged whether anything was shared from this run's configuration
rather than from the session, so routed to a daemon it would start with
nothing to serve and report success.

The TUI showed an empty conversation when attached, because the daemon's
message history was never asked for. It is now a SessionApi method, empty
locally, which puts the difference behind the seam.

main.rs is now a thin entry point over the library rather than a second copy
of every module, so the program compiles once instead of twice.
2026-07-27 10:58:27 +02:00
Michel de Graaf a0354f66a8 feat(daemon): route commands and the TUI through a running daemon
Session::open attaches to a daemon when one is reachable and logs in
otherwise, so every existing command works unchanged against either. A local
socket is found automatically, which is what makes an agent's invocation need
no flags at all; --no-daemon opts out.

Commands now read identity, shares, server, and download directory from the
session rather than from this run's config: routed to a daemon those differ,
and the session is the one actually on the network. Without that, whoami
named the wrong server and shares reindex would have imposed the client
machine's folders on the daemon.
2026-07-27 10:33:41 +02:00
Michel de Graaf f54f8f9b82 feat(daemon): resident session, auth-gated control socket, and event fan-out
The daemon drains the library's destructive take_* accessors once and fans
events out per connection, which is what lets more than one client share a
session. A Unix socket at 0600 is the local transport and its permissions
are the authentication; TCP is opt-in and needs the generated token. Rooms,
transfers, and the conversation are daemon-owned and restored on restart.
2026-07-27 10:05:25 +02:00