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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 18:11:07 +03:00
1 parent 5116923858
commit cf61453e28
12 files changed
+178 -162

No files matched your search

+2 -3
View File
@@ -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) - 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 - 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) - 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 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, lets you pick it or your home-network address, and says when your router needs the port forwarded - 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
- The password goes into an invite link only when you tick "Include the password in the link"
- Starsync runs once: a link clicked while it is open goes to the open window - 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 - 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 - 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
+10 -11
View File
@@ -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 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 & list): that opens it on your network at the port from Settings → Party &
network (default 8080, with the password set there; blank = anyone can network (default 8080, with the password set there; blank = anyone can
join) and copies an invite link ("Join me on Starsync: join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
starsync://203.0.113.7:8080/?name=… · Starsync 1.3.0") to send to just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
friends. Nothing restarts: what plays and Up next carry on, and you are
the host (the crown). the host (the crown).
The link carries your internet address when the app can find it (it asks 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 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 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** bridge). In the member list, **Copy invite link** copies it again;
for friends elsewhere, **Home network** for friends on the same Wi-Fi or **Other addresses** there lets you pick another: **Internet** for friends
router, **VPN** (Tailscale and the like). Behind a router, friends elsewhere, **Home network** for friends on the same Wi-Fi or router,
elsewhere get in only when the TCP port is forwarded on your router to **VPN** (Tailscale and the like). Behind a router, friends elsewhere get
this computer (the card says so and names this computer's address); the in only when the TCP port is forwarded on your router to this computer
app does not open ports itself. The password is never in the link unless (the card says so); the app does not open ports itself. The link
you tick **Include the password in the link**; otherwise friends ask you includes the password when one is set, so friends just click it: share
for it. Friends open nothing. **Stop sharing** (in the member list) it only with them. Friends open nothing. **Stop sharing** (in the member list)
closes it again: closes it again:
everyone else leaves (their app goes back to their own server and says everyone else leaves (their app goes back to their own server and says
why), you and Up next stay. why), you and Up next stay.
+4 -13
View File
@@ -36,20 +36,11 @@ class Invite {
Invite at(String host) => Invite at(String host) =>
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp); 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); 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 @override
bool operator ==(Object other) => bool operator ==(Object other) =>
other is Invite && other is Invite &&
@@ -69,7 +60,7 @@ class Invite {
/// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "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 /// 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. /// password; [uri] does.
class InviteLink { class InviteLink {
final String host; final String host;
+11 -2
View File
@@ -98,6 +98,13 @@ class YourServer {
/// An [invite] or [stopSharing] is under way. /// An [invite] or [stopSharing] is under way.
final busy = signal(false); 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 /// Set when the server died without being stopped; the shell says so
/// and clears it. The next [start] makes a new one. /// and clears it. The next [start] makes a new one.
final crash = signal<String?>(null); final crash = signal<String?>(null);
@@ -171,13 +178,15 @@ class YourServer {
final form = forms.form; final form = forms.form;
final want = int.tryParse(form.hostPort.trim()) ?? 8080; final want = int.tryParse(form.hostPort.trim()) ?? 8080;
await _accounts.ensureLoaded(validate: false); await _accounts.ensureLoaded(validate: false);
final secret = form.hostSecret;
final bound = await server.share( final bound = await server.share(
port: want, port: want,
secret: form.hostSecret, secret: secret,
// Guests without an account watch Jellyfin items through the // Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in on the item's server. // server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts), jellyfin: relayCredentialsFor(_accounts),
); );
_secret = secret;
final local = inviteAddresses(await _interfaces()); final local = inviteAddresses(await _interfaces());
final gen = ++_shareGen; final gen = ++_shareGen;
reach.value = InviteReach(local: local, looking: true); 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 // The internet address when it comes quickly; else a local one now
// and the internet one when it lands. // and the internet one when it lands.
final public = await lookup.timeout(publicWait, onTimeout: () => null); 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; shared.value = invite;
if (public == null) { if (public == null) {
unawaited(lookup.then((ip) { unawaited(lookup.then((ip) {
@@ -231,7 +231,7 @@ class JoinCard extends StatelessWidget {
const Padding( const Padding(
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: Text( 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.', 'No account or Jellyfin needed. Your own server stays as it is.',
style: TextStyle(color: Colors.grey, fontSize: 12), style: TextStyle(color: Colors.grey, fontSize: 12),
), ),
+74 -80
View File
@@ -7,12 +7,12 @@ import '../session/invite.dart';
import '../widgets/copy_open.dart'; import '../widgets/copy_open.dart';
/// Your server in the members card. Not shared: "Only you can reach it" /// Your server in the members card. Not shared: "Only you can reach it"
/// and Invite (opens it and copies the invite link). Shared: where /// and Invite (opens it and copies the invite link). Shared: Copy invite
/// friends can reach it ([YourServer.reach]: the internet address, with /// link (the bare `starsync://` link with the password, at the address
/// whether the port needs forwarding, and this machine's home-network or /// picked: the internet one when known, else this machine's), a short
/// VPN address) to pick from, "Include the password" (off each time; the /// line only when something needs the user ([YourServer.reach]: the port
/// password is never copied unless ticked), Copy invite link and Stop /// needs forwarding, no internet address), Stop sharing, and the other
/// sharing. /// addresses (Internet / Home network / VPN) folded under a toggle.
class YourInvite extends StatefulWidget { class YourInvite extends StatefulWidget {
const YourInvite({super.key}); const YourInvite({super.key});
@@ -23,7 +23,7 @@ class YourInvite extends StatefulWidget {
class _YourInviteState extends State<YourInvite> { class _YourInviteState extends State<YourInvite> {
/// The address picked (null: the invite's own, the best one). /// The address picked (null: the invite's own, the best one).
String? _picked; String? _picked;
bool _withPassword = false; bool _others = false;
static String _kindLabel(AddressKind k) => switch (k) { static String _kindLabel(AddressKind k) => switch (k) {
AddressKind.public => 'Internet', AddressKind.public => 'Internet',
@@ -66,28 +66,77 @@ class _YourInviteState extends State<YourInvite> {
final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))]; final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))];
final picked = choices.any((c) => c.host == _picked) ? _picked! : 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 kind = choices.where((c) => c.host == picked).firstOrNull?.kind ?? addressKind(picked);
final secret = graph.forms.form.hostSecret; final looking = reach?.looking ?? false;
final local = reach?.local.firstOrNull;
final note = switch (kind) { final note = switch (kind) {
AddressKind.public when reach?.forwarding ?? false => AddressKind.public when reach?.forwarding ?? false =>
'This is your router\'s address. Friends elsewhere get in only when port ${invite.port} is ' 'Friends outside your home network need port ${invite.port} forwarded to this computer.',
'forwarded on your router to this computer${local == null ? '' : ' ($local)'}.', AddressKind.public => null,
AddressKind.public => 'This computer has this internet address itself; a firewall can still ' _ when reach != null && !looking && reach.public == null =>
'block port ${invite.port}.', "Couldn't find your internet address: this link works on your home network only.",
AddressKind.lan => 'Works for friends on your network (same Wi-Fi or router).', _ => null,
AddressKind.vpn => 'Works for friends on the same VPN (Tailscale and the like).',
}; };
final noPublic = reach != null && !reach.looking && reach.public == null;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column( child: Column(
key: const ValueKey('your-invite'), key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Wrap(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), spacing: 8,
child: Text('Friends join at', style: small), 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'),
),
),
],
),
if (looking)
Padding(
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),
),
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,
),
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) for (final c in choices)
_AddressRow( _AddressRow(
key: ValueKey('invite-address-${c.host}'), key: ValueKey('invite-address-${c.host}'),
@@ -96,63 +145,7 @@ class _YourInviteState extends State<YourInvite> {
selected: c.host == picked, selected: c.host == picked,
onTap: () => setState(() => _picked = c.host), onTap: () => setState(() => _picked = c.host),
), ),
if (reach?.looking ?? false)
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 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)),
]),
),
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'),
),
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),
),
], ],
), ),
); );
@@ -189,9 +182,9 @@ class _AddressRow extends StatelessWidget {
} }
/// Invite: opens your server to friends if it is not yet and copies the /// 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 /// bare invite link (with the password, when one is set) at the best
/// this machine's; never the password); a port that can't be opened is /// address (the internet one when found, else this machine's); a port
/// a notice. /// that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async { Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context); final graph = AppScope.of(context);
final Invite invite; final Invite invite;
@@ -204,6 +197,7 @@ Future<void> inviteFriends(BuildContext context) async {
return; return;
} }
if (!context.mounted) return; if (!context.mounted) return;
graph.joinOffers.copied(invite.text); final text = graph.yourServer.inviteLink(invite);
await copyToClipboard(context, invite.text, done: 'Invite link copied · ${invite.address}'); graph.joinOffers.copied(text);
await copyToClipboard(context, text, done: 'Invite link copied');
} }
+8 -9
View File
@@ -56,16 +56,15 @@ void main() {
expect(InviteLink.parse('starsync://a b:1'), isNull); 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'); 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)')); final link = invite.link(secret: 'hunter2');
expect(invite.text, isNot(contains('pw='))); expect(link.uri, 'starsync://192.168.1.20:8080/?name=Bob%27s+server&pw=hunter2');
final withPw = invite.textWith(secret: 'hunter2'); expect(inviteIn(link.uri), link);
expect(withPw, isNot(contains('ask me'))); expect(link.toString(), isNot(contains('hunter2')));
expect(inviteIn(withPw)?.secret, 'hunter2'); expect(const Invite(host: '192.168.1.20', port: 8080).link().uri, 'starsync://192.168.1.20:8080');
expect(inviteIn(withPw)?.name, "Bob's server");
// A 1.3.x app pasting it into the Join card's address. // 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'); 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'); offers.fromClipboard('starsync://127.0.0.1:40000');
expect(offers.offer.peek(), isNull); expect(offers.offer.peek(), isNull);
// What this app just copied is not offered back to it. // 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.copied(copied);
offers.fromClipboard(copied); offers.fromClipboard(copied);
expect(offers.offer.peek(), isNull); expect(offers.offer.peek(), isNull);
+36 -16
View File
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show SystemChannels;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart' show signal; import 'package:signals/signals_flutter.dart' show signal;
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -301,13 +302,12 @@ void main() {
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse); 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'); 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.address, '192.168.1.20:8080');
expect(invite.text, expect(invite.link().uri, 'starsync://192.168.1.20:8080');
'Join me on Starsync: starsync://192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0');
expect(invite.fp, isNull, reason: 'reserved for pinned certificates'); 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('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080')); expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
expect(addressIn('just a host'), isNull); expect(addressIn('just a host'), isNull);
@@ -367,31 +367,51 @@ void main() {
yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true); yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); 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('Looking up your internet address…'), findsOneWidget);
expect(find.text('Copy invite link'), findsOneWidget); expect(find.text('Copy invite link'), findsOneWidget);
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget); expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
expect(find.textContaining('friends on your network'), findsOneWidget); // One address so far: nothing else to pick, nothing to say.
// No password set: nothing to tick. expect(find.byKey(const ValueKey('invite-other-addresses')), findsNothing);
expect(find.byKey(const ValueKey('invite-with-password')), findsNothing); expect(find.byKey(const ValueKey('invite-note')), findsNothing);
// Behind a router: the internet address first, with the forwarding // Behind a router: the internet address, one line on forwarding;
// it needs; the home address stays a choice. // the home address waits behind Other addresses.
yours.shared.value = const Invite(host: '203.0.113.7', port: 8080, version: '1.3.0'); 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']); yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('203.0.113.7:8080'), findsOneWidget); expect(find.text('Looking up your internet address…'), findsNothing);
expect(find.text('192.168.1.20:8080'), findsOneWidget); expect(find.text('Friends outside your home network need port 8080 forwarded to this computer.'),
expect(find.textContaining('port 8080 is forwarded on your router to this computer (192.168.1.20)'),
findsOneWidget); findsOneWidget);
expect(find.text('192.168.1.20:8080'), findsNothing);
Future<String?> 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.tap(find.text('192.168.1.20:8080'));
await tester.pumpAndSettle(); 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']); yours.reach.value = const InviteReach(local: ['192.168.1.20']);
await tester.pumpAndSettle(); 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
}); });
}); });
} }
@@ -14,6 +14,7 @@ import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/server_isolate.dart'; import 'package:starsync/src/state/server_isolate.dart';
import 'package:sync_relay_server/server.dart' show SyncServer; 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 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart'; import 'test_graph.dart';
@@ -166,6 +167,11 @@ void main() {
final invite = await yours.invite(); final invite = await yours.invite();
expect(invite.port, port); expect(invite.port, port);
expect(invite.password, isTrue); 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(yours.shared.peek(), invite);
expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite'); expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite');
+18 -19
View File
@@ -61,8 +61,8 @@ attach and detach the network listener (below).
its reason; nothing else changes. its reason; nothing else changes.
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the - **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
server if it is not yet and returns an `Invite`: the host, the port, the server if it is not yet and returns an `Invite`: the host, the port, the
name ("<your name>'s server"), whether a password is needed (never the name ("<your name>'s server"), whether a password is needed (not the
password), the version and an `fp` field reserved for a pinned password itself), the version and an `fp` field reserved for a pinned
certificate (`wss://` with a self-signed one, later; always null). This certificate (`wss://` with a self-signed one, later; always null). This
machine's addresses are `inviteAddresses` over `NetworkInterface.list`: machine's addresses are `inviteAddresses` over `NetworkInterface.list`:
IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP 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 elsewhere get in only with the port forwarded). No UPnP. The invite
carries the internet address when the lookup answers within 4 s carries the internet address when the lookup answers within 4 s
(`publicWait`), else the best local one, switched to the internet one (`publicWait`), else the best local one, switched to the internet one
when it lands. `Invite.text` is what Invite copies ("Join me on when it lands. `YourServer.inviteLink(invite)` is what Invite copies:
Starsync: starsync://203.0.113.7:8080/?name=Bob%27s+server (ask me for the the bare link with the password the server was shared with
password) · Starsync 1.3.0"); `textWith(secret:)` puts the password in (`starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=…`; the link is
the link instead, only when the user ticks it. The link format: the key, nothing around it). The link format:
[PROTOCOL.md "Invite link"](PROTOCOL.md#invite-link) (`InviteLink`: [PROTOCOL.md "Invite link"](PROTOCOL.md#invite-link) (`InviteLink`:
`parse`, `uri`, a `toString` without the password). `inviteIn` finds an `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; 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 "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 on yours or Leave on a friend's (asks first, then you are home).
Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if
it is not yet and copies the invite link, never the password ("Invite it is not yet and copies the invite link ("Invite link copied"); a port
link copied · 203.0.113.7:8080"); a port it cannot open it cannot open
is a notice pointing at Settings. The stack is a notice pointing at Settings. The stack
opens the members card (the server's version under its title: yours is 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 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 "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 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; bottom, on yours: not shared, "Only you can reach it" and Invite;
shared (`YourInvite`), "Friends join at" with each address of shared (`YourInvite`), Copy invite link (filled; the address the app
`YourServer.reach` to pick (Internet, Home network, VPN; "Looking up picks, `InviteReach.best`, in its tooltip) and Stop sharing; one line
your internet address…" meanwhile), a note for the one picked (the only when something needs the user ("Looking up your internet
router's address: the port must be forwarded to this computer's home address…"; the router's address: the port needs forwarding to this
address; this computer's own: a firewall can still block it; home computer; no internet address found: the link works on the home network
network, VPN: who it works for; no internet address found: forwarding only); "Other addresses" (folded, only with more than one) lists each
or a server all can reach), "Include the password in the link" (only address of `YourServer.reach` (Internet, Home network, VPN) to pick
with a password; off each time the card opens), Copy invite link and what Copy copies; on a friend's, "Copy invite link" (the dialled address
Stop sharing; on a friend's, "Copy invite link" (the dialled address
and its name, never a password)). and its name, never a password)).
Empty strip space is the window Empty strip space is the window
drag area; the window buttons sit at its end. 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 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, 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 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 friend's server. It says to paste a friend's invite link (it carries
password, that no account or Jellyfin is needed and that your own the password) or their address and password, that no account or Jellyfin is needed and that your own
server stays as it is. server stays as it is.
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card - **Profile** (`profile_card.dart`, `session/identity.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`: tinted with your colour: picture (hover "Change" opens `CropPicker`:
+3 -2
View File
@@ -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 next episode). Invite opens it to friends on your network (a second
listener on the same server: nothing restarts, the video and Up next listener on the same server: nothing restarts, the video and Up next
carry on) and copies an invite link (`starsync://`, with your internet carry on) and copies an invite link (`starsync://`, with your internet
address when the app finds it); Stop sharing closes it again. A friend address when the app finds it and the password when one is set); Stop
clicks the link, or just copies it, and their app asks to join. 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 - **Dedicated server**: `starsync-server`, a separate program in the
release tarball, for an always-on box. See the README. release tarball, for an always-on box. See the README.
+5 -6
View File
@@ -629,15 +629,14 @@ starsync://[fe80::1]:8080
- `name` (optional): the server's name for the prompt before its - `name` (optional): the server's name for the prompt before its
`/health` answers (at most 64 characters are shown, control characters `/health` answers (at most 64 characters are shown, control characters
dropped). dropped).
- `pw` (optional): the server password, only when the host chose to put - `pw` (optional): the server password, always there when the server has
it in. Logs never show it (`redactLog` cuts a `starsync://` query to its one: the link is the key, a friend just clicks it. Logs never show it (`redactLog` cuts a `starsync://` query to its
names). names).
- Unknown query keys are ignored; anything else (no host, a port outside - Unknown query keys are ignored; anything else (no host, a port outside
1–65535) is no link. 1–65535) is no link.
- The text Invite copies is "Join me on Starsync: <link> [(ask me for the - Invite copies the bare link, nothing around it. An app also takes a
password)] · Starsync <version>"; an app also takes a 1.3.x plain 1.3.x plain invite ("Join me on Starsync: 192.168.1.20:8080 …"), and a
invite ("Join me on Starsync: 192.168.1.20:8080 …"), and a 1.3.x app 1.3.x app reads the `host:port` out of a link.
reads the `host:port` out of a link.
## Queue (proto 12) ## Queue (proto 12)