From cf61453e28afc1aede62adfa1b58082165afa79c Mon Sep 17 00:00:00 2001 From: foxyz Date: Sun, 18 Oct 2026 18:11:07 +0300 Subject: [PATCH] Invite: just the link (password included), one Copy button, a note only when needed, other addresses behind a toggle Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- README.md | 21 ++- apps/desktop/lib/src/session/invite.dart | 17 +- apps/desktop/lib/src/session/your_server.dart | 13 +- apps/desktop/lib/src/shell/connect_cards.dart | 2 +- apps/desktop/lib/src/shell/your_invite.dart | 156 +++++++++--------- apps/desktop/test/invite_links_test.dart | 17 +- apps/desktop/test/player_settings_test.dart | 52 ++++-- apps/desktop/test/server_isolate_test.dart | 6 + docs/ARCHITECTURE.md | 37 ++--- docs/HOW-IT-WORKS.md | 5 +- docs/PROTOCOL.md | 11 +- 12 files changed, 179 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc947dd..a9562a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,8 @@ Links you put up are downloaded once for everyone, quick chat and danmaku in the - What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About) - Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one - Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default) -- Invite links: Invite copies a starsync:// link; a friend clicks it (or just copies it and switches to Starsync) and the app asks "Join Bob's server?" -- Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded -- The password goes into an invite link only when you tick "Include the password in the link" +- Invite links: Invite copies just a starsync:// link, password included, so a friend just clicks it (or copies it and switches to Starsync) and the app asks "Join Bob's server?" +- Invite finds your internet address on its own and says when your router needs the port forwarded; Other addresses picks your home-network or VPN address instead - Starsync runs once: a link clicked while it is open goes to the open window - Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way - Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview diff --git a/README.md b/README.md index 898d6358..03857362 100644 --- a/README.md +++ b/README.md @@ -190,22 +190,21 @@ Your server starts with the app and stops when it closes. Only this app can reach it until you press **Invite** (in the strip, or in the member list): that opens it on your network at the port from Settings → Party & network (default 8080, with the password set there; blank = anyone can -join) and copies an invite link ("Join me on Starsync: -starsync://203.0.113.7:8080/?name=… · Starsync 1.3.0") to send to -friends. Nothing restarts: what plays and Up next carry on, and you are +join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`, +just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are the host (the crown). The link carries your internet address when the app can find it (it asks a "what is my IP" service such as ipify over HTTPS when you share), else this computer's home-network address (not a proxy one, nor a Docker or VM -bridge). The member list shows every address to pick from: **Internet** -for friends elsewhere, **Home network** for friends on the same Wi-Fi or -router, **VPN** (Tailscale and the like). Behind a router, friends -elsewhere get in only when the TCP port is forwarded on your router to -this computer (the card says so and names this computer's address); the -app does not open ports itself. The password is never in the link unless -you tick **Include the password in the link**; otherwise friends ask you -for it. Friends open nothing. **Stop sharing** (in the member list) +bridge). In the member list, **Copy invite link** copies it again; +**Other addresses** there lets you pick another: **Internet** for friends +elsewhere, **Home network** for friends on the same Wi-Fi or router, +**VPN** (Tailscale and the like). Behind a router, friends elsewhere get +in only when the TCP port is forwarded on your router to this computer +(the card says so); the app does not open ports itself. The link +includes the password when one is set, so friends just click it: share +it only with them. Friends open nothing. **Stop sharing** (in the member list) closes it again: everyone else leaves (their app goes back to their own server and says why), you and Up next stay. diff --git a/apps/desktop/lib/src/session/invite.dart b/apps/desktop/lib/src/session/invite.dart index 1ad74135..28bdf83b 100644 --- a/apps/desktop/lib/src/session/invite.dart +++ b/apps/desktop/lib/src/session/invite.dart @@ -36,20 +36,11 @@ class Invite { Invite at(String host) => Invite(host: host, port: port, name: name, password: password, version: version, fp: fp); - /// Its `starsync://` link; the password only when given ([secret]). + /// Its `starsync://` link with the server's password ([secret], the + /// host's; empty when none is set): what Invite copies, nothing around + /// it, so a friend just clicks it. InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret); - /// The text Invite copies: the link, whether to ask for the password - /// (not when [secret] puts it in the link), the version to match. - String textWith({String secret = ''}) => [ - 'Join me on Starsync: ${link(secret: secret).uri}', - if (password && secret.isEmpty) '(ask me for the password)', - if (version.isNotEmpty) '· Starsync $version', - ].join(' '); - - /// [textWith] without the password. - String get text => textWith(); - @override bool operator ==(Object other) => other is Invite && @@ -69,7 +60,7 @@ class Invite { /// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "Invite /// link"): where to join, the server's name for the prompt, and the -/// password when the host chose to put it in. [toString] never shows the +/// password when the server has one. [toString] never shows the /// password; [uri] does. class InviteLink { final String host; diff --git a/apps/desktop/lib/src/session/your_server.dart b/apps/desktop/lib/src/session/your_server.dart index f6b9a97d..9e8f97f1 100644 --- a/apps/desktop/lib/src/session/your_server.dart +++ b/apps/desktop/lib/src/session/your_server.dart @@ -98,6 +98,13 @@ class YourServer { /// An [invite] or [stopSharing] is under way. final busy = signal(false); + /// The password the server was shared with (empty: none). + String _secret = ''; + + /// What Invite copies: [invite]'s bare `starsync://` link with the + /// password the server was shared with, so a friend just clicks it. + String inviteLink(Invite invite) => invite.link(secret: _secret).uri; + /// Set when the server died without being stopped; the shell says so /// and clears it. The next [start] makes a new one. final crash = signal(null); @@ -171,13 +178,15 @@ class YourServer { final form = forms.form; final want = int.tryParse(form.hostPort.trim()) ?? 8080; await _accounts.ensureLoaded(validate: false); + final secret = form.hostSecret; final bound = await server.share( port: want, - secret: form.hostSecret, + secret: secret, // Guests without an account watch Jellyfin items through the // server, with this app's own sign-in on the item's server. jellyfin: relayCredentialsFor(_accounts), ); + _secret = secret; final local = inviteAddresses(await _interfaces()); final gen = ++_shareGen; reach.value = InviteReach(local: local, looking: true); @@ -185,7 +194,7 @@ class YourServer { // The internet address when it comes quickly; else a local one now // and the internet one when it lands. final public = await lookup.timeout(publicWait, onTimeout: () => null); - final invite = inviteFor(bound, password: form.hostSecret.isNotEmpty, host: reach.peek()?.best); + final invite = inviteFor(bound, password: secret.isNotEmpty, host: reach.peek()?.best); shared.value = invite; if (public == null) { unawaited(lookup.then((ip) { diff --git a/apps/desktop/lib/src/shell/connect_cards.dart b/apps/desktop/lib/src/shell/connect_cards.dart index 4b5c0e6d..99795de0 100644 --- a/apps/desktop/lib/src/shell/connect_cards.dart +++ b/apps/desktop/lib/src/shell/connect_cards.dart @@ -231,7 +231,7 @@ class JoinCard extends StatelessWidget { const Padding( padding: EdgeInsets.only(top: 8), child: Text( - 'Your friend gives you their invite (the address) and the password, if any. ' + 'Paste your friend\'s invite link (it carries the password), or their address and password. ' 'No account or Jellyfin needed. Your own server stays as it is.', style: TextStyle(color: Colors.grey, fontSize: 12), ), diff --git a/apps/desktop/lib/src/shell/your_invite.dart b/apps/desktop/lib/src/shell/your_invite.dart index 5701f73a..d8a36052 100644 --- a/apps/desktop/lib/src/shell/your_invite.dart +++ b/apps/desktop/lib/src/shell/your_invite.dart @@ -7,12 +7,12 @@ import '../session/invite.dart'; import '../widgets/copy_open.dart'; /// Your server in the members card. Not shared: "Only you can reach it" -/// and Invite (opens it and copies the invite link). Shared: where -/// friends can reach it ([YourServer.reach]: the internet address, with -/// whether the port needs forwarding, and this machine's home-network or -/// VPN address) to pick from, "Include the password" (off each time; the -/// password is never copied unless ticked), Copy invite link and Stop -/// sharing. +/// and Invite (opens it and copies the invite link). Shared: Copy invite +/// link (the bare `starsync://` link with the password, at the address +/// picked: the internet one when known, else this machine's), a short +/// line only when something needs the user ([YourServer.reach]: the port +/// needs forwarding, no internet address), Stop sharing, and the other +/// addresses (Internet / Home network / VPN) folded under a toggle. class YourInvite extends StatefulWidget { const YourInvite({super.key}); @@ -23,7 +23,7 @@ class YourInvite extends StatefulWidget { class _YourInviteState extends State { /// The address picked (null: the invite's own, the best one). String? _picked; - bool _withPassword = false; + bool _others = false; static String _kindLabel(AddressKind k) => switch (k) { AddressKind.public => 'Internet', @@ -66,93 +66,86 @@ class _YourInviteState extends State { final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))]; final picked = choices.any((c) => c.host == _picked) ? _picked! : invite.host; final kind = choices.where((c) => c.host == picked).firstOrNull?.kind ?? addressKind(picked); - final secret = graph.forms.form.hostSecret; - final local = reach?.local.firstOrNull; + final looking = reach?.looking ?? false; final note = switch (kind) { AddressKind.public when reach?.forwarding ?? false => - 'This is your router\'s address. Friends elsewhere get in only when port ${invite.port} is ' - 'forwarded on your router to this computer${local == null ? '' : ' ($local)'}.', - AddressKind.public => 'This computer has this internet address itself; a firewall can still ' - 'block port ${invite.port}.', - AddressKind.lan => 'Works for friends on your network (same Wi-Fi or router).', - AddressKind.vpn => 'Works for friends on the same VPN (Tailscale and the like).', + 'Friends outside your home network need port ${invite.port} forwarded to this computer.', + AddressKind.public => null, + _ when reach != null && !looking && reach.public == null => + "Couldn't find your internet address: this link works on your home network only.", + _ => null, }; - final noPublic = reach != null && !reach.looking && reach.public == null; return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: Column( key: const ValueKey('your-invite'), crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), - child: Text('Friends join at', style: small), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Tooltip( + message: invite.at(picked).address, + child: FilledButton.icon( + key: const ValueKey('copy-invite'), + onPressed: () { + final text = yours.inviteLink(invite.at(picked)); + graph.joinOffers.copied(text); + copyToClipboard(context, text, done: 'Invite link copied'); + }, + icon: const Icon(Icons.link, size: 18), + label: const Text('Copy invite link'), + ), + ), + Tooltip( + message: 'Sends everyone else out; you and Up next stay', + child: TextButton( + key: const ValueKey('stop-sharing'), + onPressed: busy ? null : () => yours.stopSharing(), + child: const Text('Stop sharing'), + ), + ), + ], ), - for (final c in choices) - _AddressRow( - key: ValueKey('invite-address-${c.host}'), - label: _kindLabel(c.kind), - address: invite.at(c.host).address, - selected: c.host == picked, - onTap: () => setState(() => _picked = c.host), - ), - if (reach?.looking ?? false) + if (looking) Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), + padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), child: Row(children: [ const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 8), Flexible(child: Text('Looking up your internet address…', style: small)), ]), + ) + else if (note != null) + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), + child: Text(note, key: const ValueKey('invite-note'), style: small), ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), - child: Text( - key: const ValueKey('invite-note'), - [ - note, - if (noPublic) - "Couldn't find your internet address: friends elsewhere need port ${invite.port} " - 'forwarded on your router, or a server they can all reach (like a VPS).', - ].join(' '), - style: small, - ), - ), - if (secret.isNotEmpty) - CheckboxListTile( - key: const ValueKey('invite-with-password'), - dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 4), - controlAffinity: ListTileControlAffinity.leading, - value: _withPassword, - onChanged: (v) => setState(() => _withPassword = v ?? false), - title: const Text('Include the password in the link'), - ), - Wrap( - spacing: 4, - children: [ - TextButton.icon( - key: const ValueKey('copy-invite'), - onPressed: () { - final text = invite.at(picked).textWith(secret: _withPassword ? secret : ''); - graph.joinOffers.copied(text); - copyToClipboard(context, text, - done: _withPassword ? 'Invite link copied, with the password' : 'Invite link copied'); - }, - icon: const Icon(Icons.copy, size: 16), - label: const Text('Copy invite link'), + if (choices.length > 1) ...[ + const SizedBox(height: 4), + TextButton.icon( + key: const ValueKey('invite-other-addresses'), + style: TextButton.styleFrom( + foregroundColor: muted, + visualDensity: VisualDensity.compact, + textStyle: Theme.of(context).textTheme.bodySmall, ), - TextButton( - key: const ValueKey('stop-sharing'), - onPressed: busy ? null : () => yours.stopSharing(), - child: const Text('Stop sharing'), - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 2, 8, 0), - child: Text('Stop sharing sends everyone else out; you and Up next stay.', style: small), - ), + onPressed: () => setState(() => _others = !_others), + icon: Icon(_others ? Icons.expand_less : Icons.expand_more, size: 16), + label: const Text('Other addresses'), + ), + if (_others) + for (final c in choices) + _AddressRow( + key: ValueKey('invite-address-${c.host}'), + label: _kindLabel(c.kind), + address: invite.at(c.host).address, + selected: c.host == picked, + onTap: () => setState(() => _picked = c.host), + ), + ], ], ), ); @@ -189,9 +182,9 @@ class _AddressRow extends StatelessWidget { } /// Invite: opens your server to friends if it is not yet and copies the -/// invite link at the best address (the internet one when found, else -/// this machine's; never the password); a port that can't be opened is -/// a notice. +/// bare invite link (with the password, when one is set) at the best +/// address (the internet one when found, else this machine's); a port +/// that can't be opened is a notice. Future inviteFriends(BuildContext context) async { final graph = AppScope.of(context); final Invite invite; @@ -204,6 +197,7 @@ Future inviteFriends(BuildContext context) async { return; } if (!context.mounted) return; - graph.joinOffers.copied(invite.text); - await copyToClipboard(context, invite.text, done: 'Invite link copied · ${invite.address}'); + final text = graph.yourServer.inviteLink(invite); + graph.joinOffers.copied(text); + await copyToClipboard(context, text, done: 'Invite link copied'); } diff --git a/apps/desktop/test/invite_links_test.dart b/apps/desktop/test/invite_links_test.dart index 61677504..f493b5cf 100644 --- a/apps/desktop/test/invite_links_test.dart +++ b/apps/desktop/test/invite_links_test.dart @@ -56,16 +56,15 @@ void main() { expect(InviteLink.parse('starsync://a b:1'), isNull); }); - test('the invite copies its link, the password only when asked; old apps still read the address', () { + test('the invite is just its link, with the password when one is set', () { const invite = Invite(host: '192.168.1.20', port: 8080, name: "Bob's server", password: true, version: '1.3.2'); - expect(invite.text, contains('(ask me for the password)')); - expect(invite.text, isNot(contains('pw='))); - final withPw = invite.textWith(secret: 'hunter2'); - expect(withPw, isNot(contains('ask me'))); - expect(inviteIn(withPw)?.secret, 'hunter2'); - expect(inviteIn(withPw)?.name, "Bob's server"); + final link = invite.link(secret: 'hunter2'); + expect(link.uri, 'starsync://192.168.1.20:8080/?name=Bob%27s+server&pw=hunter2'); + expect(inviteIn(link.uri), link); + expect(link.toString(), isNot(contains('hunter2'))); + expect(const Invite(host: '192.168.1.20', port: 8080).link().uri, 'starsync://192.168.1.20:8080'); // A 1.3.x app pasting it into the Join card's address. - expect(addressIn(withPw), (host: '192.168.1.20', port: '8080')); + expect(addressIn(link.uri), (host: '192.168.1.20', port: '8080')); expect(invite.at('203.0.113.7').address, '203.0.113.7:8080'); }); @@ -172,7 +171,7 @@ void main() { offers.fromClipboard('starsync://127.0.0.1:40000'); expect(offers.offer.peek(), isNull); // What this app just copied is not offered back to it. - const copied = 'Join me on Starsync: starsync://198.51.100.4:8080'; + const copied = 'starsync://198.51.100.4:8080'; offers.copied(copied); offers.fromClipboard(copied); expect(offers.offer.peek(), isNull); diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart index d45c1658..69e2ddc9 100644 --- a/apps/desktop/test/player_settings_test.dart +++ b/apps/desktop/test/player_settings_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show SystemChannels; import 'package:flutter_test/flutter_test.dart'; import 'package:signals/signals_flutter.dart' show signal; import 'package:sync_relay_core/core.dart'; @@ -301,13 +302,12 @@ void main() { expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse); }); - test('an invite: the address, whether to ask for a password, the version; pasted back', () { + test('an invite: the address, its link; pasted back', () { const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0'); expect(invite.address, '192.168.1.20:8080'); - expect(invite.text, - 'Join me on Starsync: starsync://192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'); + expect(invite.link().uri, 'starsync://192.168.1.20:8080'); expect(invite.fp, isNull, reason: 'reserved for pinned certificates'); - expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080')); + expect(addressIn(invite.link().uri), (host: '192.168.1.20', port: '8080')); expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000')); expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080')); expect(addressIn('just a host'), isNull); @@ -367,31 +367,51 @@ void main() { yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); - expect(find.text('192.168.1.20:8080'), findsOneWidget); expect(find.text('Looking up your internet address…'), findsOneWidget); expect(find.text('Copy invite link'), findsOneWidget); expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget); - expect(find.textContaining('friends on your network'), findsOneWidget); - // No password set: nothing to tick. - expect(find.byKey(const ValueKey('invite-with-password')), findsNothing); + // One address so far: nothing else to pick, nothing to say. + expect(find.byKey(const ValueKey('invite-other-addresses')), findsNothing); + expect(find.byKey(const ValueKey('invite-note')), findsNothing); - // Behind a router: the internet address first, with the forwarding - // it needs; the home address stays a choice. + // Behind a router: the internet address, one line on forwarding; + // the home address waits behind Other addresses. yours.shared.value = const Invite(host: '203.0.113.7', port: 8080, version: '1.3.0'); yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']); await tester.pumpAndSettle(); - expect(find.text('203.0.113.7:8080'), findsOneWidget); - expect(find.text('192.168.1.20:8080'), findsOneWidget); - expect(find.textContaining('port 8080 is forwarded on your router to this computer (192.168.1.20)'), + expect(find.text('Looking up your internet address…'), findsNothing); + expect(find.text('Friends outside your home network need port 8080 forwarded to this computer.'), findsOneWidget); + expect(find.text('192.168.1.20:8080'), findsNothing); + Future copy() async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.tap(find.byKey(const ValueKey('copy-invite'))); + await tester.pumpAndSettle(); + return copied; + } + + // Just the link, nothing around it. + expect(await copy(), matches(RegExp(r'^starsync://203\.0\.113\.7:8080(/\?name=\S+)?$'))); + + await tester.tap(find.byKey(const ValueKey('invite-other-addresses'))); + await tester.pumpAndSettle(); + expect(find.text('203.0.113.7:8080'), findsOneWidget); await tester.tap(find.text('192.168.1.20:8080')); await tester.pumpAndSettle(); - expect(find.textContaining('friends on your network'), findsOneWidget); + expect(find.byKey(const ValueKey('invite-note')), findsNothing, reason: 'home network picked'); + expect(await copy(), startsWith('starsync://192.168.1.20:8080')); - // No internet address found: say what friends elsewhere need. + // No internet address found: one line saying so. yours.reach.value = const InviteReach(local: ['192.168.1.20']); await tester.pumpAndSettle(); - expect(find.textContaining('VPS'), findsOneWidget); + expect(find.textContaining("Couldn't find your internet address"), findsOneWidget); + expect(_graph.notifier.toasts.peek().map((t) => t.text), contains('Invite link copied')); + await tester.pump(const Duration(seconds: 6)); // the toasts go }); }); } diff --git a/apps/desktop/test/server_isolate_test.dart b/apps/desktop/test/server_isolate_test.dart index e6722337..17b076d7 100644 --- a/apps/desktop/test/server_isolate_test.dart +++ b/apps/desktop/test/server_isolate_test.dart @@ -14,6 +14,7 @@ import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/state/server_isolate.dart'; import 'package:sync_relay_server/server.dart' show SyncServer; +import 'package:starsync/src/session/invite.dart' show InviteLink; import 'package:starsync/src/session/navigation.dart' show Place; import 'test_graph.dart'; @@ -166,6 +167,11 @@ void main() { final invite = await yours.invite(); expect(invite.port, port); expect(invite.password, isTrue); + // The link is the key: the password it was shared with, even when + // the setting changes later. + graph.forms.form.hostSecret = 'changed'; + expect(InviteLink.parse(yours.inviteLink(invite))?.secret, 'pw'); + graph.forms.form.hostSecret = 'pw'; expect(yours.shared.peek(), invite); expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite'); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9179f459..eafbb1fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,8 +61,8 @@ 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 ("'s server"), whether a password is needed (never the - password), the version and an `fp` field reserved for a pinned + name ("'s server"), whether a password is needed (not the + password itself), 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 @@ -82,10 +82,10 @@ attach and detach the network listener (below). 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: + when it lands. `YourServer.inviteLink(invite)` is what Invite copies: + the bare link with the password the server was shared with + (`starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=…`; the link is + the key, nothing around 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; @@ -1993,8 +1993,8 @@ panel) and chat messages (Copy message, and a link in it). "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`, `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 + it is not yet and copies the invite link ("Invite link copied"); 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 @@ -2004,15 +2004,14 @@ 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 (`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 + shared (`YourInvite`), Copy invite link (filled; the address the app + picks, `InviteReach.best`, in its tooltip) and Stop sharing; one line + only when something needs the user ("Looking up your internet + address…"; the router's address: the port needs forwarding to this + computer; no internet address found: the link works on the home network + only); "Other addresses" (folded, only with more than one) lists each + address of `YourServer.reach` (Internet, Home network, VPN) to pick + what Copy copies; 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. @@ -2035,8 +2034,8 @@ panel) and chat messages (Copy message, and a link in it). owned by the shell so an attempt and its error outlive the card; the version gate's refusal says who has to update and, when it is you, offers "Check for the update". The card closes itself once in the - friend's server. It says that a friend gives the invite and the - password, that no account or Jellyfin is needed and that your own + friend's server. It says to paste a friend's invite link (it carries + the password) or their address and password, that no account or Jellyfin is needed and that your own server stays as it is. - **Profile** (`profile_card.dart`, `session/identity.dart`): a card tinted with your colour: picture (hover "Change" opens `CropPicker`: diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index edaed4a5..7f24475a 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -46,8 +46,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format: 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 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. + address when the app finds it and the password when one is set); 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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 07746044..3fe44bee 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -629,15 +629,14 @@ starsync://[fe80::1]:8080 - `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 +- `pw` (optional): the server password, always there when the server has + one: the link is the key, a friend just clicks it. 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: [(ask me for the - password)] · Starsync "; 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. +- Invite copies the bare link, nothing around it. 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)