Skip to content

feat(remote): add remote control settings - #322

Merged
MaxMB15 merged 113 commits into
devfrom
feat/remote-settings
Oct 20, 2026
Merged

MaxMB15 merged 113 commits into
devfrom
feat/remote-settings

Conversation

@MaxMB15

@MaxMB15 MaxMB15 commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

What this changes

Remote control settings on macOS and Linux (spec 4 A and B). This is the last step of remote control on the desktop. With it, two Cinette instances on one network can find each other, ask, allow and control.

This builds on #321, which is merged in, and merges last of the four, right after #318, #319 and #321. dev is merged in, and the changelog line sits under ## [Unreleased], ### Added, after dev's two, above ### Changed.

A Remote control card in Settings:

  • Discoverable on this network: on by default. The text says that turning it off stops new connections, and that devices already connected stay connected until they're disconnected.
  • Remote control: Always allow, Only when allowed (the default) and Never allow, each with a line saying what it means, all shown before one is chosen.
  • Device name: the device type is the placeholder, so the OS's device name, which often has the owner's name in it, is never used by default. The name is saved when the field loses focus or on Enter, and clearing it goes back to the default. It's cut to the 63 bytes that go out whole.
  • Connected devices: each with its type, "reconnecting" while its session waits to resume, and Disconnect. Two with one name show the start of their device ids.
  • Reset device identity: after a confirm step, and the card says when it worked.

The card shows nothing where remote control doesn't run (iOS and Android for now), and says so when it didn't start. Settings are kept under remote in settings.json.

CHANGELOG.md gets one entry under Unreleased, Added, for remote control as a whole.

Since the first version

A fresh-eyes review found these, all fixed:

  • Saves no longer undo each other. Saves go one after another, and only the newest answer shows. Before, a name typed just before a mode change could be lost. (After the feat(remote): add remote control settings #322 review, each save is built when its turn comes, from the device's last answer; see below.)
  • Errors say what didn't work in words, not the backend's text.
  • The name limit counts bytes, as Rust does, so a Cyrillic or emoji name isn't cut short after saving with no warning.
  • The mode choice has an accessible name.
  • The changelog mentions resetting the identity, and says macOS asks once for local network access. (After the feat(remote): add remote control settings #322 review it says the request and the code come under Only when allowed, the default.)

After the #322 review

  1. A failed save no longer comes back with the next one. Each queued save is built when its turn comes, from the device's last answer plus its own change. After a failure the card asks the device what it runs and shows that, since feat(remote): run this device as a remote-control target on macOS and Linux #318 applies a change before saving it (Never allow holds even when settings.json can't be written). The error says the choice lasts only until Cinette quits. If the device can't be asked either, the card goes back to the last answer. A tested case: a change that failed and that the device dropped isn't sent with the next save.
  2. A change shows at once, so two quick clicks on the discovery button send off, then on.
  3. Every choice says what it means before it's made, and Always allow asks first with its warning. Never allow's text reads right before and after it's chosen.
  4. Session rows put the name in <bdi>, wrap long names, keep Disconnect on the card, and show the start of the device id when two names match, in the row and in Disconnect's label.
  5. A save's answer keeps what's typed in the name field since that save went out.
  6. Smaller items: a load error other than "not available here" says remote control didn't start; a reset says it worked; clicking the choice already made saves nothing; the load effect sets its state itself, so show isn't a missing dependency; fitName moved to src/lib/remote-devices.ts with a test.
  7. CHANGELOG: "Under Only when allowed, the default, the other device asks first".
  8. A reset's answer gives only the new device id and default name. Before, a reset that answered after a Never allow save put the older settings back on screen, and the next save sent them.

Follow-ups, not fixed here:

  • An older save that failed, whose change the device dropped, disappears without a message when a newer save answers. Today that can only happen without the remote service.
  • setName and save update the form from the last render rather than with a functional update.
  • The short id shows only for names that match exactly, so names that only look alike don't get one. The same goes for feat(remote): add the device picker, consent dialog and remote control bars #321's picker.

How it was tested

  • macOS
  • Linux (Wayland)
  • Linux (X11)

On Linux, with no display:

  • Vitest: 31 tests for the card cover:

    • the defaults, each setting, and every choice described up front
    • Always allow's confirm step, and nothing saved for the choice already made
    • saving the name only when it changed, clearing it, the byte limit, and keeping what's typed while a save is on its way
    • saves in order with only the newest answer shown, a change shown at once, and an older save's failure kept quiet
    • a failed save showing what the device runs, a change the device dropped not sent again, a save queued behind a failed one built on what the device ran then, and going back to the last answer when the device can't be asked
    • the controllers list with Disconnect, two with one name, and reset with its confirm step, its note, and a reset answering after a save
    • a load error when remote control didn't start

    Settings' own tests check the card is on the page.

  • StrykerJS: every mutant on this PR's changed lines is caught. One // Stryker disable next-line ArrayDeclaration sits on the first load's []: a list that never changes runs the effect once either way.

Still needs hardware: the user's Mac and Linux PC on one Wi-Fi, going through the whole flow:

  1. Rename both devices.
  2. Find each other, ask, compare the code, Allow.
  3. Control the other device and browse its catalog.
  4. Disconnect from the target.
  5. Switch to Never allow while connected.
  6. Turn discovery off with a session open.
  7. Reset the identity.

After the review: Vitest passes (3,067 tests), tsc, lint and Prettier are clean, pnpm build:desktop builds with the React Compiler, Clippy with -D warnings and the workspace's Rust tests pass. StrykerJS on this PR's changed lines, as CI runs it, left 10 survivors in the review fixes; each got a test or a code change, and a rerun of the card catches all 171 mutants. The one Stryker comment is the load effect's empty list (ArrayDeclaration), which never changes.

Checklist

  • The pull request targets dev
  • pnpm lint, pnpm test and cargo clippy --workspace --all-targets -- -D warnings pass (on this branch, which has the whole stack: 3,067 Vitest tests, 500 app and 788 core Rust tests)
  • User-facing changes are added to the Unreleased section of CHANGELOG.md
  • No playlist URLs, logins or API keys in the code, tests or screenshots
  • I have read the Contributor License Agreement and agree to it

… Linux

The platform layer around mvp_core::remote's Target. One tokio task owns
the Target and carries out its actions: it listens on TCP (port 47130
when it's free), advertises _cinette._tcp through the discovery module,
runs each connection's reader and writer, and calls tick at the core's
next deadline.

Time comes from a clock that counts sleep (CLOCK_BOOTTIME on Linux,
CLOCK_MONOTONIC on macOS), so a session whose 30 s grace ran out while
the computer slept ends instead of resuming without asking.

The device key is a 0600 file in the app data folder, replaced in one
step. Catalog queries are answered from the cache, with every item built
by CatalogItem::from_channel.

Commands for the player go to the webview as remote://command. The
player carries them out through its playback destination, so a remote
load is the target's own play and goes in its history, and it sends its
state back through remote_set_state while a controller is connected. A
command the player doesn't finish in 10 s fails.
Starts the target through the mock app on a loopback port, with an
advertiser that only records, and checks the settings file, the consent,
sessions and command events, state and catalog answers, a new identity
and quitting against a phone on a real socket. The loopback controller
moves into a test module both test files share.
Browses _cinette._tcp and keeps a list of the other devices, leaving out
this one and records that don't read as Cinette protocol 1. The device
being controlled stays listed as unavailable when discovery loses it.

A link task per target owns the core's Controller: it connects to each
address in turn, runs the handshake, reports the code and the target's
answer, and passes commands and catalog queries through, with a ping
after 10 s of quiet. A connection that drops without the target closing
it reconnects with backoff and the session id, so the session resumes
without a dialog, until the target's 30 s grace runs out, counted with
time asleep. Each link has a generation, so events from a link that was
left or replaced never reach the webview.

The webview gets remote://devices, remote://controller and remote://state,
and the remote_devices, remote_connect, remote_leave, remote_command and
remote_catalog commands. On the frontend, a CinetteDestination plays the
other device's items by id, and the remote-control store holds the
devices and the link.
…l bars

- The device picker lists this device, other Cinette devices (TVs first),
  and room for Cast and AirPlay, each group under its heading. A row that
  can't be chosen stays listed, greyed out, with the reason. A route this
  device has to convert asks before it starts. The Devices button in the
  sidebar opens it.
- The consent dialog on the target names the device and its type, shows
  the code in large digits and counts down. Closing it denies.
- While this device asks another, a dialog shows the same code and Cancel.
- While it controls another, a bar on every page says which device plays
  and what, with play, pause and stop, and Return to independent
  playback. A link that ends by itself says why once.
- While other devices control this one, a bar names them, each with
  Disconnect.
- The channels page reads the other device's catalog while it's the one
  that plays: groups, pages of items, series episodes and search.
A Remote control card in Settings, on macOS and Linux:

- Discoverable on this network, on by default, with text saying that
  turning it off stops new connections and leaves connected devices
  connected until they're disconnected.
- Remote control: Always allow, Only when allowed (the default) and Never
  allow, each with a line saying what it means. Never allow ends every
  session at once.
- The device name, with the device type as the placeholder, saved when
  the field loses focus or on Enter.
- The devices controlling this one, each with Disconnect.
- Reset device identity, after a confirm step.
The consent dialog's countdown moves into a component of its own, keyed
by request, so each request gets a clock that starts when it arrives.
The remote catalog keeps one view state (loading, failed or ready) in
place of three flags. Class names in constants go through cn(), which
the mutation run leaves out. New tests cover the picker's icons and
spinner, opening it again, the remote catalog's failures, search delay,
going back from a series found by search, and the rest.
Commands keep their own tokens and a closed connection only drops its
own; a command fails at its deadline, not at the next look at the clock;
a player may take its time within the limit; an episode keeps its show,
season and number; a key file the app may not read is an error and is
left alone. Removing a leftover temporary key file has a skip comment:
another error leaves it in place and create_new then fails on it.
The saved settings and the typed name share one state, so nothing is
shown before they load. New tests cover a late first load, an empty
default name, the On and Off buttons, and that only Enter saves.
The On state now leaves the variant out, so the button's default applies,
in place of naming it; cva gave an empty name the default too, which no
test could tell apart.
Copilot AI balanced review requested due to automatic review settings October 18, 2026 16:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…rors

iOS registers the remote_* commands so the frontend's calls are the same
everywhere, but only their error path builds there. The settings store
names build on macOS and Linux only, and the commands module allows the
types it doesn't read on other platforms.
…ller opens it

An Xtream series asked for by a controller is now loaded from the
provider, as the series list does when it opens one, with the cached
episodes when that fails or finds none. The target keeps the episodes of
the last four series it answered, so a load of one of them comes with the
episode: the player's channel list has no episodes to find it in.
The App and the player now have tests that fail without the remote
target hooks. useRemoteSessions takes anything but a list as none, and
its tests cover a late first load and a player that changes. Two
dependency lists that never change get disable comments.
The link's handshake and ping times are now part of its config, so tests
can shorten them. New tests cover a silent device, a slow consent answer,
pings only after a quiet spell, the growing wait between attempts, and a
device that leaves discovery for a moment. A request that comes while
the link waits to reconnect is dropped, which answers it the same way
the removed refuse did. Events from a replaced link are tested to stay
away from the webview.
…link's codes

A state from the other device that came before the load's answer is no
longer overwritten with an opening state, which also drops the old
item's tracks now. The link's own error codes start with link_.
# Conflicts:
#	apps/player/src-tauri/src/commands/tests/mod.rs
#	apps/player/src/components/player/VideoPlayer.test.tsx
#	apps/player/src/components/player/VideoPlayer.tsx
#	apps/player/src/lib/tauri.ts
# Conflicts:
#	apps/player/src/lib/tauri.ts
# Conflicts:
#	apps/player/src/hooks/useRemoteTarget.ts
…rches from paging

clean_name also drops format characters, such as bidi overrides and
zero-width spaces, and line and paragraph separators. A search page cut
to fit a frame has no next, since a search has no cursor to go on from.
remoteSetState calls go out one after another, so a playing state still
on its way can't land after the idle one a closing player sends. New
bucket tests catch its refill mutants. The mutants no test can tell
apart are skipped: the window calls the mock can't see, adding nothing
earned, and the load gap's < against <=. The local network test passes
over an interface with no subnet.
…wrong key

A decoy that closes, says nothing or breaks the handshake before the
key is checked no longer ends a connect or a resume: the next address is
tried, and with none left the link ends with the most telling reason. A
connect numbered at or below a link already left is refused, so a leave
that overtook it can't leave a link no one tracks.
…dent

Allow works only a second after a request shows or the window gains
focus, and ignores a click that began before it showed. Names are set
off from the text around them. The controllers bar lists a device that's
reconnecting, the picker looks before it says nothing was found, names
the same show the start of their ids, and rows keep still under the
pointer. A failed cancel says so, a link that comes back keeps the pages
loaded, a search says it's one, a request raised before the page
listened shows, and the connect dialog and catalog stay off phones.
… device runs

Each save builds on the device's last answer when its turn comes, so a
change that failed isn't sent again with the next one, and after a
failure the card shows what the device runs. A change shows at once,
Always allow asks first, and every choice says what it means before
it's made. Session names are set apart and wrap, two with one name show
the start of their ids, a reply no longer clears a name being typed, a
load error says remote control didn't start, a reset says it worked,
and fitName moves to src/lib.
…f a held picker order

The remote bar's notice shows outside the link's branch, since leaving
clears the link here before the backend answers, and it says what to do
rather than to try again. The picker lets go of the order it held when
it closes or opens. Quotes sit outside a name's bdi, and the confirm
step's heading sets the name apart too.
A save on its way says what the device runs, so a reset that answers
after it no longer puts the older settings back. Tests also cover a save
queued behind one that failed, and the card on the settings page.
@MaxMB15
MaxMB15 merged commit c45a488 into dev Oct 20, 2026
32 checks passed
@MaxMB15
MaxMB15 deleted the feat/remote-settings branch October 20, 2026 06:24
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.

2 participants