Invite links: starsync:// opens the app and asks to join; one app per profile; real invite address; clipboard pick-up

- starsync://host:port/?name=&pw= (PROTOCOL.md "Invite link"); the password only when ticked, never in logs
- Linux: launcher entry takes x-scheme-handler/starsync (%u, StartupNotify) and becomes its xdg-mime default; Windows: HKCU\Software\Classes\starsync -> starsync.exe
- InstanceLock: lock file + loopback handoff per profile folder; the running app comes up with the second start's activation token (Wayland/X11) or AllowSetForegroundWindow (Windows)
- Sharing looks up the internet address (HTTPS services with fallbacks); members card picks Internet / Home network / VPN and says when the port needs forwarding
- JoinOffers: link and clipboard prompt with /health, joins through ConnectFlow; dismissed clipboard invites kept as fingerprints

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 17:18:25 +03:00
1 parent d48915dc47
commit 6048fbe7d1
35 files changed
+1943 -181

No files matched your search

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