forked from Foxyz/sync_replay_project
Invite links: starsync:// opens the app and asks to join; one app per profile; real invite address; clipboard pick-up
- starsync://host:port/?name=&pw= (PROTOCOL.md "Invite link"); the password only when ticked, never in logs - Linux: launcher entry takes x-scheme-handler/starsync (%u, StartupNotify) and becomes its xdg-mime default; Windows: HKCU\Software\Classes\starsync -> starsync.exe - InstanceLock: lock file + loopback handoff per profile folder; the running app comes up with the second start's activation token (Wayland/X11) or AllowSetForegroundWindow (Windows) - Sharing looks up the internet address (HTTPS services with fallbacks); members card picks Internet / Home network / VPN and says when the port needs forwarding - JoinOffers: link and clipboard prompt with /health, joins through ConnectFlow; dismissed clipboard invites kept as fingerprints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d48915dc47
commit
6048fbe7d1
35 files changed
+1940
-178
No files matched your search
@@ -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)
|
- 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)
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
of the Library the first time; none needs Jellyfin or an account.
|
||||||
|
|
||||||
- **Join a friend** (most people): your friend sends you an invite (an
|
- **Join a friend** (most people): your friend sends you an invite (a
|
||||||
address) and, maybe, a password. See [Join](#join).
|
`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.
|
- **Invite friends**: you have the video and friends watch it with you.
|
||||||
See [Your server and Invite](#your-server-and-invite).
|
See [Your server and Invite](#your-server-and-invite).
|
||||||
- **Watch on your own**: just play something from the Library, with
|
- **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
|
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,
|
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
|
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
|
server where friends can reach it, Copy invite link and Stop sharing,
|
||||||
invite address"). Beside them the server and "ready n/m" (who is sitting out or
|
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
|
reconnecting does not count), the chat button with unread
|
||||||
count, and Invite on your server or Leave on a friend's. Empty strip
|
count, and Invite on your server or Leave on a friend's. Empty strip
|
||||||
space drags the window.
|
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
|
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 &
|
list): that opens it on your network at the port from Settings → Party &
|
||||||
network (default 8080, with the password set there; blank = anyone can
|
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 ·
|
join) and copies an invite link ("Join me on Starsync:
|
||||||
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
|
starsync://203.0.113.7:8080/?name=… · Starsync 1.3.0") to send to
|
||||||
next carry on, and you are the host (the crown). The invite picks this
|
friends. Nothing restarts: what plays and Up next carry on, and you are
|
||||||
computer's home-network address (not a VPN or proxy one, nor a Docker or
|
the host (the crown).
|
||||||
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
|
The link carries your internet address when the app can find it (it asks
|
||||||
open nothing. **Stop sharing** (in the member list) closes it again:
|
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
|
everyone else leaves (their app goes back to their own server and says
|
||||||
why), you and Up next stay.
|
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,
|
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
|
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)
|
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
|
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
|
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
|
update"), and offers the update when it is you. Your name and picture
|
||||||
|
|||||||
@@ -10,16 +10,26 @@ import 'src/app/app_graph.dart';
|
|||||||
import 'src/files/thumbnails.dart' show thumbnailPlayers;
|
import 'src/files/thumbnails.dart' show thumbnailPlayers;
|
||||||
import 'src/platform/app_log.dart';
|
import 'src/platform/app_log.dart';
|
||||||
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
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/desktop_app_dirs.dart';
|
||||||
|
import 'src/platform/desktop/windows_api.dart' show allowSetForegroundWindow;
|
||||||
import 'src/platform/desktop_entry.dart';
|
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/platform/smoke.dart';
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||||
|
import 'src/session/invite.dart' show InviteLink;
|
||||||
import 'src/state/settings.dart';
|
import 'src/state/settings.dart';
|
||||||
import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
|
import 'src/update/update_controller.dart' show UpdatePhase, launchArgs;
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
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')) {
|
if (args.contains('--upgrade')) {
|
||||||
exitCode = await runUpgrade();
|
exitCode = await runUpgrade();
|
||||||
exit(exitCode);
|
exit(exitCode);
|
||||||
@@ -44,6 +54,15 @@ Future<void> main(List<String> args) async {
|
|||||||
);
|
);
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final dirs = await const DesktopAppDirs().resolve();
|
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);
|
await startAppLogging(dirs);
|
||||||
Hive.init(dirs.hive);
|
Hive.init(dirs.hive);
|
||||||
await AppSettings.instance.ensureLoaded();
|
await AppSettings.instance.ensureLoaded();
|
||||||
@@ -52,14 +71,19 @@ Future<void> main(List<String> args) async {
|
|||||||
final ports = graph.ports;
|
final ports = graph.ports;
|
||||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||||
// see "left", your server shuts down and no player outlives the engine.
|
// 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(
|
await ports.shell.start(
|
||||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
|
||||||
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
||||||
// the background, notices go out as system notifications).
|
// 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) {
|
ports.shell.watchActivity((a) {
|
||||||
graph.coordinator.windowActivity(a);
|
graph.coordinator.windowActivity(a);
|
||||||
graph.notifier.window = a;
|
graph.notifier.window = a;
|
||||||
|
if (a == WindowActivity.focused) {
|
||||||
|
unawaited(ports.shell.readClipboardText().then(graph.joinOffers.fromClipboard));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await graph.forms.load();
|
await graph.forms.load();
|
||||||
@@ -76,6 +100,8 @@ Future<void> main(List<String> args) async {
|
|||||||
ports.links.warmUp();
|
ports.links.warmUp();
|
||||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
unawaited(installDesktopEntry());
|
unawaited(installDesktopEntry());
|
||||||
|
// Windows: starsync:// links start this install (HKCU, release builds).
|
||||||
|
unawaited(registerWindowsScheme());
|
||||||
// A found update is an event too (the band shows it).
|
// A found update is an event too (the band shows it).
|
||||||
final updates = graph.updates;
|
final updates = graph.updates;
|
||||||
updates.status.addListener(() {
|
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
|
// Home: your own server, quietly (only this app can reach it until
|
||||||
// Invite).
|
// Invite).
|
||||||
unawaited(graph.navigation.start());
|
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.
|
// The sound device opens once the window is up, not on the first sound.
|
||||||
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../schedule/animeflow.dart';
|
|||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../session/connection.dart' show Connection;
|
import '../session/connection.dart' show Connection;
|
||||||
import '../session/identity.dart' show ClientIdentity, Profile;
|
import '../session/identity.dart' show ClientIdentity, Profile;
|
||||||
|
import '../session/join_offers.dart';
|
||||||
import '../session/navigation.dart';
|
import '../session/navigation.dart';
|
||||||
import '../session/notifier.dart';
|
import '../session/notifier.dart';
|
||||||
import '../session/session_coordinator.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.
|
/// What's new once after an update; keeps the notes the updater saves.
|
||||||
final WhatsNew whatsNew;
|
final WhatsNew whatsNew;
|
||||||
|
|
||||||
|
/// The join prompt for a clicked invite link or one on the clipboard.
|
||||||
|
final JoinOffers joinOffers;
|
||||||
|
|
||||||
AppGraph._({
|
AppGraph._({
|
||||||
required this.ports,
|
required this.ports,
|
||||||
required this.sfx,
|
required this.sfx,
|
||||||
@@ -78,10 +82,12 @@ class AppGraph {
|
|||||||
required this.watch,
|
required this.watch,
|
||||||
required this.updates,
|
required this.updates,
|
||||||
required this.whatsNew,
|
required this.whatsNew,
|
||||||
|
required this.joinOffers,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
/// 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({
|
factory AppGraph.build({
|
||||||
Ports? ports,
|
Ports? ports,
|
||||||
ClientIdentity? identity,
|
ClientIdentity? identity,
|
||||||
@@ -90,6 +96,7 @@ class AppGraph {
|
|||||||
JellyfinAccounts? jellyfin,
|
JellyfinAccounts? jellyfin,
|
||||||
ShowMatcher? library,
|
ShowMatcher? library,
|
||||||
WatchHistory? history,
|
WatchHistory? history,
|
||||||
|
Future<String?> Function()? publicAddress,
|
||||||
}) {
|
}) {
|
||||||
final p = ports ?? desktopPorts();
|
final p = ports ?? desktopPorts();
|
||||||
final settings = AppSettings.instance;
|
final settings = AppSettings.instance;
|
||||||
@@ -142,6 +149,7 @@ class AppGraph {
|
|||||||
},
|
},
|
||||||
logDir: () => appLogDir,
|
logDir: () => appLogDir,
|
||||||
starter: serverStarter,
|
starter: serverStarter,
|
||||||
|
publicAddress: publicAddress,
|
||||||
);
|
);
|
||||||
final navigation = Navigation(
|
final navigation = Navigation(
|
||||||
coordinator: coordinator,
|
coordinator: coordinator,
|
||||||
@@ -172,6 +180,7 @@ class AppGraph {
|
|||||||
watch: watch,
|
watch: watch,
|
||||||
updates: updates,
|
updates: updates,
|
||||||
whatsNew: whatsNew,
|
whatsNew: whatsNew,
|
||||||
|
joinOffers: JoinOffers(navigation: navigation, yourServer: yourServer, forms: forms),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
|
import 'single_instance.dart';
|
||||||
|
|
||||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
// 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
|
// 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.
|
/// The systems the close stops; `main` hands them over once.
|
||||||
AppGraph? _graph;
|
AppGraph? _graph;
|
||||||
|
|
||||||
/// Makes [graph] the one [shutDownApp] stops and returns the close
|
/// This app's claim on its profile folder (one app at a time).
|
||||||
/// request handler for the window.
|
InstanceLock? _instance;
|
||||||
Future<void> Function() closeAppWith(AppGraph graph) {
|
|
||||||
|
/// 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;
|
_graph = graph;
|
||||||
|
_instance = instance;
|
||||||
return shutDownApp;
|
return shutDownApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +43,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
|||||||
final graph = _graph;
|
final graph = _graph;
|
||||||
if (_shuttingDown || graph == null) return;
|
if (_shuttingDown || graph == null) return;
|
||||||
_shuttingDown = true;
|
_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 shell = graph.ports.shell;
|
||||||
final total = Stopwatch()..start();
|
final total = Stopwatch()..start();
|
||||||
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
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.
|
// server: 3 s in all.
|
||||||
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||||
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
|
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));
|
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||||
await shell.quit();
|
await shell.quit();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
@@ -17,3 +19,41 @@ Future<void> requestWindowAttention() async {
|
|||||||
debugPrint('window attention: $e');
|
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
|
@override
|
||||||
Future<void> focus() async {
|
Future<void> focus({String activation = ''}) async {
|
||||||
try {
|
try {
|
||||||
if (await windowManager.isMinimized()) await windowManager.restore();
|
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||||
if (!await windowManager.isVisible()) await windowManager.show();
|
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();
|
await windowManager.focus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('window focus failed: $e');
|
debugPrint('window focus failed: $e');
|
||||||
@@ -126,6 +129,15 @@ class DesktopShell implements ShellPort {
|
|||||||
return (files: files, text: text);
|
return (files: files, text: text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readClipboardText() async {
|
||||||
|
try {
|
||||||
|
return await ClipboardX.readText();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget dropTarget({
|
Widget dropTarget({
|
||||||
Key? key,
|
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:flutter/foundation.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
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
|
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||||
/// compositor matches to [desktopFileName] for the window's icon.
|
/// compositor matches to [desktopFileName] for the window's icon.
|
||||||
const appId = 'dev.foxyz.starsync';
|
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
|
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
||||||
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
||||||
/// the hicolor theme, only when something changed (a moved install, a new
|
/// the hicolor theme, only when something changed (a moved install, a new
|
||||||
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
|
/// icon). The entry and icons of [legacyAppId] go. The entry also takes
|
||||||
/// (unless [force], for tests); failures only log.
|
/// `starsync://` links ([schemeMimeType], %u) and the app becomes their
|
||||||
Future<void> installDesktopEntry(
|
/// default handler ([registerScheme], default [registerLinuxScheme]).
|
||||||
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
|
/// 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;
|
if (!Platform.isLinux || (kDebugMode && !force)) return;
|
||||||
try {
|
try {
|
||||||
final exe = executable ?? Platform.resolvedExecutable;
|
final exe = executable ?? Platform.resolvedExecutable;
|
||||||
@@ -41,9 +50,11 @@ Future<void> installDesktopEntry(
|
|||||||
}
|
}
|
||||||
final entry = desktopEntryText(exe);
|
final entry = desktopEntryText(exe);
|
||||||
final file = File('$share/applications/$desktopFileName');
|
final file = File('$share/applications/$desktopFileName');
|
||||||
if (file.existsSync() && file.readAsStringSync() == entry) return;
|
if (!file.existsSync() || file.readAsStringSync() != entry) {
|
||||||
await file.parent.create(recursive: true);
|
await file.parent.create(recursive: true);
|
||||||
await file.writeAsString(entry);
|
await file.writeAsString(entry);
|
||||||
|
}
|
||||||
|
await (registerScheme ?? registerLinuxScheme)(desktopFileName, file.parent.path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('desktop entry: $e');
|
debugPrint('desktop entry: $e');
|
||||||
}
|
}
|
||||||
@@ -69,9 +80,11 @@ String desktopEntryText(String exe) => '''[Desktop Entry]
|
|||||||
Type=Application
|
Type=Application
|
||||||
Name=Starsync
|
Name=Starsync
|
||||||
Comment=Watch videos in sync with friends
|
Comment=Watch videos in sync with friends
|
||||||
Exec="$exe"
|
Exec="$exe" %u
|
||||||
Icon=$appId
|
Icon=$appId
|
||||||
StartupWMClass=$appId
|
StartupWMClass=$appId
|
||||||
|
StartupNotify=true
|
||||||
|
MimeType=$schemeMimeType;
|
||||||
Categories=AudioVideo;Video;Player;Network;
|
Categories=AudioVideo;Video;Player;Network;
|
||||||
Terminal=false
|
Terminal=false
|
||||||
''';
|
''';
|
||||||
@@ -43,8 +43,10 @@ abstract interface class ShellPort {
|
|||||||
Future<void> quit();
|
Future<void> quit();
|
||||||
|
|
||||||
/// Brings the window up and gives it the focus (restored when
|
/// Brings the window up and gives it the focus (restored when
|
||||||
/// minimised, shown when hidden): a click on a system notification.
|
/// minimised, shown when hidden): a click on a system notification, a
|
||||||
Future<void> focus();
|
/// 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
|
/// Points the desktop at the window when it is not focused (taskbar
|
||||||
/// flash, urgency hint); nothing when it is.
|
/// 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).
|
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
||||||
Future<Paste> readPaste();
|
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
|
/// [child] taking dropped files ([onDrop]: their paths and where they
|
||||||
/// landed); [enable] off lets drops through.
|
/// landed); [enable] off lets drops through.
|
||||||
Widget dropTarget({
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
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
|
/// What a friend needs to join your server: where ([host], [port]), what
|
||||||
/// it is called ([name]), whether it asks a password ([password]; the
|
/// it is called ([name]), whether it asks a password ([password]; the
|
||||||
/// password itself is never in it) and which Starsync runs it
|
/// 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.
|
/// What goes in the Join card's address and port.
|
||||||
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
|
||||||
|
|
||||||
/// The text Invite copies: the address, whether to ask for the password,
|
/// The same invite at another of this machine's addresses.
|
||||||
/// the version to match.
|
Invite at(String host) =>
|
||||||
String get text => [
|
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
|
||||||
'Join me on Starsync: $address',
|
|
||||||
if (password) '(ask me for the password)',
|
/// 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',
|
if (version.isNotEmpty) '· Starsync $version',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
/// [textWith] without the password.
|
||||||
|
String get text => textWith();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
other is Invite &&
|
other is Invite &&
|
||||||
@@ -51,6 +67,97 @@ class Invite {
|
|||||||
String toString() => 'Invite($address${password ? ', password' : ''})';
|
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
|
/// The `host:port` in a pasted invite (or a bare address); null when
|
||||||
/// there is none.
|
/// there is none.
|
||||||
({String host, String port})? addressIn(String text) {
|
({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) =>
|
bool _isPrivate(List<int> b) =>
|
||||||
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
|
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;
|
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,
|
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
|
||||||
/// no loopback, link-local or fake-IP address, no container or VM bridge;
|
/// 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
|
/// 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();
|
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
|
/// This machine's interfaces as [inviteAddresses] takes them (empty when
|
||||||
/// they can't be listed).
|
/// they can't be listed).
|
||||||
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../jellyfin/session_media.dart' show relayCredentialsFor;
|
|||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../state/server_isolate.dart';
|
import '../state/server_isolate.dart';
|
||||||
import 'invite.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
|
/// 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
|
/// 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
|
/// port, so only this app reaches it; [invite] attaches a second
|
||||||
/// listener on every interface at the configured port (Settings → Party
|
/// listener on every interface at the configured port (Settings → Party
|
||||||
/// & network: port and password) and [stopSharing] detaches it. Members,
|
/// & network: port and password) and [stopSharing] detaches it. Members,
|
||||||
/// what plays and Up next never restart or reconnect for either. Made
|
/// what plays and Up next never restart or reconnect for either. Sharing
|
||||||
/// by the composition root.
|
/// also looks up the internet address ([reach]). Made by the composition
|
||||||
|
/// root.
|
||||||
class YourServer {
|
class YourServer {
|
||||||
YourServer({
|
YourServer({
|
||||||
required this.forms,
|
required this.forms,
|
||||||
@@ -54,10 +56,13 @@ class YourServer {
|
|||||||
String? Function()? logDir,
|
String? Function()? logDir,
|
||||||
ServerStarter? starter,
|
ServerStarter? starter,
|
||||||
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
|
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
|
||||||
|
Future<String?> Function()? publicAddress,
|
||||||
|
this.publicWait = const Duration(seconds: 4),
|
||||||
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
|
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
|
||||||
_logDir = logDir ?? (() => null),
|
_logDir = logDir ?? (() => null),
|
||||||
_starter = starter ?? _startIsolate,
|
_starter = starter ?? _startIsolate,
|
||||||
_interfaces = interfaces ?? machineInterfaces;
|
_interfaces = interfaces ?? machineInterfaces,
|
||||||
|
_publicAddress = publicAddress ?? lookUpPublicAddress;
|
||||||
|
|
||||||
final ConnectStore forms;
|
final ConnectStore forms;
|
||||||
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
||||||
@@ -66,6 +71,18 @@ class YourServer {
|
|||||||
final String? Function() _logDir;
|
final String? Function() _logDir;
|
||||||
final ServerStarter _starter;
|
final ServerStarter _starter;
|
||||||
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
|
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]).
|
/// Sent by this app's own client (see [newHostKey]).
|
||||||
final String hostKey = newHostKey();
|
final String hostKey = newHostKey();
|
||||||
@@ -106,12 +123,13 @@ class YourServer {
|
|||||||
|
|
||||||
Future<int> _start(String name) async {
|
Future<int> _start(String name) async {
|
||||||
final who = name.trim();
|
final who = name.trim();
|
||||||
|
_name = who.isEmpty ? '' : "$who's server";
|
||||||
late final ServerIsolate server;
|
late final ServerIsolate server;
|
||||||
server = await _starter(
|
server = await _starter(
|
||||||
AppConfig(
|
AppConfig(
|
||||||
port: 0,
|
port: 0,
|
||||||
secret: '',
|
secret: '',
|
||||||
name: who.isEmpty ? 'server' : "$who's server",
|
name: who.isEmpty ? 'server' : _name,
|
||||||
hostKey: hostKey,
|
hostKey: hostKey,
|
||||||
ytDlpDir: await _ytDlpDir(),
|
ytDlpDir: await _ytDlpDir(),
|
||||||
),
|
),
|
||||||
@@ -133,6 +151,7 @@ class YourServer {
|
|||||||
_server = null;
|
_server = null;
|
||||||
port.value = null;
|
port.value = null;
|
||||||
shared.value = null;
|
shared.value = null;
|
||||||
|
reach.value = null;
|
||||||
final message = 'server crashed: $cause';
|
final message = 'server crashed: $cause';
|
||||||
debugPrint('[server] $message');
|
debugPrint('[server] $message');
|
||||||
crash.value = message;
|
crash.value = message;
|
||||||
@@ -159,26 +178,68 @@ class YourServer {
|
|||||||
// server, with this app's own sign-in on the item's server.
|
// server, with this app's own sign-in on the item's server.
|
||||||
jellyfin: relayCredentialsFor(_accounts),
|
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;
|
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;
|
return invite;
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The invite for [port] from this machine's best address (see
|
/// Bumped by every share and unshare: a lookup of an earlier one is
|
||||||
/// [inviteAddresses]); 127.0.0.1 when there is none.
|
/// dropped.
|
||||||
Future<Invite> inviteFor(int port, {required bool password}) async {
|
int _shareGen = 0;
|
||||||
final addresses = inviteAddresses(await _interfaces());
|
|
||||||
|
/// 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(
|
return Invite(
|
||||||
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
|
host: host ?? (local.isEmpty ? '127.0.0.1' : local.first),
|
||||||
port: port,
|
port: port,
|
||||||
|
name: _name,
|
||||||
password: password,
|
password: password,
|
||||||
version: appVersion,
|
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
|
/// Closes it to friends again: everyone but you leaves (told, so their
|
||||||
/// apps do not retry); you, what plays and Up next stay.
|
/// apps do not retry); you, what plays and Up next stay.
|
||||||
Future<void> stopSharing() async {
|
Future<void> stopSharing() async {
|
||||||
@@ -187,7 +248,9 @@ class YourServer {
|
|||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
await server.unshare();
|
await server.unshare();
|
||||||
|
_shareGen++;
|
||||||
shared.value = null;
|
shared.value = null;
|
||||||
|
reach.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -202,6 +265,7 @@ class YourServer {
|
|||||||
_server = null;
|
_server = null;
|
||||||
port.value = null;
|
port.value = null;
|
||||||
shared.value = null;
|
shared.value = null;
|
||||||
|
reach.value = null;
|
||||||
await server?.stop();
|
await server?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ import '../widgets/whats_new_card.dart';
|
|||||||
import 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
import 'handoff.dart';
|
import 'handoff.dart';
|
||||||
|
import 'join_offer_card.dart';
|
||||||
import 'library_link.dart' show LinkProber;
|
import 'library_link.dart' show LinkProber;
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -662,6 +663,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
|
|
||||||
void _invite() => unawaited(inviteFriends(context));
|
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
|
// ----------------------------------------------------------------- build
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -818,6 +835,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
),
|
),
|
||||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||||
_toasts(full && online),
|
_toasts(full && online),
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Center(child: JoinOfferCard(offers: _graph.joinOffers, onJoin: _acceptOffer)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../app/app_graph.dart';
|
|||||||
import '../screens/home/connect_flow.dart';
|
import '../screens/home/connect_flow.dart';
|
||||||
import '../screens/home/connect_status.dart';
|
import '../screens/home/connect_status.dart';
|
||||||
import '../screens/home/health_probe.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 '../session/navigation.dart' show Place;
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
@@ -138,9 +138,17 @@ class JoinCard extends StatelessWidget {
|
|||||||
_join();
|
_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) {
|
void _onAddress(String v) {
|
||||||
flow.clearError();
|
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);
|
final a = addressIn(v);
|
||||||
if (a == null || v.trim() == a.host) return;
|
if (a == null || v.trim() == a.host) return;
|
||||||
fields.host.text = a.host;
|
fields.host.text = a.host;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../session/join_offers.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
|
/// The join prompt, top centre over the body: "Join Bob's server
|
||||||
|
/// (1.2.3.4:8080)?" with what its `/health` says (version, who is in,
|
||||||
|
/// the version gate, unreachable), Not now and Join. Shown while
|
||||||
|
/// [offers] has an offer (a clicked invite link, or one on the
|
||||||
|
/// clipboard).
|
||||||
|
class JoinOfferCard extends StatelessWidget {
|
||||||
|
final JoinOffers offers;
|
||||||
|
final VoidCallback onJoin;
|
||||||
|
const JoinOfferCard({super.key, required this.offers, required this.onJoin});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||||
|
final o = offers.offer.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: easeTime,
|
||||||
|
switchInCurve: ease,
|
||||||
|
switchOutCurve: ease,
|
||||||
|
transitionBuilder: (child, a) => FadeTransition(
|
||||||
|
opacity: a,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(begin: const Offset(0, -0.2), end: Offset.zero).animate(a),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: o == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: _OfferBody(key: ValueKey(o.link), offer: o, onJoin: onJoin, onDismiss: offers.dismiss),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfferBody extends StatelessWidget {
|
||||||
|
final JoinOffer offer;
|
||||||
|
final VoidCallback onJoin;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
const _OfferBody({super.key, required this.offer, required this.onJoin, required this.onDismiss});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final h = offer.health;
|
||||||
|
final problem = h != null && (!h.online || h.needsUpdate);
|
||||||
|
final detail = h == null ? 'Checking the server…' : offer.detail;
|
||||||
|
return Material(
|
||||||
|
key: const ValueKey('join-offer'),
|
||||||
|
elevation: 12,
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(offer.source == OfferSource.clipboard ? Icons.content_paste_go : Icons.link,
|
||||||
|
color: scheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(offer.question,
|
||||||
|
key: const ValueKey('join-offer-question'),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
if (detail.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
[
|
||||||
|
if (offer.source == OfferSource.clipboard) 'Invite on your clipboard',
|
||||||
|
detail,
|
||||||
|
].join(' · '),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: problem ? Colors.amber.shade300 : scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
TextButton(key: const ValueKey('join-offer-dismiss'), onPressed: onDismiss, child: const Text('Not now')),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(key: const ValueKey('join-offer-join'), onPressed: onJoin, child: const Text('Join')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
|||||||
import '../session/delivery.dart';
|
import '../session/delivery.dart';
|
||||||
import '../session/party.dart';
|
import '../session/party.dart';
|
||||||
import '../app/app_graph.dart';
|
import '../app/app_graph.dart';
|
||||||
import '../session/app_events.dart' show ActionFailed;
|
|
||||||
import '../session/invite.dart';
|
import '../session/invite.dart';
|
||||||
import '../session/navigation.dart' show Place;
|
import '../session/navigation.dart' show Place;
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
@@ -16,6 +15,9 @@ import '../widgets/window_buttons.dart';
|
|||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'delivery_line.dart';
|
import 'delivery_line.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
import 'your_invite.dart';
|
||||||
|
|
||||||
|
export 'your_invite.dart' show YourInvite, inviteFriends;
|
||||||
|
|
||||||
/// The shell's tabs, in strip order.
|
/// The shell's tabs, in strip order.
|
||||||
enum ShellTab {
|
enum ShellTab {
|
||||||
@@ -506,9 +508,10 @@ class MembersCard extends StatelessWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
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),
|
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}) =>
|
bool versionDiffers(String version, {String own = appVersion}) =>
|
||||||
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
|
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
|
/// The invite link to a friend's server you are in ([address] as dialled,
|
||||||
/// (Copy invite), a hint for friends outside the network and Stop
|
/// "host:port"; never its password).
|
||||||
/// sharing; not shared, Invite (opens it and copies the invite).
|
String friendInviteLink(String address, String name) {
|
||||||
class YourInvite extends StatelessWidget {
|
final a = addressIn(address);
|
||||||
const YourInvite({super.key});
|
final port = a == null ? null : int.tryParse(a.port);
|
||||||
|
if (a == null || port == null) return address;
|
||||||
@override
|
return InviteLink(host: a.host, port: port, name: name).uri;
|
||||||
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}');
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
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;
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
#ifdef GDK_WINDOWING_X11
|
#ifdef GDK_WINDOWING_X11
|
||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef GDK_WINDOWING_WAYLAND
|
||||||
|
#include <gdk/gdkwayland.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#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)));
|
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
|
// "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,
|
static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
||||||
gpointer user_data) {
|
gpointer user_data) {
|
||||||
GtkWindow* window = GTK_WINDOW(user_data);
|
GtkWindow* window = GTK_WINDOW(user_data);
|
||||||
g_autoptr(FlMethodResponse) response = nullptr;
|
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)) {
|
if (!gtk_window_is_active(window)) {
|
||||||
gtk_window_set_urgency_hint(window, TRUE);
|
gtk_window_set_urgency_hint(window, TRUE);
|
||||||
}
|
}
|
||||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
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 {
|
} else {
|
||||||
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
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.
|
// the application to be recognized beyond its binary name.
|
||||||
g_set_prgname(APPLICATION_ID);
|
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(),
|
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||||
"application-id", APPLICATION_ID, "flags",
|
"application-id", APPLICATION_ID, "flags",
|
||||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||||
|
|||||||
@@ -19,16 +19,25 @@ void main() {
|
|||||||
..createSync(recursive: true);
|
..createSync(recursive: true);
|
||||||
final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync();
|
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(oldEntry.existsSync(), isFalse);
|
||||||
expect(oldIcon.existsSync(), isFalse);
|
expect(oldIcon.existsSync(), isFalse);
|
||||||
expect(other.existsSync(), isTrue);
|
expect(other.existsSync(), isTrue);
|
||||||
expect(otherIcon.existsSync(), isTrue);
|
expect(otherIcon.existsSync(), isTrue);
|
||||||
final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync();
|
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'));
|
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);
|
expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue);
|
||||||
} finally {
|
} finally {
|
||||||
await tmp.delete(recursive: true);
|
await tmp.delete(recursive: true);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -277,7 +277,8 @@ void main() {
|
|||||||
test('an invite: the address, whether to ask for a password, the version; pasted back', () {
|
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');
|
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.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(invite.fp, isNull, reason: 'reserved for pinned certificates');
|
||||||
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080'));
|
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080'));
|
||||||
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
|
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);
|
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
|
||||||
|
|
||||||
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
|
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('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.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);
|
expect(find.textContaining('VPS'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// until a test calls `navigation.start()` (or `goHome`), so widget tests
|
||||||
/// see the app before your server is up.
|
/// see the app before your server is up. Sharing looks up no internet
|
||||||
AppGraph testGraph({HealthGetter healthGet = defaultHealthGet}) =>
|
/// address unless [publicAddress] gives one.
|
||||||
AppGraph.build(identity: ClientIdentity.ephemeral(), healthGet: healthGet);
|
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].
|
/// [child] under [graph]'s [AppScope].
|
||||||
Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child);
|
Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child);
|
||||||
@@ -52,6 +52,13 @@ bool FlutterWindow::OnCreate() {
|
|||||||
window_channel_->SetMethodCallHandler(
|
window_channel_->SetMethodCallHandler(
|
||||||
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
|
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
|
||||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
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") {
|
if (call.method_name() != "attention") {
|
||||||
result->NotImplemented();
|
result->NotImplemented();
|
||||||
return;
|
return;
|
||||||
|
|||||||
+111
-25
@@ -61,21 +61,53 @@ attach and detach the network listener (below).
|
|||||||
its reason; nothing else changes.
|
its reason; nothing else changes.
|
||||||
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
|
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
|
||||||
server if it is not yet and returns an `Invite`: the host, the port, 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
|
name ("<your name>'s server"), whether a password is needed (never the
|
||||||
and an `fp` field reserved for a pinned certificate (`wss://` with a
|
password), the version and an `fp` field reserved for a pinned
|
||||||
self-signed one, later; always null). The host is the best of
|
certificate (`wss://` with a self-signed one, later; always null). This
|
||||||
`inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback,
|
machine's addresses are `inviteAddresses` over `NetworkInterface.list`:
|
||||||
link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN
|
IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP
|
||||||
proxies like Clash hand out) address and no container or VM bridge
|
(198.18.0.0/15, what TUN proxies like Clash hand out) address and no
|
||||||
(`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`,
|
container or VM bridge (`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`,
|
||||||
`podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168
|
`vboxnet*`, `cni*`, `podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`,
|
||||||
first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any
|
VirtualBox, VMware); 192.168 first, then 10/8 and 172.16/12, then a mesh
|
||||||
other (public) address. No public-address lookup: the members card says
|
VPN's 100.64/10, then any other (public) address. Sharing also looks up
|
||||||
that friends elsewhere need the port forwarded or a server they can all
|
the internet address (`lookUpPublicAddress`, `session/public_address.dart`):
|
||||||
reach. `Invite.text` is what Invite copies ("Join me on Starsync:
|
plain-text "what is my IP" services over HTTPS (ipify, icanhazip,
|
||||||
192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0");
|
checkip.amazonaws, ifconfig.me), asked in order until one answers a
|
||||||
`addressIn` reads the address back out of a pasted invite (the Join
|
public IPv4 address, 3 s each; that tells those services this network's
|
||||||
card's address field). `stopSharing` unshares.
|
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
|
- **Where you are** (`session/navigation.dart`, `Navigation`): `place` is
|
||||||
`none` (starting, or between two servers), `yours` or `friend`. The app
|
`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
|
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
|
`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
|
`SessionCoordinator` (handed the settings and the watch tracker), the
|
||||||
connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
|
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
|
"Library", whose `refForPath` it hands the coordinator as `fileRef`, and
|
||||||
a `LocalNextFinder` over it as `nextFile`) and watch tracking
|
a `LocalNextFinder` over it as `nextFile`) and watch tracking
|
||||||
(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it
|
(`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
|
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
|
||||||
focus, blur, minimise and restore, and the shell's own `hide`, wired to
|
focus, blur, minimise and restore, and the shell's own `hide`, wired to
|
||||||
`SessionCoordinator.windowActivity` and `Notifier.window` in `main`),
|
`SessionCoordinator.windowActivity` and `Notifier.window` in `main`),
|
||||||
bringing the window up and focusing it (`focus`), dragging (`WindowDragArea`), close, the way out
|
bringing the window up and focusing it (`focus`, with another start's
|
||||||
(save, hide, quit), attention, the clipboard paste (files, else text)
|
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).
|
and drop targets (fox_utils).
|
||||||
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
|
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
|
||||||
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
|
- `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
|
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
|
||||||
rename; resolved in `main` before the composition root exists, so not a
|
rename; resolved in `main` before the composition root exists, so not a
|
||||||
`Ports` field.
|
`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
|
Still direct: the updater's restart
|
||||||
(`Process.start`), the session's own file reads for what it plays or
|
(`Process.start`), the session's own file reads for what it plays or
|
||||||
relays (`QueueController`, thumbnails, cookies import), app-private
|
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…",
|
`PresencePolicy`, so not away, gone or errored; "connecting…",
|
||||||
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
|
"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 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
|
Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if
|
||||||
the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open
|
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
|
is a notice pointing at Settings. The stack
|
||||||
opens the members card (the server's version under its title: yours is
|
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
|
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
|
"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
|
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;
|
bottom, on yours: not shared, "Only you can reach it" and Invite;
|
||||||
shared, the invite's address, Copy invite, Stop sharing and a note
|
shared (`YourInvite`), "Friends join at" with each address of
|
||||||
that friends outside the LAN need a forwarded port or a reachable
|
`YourServer.reach` to pick (Internet, Home network, VPN; "Looking up
|
||||||
server; on a friend's, "Copy invite address" (the dialled address)).
|
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
|
Empty strip space is the window
|
||||||
drag area; the window buttons sit at its end.
|
drag area; the window buttons sit at its end.
|
||||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
- **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.
|
once Flutter is up) are kept and written first.
|
||||||
- **Redaction** (`redactLog`): every line, in one place
|
- **Redaction** (`redactLog`): every line, in one place
|
||||||
(`formatLogLine`), before console or file: URL query strings cut to
|
(`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
|
`user:pass@` in URLs, member tokens in `/r /m /j /a /i /l` paths, and the
|
||||||
values of passwords, secrets, tokens, API keys, cookies,
|
values of passwords, secrets, tokens, API keys, cookies,
|
||||||
`Authorization` / `X-Emby-*` headers (`name=value`, `"name":"value"`,
|
`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`,
|
none, so on start a Linux release build (`platform/desktop_entry.dart`,
|
||||||
skipped in debug) writes
|
skipped in debug) writes
|
||||||
`~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the
|
`~/.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
|
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
|
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).
|
files only). The runner also sets the window icon from the bundle (X11).
|
||||||
|
|||||||
+6
-3
@@ -61,8 +61,9 @@ the timings of each media change (`SwitchTrace`).
|
|||||||
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
|
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||||
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
|
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
|
||||||
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
|
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
|
||||||
client id: two instances without it are one install, and the second takes
|
client id: without it a second start is the same install, so it hands
|
||||||
the first's member over) and logs under
|
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;
|
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
|
||||||
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
|
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
|
||||||
UI"). Without it nothing changes. Run one plain instance and one with
|
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
|
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||||
shader list, sound engine with a fake backend, invite addresses, the
|
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
|
the "Waiting for" status (`media_plan_test`), watch tracking and the
|
||||||
device history (`watch_tracker_test`), what continues a file of a
|
device history (`watch_tracker_test`), what continues a file of a
|
||||||
local show (`queue_logic_test`), the video view's output size and
|
local show (`queue_logic_test`), the video view's output size and
|
||||||
|
|||||||
@@ -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,
|
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
|
next episode). Invite opens it to friends on your network (a second
|
||||||
listener on the same server: nothing restarts, the video and Up next
|
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
|
- **Dedicated server**: `starsync-server`, a separate program in the
|
||||||
release tarball, for an always-on box. See the README.
|
release tarball, for an always-on box. See the README.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
non-resumed `welcome` (grace over, server restarted) the client sends
|
||||||
its intent again (`ready`, `presence`).
|
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)
|
## Queue (proto 12)
|
||||||
|
|
||||||
Up next: what plays after the current media, held by the server for
|
Up next: what plays after the current media, held by the server for
|
||||||
|
|||||||
+5
-2
@@ -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
|
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;
|
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
|
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
|
from 1.2.1-dev.3 in real use. No UPnP: the members card says when
|
||||||
members card only says friends elsewhere need the port forwarded. Your
|
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
|
server's `/health` name is the profile name at start (a rename shows
|
||||||
after a restart). Joining a friend while others are on
|
after a restart). Joining a friend while others are on
|
||||||
your server leaves them there without you, unwarned; a join refused
|
your server leaves them there without you, unwarned; a join refused
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ void relogLine(String tag, String line) {
|
|||||||
// `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header).
|
// `"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|'
|
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'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 _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false);
|
||||||
final _quotedSecret = RegExp('\\b($_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
|
// Header-like `Name: value` runs to the end of the line (a cookie
|
||||||
@@ -127,7 +127,8 @@ final _headerSecret = RegExp(
|
|||||||
caseSensitive: false);
|
caseSensitive: false);
|
||||||
final _pairSecret = RegExp('\\b($_secretNames)\\s*=\\s*[^&\\s"\',;)}\\]]+', 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 _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.
|
// 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 _pathToken = RegExp(r'(/[rmjail]/[^/\s?#]+/)[0-9a-fA-F]{32}\b');
|
||||||
final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
|
final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ void main() {
|
|||||||
expect(redactLog('http://user:pw@host/x#frag'), 'http://***@host/x');
|
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', () {
|
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('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=…');
|
expect(redactLog('ws://h:1/r/default/$tok/source?q=7'), 'ws://h:1/r/default/***/source?q=…');
|
||||||
|
|||||||
Reference in new issue
Block a user