- 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>
210 lines
8.6 KiB
Dart
210 lines
8.6 KiB
Dart
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}');
|
|
}
|