forked from Foxyz/sync_replay_project
Invite links: starsync:// opens the app and asks to join; one app per profile; real invite address; clipboard pick-up
- starsync://host:port/?name=&pw= (PROTOCOL.md "Invite link"); the password only when ticked, never in logs - Linux: launcher entry takes x-scheme-handler/starsync (%u, StartupNotify) and becomes its xdg-mime default; Windows: HKCU\Software\Classes\starsync -> starsync.exe - InstanceLock: lock file + loopback handoff per profile folder; the running app comes up with the second start's activation token (Wayland/X11) or AllowSetForegroundWindow (Windows) - Sharing looks up the internet address (HTTPS services with fallbacks); members card picks Internet / Home network / VPN and says when the port needs forwarding - JoinOffers: link and clipboard prompt with /health, joins through ConnectFlow; dismissed clipboard invites kept as fingerprints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d48915dc47
commit
6048fbe7d1
35 files changed
+1943
-181
No files matched your search
@@ -29,6 +29,7 @@ import '../widgets/whats_new_card.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'join_offer_card.dart';
|
||||
import 'library_link.dart' show LinkProber;
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
@@ -662,6 +663,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
void _invite() => unawaited(inviteFriends(context));
|
||||
|
||||
/// The join prompt's Join: the invite into the Join card's fields, then
|
||||
/// the join (version gate and all); a failure opens the card with it.
|
||||
Future<void> _acceptOffer() async {
|
||||
final link = _graph.joinOffers.accept();
|
||||
if (link == null) return;
|
||||
final port = '${link.port}';
|
||||
_join.host.text = link.host;
|
||||
_join.port.text = port;
|
||||
_join.secret.text = link.secret;
|
||||
await _flow.submit((f) => f
|
||||
..host = link.host
|
||||
..port = port
|
||||
..secret = link.secret);
|
||||
if (mounted && _flow.error != null) _joinFriend();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
@@ -818,6 +835,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
_toasts(full && online),
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Center(child: JoinOfferCard(offers: _graph.joinOffers, onJoin: _acceptOffer)),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import '../app/app_graph.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../screens/home/connect_status.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../session/invite.dart' show addressIn;
|
||||
import '../session/invite.dart' show addressIn, inviteIn;
|
||||
import '../session/navigation.dart' show Place;
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../update/update_controller.dart';
|
||||
@@ -138,9 +138,17 @@ class JoinCard extends StatelessWidget {
|
||||
_join();
|
||||
}
|
||||
|
||||
/// An invite or "host:port" pasted into the address: split it.
|
||||
/// An invite (link or text) or "host:port" pasted into the address:
|
||||
/// split it (a link's password goes into the password field).
|
||||
void _onAddress(String v) {
|
||||
flow.clearError();
|
||||
final link = inviteIn(v);
|
||||
if (link != null) {
|
||||
fields.host.text = link.host;
|
||||
fields.port.text = '${link.port}';
|
||||
if (link.secret.isNotEmpty) fields.secret.text = link.secret;
|
||||
return;
|
||||
}
|
||||
final a = addressIn(v);
|
||||
if (a == null || v.trim() == a.host) return;
|
||||
fields.host.text = a.host;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/join_offers.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The join prompt, top centre over the body: "Join Bob's server
|
||||
/// (1.2.3.4:8080)?" with what its `/health` says (version, who is in,
|
||||
/// the version gate, unreachable), Not now and Join. Shown while
|
||||
/// [offers] has an offer (a clicked invite link, or one on the
|
||||
/// clipboard).
|
||||
class JoinOfferCard extends StatelessWidget {
|
||||
final JoinOffers offers;
|
||||
final VoidCallback onJoin;
|
||||
const JoinOfferCard({super.key, required this.offers, required this.onJoin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||
final o = offers.offer.value;
|
||||
return AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
switchInCurve: ease,
|
||||
switchOutCurve: ease,
|
||||
transitionBuilder: (child, a) => FadeTransition(
|
||||
opacity: a,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, -0.2), end: Offset.zero).animate(a),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: o == null
|
||||
? const SizedBox.shrink()
|
||||
: _OfferBody(key: ValueKey(o.link), offer: o, onJoin: onJoin, onDismiss: offers.dismiss),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _OfferBody extends StatelessWidget {
|
||||
final JoinOffer offer;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onDismiss;
|
||||
const _OfferBody({super.key, required this.offer, required this.onJoin, required this.onDismiss});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final h = offer.health;
|
||||
final problem = h != null && (!h.online || h.needsUpdate);
|
||||
final detail = h == null ? 'Checking the server…' : offer.detail;
|
||||
return Material(
|
||||
key: const ValueKey('join-offer'),
|
||||
elevation: 12,
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(offer.source == OfferSource.clipboard ? Icons.content_paste_go : Icons.link,
|
||||
color: scheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(offer.question,
|
||||
key: const ValueKey('join-offer-question'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
[
|
||||
if (offer.source == OfferSource.clipboard) 'Invite on your clipboard',
|
||||
detail,
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: problem ? Colors.amber.shade300 : scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(key: const ValueKey('join-offer-dismiss'), onPressed: onDismiss, child: const Text('Not now')),
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(key: const ValueKey('join-offer-join'), onPressed: onJoin, child: const Text('Join')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../session/app_events.dart' show ActionFailed;
|
||||
import '../session/invite.dart';
|
||||
import '../session/navigation.dart' show Place;
|
||||
import '../widgets/app_keys.dart';
|
||||
@@ -16,6 +15,9 @@ import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'delivery_line.dart';
|
||||
import 'motion.dart';
|
||||
import 'your_invite.dart';
|
||||
|
||||
export 'your_invite.dart' show YourInvite, inviteFriends;
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
enum ShellTab {
|
||||
@@ -506,9 +508,10 @@ class MembersCard extends StatelessWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'),
|
||||
onPressed: () => copyToClipboard(context, friendInviteLink(address, nav.serverLabel.peek()),
|
||||
done: 'Invite link copied'),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite address'),
|
||||
label: const Text('Copy invite link'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -623,90 +626,11 @@ class VersionText extends StatelessWidget {
|
||||
bool versionDiffers(String version, {String own = appVersion}) =>
|
||||
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
|
||||
|
||||
/// Your server in the members card: shared, the invite's address
|
||||
/// (Copy invite), a hint for friends outside the network and Stop
|
||||
/// sharing; not shared, Invite (opens it and copies the invite).
|
||||
class YourInvite extends StatelessWidget {
|
||||
const YourInvite({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final graph = AppScope.of(context);
|
||||
final yours = graph.yourServer;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final invite = yours.shared.value;
|
||||
final busy = yours.busy.value || yours.port.value == null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
key: const ValueKey('your-invite'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (invite == null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
|
||||
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: busy ? null : () => inviteFriends(context),
|
||||
icon: const Icon(Icons.group_add_outlined, size: 16),
|
||||
label: const Text('Invite'),
|
||||
),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(invite.address,
|
||||
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite'),
|
||||
),
|
||||
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(
|
||||
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
|
||||
'forwarded on your router, or a server they can all reach (like a VPS). '
|
||||
'Stop sharing sends everyone else out; you and Up next stay.',
|
||||
style: TextStyle(color: muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Invite: opens your server to friends if it is not yet and copies the
|
||||
/// invite ("Invite copied"); a port that can't be opened is a notice.
|
||||
Future<void> inviteFriends(BuildContext context) async {
|
||||
final graph = AppScope.of(context);
|
||||
final Invite invite;
|
||||
try {
|
||||
invite = await graph.yourServer.invite();
|
||||
} catch (e) {
|
||||
final port = graph.forms.form.hostPort;
|
||||
graph.notifier.notify(ActionFailed(
|
||||
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
|
||||
/// The invite link to a friend's server you are in ([address] as dialled,
|
||||
/// "host:port"; never its password).
|
||||
String friendInviteLink(String address, String name) {
|
||||
final a = addressIn(address);
|
||||
final port = a == null ? null : int.tryParse(a.port);
|
||||
if (a == null || port == null) return address;
|
||||
return InviteLink(host: a.host, port: port, name: name).uri;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../app/app_graph.dart';
|
||||
import '../session/app_events.dart' show ActionFailed;
|
||||
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.
|
||||
class YourInvite extends StatefulWidget {
|
||||
const YourInvite({super.key});
|
||||
|
||||
@override
|
||||
State<YourInvite> createState() => _YourInviteState();
|
||||
}
|
||||
|
||||
class _YourInviteState extends State<YourInvite> {
|
||||
/// The address picked (null: the invite's own, the best one).
|
||||
String? _picked;
|
||||
bool _withPassword = false;
|
||||
|
||||
static String _kindLabel(AddressKind k) => switch (k) {
|
||||
AddressKind.public => 'Internet',
|
||||
AddressKind.lan => 'Home network',
|
||||
AddressKind.vpn => 'VPN',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final graph = AppScope.of(context);
|
||||
final yours = graph.yourServer;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final invite = yours.shared.value;
|
||||
final reach = yours.reach.value;
|
||||
final busy = yours.busy.value || yours.port.value == null;
|
||||
final small = TextStyle(color: muted, fontSize: 12);
|
||||
if (invite == 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, 6),
|
||||
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
||||
style: small),
|
||||
),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: busy ? null : () => inviteFriends(context),
|
||||
icon: const Icon(Icons.group_add_outlined, size: 16),
|
||||
label: const Text('Invite'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
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 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).',
|
||||
};
|
||||
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),
|
||||
),
|
||||
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)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String address;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _AddressRow(
|
||||
{super.key, required this.label, required this.address, required this.selected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(children: [
|
||||
Icon(selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
size: 18, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 96, child: Text(label, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant))),
|
||||
Expanded(child: Text(address, style: const TextStyle(fontFamily: 'monospace'))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<void> inviteFriends(BuildContext context) async {
|
||||
final graph = AppScope.of(context);
|
||||
final Invite invite;
|
||||
try {
|
||||
invite = await graph.yourServer.invite();
|
||||
} catch (e) {
|
||||
final port = graph.forms.form.hostPort;
|
||||
graph.notifier.notify(ActionFailed(
|
||||
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
graph.joinOffers.copied(invite.text);
|
||||
await copyToClipboard(context, invite.text, done: 'Invite link copied · ${invite.address}');
|
||||
}
|
||||
Reference in new issue
Block a user