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:
foxyzandClaude Opus 5.5 committed 2026-10-18 17:18:25 +03:00
1 parent d48915dc47
commit 6048fbe7d1
35 files changed
+1943 -181

No files matched your search

+111 -25
View File
@@ -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).