Invite links: starsync:// opens the app and asks to join; one app per profile; real invite address; clipboard pick-up
- starsync://host:port/?name=&pw= (PROTOCOL.md "Invite link"); the password only when ticked, never in logs - Linux: launcher entry takes x-scheme-handler/starsync (%u, StartupNotify) and becomes its xdg-mime default; Windows: HKCU\Software\Classes\starsync -> starsync.exe - InstanceLock: lock file + loopback handoff per profile folder; the running app comes up with the second start's activation token (Wayland/X11) or AllowSetForegroundWindow (Windows) - Sharing looks up the internet address (HTTPS services with fallbacks); members card picks Internet / Home network / VPN and says when the port needs forwarding - JoinOffers: link and clipboard prompt with /health, joins through ConnectFlow; dismissed clipboard invites kept as fingerprints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d48915dc47
commit
6048fbe7d1
35 files changed
+1943
-181
No files matched your search
+111
-25
@@ -61,21 +61,53 @@ attach and detach the network listener (below).
|
||||
its reason; nothing else changes.
|
||||
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
|
||||
server if it is not yet and returns an `Invite`: the host, the port, the
|
||||
name, whether a password is needed (never the password), the version
|
||||
and an `fp` field reserved for a pinned certificate (`wss://` with a
|
||||
self-signed one, later; always null). The host is the best of
|
||||
`inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback,
|
||||
link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN
|
||||
proxies like Clash hand out) address and no container or VM bridge
|
||||
(`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`,
|
||||
`podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168
|
||||
first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any
|
||||
other (public) address. No public-address lookup: the members card says
|
||||
that friends elsewhere need the port forwarded or a server they can all
|
||||
reach. `Invite.text` is what Invite copies ("Join me on Starsync:
|
||||
192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0");
|
||||
`addressIn` reads the address back out of a pasted invite (the Join
|
||||
card's address field). `stopSharing` unshares.
|
||||
name ("<your name>'s server"), whether a password is needed (never the
|
||||
password), the version and an `fp` field reserved for a pinned
|
||||
certificate (`wss://` with a self-signed one, later; always null). This
|
||||
machine's addresses are `inviteAddresses` over `NetworkInterface.list`:
|
||||
IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP
|
||||
(198.18.0.0/15, what TUN proxies like Clash hand out) address and no
|
||||
container or VM bridge (`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`,
|
||||
`vboxnet*`, `cni*`, `podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`,
|
||||
VirtualBox, VMware); 192.168 first, then 10/8 and 172.16/12, then a mesh
|
||||
VPN's 100.64/10, then any other (public) address. Sharing also looks up
|
||||
the internet address (`lookUpPublicAddress`, `session/public_address.dart`):
|
||||
plain-text "what is my IP" services over HTTPS (ipify, icanhazip,
|
||||
checkip.amazonaws, ifconfig.me), asked in order until one answers a
|
||||
public IPv4 address, 3 s each; that tells those services this network's
|
||||
address, nothing else. `YourServer.reach` (`InviteReach`) holds both: the
|
||||
internet address first, then this machine's, each with its
|
||||
`AddressKind` (internet, home network, VPN); `forwarding` when the
|
||||
internet address is not on this machine (behind a router: friends
|
||||
elsewhere get in only with the port forwarded). No UPnP. The invite
|
||||
carries the internet address when the lookup answers within 4 s
|
||||
(`publicWait`), else the best local one, switched to the internet one
|
||||
when it lands. `Invite.text` is what Invite copies ("Join me on
|
||||
Starsync: starsync://203.0.113.7:8080/?name=Bob%27s+server (ask me for the
|
||||
password) · Starsync 1.3.0"); `textWith(secret:)` puts the password in
|
||||
the link instead, only when the user ticks it. The link format:
|
||||
[PROTOCOL.md "Invite link"](PROTOCOL.md#invite-link) (`InviteLink`:
|
||||
`parse`, `uri`, a `toString` without the password). `inviteIn` finds an
|
||||
invite in a text (a link anywhere, or a 1.3.x plain invite's address;
|
||||
never a bare address); `addressIn` reads `host:port` out of anything
|
||||
(the Join card's address field; a 1.3.x app pasting a link reads its
|
||||
address this way). `stopSharing` unshares.
|
||||
- **Links and the join prompt** (`session/join_offers.dart`, `JoinOffers`,
|
||||
in the composition root): a clicked `starsync://` link (the app's start
|
||||
arguments, or a second start's, see "Single instance" under "App
|
||||
events and platform ports") and an invite on the clipboard when the
|
||||
window gains the focus (and at start) become `offer`: "Join Bob's server
|
||||
(203.0.113.7:8080)?" with the server's `/health` (its name, version,
|
||||
how many watch, the version gate's sentence, or unreachable). Not
|
||||
offered: links to your own server (`YourServer.isOwn`), to the server
|
||||
you are in, and the text this app just copied (`copied`). Join
|
||||
(`accept`) fills the Join card's fields (a link without a password
|
||||
takes the one saved for that recent server) and joins through
|
||||
`ConnectFlow` and `Navigation.join` (version gate, recents); a failure
|
||||
opens the Join card with its error. A clipboard invite joined or
|
||||
dismissed (Not now) is not offered again: the connect form box keeps the
|
||||
last 30 as fingerprints (a hash of the link), never the links; the
|
||||
clipboard's text is only parsed, never logged or stored.
|
||||
- **Where you are** (`session/navigation.dart`, `Navigation`): `place` is
|
||||
`none` (starting, or between two servers), `yours` or `friend`. The app
|
||||
starts at home: your server, joined over 127.0.0.1 with the host key
|
||||
@@ -136,7 +168,7 @@ only stopping sharing sends guests out.
|
||||
`Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator` (handed the settings and the watch tracker), the
|
||||
connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
|
||||
`YourServer`, `Navigation`, the library's matches (`ShowMatcher`,
|
||||
`YourServer`, `Navigation`, the join prompt (`JoinOffers`), the library's matches (`ShowMatcher`,
|
||||
"Library", whose `refForPath` it hands the coordinator as `fileRef`, and
|
||||
a `LocalNextFinder` over it as `nextFile`) and watch tracking
|
||||
(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it
|
||||
@@ -1798,8 +1830,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
|
||||
focus, blur, minimise and restore, and the shell's own `hide`, wired to
|
||||
`SessionCoordinator.windowActivity` and `Notifier.window` in `main`),
|
||||
bringing the window up and focusing it (`focus`), dragging (`WindowDragArea`), close, the way out
|
||||
(save, hide, quit), attention, the clipboard paste (files, else text)
|
||||
bringing the window up and focusing it (`focus`, with another start's
|
||||
activation token, see "Single instance" below), dragging (`WindowDragArea`), close, the way out
|
||||
(save, hide, quit), attention, the clipboard paste (files, else text), the clipboard's
|
||||
text alone (`readClipboardText`, for the join prompt)
|
||||
and drop targets (fox_utils).
|
||||
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
|
||||
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
|
||||
@@ -1824,6 +1858,47 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
|
||||
rename; resolved in `main` before the composition root exists, so not a
|
||||
`Ports` field.
|
||||
|
||||
**Single instance** (`platform/single_instance.dart`, `InstanceLock`): one
|
||||
app per profile folder (`<app support>`, or `profiles/<name>/` with
|
||||
`STARSYNC_PROFILE`, so each profile is its own instance). `main` claims
|
||||
it right after resolving the folders, before the log or the window: an
|
||||
exclusive `RandomAccessFile.lock` on `instance.lock` (the OS drops it
|
||||
when the process ends, a crash too) and a loopback listener whose port,
|
||||
a random token and the pid go in `instance.json`. A start that finds the
|
||||
lock held sends its arguments and activation token there (one JSON line
|
||||
with the token) and ends at once through the runner (`exit` on the
|
||||
`starsync/window` channel: `_exit` / `TerminateProcess`, no exit
|
||||
handlers) before its window ever shows; the running app brings its
|
||||
window up (`ShellPort.focus(activation:)`) and hands the arguments to
|
||||
`JoinOffers`. Bringing a window up from another process needs the
|
||||
desktop's permission: on Linux the second start's `XDG_ACTIVATION_TOKEN`
|
||||
(Wayland) or `DESKTOP_STARTUP_ID` (X11), which the runner keeps before
|
||||
GTK takes it (`launchToken`) and the running app presents its window
|
||||
with (`present`: `gdk_wayland_display_set_startup_notification_id` /
|
||||
`gtk_window_set_startup_id`, then `gtk_window_present`; the launcher
|
||||
entry has `StartupNotify=true` so launchers hand one out); on Windows
|
||||
the second start calls `AllowSetForegroundWindow` for the holder's pid
|
||||
(`platform/desktop/windows_api.dart`). While the app closes it answers
|
||||
"closing" and a new start waits for the lock (up to 20 s; 5 s without
|
||||
any answer), then starts anyway; Update and restart gives the lock up
|
||||
before starting the new binary. Invite links among the arguments are
|
||||
not passed on to that restart (`launchArgs`).
|
||||
|
||||
**Link handler** (`platform/url_scheme.dart`): on Linux the launcher
|
||||
entry ("App icon and launcher entry") lists
|
||||
`MimeType=x-scheme-handler/starsync;` with `Exec=... %u`, and
|
||||
`xdg-mime default dev.foxyz.starsync.desktop x-scheme-handler/starsync`
|
||||
runs when `xdg-mime query default` names another (then
|
||||
`update-desktop-database` on the applications folder). On Windows
|
||||
(release builds) `HKCU\Software\Classes\starsync`: default value
|
||||
`URL:Starsync invite`, `URL Protocol` empty, `DefaultIcon` and
|
||||
`shell\open\command` = `"<install>\starsync.exe" "%1"` (`starsync.exe`
|
||||
next to an old-named `watch_together.exe` copy when it is there),
|
||||
written per user (no admin) through `RegSetKeyValueW` at start only when
|
||||
the command differs (first start, a moved install, an update from an old
|
||||
name).
|
||||
|
||||
Still direct: the updater's restart
|
||||
(`Process.start`), the session's own file reads for what it plays or
|
||||
relays (`QueueController`, thumbnails, cookies import), app-private
|
||||
@@ -1886,8 +1961,9 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
`PresencePolicy`, so not away, gone or errored; "connecting…",
|
||||
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
|
||||
Invite on yours or Leave on a friend's (asks first, then you are home).
|
||||
Invite (`inviteFriends`) shares your server if it is not yet and copies
|
||||
the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open
|
||||
Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if
|
||||
it is not yet and copies the invite link, never the password ("Invite
|
||||
link copied · 203.0.113.7:8080"); a port it cannot open
|
||||
is a notice pointing at Settings. The stack
|
||||
opens the members card (the server's version under its title: yours is
|
||||
this app's, a joined one's from `/health.version`; the source
|
||||
@@ -1897,9 +1973,16 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
"reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit
|
||||
out (anyone, the host too), your own row Sit out / Join back; at the
|
||||
bottom, on yours: not shared, "Only you can reach it" and Invite;
|
||||
shared, the invite's address, Copy invite, Stop sharing and a note
|
||||
that friends outside the LAN need a forwarded port or a reachable
|
||||
server; on a friend's, "Copy invite address" (the dialled address)).
|
||||
shared (`YourInvite`), "Friends join at" with each address of
|
||||
`YourServer.reach` to pick (Internet, Home network, VPN; "Looking up
|
||||
your internet address…" meanwhile), a note for the one picked (the
|
||||
router's address: the port must be forwarded to this computer's home
|
||||
address; this computer's own: a firewall can still block it; home
|
||||
network, VPN: who it works for; no internet address found: forwarding
|
||||
or a server all can reach), "Include the password in the link" (only
|
||||
with a password; off each time the card opens), Copy invite link and
|
||||
Stop sharing; on a friend's, "Copy invite link" (the dialled address
|
||||
and its name, never a password)).
|
||||
Empty strip space is the window
|
||||
drag area; the window buttons sit at its end.
|
||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||
@@ -2456,7 +2539,8 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
||||
once Flutter is up) are kept and written first.
|
||||
- **Redaction** (`redactLog`): every line, in one place
|
||||
(`formatLogLine`), before console or file: URL query strings cut to
|
||||
their parameter names (signed CDN links, Jellyfin `api_key`),
|
||||
their parameter names (signed CDN links, Jellyfin `api_key`, an invite
|
||||
link's `pw`: `starsync://` counts as a URL),
|
||||
`user:pass@` in URLs, member tokens in `/r /m /j /a /i /l` paths, and the
|
||||
values of passwords, secrets, tokens, API keys, cookies,
|
||||
`Authorization` / `X-Emby-*` headers (`name=value`, `"name":"value"`,
|
||||
@@ -2553,7 +2637,9 @@ take the icon from the `.desktop` file of that id. A tarball install has
|
||||
none, so on start a Linux release build (`platform/desktop_entry.dart`,
|
||||
skipped in debug) writes
|
||||
`~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the
|
||||
running binary, `StartupWMClass` and `Icon` = the id) and the icons into
|
||||
running binary with `%u`, `StartupWMClass` and `Icon` = the id,
|
||||
`StartupNotify`, the `starsync://` link type: "Link handler" under "App
|
||||
events and platform ports") and the icons into
|
||||
the hicolor theme, only when they changed, and deletes the entry and
|
||||
icons 1.2.x wrote under its id `moe.senkochan.watch_together` (those exact
|
||||
files only). The runner also sets the window icon from the bundle (X11).
|
||||
|
||||
+6
-3
@@ -61,8 +61,9 @@ the timings of each media change (`SwitchTrace`).
|
||||
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
|
||||
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
|
||||
client id: two instances without it are one install, and the second takes
|
||||
the first's member over) and logs under
|
||||
client id: without it a second start is the same install, so it hands
|
||||
its arguments to the running app and ends; each profile is its own
|
||||
instance) and logs under
|
||||
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
|
||||
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
|
||||
UI"). Without it nothing changes. Run one plain instance and one with
|
||||
@@ -161,7 +162,9 @@ Three suites, run from each package's folder:
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||
shader list, sound engine with a fake backend, invite addresses, the
|
||||
version gate, the media plan's table with the Jellyfin relay rule and
|
||||
`starsync://` links, the internet address lookup against fake
|
||||
services, the join prompt and one app per profile
|
||||
(`invite_links_test`), the version gate, the media plan's table with the Jellyfin relay rule and
|
||||
the "Waiting for" status (`media_plan_test`), watch tracking and the
|
||||
device history (`watch_tracker_test`), what continues a file of a
|
||||
local show (`queue_logic_test`), the video view's output size and
|
||||
|
||||
@@ -45,7 +45,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
not in a friend's, so watching alone works like a session (progress,
|
||||
next episode). Invite opens it to friends on your network (a second
|
||||
listener on the same server: nothing restarts, the video and Up next
|
||||
carry on) and copies an invite; Stop sharing closes it again.
|
||||
carry on) and copies an invite link (`starsync://`, with your internet
|
||||
address when the app finds it); Stop sharing closes it again. A friend
|
||||
clicks the link, or just copies it, and their app asks to join.
|
||||
- **Dedicated server**: `starsync-server`, a separate program in the
|
||||
release tarball, for an always-on box. See the README.
|
||||
|
||||
|
||||
@@ -613,6 +613,32 @@ media URL unchanged, its ready flag and presence kept; after a
|
||||
non-resumed `welcome` (grace over, server restarted) the client sends
|
||||
its intent again (`ready`, `presence`).
|
||||
|
||||
## Invite link
|
||||
|
||||
Not a WebSocket message: what one app hands another (clipboard, chat,
|
||||
a clicked link), so every version reads the same format.
|
||||
|
||||
```
|
||||
starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=hunter2
|
||||
starsync://192.168.1.20:8080
|
||||
starsync://[fe80::1]:8080
|
||||
```
|
||||
|
||||
- Scheme `starsync`, the server's host (IPv6 in brackets) and port; no
|
||||
port means 8080. The path is `/` or empty.
|
||||
- `name` (optional): the server's name for the prompt before its
|
||||
`/health` answers (at most 64 characters are shown, control characters
|
||||
dropped).
|
||||
- `pw` (optional): the server password, only when the host chose to put
|
||||
it in. Logs never show it (`redactLog` cuts a `starsync://` query to its
|
||||
names).
|
||||
- Unknown query keys are ignored; anything else (no host, a port outside
|
||||
1–65535) is no link.
|
||||
- The text Invite copies is "Join me on Starsync: <link> [(ask me for the
|
||||
password)] · Starsync <version>"; an app also takes a 1.3.x plain
|
||||
invite ("Join me on Starsync: 192.168.1.20:8080 …"), and a 1.3.x app
|
||||
reads the `host:port` out of a link.
|
||||
|
||||
## Queue (proto 12)
|
||||
|
||||
Up next: what plays after the current media, held by the server for
|
||||
|
||||
+5
-2
@@ -112,8 +112,11 @@ ever happen mid-session (D8 says never during playback).
|
||||
Left of step 3 (your server): a human check of Invite (Linux and, at
|
||||
1.3.0, Windows' firewall prompt, now on Invite rather than at start;
|
||||
that the invite skips the proxy's 198.18.x here) and of the update band
|
||||
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
|
||||
members card only says friends elsewhere need the port forwarded. Your
|
||||
from 1.2.1-dev.3 in real use. No UPnP: the members card says when
|
||||
friends elsewhere need the port forwarded. Human checks of the
|
||||
`starsync://` links: clicked in a browser and in Discord on KDE Wayland,
|
||||
X11 and Windows (the window coming to the front, the registry key after
|
||||
first start and after an update from a `watch_together.exe` copy). Your
|
||||
server's `/health` name is the profile name at start (a rename shows
|
||||
after a restart). Joining a friend while others are on
|
||||
your server leaves them there without you, unwarned; a join refused
|
||||
|
||||
Reference in new issue
Block a user