forked from Foxyz/sync_replay_project
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:
1 parent
5116923858
commit
cf61453e28
12 files changed
+178
-162
No files matched your search
+2
-3
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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`:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user