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
+179 -163

No files matched your search

+4 -13
View File
@@ -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;
+11 -2
View File
@@ -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<String?>(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) {
@@ -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),
),
+75 -81
View File
@@ -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<YourInvite> {
/// 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<YourInvite> {
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<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
@@ -204,6 +197,7 @@ Future<void> 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');
}
+8 -9
View File
@@ -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);
+36 -16
View File
@@ -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<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.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
});
});
}
@@ -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');