From 6048fbe7d1ed2c8a52494185ff24f4fed5bcfd94 Mon Sep 17 00:00:00 2001 From: foxyz Date: Sun, 18 Oct 2026 17:18:25 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + README.md | 43 ++- apps/desktop/lib/main.dart | 38 ++- apps/desktop/lib/src/app/app_graph.dart | 11 +- .../lib/src/platform/app_shutdown.dart | 16 +- .../lib/src/platform/desktop/attention.dart | 40 +++ .../src/platform/desktop/desktop_shell.dart | 14 +- .../lib/src/platform/desktop/windows_api.dart | 60 ++++ .../lib/src/platform/desktop_entry.dart | 29 +- .../lib/src/platform/ports/shell_port.dart | 10 +- .../lib/src/platform/single_instance.dart | 205 ++++++++++++ apps/desktop/lib/src/platform/url_scheme.dart | 81 +++++ apps/desktop/lib/src/session/invite.dart | 188 ++++++++++- apps/desktop/lib/src/session/join_offers.dart | 141 +++++++++ .../lib/src/session/public_address.dart | 66 ++++ apps/desktop/lib/src/session/your_server.dart | 84 ++++- apps/desktop/lib/src/shell/app_shell.dart | 23 ++ apps/desktop/lib/src/shell/connect_cards.dart | 12 +- .../lib/src/shell/join_offer_card.dart | 98 ++++++ apps/desktop/lib/src/shell/party_strip.dart | 102 +----- apps/desktop/lib/src/shell/your_invite.dart | 209 +++++++++++++ apps/desktop/lib/src/state/connect_prefs.dart | 42 +++ apps/desktop/linux/runner/my_application.cc | 58 +++- apps/desktop/test/desktop_entry_test.dart | 15 +- apps/desktop/test/invite_links_test.dart | 294 ++++++++++++++++++ apps/desktop/test/player_settings_test.dart | 31 +- apps/desktop/test/test_graph.dart | 10 +- .../desktop/windows/runner/flutter_window.cpp | 7 + docs/ARCHITECTURE.md | 136 ++++++-- docs/DEVELOPMENT.md | 9 +- docs/HOW-IT-WORKS.md | 4 +- docs/PROTOCOL.md | 26 ++ docs/ROADMAP.md | 7 +- packages/core/lib/src/logging.dart | 5 +- packages/core/test/logging_test.dart | 6 + 35 files changed, 1943 insertions(+), 181 deletions(-) create mode 100644 apps/desktop/lib/src/platform/desktop/windows_api.dart create mode 100644 apps/desktop/lib/src/platform/single_instance.dart create mode 100644 apps/desktop/lib/src/platform/url_scheme.dart create mode 100644 apps/desktop/lib/src/session/join_offers.dart create mode 100644 apps/desktop/lib/src/session/public_address.dart create mode 100644 apps/desktop/lib/src/shell/join_offer_card.dart create mode 100644 apps/desktop/lib/src/shell/your_invite.dart create mode 100644 apps/desktop/test/invite_links_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab2754..b1a30f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ Links you put up are downloaded once for everyone, plus What's new and fixes for - What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About) - Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default) +- Invite links: Invite copies a starsync:// link; a friend clicks it (or just copies it and switches to Starsync) and the app asks "Join Bob's server?" +- Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded +- The password goes into an invite link only when you tick "Include the password in the link" +- Starsync runs once: a link clicked while it is open goes to the open window ### Changed diff --git a/README.md b/README.md index 662447b..ae9cb78 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ The app always runs its own server, quietly: your server. When it starts you are on it, alone, and it is a plain player. Three ways in, at the top of the Library the first time; none needs Jellyfin or an account. -- **Join a friend** (most people): your friend sends you an invite (an - address) and, maybe, a password. See [Join](#join). +- **Join a friend** (most people): your friend sends you an invite (a + `starsync://` link) and, maybe, a password. Click the link, or just + copy it: the app asks "Join ?". See [Join](#join). - **Invite friends**: you have the video and friends watch it with you. See [Your server and Invite](#your-server-and-invite). - **Watch on your own**: just play something from the Library, with @@ -88,8 +89,8 @@ out; a crown on whoever hosts, a tower on whoever passes the video on). Click the avatars for the member list (each member's app version, amber when it differs from yours, and the server's at the top; force ready, Nudge, Sit out; on your -server the invite with Copy invite and Stop sharing, on a friend's "Copy -invite address"). Beside them the server and "ready n/m" (who is sitting out or +server where friends can reach it, Copy invite link and Stop sharing, +on a friend's "Copy invite link"). Beside them the server and "ready n/m" (who is sitting out or reconnecting does not count), the chat button with unread count, and Invite on your server or Leave on a friend's. Empty strip space drags the window. @@ -169,13 +170,23 @@ Your server starts with the app and stops when it closes. Only this app can reach it until you press **Invite** (in the strip, or in the member list): that opens it on your network at the port from Settings → Party & network (default 8080, with the password set there; blank = anyone can -join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 · -Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up -next carry on, and you are the host (the crown). The invite picks this -computer's home-network address (not a VPN or proxy one, nor a Docker or -VM bridge). Friends elsewhere need the TCP port forwarded on your router -(or a VPN address, or a server they can all reach, like a VPS). Friends -open nothing. **Stop sharing** (in the member list) closes it again: +join) and copies an invite link ("Join me on Starsync: +starsync://203.0.113.7:8080/?name=… · Starsync 1.3.0") to send to +friends. Nothing restarts: what plays and Up next carry on, and you are +the host (the crown). + +The link carries your internet address when the app can find it (it asks +a "what is my IP" service such as ipify over HTTPS when you share), else +this computer's home-network address (not a proxy one, nor a Docker or VM +bridge). The member list shows every address to pick from: **Internet** +for friends elsewhere, **Home network** for friends on the same Wi-Fi or +router, **VPN** (Tailscale and the like). Behind a router, friends +elsewhere get in only when the TCP port is forwarded on your router to +this computer (the card says so and names this computer's address); the +app does not open ports itself. The password is never in the link unless +you tick **Include the password in the link**; otherwise friends ask you +for it. Friends open nothing. **Stop sharing** (in the member list) +closes it again: everyone else leaves (their app goes back to their own server and says why), you and Up next stay. @@ -351,7 +362,15 @@ Join a friend in the strip. Servers you got into are listed first (up to 6), with live status (online and how many are watching, or offline, plus "needs update" when its version and yours do not match): click one to rejoin, x to forget it. Or paste the invite (or the address and port) -and the password if there is one, then Enter or Join. Before connecting +and the password if there is one, then Enter or Join. + +Faster: click a friend's `starsync://` link, or copy their invite and +switch to Starsync. The app asks "Join Bob's server (203.0.113.7:8080)?" +with the server's version and who is watching; Join joins (and adds it +to your recent servers), Not now leaves it (a copied invite is then not +offered again). The clipboard is only looked at for an invite when the +window comes to the front, and never saved or logged. The app runs once: +a link clicked while it runs goes to the open window. Before connecting the app checks the server's version: when they do not match it says who has to update ("Bob's server runs Starsync 1.2.1 — they need to update"), and offers the update when it is you. Your name and picture diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 8c0cf5f..d2aa4fc 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -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 main(List 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 main(List 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 main(List 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 main(List 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 main(List 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); } diff --git a/apps/desktop/lib/src/app/app_graph.dart b/apps/desktop/lib/src/app/app_graph.dart index 0fb6497..cb84601 100644 --- a/apps/desktop/lib/src/app/app_graph.dart +++ b/apps/desktop/lib/src/app/app_graph.dart @@ -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 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), ); } diff --git a/apps/desktop/lib/src/platform/app_shutdown.dart b/apps/desktop/lib/src/platform/app_shutdown.dart index eb71d0c..eab4c89 100644 --- a/apps/desktop/lib/src/platform/app_shutdown.dart +++ b/apps/desktop/lib/src/platform/app_shutdown.dart @@ -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 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 Function() closeAppWith(AppGraph graph, {InstanceLock? instance}) { _graph = graph; + _instance = instance; return shutDownApp; } @@ -38,6 +43,9 @@ Future shutDownApp({Future 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 shutDownApp({Future 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(); diff --git a/apps/desktop/lib/src/platform/desktop/attention.dart b/apps/desktop/lib/src/platform/desktop/attention.dart index 243c3eb..a1affef 100644 --- a/apps/desktop/lib/src/platform/desktop/attention.dart +++ b/apps/desktop/lib/src/platform/desktop/attention.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -17,3 +19,41 @@ Future 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 windowLaunchToken() async { + if (!Platform.isLinux) return ''; + try { + return await _window.invokeMethod('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 presentWindow(String token) async { + if (!Platform.isLinux) return false; + try { + await _window.invokeMethod('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 exitNow() async { + try { + await _window.invokeMethod('exit'); + } catch (_) {} + Process.killPid(pid, ProcessSignal.sigkill); + // The kill lands before anything else runs. + await Future.delayed(const Duration(days: 1)); + throw StateError('unreachable'); +} diff --git a/apps/desktop/lib/src/platform/desktop/desktop_shell.dart b/apps/desktop/lib/src/platform/desktop/desktop_shell.dart index d68f81d..51aba43 100644 --- a/apps/desktop/lib/src/platform/desktop/desktop_shell.dart +++ b/apps/desktop/lib/src/platform/desktop/desktop_shell.dart @@ -98,10 +98,13 @@ class DesktopShell implements ShellPort { } @override - Future focus() async { + Future 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 readClipboardText() async { + try { + return await ClipboardX.readText(); + } catch (_) { + return null; + } + } + @override Widget dropTarget({ Key? key, diff --git a/apps/desktop/lib/src/platform/desktop/windows_api.dart b/apps/desktop/lib/src/platform/desktop/windows_api.dart new file mode 100644 index 0000000..8bedc0a --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/windows_api.dart @@ -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 subKey, Pointer name, Uint32 type, Pointer data, Uint32 size); +typedef _Set = int Function( + int key, Pointer subKey, Pointer name, int type, Pointer data, int size); +typedef _GetNative = Int32 Function(IntPtr key, Pointer subKey, Pointer name, Uint32 flags, + Pointer type, Pointer data, Pointer size); +typedef _Get = int Function(int key, Pointer subKey, Pointer name, int flags, + Pointer type, Pointer data, Pointer 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()..value = 0; + if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, nullptr, size) != 0) return null; + final data = arena(size.value ~/ 2 + 1); + if (get(_hkeyCurrentUser, key, value, _rrfRtRegSz, nullptr, data.cast(), size) != 0) return null; + return data.cast().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; + }); +} diff --git a/apps/desktop/lib/src/platform/desktop_entry.dart b/apps/desktop/lib/src/platform/desktop_entry.dart index e65678a..49f32f8 100644 --- a/apps/desktop/lib/src/platform/desktop_entry.dart +++ b/apps/desktop/lib/src/platform/desktop_entry.dart @@ -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 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 installDesktopEntry({ + String? executable, + String? dataHome, + String? home, + @visibleForTesting bool force = false, + Future Function(String desktopFile, String applicationsDir)? registerScheme, +}) async { if (!Platform.isLinux || (kDebugMode && !force)) return; try { final exe = executable ?? Platform.resolvedExecutable; @@ -41,9 +50,11 @@ Future 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 '''; diff --git a/apps/desktop/lib/src/platform/ports/shell_port.dart b/apps/desktop/lib/src/platform/ports/shell_port.dart index 8716b48..25ba32a 100644 --- a/apps/desktop/lib/src/platform/ports/shell_port.dart +++ b/apps/desktop/lib/src/platform/ports/shell_port.dart @@ -43,8 +43,10 @@ abstract interface class ShellPort { Future quit(); /// Brings the window up and gives it the focus (restored when - /// minimised, shown when hidden): a click on a system notification. - Future 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 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 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 readClipboardText(); + /// [child] taking dropped files ([onDrop]: their paths and where they /// landed); [enable] off lets drops through. Widget dropTarget({ diff --git a/apps/desktop/lib/src/platform/single_instance.dart b/apps/desktop/lib/src/platform/single_instance.dart new file mode 100644 index 0000000..c37813a --- /dev/null +++ b/apps/desktop/lib/src/platform/single_instance.dart @@ -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 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 = {}; + +/// 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(); + bool _closing = false; + bool _released = false; + + /// What later starts handed over, in order (buffered until listened to). + Stream 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 claim( + String dir, { + List 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 _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 _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 _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 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 _quiet(Future f) async { + try { + await f; + } catch (_) {} +} diff --git a/apps/desktop/lib/src/platform/url_scheme.dart b/apps/desktop/lib/src/platform/url_scheme.dart new file mode 100644 index 0000000..e534ce6 --- /dev/null +++ b/apps/desktop/lib/src/platform/url_scheme.dart @@ -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 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 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'); + } +} diff --git a/apps/desktop/lib/src/session/invite.dart b/apps/desktop/lib/src/session/invite.dart index 71f187a..1ad7413 100644 --- a/apps/desktop/lib/src/session/invite.dart +++ b/apps/desktop/lib/src/session/invite.dart @@ -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 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 b) => b[0] == 198 && (b[1] == 18 || b[1] == 19); bool _isPrivate(List 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 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 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 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 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> machineInterfaces() async { diff --git a/apps/desktop/lib/src/session/join_offers.dart b/apps/desktop/lib/src/session/join_offers.dart new file mode 100644 index 0000000..618cbed --- /dev/null +++ b/apps/desktop/lib/src/session/join_offers.dart @@ -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 (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(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 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 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)); + } +} diff --git a/apps/desktop/lib/src/session/public_address.dart b/apps/desktop/lib/src/session/public_address.dart new file mode 100644 index 0000000..f6f4c4a --- /dev/null +++ b/apps/desktop/lib/src/session/public_address.dart @@ -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 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 lookUpPublicAddress({ + TextGet get = defaultTextGet, + List? 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 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(); + 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); + } +} diff --git a/apps/desktop/lib/src/session/your_server.dart b/apps/desktop/lib/src/session/your_server.dart index fd2eaaa..f6b9a97 100644 --- a/apps/desktop/lib/src/session/your_server.dart +++ b/apps/desktop/lib/src/session/your_server.dart @@ -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 _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> Function()? interfaces, + Future 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> Function() _interfaces; + final Future 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(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 _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 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 _lookUp(List 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 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(); } } diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index e6de3dd..67cc437 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -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 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 _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 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)), + ), ], ); }); diff --git a/apps/desktop/lib/src/shell/connect_cards.dart b/apps/desktop/lib/src/shell/connect_cards.dart index 8d1dc29..4b5c0e6 100644 --- a/apps/desktop/lib/src/shell/connect_cards.dart +++ b/apps/desktop/lib/src/shell/connect_cards.dart @@ -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; diff --git a/apps/desktop/lib/src/shell/join_offer_card.dart b/apps/desktop/lib/src/shell/join_offer_card.dart new file mode 100644 index 0000000..21a5eb3 --- /dev/null +++ b/apps/desktop/lib/src/shell/join_offer_card.dart @@ -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')), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index e68d0c3..87ae2f3 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -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 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; } diff --git a/apps/desktop/lib/src/shell/your_invite.dart b/apps/desktop/lib/src/shell/your_invite.dart new file mode 100644 index 0000000..5701f73 --- /dev/null +++ b/apps/desktop/lib/src/shell/your_invite.dart @@ -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 createState() => _YourInviteState(); +} + +class _YourInviteState extends State { + /// 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 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}'); +} diff --git a/apps/desktop/lib/src/state/connect_prefs.dart b/apps/desktop/lib/src/state/connect_prefs.dart index 42c7595..2b02b74 100644 --- a/apps/desktop/lib/src/state/connect_prefs.dart +++ b/apps/desktop/lib/src/state/connect_prefs.dart @@ -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 = []; + + bool seenInvite(String fingerprint) { + _loadSeen(); + return _seenInvites.contains(fingerprint); + } + + Future 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; diff --git a/apps/desktop/linux/runner/my_application.cc b/apps/desktop/linux/runner/my_application.cc index 80c53d9..b3c1753 100644 --- a/apps/desktop/linux/runner/my_application.cc +++ b/apps/desktop/linux/runner/my_application.cc @@ -7,6 +7,9 @@ #ifdef GDK_WINDOWING_X11 #include #endif +#ifdef GDK_WINDOWING_WAYLAND +#include +#endif #include "flutter/generated_plugin_registrant.h" @@ -24,17 +27,63 @@ static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); } +// The desktop's activation token this process was started with +// (XDG_ACTIVATION_TOKEN on Wayland, DESKTOP_STARTUP_ID on X11), read +// before GTK starts and consumes it. A second start hands it to the +// running app (lib/src/platform/single_instance.dart), which brings its +// window up with it: compositors only let a window take the focus with +// a fresh token. +static gchar* launch_token = nullptr; + +// Brings the window up with an activation token from another process. +static void present_with_token(GtkWindow* window, const gchar* token) { + if (token != nullptr && token[0] != '\0') { + GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); +#ifdef GDK_WINDOWING_WAYLAND + if (GDK_IS_WAYLAND_DISPLAY(display)) { + gdk_wayland_display_set_startup_notification_id(display, token); + } +#endif +#ifdef GDK_WINDOWING_X11 + if (GDK_IS_X11_DISPLAY(display)) { + gtk_window_set_startup_id(window, token); + } +#endif + } + gtk_window_present(window); +} + // "starsync/window": `attention` sets the urgency hint while the window is -// not focused; focusing it clears the hint. +// not focused (focusing it clears the hint); `launchToken` is this +// process's activation token ('' when none); `present` brings the window +// up with a token (argument, may be empty); `exit` ends a second start +// that handed over (no exit handlers, see main.cc). static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call, gpointer user_data) { GtkWindow* window = GTK_WINDOW(user_data); g_autoptr(FlMethodResponse) response = nullptr; - if (g_strcmp0(fl_method_call_get_name(call), "attention") == 0) { + const gchar* method = fl_method_call_get_name(call); + if (g_strcmp0(method, "attention") == 0) { if (!gtk_window_is_active(window)) { gtk_window_set_urgency_hint(window, TRUE); } response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else if (g_strcmp0(method, "launchToken") == 0) { + g_autoptr(FlValue) value = + fl_value_new_string(launch_token != nullptr ? launch_token : ""); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(value)); + } else if (g_strcmp0(method, "present") == 0) { + FlValue* args = fl_method_call_get_args(call); + const gchar* token = nullptr; + if (args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING) { + token = fl_value_get_string(args); + } + present_with_token(window, token); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else if (g_strcmp0(method, "exit") == 0) { + fflush(stdout); + fflush(stderr); + _exit(0); } else { response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); } @@ -219,6 +268,11 @@ MyApplication* my_application_new() { // the application to be recognized beyond its binary name. g_set_prgname(APPLICATION_ID); + // Before GTK starts (it takes these out of the environment). + const gchar* token = g_getenv("XDG_ACTIVATION_TOKEN"); + if (token == nullptr || token[0] == '\0') token = g_getenv("DESKTOP_STARTUP_ID"); + launch_token = g_strdup(token); + return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", G_APPLICATION_NON_UNIQUE, nullptr)); diff --git a/apps/desktop/test/desktop_entry_test.dart b/apps/desktop/test/desktop_entry_test.dart index 5fdc193..e2e2483 100644 --- a/apps/desktop/test/desktop_entry_test.dart +++ b/apps/desktop/test/desktop_entry_test.dart @@ -19,16 +19,25 @@ void main() { ..createSync(recursive: true); final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync(); - await installDesktopEntry(executable: '$root/starsync', dataHome: share, force: true); + final registered = []; + await installDesktopEntry( + executable: '$root/starsync', + dataHome: share, + force: true, + registerScheme: (file, dir) async => registered.add('$file in $dir')); expect(oldEntry.existsSync(), isFalse); expect(oldIcon.existsSync(), isFalse); expect(other.existsSync(), isTrue); expect(otherIcon.existsSync(), isTrue); final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync(); - expect(entry, contains('Exec="$root/starsync"')); - expect(entry, contains('StartupWMClass=dev.foxyz.starsync')); + expect(entry, contains('StartupWMClass=dev.foxyz.starsync')); expect(entry, contains('Icon=dev.foxyz.starsync')); + // starsync:// links: the entry takes them and becomes their default. + expect(entry, contains('Exec="$root/starsync" %u')); + expect(entry, contains('MimeType=x-scheme-handler/starsync;')); + expect(entry, contains('StartupNotify=true')); + expect(registered, ['dev.foxyz.starsync.desktop in ${apps.path}']); expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue); } finally { await tmp.delete(recursive: true); diff --git a/apps/desktop/test/invite_links_test.dart b/apps/desktop/test/invite_links_test.dart new file mode 100644 index 0000000..6167750 --- /dev/null +++ b/apps/desktop/test/invite_links_test.dart @@ -0,0 +1,294 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:starsync/src/platform/single_instance.dart'; +import 'package:starsync/src/platform/url_scheme.dart'; +import 'package:starsync/src/session/invite.dart'; +import 'package:starsync/src/session/join_offers.dart'; +import 'package:starsync/src/session/navigation.dart' show Place; +import 'package:starsync/src/session/public_address.dart'; +import 'package:starsync/src/shell/join_offer_card.dart'; +import 'package:starsync/src/state/connect_prefs.dart' show RecentServer; + +import 'test_graph.dart'; + +// starsync:// invite links, the internet address lookup, the join +// prompt (links and the clipboard), one app per profile and the link +// handler's registration values. + +String health({String name = "Bob's server", String version = '1.3.2', int viewers = 2}) => jsonEncode({ + 'ok': true, + 'name': name, + 'proto': RelayConstants.protoMax, + 'protoMin': RelayConstants.protoMin, + 'protoMax': RelayConstants.protoMax, + 'version': version, + 'viewers': viewers, + }); + +final _graph = testGraph(healthGet: (uri) async => health()); + +void main() { + group('invite link', () { + test('round trip: address, name and password, which never shows in toString', () { + const link = InviteLink(host: '203.0.113.7', port: 9000, name: "Bob's server & co", secret: 'p@ss w&rd=1'); + expect(link.uri, startsWith('starsync://203.0.113.7:9000/?name=')); + expect(InviteLink.parse(link.uri), link); + expect(link.toString(), isNot(contains('p@ss'))); + expect(link.toString(), contains('password')); + expect(redactLog('opening ${link.uri}'), isNot(contains('w%26rd'))); + }); + + test('bare links, IPv6, the default port; anything else is no link', () { + expect(InviteLink.parse('starsync://192.168.1.20:8080'), const InviteLink(host: '192.168.1.20', port: 8080)); + expect(const InviteLink(host: '192.168.1.20', port: 8080).uri, 'starsync://192.168.1.20:8080'); + expect(InviteLink.parse('STARSYNC://vps.example.com'), const InviteLink(host: 'vps.example.com', port: 8080)); + const v6 = InviteLink(host: 'fe80::1', port: 8080); + expect(v6.uri, 'starsync://[fe80::1]:8080'); + expect(InviteLink.parse(v6.uri)?.port, 8080); + expect(InviteLink.parse('starsync://host:99999'), isNull); + expect(InviteLink.parse('starsync://'), isNull); + expect(InviteLink.parse('https://192.168.1.20:8080'), isNull); + expect(InviteLink.parse('starsync://a b:1'), isNull); + }); + + test('the invite copies its link, the password only when asked; old apps still read the address', () { + const invite = Invite(host: '192.168.1.20', port: 8080, name: "Bob's server", password: true, version: '1.3.2'); + expect(invite.text, contains('(ask me for the password)')); + expect(invite.text, isNot(contains('pw='))); + final withPw = invite.textWith(secret: 'hunter2'); + expect(withPw, isNot(contains('ask me'))); + expect(inviteIn(withPw)?.secret, 'hunter2'); + expect(inviteIn(withPw)?.name, "Bob's server"); + // A 1.3.x app pasting it into the Join card's address. + expect(addressIn(withPw), (host: '192.168.1.20', port: '8080')); + expect(invite.at('203.0.113.7').address, '203.0.113.7:8080'); + }); + + test('found in a text: a link anywhere, an old plain invite, never a bare address', () { + expect(inviteIn('hey, come watch: starsync://10.0.0.5:8081/?name=Ann see you')?.address, '10.0.0.5:8081'); + expect(inviteIn('Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'), + const InviteLink(host: '192.168.1.20', port: 8080)); + expect(inviteIn('192.168.1.20:8080'), isNull); + expect(inviteIn('see https://example.com:443/x'), isNull); + expect(inviteIn('${'x' * 5000} starsync://1.2.3.4:1'), isNull, reason: 'only the start of a long text'); + }); + + test('reach: the internet address first; forwarding when it is the router\'s', () { + const behindRouter = InviteReach(public: '203.0.113.7', local: ['192.168.1.20', '100.64.1.2']); + expect(behindRouter.best, '203.0.113.7'); + expect(behindRouter.forwarding, isTrue); + expect([for (final c in behindRouter.choices) c.kind], [AddressKind.public, AddressKind.lan, AddressKind.vpn]); + const direct = InviteReach(public: '203.0.113.7', local: ['203.0.113.7']); + expect(direct.forwarding, isFalse); + expect(direct.choices.length, 1); + expect(const InviteReach(local: ['192.168.1.20']).best, '192.168.1.20'); + expect(isPublicIpv4('8.8.8.8'), isTrue); + for (final ip in ['10.1.2.3', '192.168.0.1', '172.20.0.1', '100.100.1.1', '127.0.0.1', '198.18.0.1', '::1', 'x']) { + expect(isPublicIpv4(ip), isFalse, reason: ip); + } + }); + }); + + group('internet address', () { + final a = Uri.parse('https://a.test'), b = Uri.parse('https://b.test'), c = Uri.parse('https://c.test'); + + test('the first service with a public address wins; failing, private or odd answers are skipped', () async { + final asked = []; + final ip = await lookUpPublicAddress( + services: [a, b, c], + get: (uri) async { + asked.add(uri.host); + return switch (uri.host) { + 'a.test' => throw const SocketException('blocked'), + 'b.test' => '192.168.1.1\n', + _ => '203.0.113.7\n', + }; + }, + ); + expect(ip, '203.0.113.7'); + expect(asked, ['a.test', 'b.test', 'c.test']); + }); + + test('a slow service is given up on; none answering is no address', () async { + final ip = await lookUpPublicAddress( + services: [a, b], + each: const Duration(milliseconds: 50), + get: (uri) => uri.host == 'a.test' ? Completer().future : Future.value('203.0.113.9'), + ); + expect(ip, '203.0.113.9'); + expect(await lookUpPublicAddress(services: [a], get: (_) async => 'nope'), isNull); + }); + }); + + group('join prompt', () { + final offers = _graph.joinOffers; + final yours = _graph.yourServer; + tearDown(() { + offers.offer.value = null; + yours.shared.value = null; + yours.reach.value = null; + _graph.forms.recents.value = const []; + }); + + test('a clicked link becomes an offer with what /health says', () async { + offers.fromArgs(['--whatever', 'starsync://203.0.113.7:8080/?name=Bob']); + expect(offers.offer.peek()?.source, OfferSource.link); + expect(offers.offer.peek()?.question, 'Join Bob (203.0.113.7:8080)?'); + await pumpEventQueue(); + final o = offers.offer.peek()!; + expect(o.question, "Join Bob's server (203.0.113.7:8080)?"); + expect(o.detail, 'Starsync 1.3.2 · 2 watching'); + }); + + test('the clipboard: offered once; Not now and Join both stop it coming back', () async { + offers.fromClipboard('come: starsync://203.0.113.20:8080'); + expect(offers.offer.peek()?.source, OfferSource.clipboard); + await offers.dismiss(); + expect(offers.offer.peek(), isNull); + offers.fromClipboard('something else'); + offers.fromClipboard('come: starsync://203.0.113.20:8080'); + expect(offers.offer.peek(), isNull, reason: 'dismissed'); + + offers.fromClipboard('starsync://203.0.113.21:8080'); + _graph.forms.recents.value = const [RecentServer(host: '203.0.113.21', port: '8080', secret: 'saved')]; + final link = offers.accept(); + expect(link?.secret, 'saved', reason: 'a link without its password takes the saved one'); + offers.fromClipboard('x'); + offers.fromClipboard('starsync://203.0.113.21:8080'); + expect(offers.offer.peek(), isNull, reason: 'joined'); + }); + + test('never your own invite, nor the server you are in, nor plain text', () { + yours.port.value = 40000; + addTearDown(() => yours.port.value = null); + yours.shared.value = const Invite(host: '203.0.113.7', port: 8080); + yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']); + offers.fromClipboard('starsync://192.168.1.20:8080'); + offers.fromClipboard('starsync://127.0.0.1:40000'); + expect(offers.offer.peek(), isNull); + // What this app just copied is not offered back to it. + const copied = 'Join me on Starsync: starsync://198.51.100.4:8080'; + offers.copied(copied); + offers.fromClipboard(copied); + expect(offers.offer.peek(), isNull); + offers.fromClipboard('just some text 10.0.0.1:80'); + expect(offers.offer.peek(), isNull); + + final nav = _graph.navigation; + nav.place.value = Place.friend; + nav.address.value = '198.51.100.9:8080'; + addTearDown(() { + nav.place.value = Place.none; + nav.address.value = ''; + }); + offers.fromArgs(['starsync://198.51.100.9:8080']); + expect(offers.offer.peek(), isNull); + }); + + testWidgets('the card: the question, Join and Not now', (tester) async { + var joined = 0; + await tester.pumpWidget(scoped( + _graph, + MaterialApp( + theme: ThemeData.dark(), + home: Scaffold(body: JoinOfferCard(offers: offers, onJoin: () => joined++))))); + expect(find.byKey(const ValueKey('join-offer')), findsNothing); + offers.offer.value = const JoinOffer(InviteLink(host: '203.0.113.7', port: 8080, name: 'Ann'), OfferSource.clipboard); + await tester.pumpAndSettle(); + expect(find.text('Join Ann (203.0.113.7:8080)?'), findsOneWidget); + expect(find.text('Invite on your clipboard · Checking the server…'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('join-offer-join'))); + expect(joined, 1); + await tester.tap(find.byKey(const ValueKey('join-offer-dismiss'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('join-offer')), findsNothing); + }); + }); + + group('one app per profile', () { + late Directory tmp; + setUp(() async => tmp = await Directory.systemTemp.createTemp('starsync-instance')); + tearDown(() async => tmp.delete(recursive: true)); + + test('a second start hands its arguments and token over and ends', () async { + final first = await InstanceLock.claim(tmp.path); + expect(first, isNotNull); + expect(first!.held, isTrue); + final got = first.handoffs.first; + int? allowed; + final second = await InstanceLock.claim(tmp.path, + args: ['starsync://1.2.3.4:8080/?pw=x'], activation: 'tok', beforeSend: (p) => allowed = p); + expect(second, isNull, reason: 'handed over'); + expect(allowed, pid); + final h = await got; + expect(h.args, ['starsync://1.2.3.4:8080/?pw=x']); + expect(h.activation, 'tok'); + await first.release(); + }); + + test('each profile folder is its own instance', () async { + final a = await InstanceLock.claim('${tmp.path}/a'); + final b = await InstanceLock.claim('${tmp.path}/b'); + expect(a?.held, isTrue); + expect(b?.held, isTrue); + await a!.release(); + await b!.release(); + }); + + test('a start while the app closes waits and takes over once it is gone', () async { + final first = (await InstanceLock.claim(tmp.path))!; + first.closing(); + final second = InstanceLock.claim(tmp.path, retryEvery: const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 150)); + await first.release(); + final taken = await second; + expect(taken?.held, isTrue); + await taken!.release(); + }); + + test('a wrong token is refused (nothing handed over)', () async { + final first = (await InstanceLock.claim(tmp.path))!; + var handed = false; + first.handoffs.listen((_) => handed = true); + final s = await Socket.connect(InternetAddress.loopbackIPv4, first.port!); + s.writeln(jsonEncode({'token': 'guess', 'args': ['x']})); + final reply = await utf8.decoder.bind(s).join(); + expect(reply, contains('"ok":false')); + expect(handed, isFalse); + s.destroy(); + await first.release(); + }); + + test('no lock and nobody answering: starts anyway after a while', () async { + final first = (await InstanceLock.claim(tmp.path))!; + first.closing(); + final started = await InstanceLock.claim(tmp.path, + retryFor: const Duration(milliseconds: 100), + closingWait: const Duration(milliseconds: 100), + retryEvery: const Duration(milliseconds: 20)); + expect(started?.held, isFalse); + await first.release(); + }); + }); + + group('link handler', () { + test('Windows: HKCU\\Software\\Classes\\starsync opens starsync.exe with the link', () { + final v = windowsSchemeValues(r'C:\Apps\Starsync\starsync.exe'); + expect(v.first, (r'Software\Classes\starsync', '', 'URL:Starsync invite')); + expect(v, contains((r'Software\Classes\starsync', 'URL Protocol', ''))); + expect(v.last, (r'Software\Classes\starsync\shell\open\command', '', r'"C:\Apps\Starsync\starsync.exe" "%1"')); + }); + + test('an old-named copy points links at starsync next to it', () { + expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => true), r'C:\S\starsync.exe'); + expect(schemeExecutable(r'C:\S\watch_together.exe', exists: (_) => false), r'C:\S\watch_together.exe'); + expect(schemeExecutable('/opt/s/watch_together', exists: (_) => true), '/opt/s/starsync'); + expect(schemeExecutable('/opt/s/starsync', exists: (_) => true), '/opt/s/starsync'); + }); + }); +} diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart index 7c7d4da..6df95e1 100644 --- a/apps/desktop/test/player_settings_test.dart +++ b/apps/desktop/test/player_settings_test.dart @@ -277,7 +277,8 @@ void main() { test('an invite: the address, whether to ask for a password, the version; pasted back', () { const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0'); expect(invite.address, '192.168.1.20:8080'); - expect(invite.text, 'Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'); + expect(invite.text, + 'Join me on Starsync: starsync://192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'); expect(invite.fp, isNull, reason: 'reserved for pinned certificates'); expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080')); expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000')); @@ -336,11 +337,33 @@ void main() { expect(find.byKey(const ValueKey('stop-sharing')), findsNothing); yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0'); - await tester.pumpAndSettle(); + yours.reach.value = const InviteReach(local: ['192.168.1.20'], looking: true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); expect(find.text('192.168.1.20:8080'), findsOneWidget); - expect(find.text('Copy invite'), findsOneWidget); + expect(find.text('Looking up your internet address…'), findsOneWidget); + expect(find.text('Copy invite link'), findsOneWidget); expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget); - expect(find.textContaining('port 8080 forwarded'), findsOneWidget); + expect(find.textContaining('friends on your network'), findsOneWidget); + // No password set: nothing to tick. + expect(find.byKey(const ValueKey('invite-with-password')), findsNothing); + + // Behind a router: the internet address first, with the forwarding + // it needs; the home address stays a choice. + yours.shared.value = const Invite(host: '203.0.113.7', port: 8080, version: '1.3.0'); + yours.reach.value = const InviteReach(public: '203.0.113.7', local: ['192.168.1.20']); + await tester.pumpAndSettle(); + expect(find.text('203.0.113.7:8080'), findsOneWidget); + expect(find.text('192.168.1.20:8080'), findsOneWidget); + expect(find.textContaining('port 8080 is forwarded on your router to this computer (192.168.1.20)'), + findsOneWidget); + await tester.tap(find.text('192.168.1.20:8080')); + await tester.pumpAndSettle(); + expect(find.textContaining('friends on your network'), findsOneWidget); + + // No internet address found: say what friends elsewhere need. + yours.reach.value = const InviteReach(local: ['192.168.1.20']); + await tester.pumpAndSettle(); expect(find.textContaining('VPS'), findsOneWidget); }); }); diff --git a/apps/desktop/test/test_graph.dart b/apps/desktop/test/test_graph.dart index 14236c8..e4210a1 100644 --- a/apps/desktop/test/test_graph.dart +++ b/apps/desktop/test/test_graph.dart @@ -6,9 +6,13 @@ import 'package:starsync/src/session/identity.dart' show ClientIdentity; /// One graph per test file, like the app's one per run: nothing starts /// until a test calls `navigation.start()` (or `goHome`), so widget tests -/// see the app before your server is up. -AppGraph testGraph({HealthGetter healthGet = defaultHealthGet}) => - AppGraph.build(identity: ClientIdentity.ephemeral(), healthGet: healthGet); +/// see the app before your server is up. Sharing looks up no internet +/// address unless [publicAddress] gives one. +AppGraph testGraph({HealthGetter healthGet = defaultHealthGet, Future Function()? publicAddress}) => + AppGraph.build( + identity: ClientIdentity.ephemeral(), + healthGet: healthGet, + publicAddress: publicAddress ?? () async => null); /// [child] under [graph]'s [AppScope]. Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child); diff --git a/apps/desktop/windows/runner/flutter_window.cpp b/apps/desktop/windows/runner/flutter_window.cpp index 83ae4aa..aa4104d 100644 --- a/apps/desktop/windows/runner/flutter_window.cpp +++ b/apps/desktop/windows/runner/flutter_window.cpp @@ -52,6 +52,13 @@ bool FlutterWindow::OnCreate() { window_channel_->SetMethodCallHandler( [this](const flutter::MethodCall& call, std::unique_ptr> result) { + // A second start that handed its link to the running app ends + // here, before its window ever shows (no CRT exit, no DLL detach). + if (call.method_name() == "exit") { + fflush(nullptr); + ::TerminateProcess(::GetCurrentProcess(), 0); + return; + } if (call.method_name() != "attention") { result->NotImplemented(); return; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bdc1b45..06b869a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,21 +61,53 @@ attach and detach the network listener (below). its reason; nothing else changes. - **Invite** (`session/invite.dart`): `YourServer.invite()` shares the server if it is not yet and returns an `Invite`: the host, the port, the - name, whether a password is needed (never the password), the version - and an `fp` field reserved for a pinned certificate (`wss://` with a - self-signed one, later; always null). The host is the best of - `inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback, - link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN - proxies like Clash hand out) address and no container or VM bridge - (`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`, - `podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168 - first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any - other (public) address. No public-address lookup: the members card says - that friends elsewhere need the port forwarded or a server they can all - reach. `Invite.text` is what Invite copies ("Join me on Starsync: - 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0"); - `addressIn` reads the address back out of a pasted invite (the Join - card's address field). `stopSharing` unshares. + name ("'s server"), whether a password is needed (never the + password), the version and an `fp` field reserved for a pinned + certificate (`wss://` with a self-signed one, later; always null). This + machine's addresses are `inviteAddresses` over `NetworkInterface.list`: + IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP + (198.18.0.0/15, what TUN proxies like Clash hand out) address and no + container or VM bridge (`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, + `vboxnet*`, `cni*`, `podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, + VirtualBox, VMware); 192.168 first, then 10/8 and 172.16/12, then a mesh + VPN's 100.64/10, then any other (public) address. Sharing also looks up + the internet address (`lookUpPublicAddress`, `session/public_address.dart`): + plain-text "what is my IP" services over HTTPS (ipify, icanhazip, + checkip.amazonaws, ifconfig.me), asked in order until one answers a + public IPv4 address, 3 s each; that tells those services this network's + address, nothing else. `YourServer.reach` (`InviteReach`) holds both: the + internet address first, then this machine's, each with its + `AddressKind` (internet, home network, VPN); `forwarding` when the + internet address is not on this machine (behind a router: friends + elsewhere get in only with the port forwarded). No UPnP. The invite + carries the internet address when the lookup answers within 4 s + (`publicWait`), else the best local one, switched to the internet one + when it lands. `Invite.text` is what Invite copies ("Join me on + Starsync: starsync://203.0.113.7:8080/?name=Bob%27s+server (ask me for the + password) · Starsync 1.3.0"); `textWith(secret:)` puts the password in + the link instead, only when the user ticks it. The link format: + [PROTOCOL.md "Invite link"](PROTOCOL.md#invite-link) (`InviteLink`: + `parse`, `uri`, a `toString` without the password). `inviteIn` finds an + invite in a text (a link anywhere, or a 1.3.x plain invite's address; + never a bare address); `addressIn` reads `host:port` out of anything + (the Join card's address field; a 1.3.x app pasting a link reads its + address this way). `stopSharing` unshares. +- **Links and the join prompt** (`session/join_offers.dart`, `JoinOffers`, + in the composition root): a clicked `starsync://` link (the app's start + arguments, or a second start's, see "Single instance" under "App + events and platform ports") and an invite on the clipboard when the + window gains the focus (and at start) become `offer`: "Join Bob's server + (203.0.113.7:8080)?" with the server's `/health` (its name, version, + how many watch, the version gate's sentence, or unreachable). Not + offered: links to your own server (`YourServer.isOwn`), to the server + you are in, and the text this app just copied (`copied`). Join + (`accept`) fills the Join card's fields (a link without a password + takes the one saved for that recent server) and joins through + `ConnectFlow` and `Navigation.join` (version gate, recents); a failure + opens the Join card with its error. A clipboard invite joined or + dismissed (Not now) is not offered again: the connect form box keeps the + last 30 as fingerprints (a hash of the link), never the links; the + clipboard's text is only parsed, never logged or stored. - **Where you are** (`session/navigation.dart`, `Navigation`): `place` is `none` (starting, or between two servers), `yours` or `friend`. The app starts at home: your server, joined over 127.0.0.1 with the host key @@ -136,7 +168,7 @@ only stopping sharing sends guests out. `Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the `SessionCoordinator` (handed the settings and the watch tracker), the connect form and recents (`ConnectStore`, `state/connect_prefs.dart`), -`YourServer`, `Navigation`, the library's matches (`ShowMatcher`, +`YourServer`, `Navigation`, the join prompt (`JoinOffers`), the library's matches (`ShowMatcher`, "Library", whose `refForPath` it hands the coordinator as `fileRef`, and a `LocalNextFinder` over it as `nextFile`) and watch tracking (`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it @@ -1798,8 +1830,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and (`watchActivity`: focused, unfocused, minimised, hidden; window_manager's focus, blur, minimise and restore, and the shell's own `hide`, wired to `SessionCoordinator.windowActivity` and `Notifier.window` in `main`), - bringing the window up and focusing it (`focus`), dragging (`WindowDragArea`), close, the way out - (save, hide, quit), attention, the clipboard paste (files, else text) + bringing the window up and focusing it (`focus`, with another start's + activation token, see "Single instance" below), dragging (`WindowDragArea`), close, the way out + (save, hide, quit), attention, the clipboard paste (files, else text), the clipboard's + text alone (`readClipboardText`, for the join prompt) and drop targets (fox_utils). - `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`. - `FolderAccess` (`DesktopFolders`): folder and file dialogs @@ -1824,6 +1858,47 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and (`AppDirs`, "Desktop UI"), and the copy of the data from before the rename; resolved in `main` before the composition root exists, so not a `Ports` field. + +**Single instance** (`platform/single_instance.dart`, `InstanceLock`): one +app per profile folder (``, or `profiles//` with +`STARSYNC_PROFILE`, so each profile is its own instance). `main` claims +it right after resolving the folders, before the log or the window: an +exclusive `RandomAccessFile.lock` on `instance.lock` (the OS drops it +when the process ends, a crash too) and a loopback listener whose port, +a random token and the pid go in `instance.json`. A start that finds the +lock held sends its arguments and activation token there (one JSON line +with the token) and ends at once through the runner (`exit` on the +`starsync/window` channel: `_exit` / `TerminateProcess`, no exit +handlers) before its window ever shows; the running app brings its +window up (`ShellPort.focus(activation:)`) and hands the arguments to +`JoinOffers`. Bringing a window up from another process needs the +desktop's permission: on Linux the second start's `XDG_ACTIVATION_TOKEN` +(Wayland) or `DESKTOP_STARTUP_ID` (X11), which the runner keeps before +GTK takes it (`launchToken`) and the running app presents its window +with (`present`: `gdk_wayland_display_set_startup_notification_id` / +`gtk_window_set_startup_id`, then `gtk_window_present`; the launcher +entry has `StartupNotify=true` so launchers hand one out); on Windows +the second start calls `AllowSetForegroundWindow` for the holder's pid +(`platform/desktop/windows_api.dart`). While the app closes it answers +"closing" and a new start waits for the lock (up to 20 s; 5 s without +any answer), then starts anyway; Update and restart gives the lock up +before starting the new binary. Invite links among the arguments are +not passed on to that restart (`launchArgs`). + +**Link handler** (`platform/url_scheme.dart`): on Linux the launcher +entry ("App icon and launcher entry") lists +`MimeType=x-scheme-handler/starsync;` with `Exec=... %u`, and +`xdg-mime default dev.foxyz.starsync.desktop x-scheme-handler/starsync` +runs when `xdg-mime query default` names another (then +`update-desktop-database` on the applications folder). On Windows +(release builds) `HKCU\Software\Classes\starsync`: default value +`URL:Starsync invite`, `URL Protocol` empty, `DefaultIcon` and +`shell\open\command` = `"\starsync.exe" "%1"` (`starsync.exe` +next to an old-named `watch_together.exe` copy when it is there), +written per user (no admin) through `RegSetKeyValueW` at start only when +the command differs (first start, a moved install, an update from an old +name). + Still direct: the updater's restart (`Process.start`), the session's own file reads for what it plays or relays (`QueueController`, thumbnails, cookies import), app-private @@ -1886,8 +1961,9 @@ panel) and chat messages (Copy message, and a link in it). `PresencePolicy`, so not away, gone or errored; "connecting…", "reconnecting…"; hidden under 1100 px), chat with an unread badge, then Invite on yours or Leave on a friend's (asks first, then you are home). - Invite (`inviteFriends`) shares your server if it is not yet and copies - the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open + Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if + it is not yet and copies the invite link, never the password ("Invite + link copied · 203.0.113.7:8080"); a port it cannot open is a notice pointing at Settings. The stack opens the members card (the server's version under its title: yours is this app's, a joined one's from `/health.version`; the source @@ -1897,9 +1973,16 @@ panel) and chat messages (Copy message, and a link in it). "reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit out (anyone, the host too), your own row Sit out / Join back; at the bottom, on yours: not shared, "Only you can reach it" and Invite; - shared, the invite's address, Copy invite, Stop sharing and a note - that friends outside the LAN need a forwarded port or a reachable - server; on a friend's, "Copy invite address" (the dialled address)). + shared (`YourInvite`), "Friends join at" with each address of + `YourServer.reach` to pick (Internet, Home network, VPN; "Looking up + your internet address…" meanwhile), a note for the one picked (the + router's address: the port must be forwarded to this computer's home + address; this computer's own: a firewall can still block it; home + network, VPN: who it works for; no internet address found: forwarding + or a server all can reach), "Include the password in the link" (only + with a password; off each time the card opens), Copy invite link and + Stop sharing; on a friend's, "Copy invite link" (the dialled address + and its name, never a password)). Empty strip space is the window drag area; the window buttons sit at its end. - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the @@ -2456,7 +2539,8 @@ dedicated server (`package:logging` loggers named by area: `relay`, once Flutter is up) are kept and written first. - **Redaction** (`redactLog`): every line, in one place (`formatLogLine`), before console or file: URL query strings cut to - their parameter names (signed CDN links, Jellyfin `api_key`), + their parameter names (signed CDN links, Jellyfin `api_key`, an invite + link's `pw`: `starsync://` counts as a URL), `user:pass@` in URLs, member tokens in `/r /m /j /a /i /l` paths, and the values of passwords, secrets, tokens, API keys, cookies, `Authorization` / `X-Emby-*` headers (`name=value`, `"name":"value"`, @@ -2553,7 +2637,9 @@ take the icon from the `.desktop` file of that id. A tarball install has none, so on start a Linux release build (`platform/desktop_entry.dart`, skipped in debug) writes `~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the -running binary, `StartupWMClass` and `Icon` = the id) and the icons into +running binary with `%u`, `StartupWMClass` and `Icon` = the id, +`StartupNotify`, the `starsync://` link type: "Link handler" under "App +events and platform ports") and the icons into the hicolor theme, only when they changed, and deletes the entry and icons 1.2.x wrote under its id `moe.senkochan.watch_together` (those exact files only). The runner also sets the window icon from the bundle (X11). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 447735b..94f5c1e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -61,8 +61,9 @@ the timings of each media change (`SwitchTrace`). Two instances on one machine: `STARSYNC_PROFILE=` (letters, digits, `-`, `_`; at most 32) gives an instance its own Hive boxes (settings, profile, connect form, window bounds, folders, Jellyfin sign-ins, the -client id: two instances without it are one install, and the second takes -the first's member over) and logs under +client id: without it a second start is the same install, so it hands +its arguments to the running app and ends; each profile is its own +instance) and logs under `/profiles//`, and ` []` in the window title; yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop UI"). Without it nothing changes. Run one plain instance and one with @@ -161,7 +162,9 @@ Three suites, run from each package's folder: controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a fake AniList (`show_matcher_test`), `media.ref` producers, shader list, sound engine with a fake backend, invite addresses, the - version gate, the media plan's table with the Jellyfin relay rule and + `starsync://` links, the internet address lookup against fake + services, the join prompt and one app per profile + (`invite_links_test`), the version gate, the media plan's table with the Jellyfin relay rule and the "Waiting for" status (`media_plan_test`), watch tracking and the device history (`watch_tracker_test`), what continues a file of a local show (`queue_logic_test`), the video view's output size and diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 14d1100..edaed4a 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -45,7 +45,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format: not in a friend's, so watching alone works like a session (progress, next episode). Invite opens it to friends on your network (a second listener on the same server: nothing restarts, the video and Up next - carry on) and copies an invite; Stop sharing closes it again. + carry on) and copies an invite link (`starsync://`, with your internet + address when the app finds it); Stop sharing closes it again. A friend + clicks the link, or just copies it, and their app asks to join. - **Dedicated server**: `starsync-server`, a separate program in the release tarball, for an always-on box. See the README. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 34ff435..0774604 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -613,6 +613,32 @@ media URL unchanged, its ready flag and presence kept; after a non-resumed `welcome` (grace over, server restarted) the client sends its intent again (`ready`, `presence`). +## Invite link + +Not a WebSocket message: what one app hands another (clipboard, chat, +a clicked link), so every version reads the same format. + +``` +starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=hunter2 +starsync://192.168.1.20:8080 +starsync://[fe80::1]:8080 +``` + +- Scheme `starsync`, the server's host (IPv6 in brackets) and port; no + port means 8080. The path is `/` or empty. +- `name` (optional): the server's name for the prompt before its + `/health` answers (at most 64 characters are shown, control characters + dropped). +- `pw` (optional): the server password, only when the host chose to put + it in. Logs never show it (`redactLog` cuts a `starsync://` query to its + names). +- Unknown query keys are ignored; anything else (no host, a port outside + 1–65535) is no link. +- The text Invite copies is "Join me on Starsync: [(ask me for the + password)] · Starsync "; an app also takes a 1.3.x plain + invite ("Join me on Starsync: 192.168.1.20:8080 …"), and a 1.3.x app + reads the `host:port` out of a link. + ## Queue (proto 12) Up next: what plays after the current media, held by the server for diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cee6924..1a26cad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -112,8 +112,11 @@ ever happen mid-session (D8 says never during playback). Left of step 3 (your server): a human check of Invite (Linux and, at 1.3.0, Windows' firewall prompt, now on Invite rather than at start; that the invite skips the proxy's 198.18.x here) and of the update band -from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the -members card only says friends elsewhere need the port forwarded. Your +from 1.2.1-dev.3 in real use. No UPnP: the members card says when +friends elsewhere need the port forwarded. Human checks of the +`starsync://` links: clicked in a browser and in Discord on KDE Wayland, +X11 and Windows (the window coming to the front, the registry key after +first start and after an update from a `watch_together.exe` copy). Your server's `/health` name is the profile name at start (a rename shows after a restart). Joining a friend while others are on your server leaves them there without you, unwarned; a join refused diff --git a/packages/core/lib/src/logging.dart b/packages/core/lib/src/logging.dart index 5c2b667..9728fb8 100644 --- a/packages/core/lib/src/logging.dart +++ b/packages/core/lib/src/logging.dart @@ -117,7 +117,7 @@ void relogLine(String tag, String line) { // `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header). const _secretNames = r'(?:x-emby-token|x-mediabrowser-token|x-emby-authorization|authorization|set-cookie|cookie|' r'api_?key|access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|secret|host_?key|' - r'sessdata|bili_jct|client_?secret|client_?id|client)'; + r'sessdata|bili_jct|client_?secret|client_?id|client|pw)'; final _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false); final _quotedSecret = RegExp('\\b($_secretNames)\\s*=\\s*"[^"]*"', caseSensitive: false); // Header-like `Name: value` runs to the end of the line (a cookie @@ -127,7 +127,8 @@ final _headerSecret = RegExp( caseSensitive: false); final _pairSecret = RegExp('\\b($_secretNames)\\s*=\\s*[^&\\s"\',;)}\\]]+', caseSensitive: false); final _bearer = RegExp(r'\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}', caseSensitive: false); -final _url = RegExp(r'''\b(?:https?|wss?)://[^\s"'<>]+''', caseSensitive: false); +// starsync:// is an invite link (its `pw` is a server password). +final _url = RegExp(r'''\b(?:https?|wss?|starsync)://[^\s"'<>]+''', caseSensitive: false); // A member token in a server path: /r///..., /m, /j, /a, /i, /l. final _pathToken = RegExp(r'(/[rmjail]/[^/\s?#]+/)[0-9a-fA-F]{32}\b'); final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false); diff --git a/packages/core/test/logging_test.dart b/packages/core/test/logging_test.dart index d19a86a..fae9ea7 100644 --- a/packages/core/test/logging_test.dart +++ b/packages/core/test/logging_test.dart @@ -24,6 +24,12 @@ void main() { expect(redactLog('http://user:pw@host/x#frag'), 'http://***@host/x'); }); + test('an invite link keeps its address, never its password', () { + expect(redactLog('link starsync://203.0.113.7:8080/?name=Bob&pw=hunter2'), + 'link starsync://203.0.113.7:8080/?name&pw=***'); + expect(redactLog('pw=hunter2 port=1'), 'pw=*** port=1'); + }); + test('member tokens in server paths', () { expect(redactLog('open http://1.2.3.4:8080/r/default/$tok/2/0/name'), 'open http://1.2.3.4:8080/r/default/***/2/0/name'); expect(redactLog('ws://h:1/r/default/$tok/source?q=7'), 'ws://h:1/r/default/***/source?q=…');