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:
foxyzandClaude Opus 5.5 committed 2026-10-18 17:18:25 +03:00
1 parent d48915dc47
commit 6048fbe7d1
35 files changed
+1943 -181

No files matched your search

+36 -2
View File
@@ -10,16 +10,26 @@ import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/attention.dart' show exitNow, windowLaunchToken;
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
import 'src/platform/desktop_entry.dart';
import 'src/platform/ports/window_activity.dart';
import 'src/platform/single_instance.dart';
import 'src/platform/url_scheme.dart';
import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/session/invite.dart' show InviteLink;
import 'src/state/settings.dart';
import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args);
// An invite link is handled once: Update and restart does not pass it on.
launchArgs = List.unmodifiable([
for (final a in args)
if (InviteLink.parse(a) == null) a,
]);
if (args.contains('--upgrade')) {
exitCode = await runUpgrade();
exit(exitCode);
@@ -44,6 +54,15 @@ Future<void> main(List<String> args) async {
);
WidgetsFlutterBinding.ensureInitialized();
final dirs = await const DesktopAppDirs().resolve();
// One app per profile: a second start (a clicked starsync:// link) hands
// its arguments to the running one and ends before its window shows.
final instance = await InstanceLock.claim(
Directory(dirs.hive).parent.path,
args: args,
activation: await windowLaunchToken(),
beforeSend: Platform.isWindows ? allowSetForegroundWindow : null,
);
if (instance == null) await exitNow();
await startAppLogging(dirs);
Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded();
@@ -52,14 +71,19 @@ Future<void> main(List<String> args) async {
final ports = graph.ports;
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", your server shuts down and no player outlives the engine.
final close = closeAppWith(graph);
final close = closeAppWith(graph, instance: instance);
await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
// Focus, minimise and restore feed auto sit-out and the Notifier (in
// the background, notices go out as system notifications).
// Coming to the front also looks for an invite on the clipboard (the
// join prompt; nothing of it is logged).
ports.shell.watchActivity((a) {
graph.coordinator.windowActivity(a);
graph.notifier.window = a;
if (a == WindowActivity.focused) {
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
}
});
try {
await graph.forms.load();
@@ -76,6 +100,8 @@ Future<void> main(List<String> args) async {
ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry());
// Windows: starsync:// links start this install (HKCU, release builds).
unawaited(registerWindowsScheme());
// A found update is an event too (the band shows it).
final updates = graph.updates;
updates.status.addListener(() {
@@ -94,6 +120,14 @@ Future<void> main(List<String> args) async {
// Home: your own server, quietly (only this app can reach it until
// Invite).
unawaited(graph.navigation.start());
// A clicked invite link: the one this app started with, then each one a
// later start hands over (the window comes to the front for it).
graph.joinOffers.fromArgs(args);
instance.handoffs.listen((h) {
unawaited(ports.shell.focus(activation: h.activation));
graph.joinOffers.fromArgs(h.args);
});
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
// The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
}
+10 -1
View File
@@ -12,6 +12,7 @@ import '../schedule/animeflow.dart';
import '../screens/home/health_probe.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity, Profile;
import '../session/join_offers.dart';
import '../session/navigation.dart';
import '../session/notifier.dart';
import '../session/session_coordinator.dart';
@@ -65,6 +66,9 @@ class AppGraph {
/// What's new once after an update; keeps the notes the updater saves.
final WhatsNew whatsNew;
/// The join prompt for a clicked invite link or one on the clipboard.
final JoinOffers joinOffers;
AppGraph._({
required this.ports,
required this.sfx,
@@ -78,10 +82,12 @@ class AppGraph {
required this.watch,
required this.updates,
required this.whatsNew,
required this.joinOffers,
});
/// The desktop app's graph. Tests pass their own [ports], [identity]
/// (default: the install's stored one), [healthGet] and server starter.
/// (default: the install's stored one), [healthGet], server starter and
/// internet address lookup ([publicAddress]).
factory AppGraph.build({
Ports? ports,
ClientIdentity? identity,
@@ -90,6 +96,7 @@ class AppGraph {
JellyfinAccounts? jellyfin,
ShowMatcher? library,
WatchHistory? history,
Future<String?> Function()? publicAddress,
}) {
final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
@@ -142,6 +149,7 @@ class AppGraph {
},
logDir: () => appLogDir,
starter: serverStarter,
publicAddress: publicAddress,
);
final navigation = Navigation(
coordinator: coordinator,
@@ -172,6 +180,7 @@ class AppGraph {
watch: watch,
updates: updates,
whatsNew: whatsNew,
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
);
}
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart';
import '../app/app_graph.dart';
import 'single_instance.dart';
// The one way out of the app (close button, Alt+F4, Update and restart).
// Order matters: every mpv player goes before the engine does (a live one
@@ -21,10 +22,14 @@ bool get shuttingDown => _shuttingDown;
/// The systems the close stops; `main` hands them over once.
AppGraph? _graph;
/// Makes [graph] the one [shutDownApp] stops and returns the close
/// request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph) {
/// This app's claim on its profile folder (one app at a time).
InstanceLock? _instance;
/// Makes [graph] the one [shutDownApp] stops (and [instance] the claim it
/// gives up) and returns the close request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) {
_graph = graph;
_instance = instance;
return shutDownApp;
}
@@ -38,6 +43,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
final graph = _graph;
if (_shuttingDown || graph == null) return;
_shuttingDown = true;
// A start from now on waits for this app to end instead of handing its
// link to it.
_instance?.closing();
final shell = graph.ports.shell;
final total = Stopwatch()..start();
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
@@ -57,6 +65,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
// server: 3 s in all.
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
// Update and restart: the new binary claims the folder at once.
if (restart) await _step('instance', _instance?.release(), const Duration(milliseconds: 500));
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
await shell.quit();
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -17,3 +19,41 @@ Future<void> requestWindowAttention() async {
debugPrint('window attention: $e');
}
}
/// The desktop's activation token this process started with (Linux:
/// `XDG_ACTIVATION_TOKEN` or `DESKTOP_STARTUP_ID`, kept by the runner
/// before GTK used it); '' when none or elsewhere.
Future<String> windowLaunchToken() async {
if (!Platform.isLinux) return '';
try {
return await _window.invokeMethod<String>('launchToken') ?? '';
} catch (_) {
return '';
}
}
/// Linux: brings the window up with another start's activation token
/// ([windowLaunchToken] of the start that handed over). False where the
/// runner lacks it.
Future<bool> presentWindow(String token) async {
if (!Platform.isLinux) return false;
try {
await _window.invokeMethod<void>('present', token);
return true;
} catch (_) {
return false;
}
}
/// Ends this process at once with no exit handlers (a second start that
/// handed over; its window never showed). The runner does it; where it
/// cannot, a kill.
Future<Never> exitNow() async {
try {
await _window.invokeMethod<void>('exit');
} catch (_) {}
Process.killPid(pid, ProcessSignal.sigkill);
// The kill lands before anything else runs.
await Future<void>.delayed(const Duration(days: 1));
throw StateError('unreachable');
}
@@ -98,10 +98,13 @@ class DesktopShell implements ShellPort {
}
@override
Future<void> focus() async {
Future<void> focus({String activation = ''}) async {
try {
if (await windowManager.isMinimized()) await windowManager.restore();
if (!await windowManager.isVisible()) await windowManager.show();
// Linux: present with the token when there is one (else a
// compositor only marks the window as wanting attention).
if (activation.isNotEmpty && await presentWindow(activation)) return;
await windowManager.focus();
} catch (e) {
debugPrint('window focus failed: $e');
@@ -126,6 +129,15 @@ class DesktopShell implements ShellPort {
return (files: files, text: text);
}
@override
Future<String?> readClipboardText() async {
try {
return await ClipboardX.readText();
} catch (_) {
return null;
}
}
@override
Widget dropTarget({
Key? key,
@@ -0,0 +1,60 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// The few Win32 calls the app makes itself (no package wraps them for
// us without a new dependency). Only ever called on Windows.
/// HKEY_CURRENT_USER as the API takes it (sign-extended 0x80000001).
const _hkeyCurrentUser = -0x7FFFFFFF;
const _regSz = 1;
const _rrfRtRegSz = 0x2;
typedef _AllowNative = Int32 Function(Uint32 pid);
typedef _Allow = int Function(int pid);
/// Lets process [pid] take the foreground (a second start does this for
/// the running app before handing over its link: Windows only lets a
/// process that got the user's click bring a window up).
void allowSetForegroundWindow(int pid) {
final user32 = DynamicLibrary.open('user32.dll');
user32.lookupFunction<_AllowNative, _Allow>('AllowSetForegroundWindow')(pid);
}
typedef _SetNative = Int32 Function(
IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 type, Pointer<Void> data, Uint32 size);
typedef _Set = int Function(
int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int type, Pointer<Void> data, int size);
typedef _GetNative = Int32 Function(IntPtr key, Pointer<Utf16> subKey, Pointer<Utf16> name, Uint32 flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
typedef _Get = int Function(int key, Pointer<Utf16> subKey, Pointer<Utf16> name, int flags,
Pointer<Uint32> type, Pointer<Void> data, Pointer<Uint32> size);
DynamicLibrary get _advapi => DynamicLibrary.open('advapi32.dll');
/// The string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey]; null when missing.
String? readUserRegistryString(String subKey, String name) {
final get = _advapi.lookupFunction<_GetNative, _Get>('RegGetValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final size = arena<Uint32>()..value = 0;
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null;
final data = arena<Uint16>(size.value ~/ 2 + 1);
if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null;
return data.cast<Utf16>().toDartString();
});
}
/// Sets the string value [name] ('' = the key's default) of
/// HKEY_CURRENT_USER\[subKey], creating the key; false when refused.
bool writeUserRegistryString(String subKey, String name, String data) {
final set = _advapi.lookupFunction<_SetNative, _Set>('RegSetKeyValueW');
return using((arena) {
final key = subKey.toNativeUtf16(allocator: arena);
final value = name.isEmpty ? nullptr : name.toNativeUtf16(allocator: arena);
final bytes = data.toNativeUtf16(allocator: arena);
return set(_hkeyCurrentUser, key, value.cast(), _regSz, bytes.cast(), (data.length + 1) * 2) == 0;
});
}
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
import 'url_scheme.dart';
/// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon.
const appId = 'dev.foxyz.starsync';
@@ -18,10 +20,17 @@ const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
/// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
/// (unless [force], for tests); failures only log.
Future<void> installDesktopEntry(
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
/// default handler ([registerScheme], default [registerLinuxScheme]).
/// Never in debug runs (unless [force], for tests); failures only log.
Future<void> installDesktopEntry({
String? executable,
String? dataHome,
String? home,
@visibleForTesting bool force = false,
Future<void> Function(String desktopFile, String applicationsDir)? registerScheme,
}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
@@ -41,9 +50,11 @@ Future<void> installDesktopEntry(
}
final entry = desktopEntryText(exe);
final file = File('$share/applications/$desktopFileName');
if (file.existsSync() && file.readAsStringSync() == entry) return;
await file.parent.create(recursive: true);
await file.writeAsString(entry);
if (!file.existsSync() || file.readAsStringSync() != entry) {
await file.parent.create(recursive: true);
await file.writeAsString(entry);
}
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
} catch (e) {
debugPrint('desktop entry: $e');
}
@@ -69,9 +80,11 @@ String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
Name=Starsync
Comment=Watch videos in sync with friends
Exec="$exe"
Exec="$exe" %u
Icon=$appId
StartupWMClass=$appId
StartupNotify=true
MimeType=$schemeMimeType;
Categories=AudioVideo;Video;Player;Network;
Terminal=false
''';
@@ -43,8 +43,10 @@ abstract interface class ShellPort {
Future<void> quit();
/// Brings the window up and gives it the focus (restored when
/// minimised, shown when hidden): a click on a system notification.
Future<void> focus();
/// minimised, shown when hidden): a click on a system notification, a
/// link handed over by a second start ([activation]: that start's
/// desktop activation token, which lets the window take the focus).
Future<void> focus({String activation = ''});
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
@@ -53,6 +55,10 @@ abstract interface class ShellPort {
/// The clipboard's files, else its text (Ctrl+V outside a text field).
Future<Paste> readPaste();
/// The clipboard's text only (the join prompt looks for an invite in
/// it); null when there is none or it can't be read.
Future<String?> readClipboardText();
/// [child] taking dropped files ([onDrop]: their paths and where they
/// landed); [enable] off lets drops through.
Widget dropTarget({
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
/// What a second start hands to the running app: its arguments (a
/// clicked `starsync://` link) and the desktop's activation token for
/// bringing the window up (Linux: `XDG_ACTIVATION_TOKEN` /
/// `DESKTOP_STARTUP_ID`; '' when none).
typedef Handoff = ({List<String> args, String activation});
const _lockName = 'instance.lock';
const _infoName = 'instance.json';
/// Lock paths this process holds: a second claim from the same process
/// (tests) sees them held, as another process would.
final _heldHere = <String>{};
/// One app per profile folder at a time. The first start holds an
/// exclusive lock on `instance.lock` in [dir] (the OS drops it when the
/// process ends, a crash too) and listens on a loopback port written to
/// `instance.json` with a random token. A later start finds the lock
/// held, sends its [Handoff] there and ends; the running app comes to
/// the front and handles it ([handoffs]). Pure dart:io: the same on
/// Linux (Wayland and X11) and Windows.
class InstanceLock {
InstanceLock._(this.dir, this._lock, this._server, this._token);
/// The profile folder (each `STARSYNC_PROFILE` has its own, so each is
/// its own instance).
final String dir;
final RandomAccessFile? _lock;
final ServerSocket? _server;
final String _token;
final _handoffs = StreamController<Handoff>();
bool _closing = false;
bool _released = false;
/// What later starts handed over, in order (buffered until listened to).
Stream<Handoff> get handoffs => _handoffs.stream;
/// False when the lock could not be had nor the running app reached
/// (this app runs anyway, as before single instance).
bool get held => _lock != null;
/// Claims [dir] for this process, or hands [args] to the app that holds
/// it: null when handed over (this start should end now). A holder
/// that is closing (or not listening yet) is retried for [retryFor]
/// ([closingWait] once it said it is closing, so a start during a close
/// or an update's restart takes over after it). [beforeSend] gets the
/// holder's process id first (Windows: allow it to take the
/// foreground).
static Future<InstanceLock?> claim(
String dir, {
List<String> args = const [],
String activation = '',
void Function(int pid)? beforeSend,
Duration retryFor = const Duration(seconds: 5),
Duration closingWait = const Duration(seconds: 20),
Duration retryEvery = const Duration(milliseconds: 200),
}) async {
await Directory(dir).create(recursive: true);
final started = DateTime.now();
var closingSeen = false;
while (true) {
final lock = await _tryLock(dir);
if (lock != null) return _listen(dir, lock);
final answer = await _send(dir, (args: args, activation: activation), beforeSend);
if (answer == _Answer.ok) return null;
if (answer == _Answer.closing) closingSeen = true;
final waited = DateTime.now().difference(started);
if (waited > (closingSeen ? closingWait : retryFor)) {
debugPrint('single instance: no lock and no answer after ${waited.inMilliseconds} ms; starting anyway');
return InstanceLock._(dir, null, null, '');
}
await Future.delayed(retryEvery);
}
}
static Future<RandomAccessFile?> _tryLock(String dir) async {
final path = '$dir${Platform.pathSeparator}$_lockName';
if (_heldHere.contains(path)) return null;
RandomAccessFile? raf;
try {
raf = await File(path).open(mode: FileMode.append);
await raf.lock(FileLock.exclusive);
_heldHere.add(path);
return raf;
} catch (_) {
if (raf != null) await _quiet(raf.close());
return null;
}
}
static Future<InstanceLock> _listen(String dir, RandomAccessFile lock) async {
final token = base64Url.encode([for (var i = 0; i < 18; i++) Random.secure().nextInt(256)]);
ServerSocket? server;
try {
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
await File('$dir${Platform.pathSeparator}$_infoName')
.writeAsString(jsonEncode({'port': server.port, 'token': token, 'pid': pid}), flush: true);
} catch (e) {
debugPrint('single instance: cannot listen for later starts: $e');
}
final self = InstanceLock._(dir, lock, server, token);
server?.listen(self._serve, onError: (Object e) => debugPrint('single instance: $e'));
return self;
}
Future<void> _serve(Socket socket) async {
try {
final line = await utf8.decoder
.bind(socket)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
if (line.length > 64 * 1024) throw const FormatException('too long');
final j = jsonDecode(line);
if (j is! Map || j['token'] != _token) {
socket.writeln(jsonEncode({'ok': false}));
} else if (_closing) {
socket.writeln(jsonEncode({'closing': true}));
} else {
final args = j['args'];
final activation = j['activation'];
_handoffs.add((
args: [if (args is List) for (final a in args) if (a is String) a],
activation: activation is String ? activation : '',
));
socket.writeln(jsonEncode({'ok': true}));
}
await socket.flush();
} catch (e) {
debugPrint('single instance: a later start sent nothing usable (${e.runtimeType})');
} finally {
await _quiet(socket.close());
}
}
static Future<_Answer> _send(String dir, Handoff h, void Function(int pid)? beforeSend) async {
Socket? socket;
try {
final info = jsonDecode(await File('$dir${Platform.pathSeparator}$_infoName').readAsString());
final port = info is Map ? info['port'] : null;
final token = info is Map ? info['token'] : null;
final holder = info is Map ? info['pid'] : null;
if (port is! int || token is! String) return _Answer.none;
if (holder is int) beforeSend?.call(holder);
socket = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2));
socket.writeln(jsonEncode({'token': token, 'args': h.args, 'activation': h.activation}));
await socket.flush();
final reply = await utf8.decoder
.bind(socket)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
final r = jsonDecode(reply);
if (r is Map && r['ok'] == true) return _Answer.ok;
if (r is Map && r['closing'] == true) return _Answer.closing;
return _Answer.none;
} catch (_) {
// Not written yet, a stale one from a crash, or not listening yet.
return _Answer.none;
} finally {
socket?.destroy();
}
}
/// The app is closing: later starts are told so and wait for the lock
/// instead of handing over to an app on its way out.
void closing() => _closing = true;
/// Gives the folder up before this process ends (Update and restart:
/// the new binary claims it at once).
Future<void> release() async {
if (_released) return;
_released = true;
_closing = true;
await _server?.close();
final lock = _lock;
if (lock == null) return;
try {
await File('$dir${Platform.pathSeparator}$_infoName').delete();
} catch (_) {}
try {
await lock.unlock();
} catch (_) {}
await _quiet(lock.close());
_heldHere.remove('$dir${Platform.pathSeparator}$_lockName');
unawaited(_handoffs.close());
}
@visibleForTesting
int? get port => _server?.port;
}
enum _Answer { ok, closing, none }
Future<void> _quiet(Future<Object?> f) async {
try {
await f;
} catch (_) {}
}
@@ -0,0 +1,81 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
import '../session/invite.dart' show inviteScheme;
import 'desktop/windows_api.dart';
/// The MIME type desktops look up for a `starsync://` link (Linux).
const schemeMimeType = 'x-scheme-handler/$inviteScheme';
/// HKCU\Software\Classes\starsync: what Windows opens a link with.
const windowsSchemeKey = r'Software\Classes\' + inviteScheme;
/// The binary a link should start: `starsync.exe` next to an old-named
/// copy (`watch_together.exe`, kept for 1.2.x updaters) when it is there,
/// so links always start the current name.
String schemeExecutable(String exe, {bool Function(String path)? exists}) {
final has = exists ?? (p) => File(p).existsSync();
final sep = exe.contains(r'\') ? r'\' : '/';
final cut = exe.lastIndexOf(sep);
final name = exe.substring(cut + 1).toLowerCase();
if (name != 'watch_together.exe' && name != 'watch_together') return exe;
final current = '${exe.substring(0, cut + 1)}starsync${name.endsWith('.exe') ? '.exe' : ''}';
return has(current) ? current : exe;
}
/// The registry values of the Windows link handler for [exe]: (key under
/// HKEY_CURRENT_USER, value name ('' = default), data).
List<(String, String, String)> windowsSchemeValues(String exe) => [
(windowsSchemeKey, '', 'URL:Starsync invite'),
(windowsSchemeKey, 'URL Protocol', ''),
('$windowsSchemeKey\\DefaultIcon', '', '"$exe",0'),
('$windowsSchemeKey\\shell\\open\\command', '', '"$exe" "%1"'),
];
/// Windows: makes `starsync://` links start this install (per user, no
/// admin: HKCU\Software\Classes), only when the command there is not this
/// one already (first start, a moved install, an update from an old
/// name). Release installs only (never a debug run); failures only log.
Future<void> registerWindowsScheme({String? executable}) async {
if (!Platform.isWindows || kDebugMode) return;
try {
final exe = schemeExecutable(executable ?? Platform.resolvedExecutable);
if (findInstallRoot(exe, windows: true) == null) return;
final values = windowsSchemeValues(exe);
final command = values.last;
if (readUserRegistryString(command.$1, command.$2) == command.$3) return;
for (final (key, name, data) in values) {
if (!writeUserRegistryString(key, name, data)) {
debugPrint('url scheme: cannot write HKCU\\$key');
return;
}
}
debugPrint('url scheme: starsync:// links open $exe');
} catch (e) {
debugPrint('url scheme: $e');
}
}
/// Linux: makes this app the default for [schemeMimeType] (xdg-mime,
/// `~/.config/mimeapps.list`) unless it already is. [desktopFile]: the
/// launcher entry's file name, which lists the type.
Future<void> registerLinuxScheme(String desktopFile, String applicationsDir) async {
try {
final q = await Process.run('xdg-mime', ['query', 'default', schemeMimeType]);
if (q.exitCode == 0 && '${q.stdout}'.trim() == desktopFile) return;
final r = await Process.run('xdg-mime', ['default', desktopFile, schemeMimeType]);
if (r.exitCode != 0) {
debugPrint('url scheme: xdg-mime default failed: ${'${r.stderr}'.trim()}');
return;
}
// The MIME cache some desktops read (missing on some systems: fine).
try {
await Process.run('update-desktop-database', ['-q', applicationsDir]);
} catch (_) {}
debugPrint('url scheme: starsync:// links open $desktopFile');
} catch (e) {
debugPrint('url scheme: $e');
}
}
+182 -6
View File
@@ -2,6 +2,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
/// The URL scheme of an invite link ([InviteLink]).
const inviteScheme = 'starsync';
/// The port a link without one means (the app's default sharing port).
const defaultInvitePort = 8080;
/// What a friend needs to join your server: where ([host], [port]), what
/// it is called ([name]), whether it asks a password ([password]; the
/// password itself is never in it) and which Starsync runs it
@@ -26,14 +32,24 @@ class Invite {
/// What goes in the Join card's address and port.
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// The text Invite copies: the address, whether to ask for the password,
/// the version to match.
String get text => [
'Join me on Starsync: $address',
if (password) '(ask me for the password)',
/// The same invite at another of this machine's addresses.
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]).
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 &&
@@ -51,6 +67,97 @@ class Invite {
String toString() => 'Invite($address${password ? ', password' : ''})';
}
/// 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; [uri] does.
class InviteLink {
final String host;
final int port;
final String name;
final String secret;
const InviteLink({required this.host, required this.port, this.name = '', this.secret = ''});
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// One per server (for "already asked about this one").
String get key => '${host.toLowerCase()}:$port';
/// The link itself (with the password, when it has one).
String get uri {
final query = [
if (name.isNotEmpty) 'name=${Uri.encodeQueryComponent(name)}',
if (secret.isNotEmpty) 'pw=${Uri.encodeQueryComponent(secret)}',
];
return '$inviteScheme://$address${query.isEmpty ? '' : '/?${query.join('&')}'}';
}
/// Null unless [text] is a `starsync://` link with a host and a usable
/// port (none means [defaultInvitePort]).
static InviteLink? parse(String text) {
final t = text.trim();
if (!t.toLowerCase().startsWith('$inviteScheme://')) return null;
final Uri u;
try {
u = Uri.parse(t);
} catch (_) {
return null;
}
final host = u.host.trim();
if (host.isEmpty || !_hostChars.hasMatch(host)) return null;
final port = u.hasPort ? u.port : defaultInvitePort;
if (port <= 0 || port > 65535) return null;
final Map<String, String> q;
try {
q = u.queryParameters;
} catch (_) {
return null;
}
return InviteLink(host: host, port: port, name: _cleanName(q['name'] ?? ''), secret: q['pw'] ?? '');
}
@override
bool operator ==(Object other) =>
other is InviteLink && other.host == host && other.port == port && other.name == name && other.secret == secret;
@override
int get hashCode => Object.hash(host, port, name, secret);
@override
String toString() => 'InviteLink($address${secret.isEmpty ? '' : ', password'})';
}
final _hostChars = RegExp(r'^[A-Za-z0-9.:-]+$');
final _control = RegExp(r'[\x00-\x1f\x7f]');
/// A link's name as shown in the prompt: no control characters, at most
/// 64 characters.
String _cleanName(String raw) {
final n = raw.replaceAll(_control, '').trim();
return n.length > 64 ? n.substring(0, 64) : n;
}
final _linkIn = RegExp('$inviteScheme://[^\\s<>"\'`]+', caseSensitive: false);
final _plainInvite = RegExp(r'Join me on Starsync:\s*(\S+)');
/// Only the start of a long text is searched (a clipboard can hold a book).
const _searchLimit = 4096;
/// The invite in [text]: a `starsync://` link anywhere in it, else the
/// address of a plain invite ("Join me on Starsync: 192.168.1.20:8080",
/// what 1.3.x copied). Null for anything else, a bare address included.
InviteLink? inviteIn(String text) {
final t = text.length > _searchLimit ? text.substring(0, _searchLimit) : text;
final m = _linkIn.firstMatch(t);
if (m != null) return InviteLink.parse(m[0]!);
final plain = _plainInvite.firstMatch(t);
if (plain == null) return null;
final a = addressIn(plain[1]!);
final port = a == null ? null : int.tryParse(a.port);
if (a == null || port == null || port <= 0 || port > 65535) return null;
return InviteLink(host: a.host, port: port);
}
/// The `host:port` in a pasted invite (or a bare address); null when
/// there is none.
({String host, String port})? addressIn(String text) {
@@ -74,9 +181,48 @@ bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
bool _isPrivate(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale.
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale (and
/// carrier-grade NAT).
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
/// Where an invite address works.
enum AddressKind {
/// The internet: anyone, when the port is open to them.
public,
/// The same home network.
lan,
/// A mesh VPN (Tailscale and the like): everyone on it.
vpn,
}
/// [AddressKind] of an IPv4 address.
AddressKind addressKind(String ip) {
final a = InternetAddress.tryParse(ip);
if (a == null || a.type != InternetAddressType.IPv4) return AddressKind.public;
final b = a.rawAddress;
if (_isPrivate(b)) return AddressKind.lan;
if (_isShared(b)) return AddressKind.vpn;
return AddressKind.public;
}
/// [ip] is an IPv4 address the internet routes to this network: not
/// private, shared, loopback, link-local, fake-IP, multicast or
/// reserved (the documentation ranges pass: tests use them).
bool isPublicIpv4(String ip) {
final a = InternetAddress.tryParse(ip.trim());
if (a == null || a.type != InternetAddressType.IPv4) return false;
final b = a.rawAddress;
return !(b[0] == 0 ||
b[0] == 127 ||
b[0] >= 224 ||
(b[0] == 169 && b[1] == 254) ||
_isPrivate(b) ||
_isShared(b) ||
isFakeIp(b));
}
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
/// no loopback, link-local or fake-IP address, no container or VM bridge;
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
@@ -107,6 +253,36 @@ List<String> inviteAddresses(Iterable<({String name, InternetAddress address})>
return {for (final (_, a) in indexed) a.address}.toList();
}
/// One address an invite can carry and where it works.
typedef InviteAddress = ({String host, AddressKind kind});
/// What the members card offers for a shared server: the internet
/// address first when known ([public], from [lookUpPublicAddress]), then
/// this machine's own ([local], [inviteAddresses] order). [forwarding]:
/// the internet address is the router's, not this machine's, so friends
/// elsewhere get in only when the port is forwarded to it.
class InviteReach {
final String? public;
final List<String> local;
/// The internet lookup is still under way.
final bool looking;
const InviteReach({this.public, this.local = const [], this.looking = false});
bool get forwarding => public != null && !local.contains(public);
/// Every address, best first, each once: the internet one, then this
/// machine's.
List<InviteAddress> get choices => [
if (public != null) (host: public!, kind: AddressKind.public),
for (final l in local)
if (l != public) (host: l, kind: addressKind(l)),
];
/// The address an invite carries unless the user picks another: the
/// internet one when known, else the best of this machine's.
String? get best => choices.firstOrNull?.host;
}
/// This machine's interfaces as [inviteAddresses] takes them (empty when
/// they can't be listed).
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
@@ -0,0 +1,141 @@
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:signals/signals.dart';
import '../screens/home/health_probe.dart';
import '../state/connect_prefs.dart';
import 'invite.dart';
import 'navigation.dart';
import 'your_server.dart';
/// Where a join prompt came from.
enum OfferSource {
/// A clicked `starsync://` link (this app started with it, or a second
/// start handed it over).
link,
/// An invite found on the clipboard when the window came up.
clipboard,
}
/// A server the app offers to join: "Join Bob's server (1.2.3.4:8080)?".
class JoinOffer {
final InviteLink link;
final OfferSource source;
/// Its `/health` once read (name, version gate); null while asking.
final ServerHealth? health;
const JoinOffer(this.link, this.source, {this.health});
JoinOffer withHealth(ServerHealth h) => JoinOffer(link, source, health: h);
/// The server's own name (`/health`), else the link's, else ''.
String get name {
final own = health?.label ?? '';
return own.isNotEmpty ? own : link.name;
}
/// "Join Bob's server (1.2.3.4:8080)?"
String get question => name.isEmpty ? 'Join ${link.address}?' : 'Join $name (${link.address})?';
/// What `/health` said, in plain words: '' while asking.
String get detail {
final h = health;
if (h == null) return '';
if (!h.online) return "Can't reach it right now";
if (h.needsUpdate) return versionGateText(h);
return [
if (h.version.isNotEmpty) 'Starsync ${h.version}',
if (h.viewers > 0) '${h.viewers} watching',
].join(' · ');
}
}
/// A fingerprint of [link] (password included) for "already asked": the
/// link itself is never stored.
String inviteFingerprint(InviteLink link) =>
sha256.convert(utf8.encode('starsync-invite:${link.uri}')).toString().substring(0, 24);
/// The join prompt: a clicked invite link, or one found on the
/// clipboard, becomes an [offer] (`Join <name> (host:port)?`) with the
/// server's `/health`; [accept] hands back what to join, [dismiss] drops
/// it. A clipboard invite is offered once: joined or dismissed, the same
/// link is not offered again (a fingerprint is kept, never the link).
/// Links to your own server, or to the one you are in, are not offered.
/// Nothing here logs what the clipboard holds. Made by the composition
/// root.
class JoinOffers {
JoinOffers({required this.navigation, required this.yourServer, required this.forms});
final Navigation navigation;
final YourServer yourServer;
final ConnectStore forms;
final offer = signal<JoinOffer?>(null);
/// The clipboard text last looked at (its hash): the same text is not
/// parsed twice in a row.
int? _lastClip;
/// The app's start arguments, or a second start's: the first
/// `starsync://` link among them becomes an offer.
void fromArgs(List<String> args) {
for (final a in args) {
final link = InviteLink.parse(a);
if (link != null) {
_offer(link, OfferSource.link);
return;
}
}
}
/// The clipboard's text when the window came up ([text] null: none).
void fromClipboard(String? text) {
if (text == null || text.isEmpty) return;
final h = text.hashCode;
if (h == _lastClip) return;
_lastClip = h;
final link = inviteIn(text);
if (link == null || forms.seenInvite(inviteFingerprint(link))) return;
_offer(link, OfferSource.clipboard);
}
/// This app put [text] on the clipboard (Copy invite): never offered
/// back to it.
void copied(String text) => _lastClip = text.hashCode;
void _offer(InviteLink link, OfferSource source) {
if (yourServer.isOwn(link)) return;
if (navigation.place.peek() == Place.friend && navigation.address.peek() == '${link.host}:${link.port}') return;
final current = offer.peek();
if (current != null && current.link == link) return;
final o = JoinOffer(link, source);
offer.value = o;
unawaited(probeHealth(link.host, '${link.port}', navigation.healthGet).then((h) {
if (identical(offer.peek(), o)) offer.value = o.withHealth(h);
}));
}
/// Not now: a clipboard invite is not offered again.
Future<void> dismiss() async {
final o = offer.peek();
if (o == null) return;
offer.value = null;
if (o.source == OfferSource.clipboard) await forms.rememberInvite(inviteFingerprint(o.link));
}
/// Join: the link to join (its password, else the one saved for that
/// recent server); the invite is not offered again. Null when there
/// was none.
InviteLink? accept() {
final o = offer.peek();
if (o == null) return null;
offer.value = null;
unawaited(forms.rememberInvite(inviteFingerprint(o.link)));
final l = o.link;
if (l.secret.isNotEmpty) return l;
return InviteLink(host: l.host, port: l.port, name: l.name, secret: forms.secretFor(l.host, l.port));
}
}
@@ -0,0 +1,66 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
import 'invite.dart' show isPublicIpv4;
/// Fetches a URL's body as text; injectable so tests fake the services.
typedef TextGet = Future<String> Function(Uri uri);
/// "What is my IP" services, asked in order until one answers with a
/// public IPv4 address: each answers the plain address over HTTPS.
final publicAddressServices = [
Uri.parse('https://api.ipify.org'),
Uri.parse('https://ipv4.icanhazip.com'),
Uri.parse('https://checkip.amazonaws.com'),
Uri.parse('https://ifconfig.me/ip'),
];
const _eachTimeout = Duration(seconds: 3);
/// This network's internet (IPv4) address as the [services] see it: the
/// first that answers with a public address wins; a failing, slow or odd
/// one is skipped. Null when none does (offline, all blocked).
Future<String?> lookUpPublicAddress({
TextGet get = defaultTextGet,
List<Uri>? services,
Duration each = _eachTimeout,
}) async {
for (final uri in services ?? publicAddressServices) {
try {
final body = (await get(uri).timeout(each)).trim();
// Some answer "ip\n", a few with a label: take the first word.
final ip = body.split(RegExp(r'\s+')).first;
if (isPublicIpv4(ip)) return ip;
debugPrint('public address: ${uri.host} gave no public IPv4 address');
} catch (e) {
debugPrint('public address: ${uri.host} failed: ${e.runtimeType}');
}
}
return null;
}
/// Plain dart:io GET of a small text body; non-200 throws.
Future<String> defaultTextGet(Uri uri) async {
final client = HttpClient()
..connectionTimeout = _eachTimeout
..userAgent = 'Starsync';
try {
final req = await client.getUrl(uri);
final res = await req.close().timeout(_eachTimeout);
if (res.statusCode != 200) {
await res.drain<void>();
throw HttpException('status ${res.statusCode}', uri: uri);
}
final body = StringBuffer();
await for (final chunk in res.transform(utf8.decoder).timeout(_eachTimeout)) {
body.write(chunk);
if (body.length > 256) break; // an address, not a page
}
return body.toString();
} finally {
client.close(force: true);
}
}
+74 -10
View File
@@ -12,6 +12,7 @@ import '../jellyfin/session_media.dart' show relayCredentialsFor;
import '../state/connect_prefs.dart';
import '../state/server_isolate.dart';
import 'invite.dart';
import 'public_address.dart';
/// A fresh key per app run: this app's own client sends it in hello to be
/// recognised as the host (`members[].host`; it also stands in for the
@@ -44,8 +45,9 @@ Future<ServerIsolate> _startIsolate(
/// port, so only this app reaches it; [invite] attaches a second
/// listener on every interface at the configured port (Settings → Party
/// & network: port and password) and [stopSharing] detaches it. Members,
/// what plays and Up next never restart or reconnect for either. Made
/// by the composition root.
/// what plays and Up next never restart or reconnect for either. Sharing
/// also looks up the internet address ([reach]). Made by the composition
/// root.
class YourServer {
YourServer({
required this.forms,
@@ -54,10 +56,13 @@ class YourServer {
String? Function()? logDir,
ServerStarter? starter,
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
Future<String?> Function()? publicAddress,
this.publicWait = const Duration(seconds: 4),
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
_logDir = logDir ?? (() => null),
_starter = starter ?? _startIsolate,
_interfaces = interfaces ?? machineInterfaces;
_interfaces = interfaces ?? machineInterfaces,
_publicAddress = publicAddress ?? lookUpPublicAddress;
final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay logins.
@@ -66,6 +71,18 @@ class YourServer {
final String? Function() _logDir;
final ServerStarter _starter;
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
final Future<String?> Function() _publicAddress;
/// How long [invite] waits for the internet address before it hands
/// out a local one (the lookup goes on; [reach] and [shared] follow).
final Duration publicWait;
/// Where friends can reach it while shared: the internet address (once
/// looked up) and this machine's; null when not shared.
final reach = signal<InviteReach?>(null);
/// What `/health` calls it ("Bob's server"), the invite's name.
String _name = '';
/// Sent by this app's own client (see [newHostKey]).
final String hostKey = newHostKey();
@@ -106,12 +123,13 @@ class YourServer {
Future<int> _start(String name) async {
final who = name.trim();
_name = who.isEmpty ? '' : "$who's server";
late final ServerIsolate server;
server = await _starter(
AppConfig(
port: 0,
secret: '',
name: who.isEmpty ? 'server' : "$who's server",
name: who.isEmpty ? 'server' : _name,
hostKey: hostKey,
ytDlpDir: await _ytDlpDir(),
),
@@ -133,6 +151,7 @@ class YourServer {
_server = null;
port.value = null;
shared.value = null;
reach.value = null;
final message = 'server crashed: $cause';
debugPrint('[server] $message');
crash.value = message;
@@ -159,26 +178,68 @@ class YourServer {
// server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts),
);
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
final local = inviteAddresses(await _interfaces());
final gen = ++_shareGen;
reach.value = InviteReach(local: local, looking: true);
final lookup = _lookUp(local, gen);
// 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);
shared.value = invite;
if (public == null) {
unawaited(lookup.then((ip) {
final now = shared.peek();
if (ip != null && gen == _shareGen && identical(now, invite)) shared.value = now!.at(ip);
}));
}
return invite;
} finally {
busy.value = false;
}
}
/// The invite for [port] from this machine's best address (see
/// [inviteAddresses]); 127.0.0.1 when there is none.
Future<Invite> inviteFor(int port, {required bool password}) async {
final addresses = inviteAddresses(await _interfaces());
/// Bumped by every share and unshare: a lookup of an earlier one is
/// dropped.
int _shareGen = 0;
/// The internet address into [reach] (its own failure is no address).
Future<String?> _lookUp(List<String> local, int gen) async {
String? ip;
try {
ip = await _publicAddress();
} catch (e) {
debugPrint('public address lookup failed: $e');
}
if (gen != _shareGen || reach.peek() == null) return null;
reach.value = InviteReach(public: ip, local: local);
return ip;
}
/// The invite for [port] at [host] (else this machine's best address,
/// see [inviteAddresses]; 127.0.0.1 when there is none).
Invite inviteFor(int port, {required bool password, String? host}) {
final local = reach.peek()?.local ?? const [];
return Invite(
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
host: host ?? (local.isEmpty ? '127.0.0.1' : local.first),
port: port,
name: _name,
password: password,
version: appVersion,
);
}
/// [link] points at this server (one of its invite addresses, or this
/// machine, at its shared or loopback port).
bool isOwn(InviteLink link) {
final host = link.host.toLowerCase();
final local = host == 'localhost' || host == '127.0.0.1' || host == '::1';
if (local && link.port == port.peek()) return true;
final s = shared.peek();
if (s == null || link.port != s.port) return false;
return local || host == s.host || (reach.peek()?.choices.any((c) => c.host == host) ?? false);
}
/// Closes it to friends again: everyone but you leaves (told, so their
/// apps do not retry); you, what plays and Up next stay.
Future<void> stopSharing() async {
@@ -187,7 +248,9 @@ class YourServer {
busy.value = true;
try {
await server.unshare();
_shareGen++;
shared.value = null;
reach.value = null;
} finally {
busy.value = false;
}
@@ -202,6 +265,7 @@ class YourServer {
_server = null;
port.value = null;
shared.value = null;
reach.value = null;
await server?.stop();
}
}
+23
View File
@@ -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)),
),
],
);
});
+10 -2
View File
@@ -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')),
],
),
),
),
);
}
}
+13 -89
View File
@@ -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;
}
+209
View File
@@ -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}');
}
@@ -184,4 +184,46 @@ class ConnectStore {
];
await saveQuietly();
}
/// The saved password of a recent server at [host]:[port], '' when none.
String secretFor(String host, int port) =>
recents.peek().where((r) => r.key == '${host.toLowerCase()}:$port').firstOrNull?.secret ?? '';
/// Invites the join prompt already asked about (joined or dismissed),
/// as fingerprints ([inviteFingerprint]), newest last; never the links.
final _seenInvites = <String>[];
bool seenInvite(String fingerprint) {
_loadSeen();
return _seenInvites.contains(fingerprint);
}
Future<void> rememberInvite(String fingerprint) async {
_loadSeen();
_seenInvites
..remove(fingerprint)
..add(fingerprint);
while (_seenInvites.length > maxSeenInvites) {
_seenInvites.removeAt(0);
}
try {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
await box.put(connectKeySeenInvites, [..._seenInvites]);
} catch (e) {
debugPrint('save seen invites failed: $e');
}
}
bool _seenLoaded = false;
void _loadSeen() {
if (_seenLoaded) return;
final box = connectFormBoxIfOpen();
if (box == null) return;
_seenLoaded = true;
final raw = box.get(connectKeySeenInvites);
if (raw is List) _seenInvites.insertAll(0, [for (final v in raw) if (v is String && !_seenInvites.contains(v)) v]);
}
}
const connectKeySeenInvites = 'seenInvites';
const maxSeenInvites = 30;