Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -0,0 +1,201 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show validClientId;
|
||||
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart' show settingsBoxName;
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
|
||||
// Who this app is: to the server (the per-install client id and its
|
||||
// rising connection count, [ClientIdentity]) and to people (name, picture
|
||||
// and colour, [Profile]).
|
||||
|
||||
/// `hello.client` and `hello.seq` (PROTOCOL.md "Presence"): a random id
|
||||
/// per install, kept in the settings box (so per data folder, and per
|
||||
/// `STARSYNC_PROFILE`), and a number that rises with every connection this
|
||||
/// install makes. A server maps the id to one lasting member; the higher
|
||||
/// `seq` wins when two windows of the same install connect.
|
||||
class ClientIdentity {
|
||||
static const _keyClient = 'clientId';
|
||||
static const _keySeq = 'clientSeq';
|
||||
|
||||
final Box? _box;
|
||||
ClientIdentity._(this._box, this.clientId, this._seq);
|
||||
|
||||
/// The id: 22 characters of base64url (16 random bytes). Private: never
|
||||
/// shown, never logged.
|
||||
final String clientId;
|
||||
int _seq;
|
||||
|
||||
/// The last `seq` handed out.
|
||||
int get seq => _seq;
|
||||
|
||||
/// The stored identity (the settings box, once open); one is made and
|
||||
/// stored on first use. Without the box (tests, no store): [ephemeral].
|
||||
factory ClientIdentity.stored() {
|
||||
final box = Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null;
|
||||
if (box == null) return ClientIdentity.ephemeral();
|
||||
var id = box.get(_keyClient);
|
||||
if (id is! String || !validClientId(id)) {
|
||||
id = newClientId();
|
||||
unawaited(_put(box, _keyClient, id));
|
||||
}
|
||||
final seq = box.get(_keySeq);
|
||||
return ClientIdentity._(box, id, seq is int && seq > 0 ? seq : 0);
|
||||
}
|
||||
|
||||
/// A fresh id kept in memory only (each test coordinator is its own
|
||||
/// install).
|
||||
factory ClientIdentity.ephemeral() => ClientIdentity._(null, newClientId(), 0);
|
||||
|
||||
/// The `seq` for the next hello: one higher than any before, stored at
|
||||
/// once.
|
||||
int nextSeq() {
|
||||
_seq++;
|
||||
final box = _box;
|
||||
if (box != null) unawaited(_put(box, _keySeq, _seq));
|
||||
return _seq;
|
||||
}
|
||||
|
||||
static Future<void> _put(Box box, String key, Object value) async {
|
||||
try {
|
||||
await box.put(key, value);
|
||||
} catch (e) {
|
||||
debugPrint('save $key failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 16 random bytes as 22 characters of base64url.
|
||||
static String newClientId() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode([for (var i = 0; i < 16; i++) r.nextInt(256)]).replaceAll('=', '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Colours offered in the profile card; the first is the default.
|
||||
const profileColors = <Color>[
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.teal,
|
||||
Colors.pink,
|
||||
Colors.orange,
|
||||
Colors.blueGrey,
|
||||
];
|
||||
|
||||
const _keyPicture = 'avatarPng';
|
||||
const _keyColor = 'profileColor';
|
||||
|
||||
/// Sends a mid-session name/picture change (null = unchanged, empty
|
||||
/// avatar = removed). Swappable so tests can watch it.
|
||||
typedef ProfilePush = void Function({String? name, String? avatar});
|
||||
|
||||
/// The one place a profile change reaches the server.
|
||||
void pushProfileToSession({String? name, String? avatar}) {
|
||||
final session = SessionState.instance;
|
||||
if (session.route.peek() != AppRoute.session) return;
|
||||
session.coordinator.updateProfile(name: name, avatar: avatar);
|
||||
}
|
||||
|
||||
/// Display name, picture and colour of this app's user. Backed by
|
||||
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
|
||||
/// 256 px PNG and the colour); every change is persisted, and while in a
|
||||
/// server it is pushed through [push] so everyone sees it at once.
|
||||
class Profile {
|
||||
static final Profile instance = Profile._();
|
||||
Profile._();
|
||||
|
||||
final name = signal('');
|
||||
/// The cropped PNG; null shows the initial on [color].
|
||||
final picture = signal<Uint8List?>(null);
|
||||
final color = signal<Color>(profileColors.first);
|
||||
final error = signal<String?>(null);
|
||||
ProfilePush push = pushProfileToSession;
|
||||
bool _loaded = false;
|
||||
Timer? _nameTimer;
|
||||
|
||||
/// Reads the stored profile once (the app shell starts it). Old installs
|
||||
/// stored a picture path: it is shrunk through [encodeAvatar] once and
|
||||
/// kept as bytes. A caller during the first read does not wait for it
|
||||
/// (no shared Future: tests run each in its own zone).
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final form = SessionState.instance.lastForm;
|
||||
name.value = form.name;
|
||||
try {
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
final png = box.get(_keyPicture);
|
||||
final c = box.get(_keyColor);
|
||||
if (c is int) color.value = Color(c);
|
||||
if (png is Uint8List && png.isNotEmpty) {
|
||||
picture.value = png;
|
||||
} else if (form.avatarPath.isNotEmpty) {
|
||||
final b64 = await encodeAvatar(form.avatarPath);
|
||||
if (b64.isNotEmpty) picture.value = base64Decode(b64);
|
||||
form.avatarPath = '';
|
||||
await _save();
|
||||
}
|
||||
} catch (e) {
|
||||
// No store (tests) or an unreadable old picture: start without.
|
||||
debugPrint('profile load: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture as sent in hello and profile updates; '' when none.
|
||||
String avatarBase64() {
|
||||
final png = picture.peek();
|
||||
return png == null ? '' : avatarWireBase64(png);
|
||||
}
|
||||
|
||||
void setName(String value) {
|
||||
final v = value.trim();
|
||||
if (v.isEmpty || v == name.peek()) return;
|
||||
name.value = v;
|
||||
SessionState.instance.lastForm.name = v;
|
||||
unawaited(_save());
|
||||
// Typing renames once it settles, not on every key.
|
||||
_nameTimer?.cancel();
|
||||
_nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek()));
|
||||
}
|
||||
|
||||
Future<void> setPicture(Uint8List? png) async {
|
||||
picture.value = png;
|
||||
error.value = null;
|
||||
await _save();
|
||||
try {
|
||||
push(avatar: png == null ? '' : avatarWireBase64(png));
|
||||
} on FormatException catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setColor(Color c) async {
|
||||
color.value = c;
|
||||
await _save();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
await SessionState.instance.saveForm();
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
final png = picture.peek();
|
||||
await box.putAll({
|
||||
_keyPicture: png ?? Uint8List(0),
|
||||
_keyColor: color.peek().toARGB32(),
|
||||
});
|
||||
} catch (_) {
|
||||
error.value = 'could not save profile — try again';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user