Invite: just the link (password included), one Copy button, a note only when needed, other addresses behind a toggle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 18:11:07 +03:00
1 parent 5116923858
commit cf61453e28
12 files changed
+179 -163

No files matched your search

+2 -3
View File
@@ -14,9 +14,8 @@ Links you put up are downloaded once for everyone, quick chat and danmaku in the
- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About)
- Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one
- 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"
- Invite links: Invite copies just a starsync:// link, password included, so a friend just clicks it (or copies it and switches to Starsync) and the app asks "Join Bob's server?"
- Invite finds your internet address on its own and says when your router needs the port forwarded; Other addresses picks your home-network or VPN address instead
- Starsync runs once: a link clicked while it is open goes to the open window
- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way
- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview
+10 -11
View File
@@ -190,22 +190,21 @@ Your server starts with the app and stops when it closes. Only this app
can reach it until you press **Invite** (in the strip, or in the member
list): that opens it on your network at the port from Settings → Party &
network (default 8080, with the password set there; blank = anyone can
join) and copies an invite link ("Join me on Starsync:
starsync://203.0.113.7:8080/?name=… · Starsync 1.3.0") to send to
friends. Nothing restarts: what plays and Up next carry on, and you are
join) and copies an invite link (`starsync://203.0.113.7:8080/?name=…`,
just the link) to send to friends. Nothing restarts: what plays and Up next carry on, and you are
the host (the crown).
The link carries your internet address when the app can find it (it asks
a "what is my IP" service such as ipify over HTTPS when you share), else
this computer's home-network address (not a proxy one, nor a Docker or VM
bridge). The member list shows every address to pick from: **Internet**
for friends elsewhere, **Home network** for friends on the same Wi-Fi or
router, **VPN** (Tailscale and the like). Behind a router, friends
elsewhere get in only when the TCP port is forwarded on your router to
this computer (the card says so and names this computer's address); the
app does not open ports itself. The password is never in the link unless
you tick **Include the password in the link**; otherwise friends ask you
for it. Friends open nothing. **Stop sharing** (in the member list)
bridge). In the member list, **Copy invite link** copies it again;
**Other addresses** there lets you pick another: **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); the app does not open ports itself. The link
includes the password when one is set, so friends just click it: share
it only with them. Friends open nothing. **Stop sharing** (in the member list)
closes it again:
everyone else leaves (their app goes back to their own server and says
why), you and Up next stay.
+4 -13
View File
@@ -36,20 +36,11 @@ class Invite {
Invite at(String host) =>
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
/// Its `starsync://` link; the password only when given ([secret]).
/// Its `starsync://` link with the server's password ([secret], the
/// host's; empty when none is set): what Invite copies, nothing around
/// it, so a friend just clicks it.
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
/// The text Invite copies: the link, whether to ask for the password
/// (not when [secret] puts it in the link), the version to match.
String textWith({String secret = ''}) => [
'Join me on Starsync: ${link(secret: secret).uri}',
if (password && secret.isEmpty) '(ask me for the password)',
if (version.isNotEmpty) '· Starsync $version',
].join(' ');
/// [textWith] without the password.
String get text => textWith();
@override
bool operator ==(Object other) =>
other is Invite &&
@@ -69,7 +60,7 @@ class Invite {
/// 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 when the server has one. [toString] never shows the
/// password; [uri] does.
class InviteLink {
final String host;
+11 -2
View File
@@ -98,6 +98,13 @@ class YourServer {
/// An [invite] or [stopSharing] is under way.
final busy = signal(false);
/// The password the server was shared with (empty: none).
String _secret = '';
/// What Invite copies: [invite]'s bare `starsync://` link with the
/// password the server was shared with, so a friend just clicks it.
String inviteLink(Invite invite) => invite.link(secret: _secret).uri;
/// Set when the server died without being stopped; the shell says so
/// and clears it. The next [start] makes a new one.
final crash = signal<String?>(null);
@@ -171,13 +178,15 @@ class YourServer {
final form = forms.form;
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
await _accounts.ensureLoaded(validate: false);
final secret = form.hostSecret;
final bound = await server.share(
port: want,
secret: form.hostSecret,
secret: secret,
// Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts),
);
_secret = secret;
final local = inviteAddresses(await _interfaces());
final gen = ++_shareGen;
reach.value = InviteReach(local: local, looking: true);
@@ -185,7 +194,7 @@ class YourServer {
// 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);
final invite = inviteFor(bound, password: secret.isNotEmpty, host: reach.peek()?.best);
shared.value = invite;
if (public == null) {
unawaited(lookup.then((ip) {
@@ -231,7 +231,7 @@ class JoinCard extends StatelessWidget {
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Your friend gives you their invite (the address) and the password, if any. '
'Paste your friend\'s invite link (it carries the password), or their address and password. '
'No account or Jellyfin needed. Your own server stays as it is.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
+75 -81
View File
@@ -7,12 +7,12 @@ 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.
/// and Invite (opens it and copies the invite link). Shared: Copy invite
/// link (the bare `starsync://` link with the password, at the address
/// picked: the internet one when known, else this machine's), a short
/// line only when something needs the user ([YourServer.reach]: the port
/// needs forwarding, no internet address), Stop sharing, and the other
/// addresses (Internet / Home network / VPN) folded under a toggle.
class YourInvite extends StatefulWidget {
const YourInvite({super.key});
@@ -23,7 +23,7 @@ class YourInvite extends StatefulWidget {
class _YourInviteState extends State<YourInvite> {
/// The address picked (null: the invite's own, the best one).
String? _picked;
bool _withPassword = false;
bool _others = false;
static String _kindLabel(AddressKind k) => switch (k) {
AddressKind.public => 'Internet',
@@ -66,93 +66,86 @@ class _YourInviteState extends State<YourInvite> {
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 looking = reach?.looking ?? false;
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).',
'Friends outside your home network need port ${invite.port} forwarded to this computer.',
AddressKind.public => null,
_ when reach != null && !looking && reach.public == null =>
"Couldn't find your internet address: this link works on your home network only.",
_ => null,
};
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),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Tooltip(
message: invite.at(picked).address,
child: FilledButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () {
final text = yours.inviteLink(invite.at(picked));
graph.joinOffers.copied(text);
copyToClipboard(context, text, done: 'Invite link copied');
},
icon: const Icon(Icons.link, size: 18),
label: const Text('Copy invite link'),
),
),
Tooltip(
message: 'Sends everyone else out; you and Up next stay',
child: TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
),
],
),
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)
if (looking)
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 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)),
]),
)
else if (note != null)
Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
child: Text(note, key: const ValueKey('invite-note'), 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'),
if (choices.length > 1) ...[
const SizedBox(height: 4),
TextButton.icon(
key: const ValueKey('invite-other-addresses'),
style: TextButton.styleFrom(
foregroundColor: muted,
visualDensity: VisualDensity.compact,
textStyle: Theme.of(context).textTheme.bodySmall,
),
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),
),
onPressed: () => setState(() => _others = !_others),
icon: Icon(_others ? Icons.expand_less : Icons.expand_more, size: 16),
label: const Text('Other addresses'),
),
if (_others)
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),
),
],
],
),
);
@@ -189,9 +182,9 @@ class _AddressRow extends StatelessWidget {
}
/// 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.
/// bare invite link (with the password, when one is set) at the best
/// address (the internet one when found, else this machine's); a port
/// that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
@@ -204,6 +197,7 @@ Future<void> inviteFriends(BuildContext context) async {
return;
}
if (!context.mounted) return;
graph.joinOffers.copied(invite.text);
await copyToClipboard(context, invite.text, done: 'Invite link copied · ${invite.address}');
final text = graph.yourServer.inviteLink(invite);
graph.joinOffers.copied(text);
await copyToClipboard(context, text, done: 'Invite link copied');
}
+8 -9
View File
@@ -56,16 +56,15 @@ void main() {
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', () {
test('the invite is just its link, with the password when one is set', () {
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");
final link = invite.link(secret: 'hunter2');
expect(link.uri, 'starsync://192.168.1.20:8080/?name=Bob%27s+server&pw=hunter2');
expect(inviteIn(link.uri), link);
expect(link.toString(), isNot(contains('hunter2')));
expect(const Invite(host: '192.168.1.20', port: 8080).link().uri, 'starsync://192.168.1.20:8080');
// A 1.3.x app pasting it into the Join card's address.
expect(addressIn(withPw), (host: '192.168.1.20', port: '8080'));
expect(addressIn(link.uri), (host: '192.168.1.20', port: '8080'));
expect(invite.at('203.0.113.7').address, '203.0.113.7:8080');
});
@@ -172,7 +171,7 @@ void main() {
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';
const copied = 'starsync://198.51.100.4:8080';
offers.copied(copied);
offers.fromClipboard(copied);
expect(offers.offer.peek(), isNull);
+36 -16
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show SystemChannels;
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart' show signal;
import 'package:sync_relay_core/core.dart';
@@ -301,13 +302,12 @@ void main() {
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse);
});
test('an invite: the address, whether to ask for a password, the version; pasted back', () {
test('an invite: the address, its link; pasted back', () {
const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0');
expect(invite.address, '192.168.1.20:8080');
expect(invite.text,
'Join me on Starsync: starsync://192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0');
expect(invite.link().uri, 'starsync://192.168.1.20:8080');
expect(invite.fp, isNull, reason: 'reserved for pinned certificates');
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080'));
expect(addressIn(invite.link().uri), (host: '192.168.1.20', port: '8080'));
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
expect(addressIn('just a host'), isNull);
@@ -367,31 +367,51 @@ void main() {
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('Looking up your internet address…'), findsOneWidget);
expect(find.text('Copy invite link'), findsOneWidget);
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
expect(find.textContaining('friends on your network'), findsOneWidget);
// No password set: nothing to tick.
expect(find.byKey(const ValueKey('invite-with-password')), findsNothing);
// One address so far: nothing else to pick, nothing to say.
expect(find.byKey(const ValueKey('invite-other-addresses')), findsNothing);
expect(find.byKey(const ValueKey('invite-note')), findsNothing);
// Behind a router: the internet address first, with the forwarding
// it needs; the home address stays a choice.
// Behind a router: the internet address, one line on forwarding;
// the home address waits behind Other addresses.
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)'),
expect(find.text('Looking up your internet address…'), findsNothing);
expect(find.text('Friends outside your home network need port 8080 forwarded to this computer.'),
findsOneWidget);
expect(find.text('192.168.1.20:8080'), findsNothing);
Future<String?> copy() async {
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String;
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
await tester.tap(find.byKey(const ValueKey('copy-invite')));
await tester.pumpAndSettle();
return copied;
}
// Just the link, nothing around it.
expect(await copy(), matches(RegExp(r'^starsync://203\.0\.113\.7:8080(/\?name=\S+)?$')));
await tester.tap(find.byKey(const ValueKey('invite-other-addresses')));
await tester.pumpAndSettle();
expect(find.text('203.0.113.7:8080'), findsOneWidget);
await tester.tap(find.text('192.168.1.20:8080'));
await tester.pumpAndSettle();
expect(find.textContaining('friends on your network'), findsOneWidget);
expect(find.byKey(const ValueKey('invite-note')), findsNothing, reason: 'home network picked');
expect(await copy(), startsWith('starsync://192.168.1.20:8080'));
// No internet address found: say what friends elsewhere need.
// No internet address found: one line saying so.
yours.reach.value = const InviteReach(local: ['192.168.1.20']);
await tester.pumpAndSettle();
expect(find.textContaining('VPS'), findsOneWidget);
expect(find.textContaining("Couldn't find your internet address"), findsOneWidget);
expect(_graph.notifier.toasts.peek().map((t) => t.text), contains('Invite link copied'));
await tester.pump(const Duration(seconds: 6)); // the toasts go
});
});
}
@@ -14,6 +14,7 @@ import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/server_isolate.dart';
import 'package:sync_relay_server/server.dart' show SyncServer;
import 'package:starsync/src/session/invite.dart' show InviteLink;
import 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart';
@@ -166,6 +167,11 @@ void main() {
final invite = await yours.invite();
expect(invite.port, port);
expect(invite.password, isTrue);
// The link is the key: the password it was shared with, even when
// the setting changes later.
graph.forms.form.hostSecret = 'changed';
expect(InviteLink.parse(yours.inviteLink(invite))?.secret, 'pw');
graph.forms.form.hostSecret = 'pw';
expect(yours.shared.peek(), invite);
expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite');
+18 -19
View File
@@ -61,8 +61,8 @@ attach and detach the network listener (below).
its reason; nothing else changes.
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
server if it is not yet and returns an `Invite`: the host, the port, the
name ("<your name>'s server"), whether a password is needed (never the
password), the version and an `fp` field reserved for a pinned
name ("<your name>'s server"), whether a password is needed (not the
password itself), the version and an `fp` field reserved for a pinned
certificate (`wss://` with a self-signed one, later; always null). This
machine's addresses are `inviteAddresses` over `NetworkInterface.list`:
IPv4 only, no loopback, link-local (169.254/16) or proxy fake-IP
@@ -82,10 +82,10 @@ attach and detach the network listener (below).
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:
when it lands. `YourServer.inviteLink(invite)` is what Invite copies:
the bare link with the password the server was shared with
(`starsync://203.0.113.7:8080/?name=Bob%27s+server&pw=…`; the link is
the key, nothing around 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;
@@ -1993,8 +1993,8 @@ panel) and chat messages (Copy message, and a link in it).
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
Invite on yours or Leave on a friend's (asks first, then you are home).
Invite (`inviteFriends`, `shell/your_invite.dart`) shares your server if
it is not yet and copies the invite link, never the password ("Invite
link copied · 203.0.113.7:8080"); a port it cannot open
it is not yet and copies the invite link ("Invite link copied"); a port
it cannot open
is a notice pointing at Settings. The stack
opens the members card (the server's version under its title: yours is
this app's, a joined one's from `/health.version`; the source
@@ -2004,15 +2004,14 @@ panel) and chat messages (Copy message, and a link in it).
"reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit
out (anyone, the host too), your own row Sit out / Join back; at the
bottom, on yours: not shared, "Only you can reach it" and Invite;
shared (`YourInvite`), "Friends join at" with each address of
`YourServer.reach` to pick (Internet, Home network, VPN; "Looking up
your internet address…" meanwhile), a note for the one picked (the
router's address: the port must be forwarded to this computer's home
address; this computer's own: a firewall can still block it; home
network, VPN: who it works for; no internet address found: forwarding
or a server all can reach), "Include the password in the link" (only
with a password; off each time the card opens), Copy invite link and
Stop sharing; on a friend's, "Copy invite link" (the dialled address
shared (`YourInvite`), Copy invite link (filled; the address the app
picks, `InviteReach.best`, in its tooltip) and Stop sharing; one line
only when something needs the user ("Looking up your internet
address…"; the router's address: the port needs forwarding to this
computer; no internet address found: the link works on the home network
only); "Other addresses" (folded, only with more than one) lists each
address of `YourServer.reach` (Internet, Home network, VPN) to pick
what Copy copies; on a friend's, "Copy invite link" (the dialled address
and its name, never a password)).
Empty strip space is the window
drag area; the window buttons sit at its end.
@@ -2035,8 +2034,8 @@ panel) and chat messages (Copy message, and a link in it).
owned by the shell so an attempt and its error outlive the card; the
version gate's refusal says who has to update and, when it is you,
offers "Check for the update". The card closes itself once in the
friend's server. It says that a friend gives the invite and the
password, that no account or Jellyfin is needed and that your own
friend's server. It says to paste a friend's invite link (it carries
the password) or their address and password, that no account or Jellyfin is needed and that your own
server stays as it is.
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`:
+3 -2
View File
@@ -46,8 +46,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
next episode). Invite opens it to friends on your network (a second
listener on the same server: nothing restarts, the video and Up next
carry on) and copies an invite 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.
address when the app finds it and the password when one is set); Stop
sharing closes it again. A friend clicks the link, or just copies it,
and their app asks to join.
- **Dedicated server**: `starsync-server`, a separate program in the
release tarball, for an always-on box. See the README.
+5 -6
View File
@@ -629,15 +629,14 @@ starsync://[fe80::1]:8080
- `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
- `pw` (optional): the server password, always there when the server has
one: the link is the key, a friend just clicks it. 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.
- Invite copies the bare link, nothing around it. 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)