network addition

This commit is contained in:
Forrest 2026-08-04 16:39:18 -04:00
parent 3bd713d667
commit 2099d3d64d
27 changed files with 2237 additions and 40 deletions

362
lib/remote/messages.dart Normal file
View file

@ -0,0 +1,362 @@
import 'dart:convert';
import 'dart:math' show Random;
import 'package:crypto/crypto.dart';
import '../subsonic/models.dart';
/// Wire protocol for LAN cross-device control (updates-features.md #3).
///
/// Deliberately dependency-light — only `dart:convert`, `crypto`, and the
/// [Song] model — so the whole protocol is unit-testable without Flutter
/// bindings or a real socket. Higher layers (`host_server.dart` /
/// `remote_session.dart`) own the transport and the just_audio-facing
/// [PlaybackState] <-> [StateSnapshot] conversion; nothing here imports
/// just_audio or riverpod.
///
/// Bump [kRemoteProtocolVersion] on any breaking envelope/field change; both
/// ends refuse to talk across a mismatch (see `host_server.dart`).
const int kRemoteProtocolVersion = 1;
/// mDNS/Bonjour service type advertised + browsed by [discovery]. iOS requires
/// this exact string listed under `NSBonjourServices` in Info.plist.
const String kRemoteServiceType = '_timbre._tcp';
// ---- Auth ---------------------------------------------------------------
/// Challenge-response auth keyed off the shared Subsonic password. Both devices
/// are signed into the same server, so both know the password; proving it
/// (without ever sending it) gates control to the same user without a manual
/// PIN. The host issues a per-connection [newNonce]; the remote returns
/// [proofFor]; the host [verify]s. A fresh nonce per connection stops replay.
class RemoteAuth {
RemoteAuth._();
static final Random _rng = Random.secure();
/// A fresh 128-bit base64url nonce for one connection's challenge.
static String newNonce() {
final bytes = List<int>.generate(16, (_) => _rng.nextInt(256));
return base64Url.encode(bytes);
}
/// `HMAC-SHA256(key = password, msg = nonce)`, hex-encoded.
static String proofFor({required String password, required String nonce}) {
final mac = Hmac(sha256, utf8.encode(password));
return mac.convert(utf8.encode(nonce)).toString();
}
/// Constant-time compare of a received [proof] against the expected one.
static bool verify({
required String password,
required String nonce,
required String proof,
}) {
final expected = proofFor(password: password, nonce: nonce);
if (expected.length != proof.length) return false;
var diff = 0;
for (var i = 0; i < expected.length; i++) {
diff |= expected.codeUnitAt(i) ^ proof.codeUnitAt(i);
}
return diff == 0;
}
}
// ---- Playback snapshot --------------------------------------------------
/// A transport-friendly mirror of the host's `PlaybackState`. `loop` is the
/// enum *name* (just_audio's `LoopMode`) so this file stays free of just_audio;
/// the remote proxy maps it back. [seq] is a monotonic counter so a remote can
/// drop a stale/out-of-order snapshot.
class StateSnapshot {
const StateSnapshot({
required this.queue,
required this.currentIndex,
required this.playing,
required this.positionMs,
required this.durationMs,
required this.shuffle,
required this.loop,
required this.seq,
});
final List<Song> queue;
final int? currentIndex;
final bool playing;
final int positionMs;
final int durationMs;
final bool shuffle;
final String loop;
final int seq;
Map<String, dynamic> toJson() => {
'queue': [for (final s in queue) s.toJson()],
'currentIndex': currentIndex,
'playing': playing,
'positionMs': positionMs,
'durationMs': durationMs,
'shuffle': shuffle,
'loop': loop,
'seq': seq,
};
factory StateSnapshot.fromJson(Map<String, dynamic> j) => StateSnapshot(
queue: (j['queue'] as List? ?? const [])
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.toList(),
currentIndex: j['currentIndex'] as int?,
playing: j['playing'] == true,
positionMs: (j['positionMs'] as int?) ?? 0,
durationMs: (j['durationMs'] as int?) ?? 0,
shuffle: j['shuffle'] == true,
loop: (j['loop'] as String?) ?? 'off',
seq: (j['seq'] as int?) ?? 0,
);
}
// ---- Commands -----------------------------------------------------------
/// The remote-control verbs, 1:1 with the `PlaybackCommands` surface the UI
/// drives. `resyncFromPlayer` is intentionally absent — it is a local-engine
/// concern with no cross-device meaning (the remote gets state pushed to it).
enum RemoteOp {
playPause,
next,
previous,
seek,
jumpTo,
playSongs,
playNext,
addToQueue,
removeAt,
reorder,
toggleShuffle,
cycleLoop,
retry,
}
/// One remote command plus whatever payload its [op] needs. A flat bag (rather
/// than a class-per-op hierarchy) keeps the codec and the call sites compact,
/// matching the model style elsewhere in the app. Use the named constructors so
/// each op only carries its own fields.
class RemoteCommand {
const RemoteCommand._(
this.op, {
this.index,
this.oldIndex,
this.newIndex,
this.startIndex,
this.positionMs,
this.songs,
this.song,
});
const RemoteCommand.playPause() : this._(RemoteOp.playPause);
const RemoteCommand.next() : this._(RemoteOp.next);
const RemoteCommand.previous() : this._(RemoteOp.previous);
const RemoteCommand.toggleShuffle() : this._(RemoteOp.toggleShuffle);
const RemoteCommand.cycleLoop() : this._(RemoteOp.cycleLoop);
const RemoteCommand.retry() : this._(RemoteOp.retry);
const RemoteCommand.seek(int positionMs)
: this._(RemoteOp.seek, positionMs: positionMs);
const RemoteCommand.jumpTo(int index) : this._(RemoteOp.jumpTo, index: index);
const RemoteCommand.removeAt(int index)
: this._(RemoteOp.removeAt, index: index);
const RemoteCommand.reorder(int oldIndex, int newIndex)
: this._(RemoteOp.reorder, oldIndex: oldIndex, newIndex: newIndex);
const RemoteCommand.playSongs(List<Song> songs, {int startIndex = 0})
: this._(RemoteOp.playSongs, songs: songs, startIndex: startIndex);
const RemoteCommand.playNext(Song song)
: this._(RemoteOp.playNext, song: song);
const RemoteCommand.addToQueue(Song song)
: this._(RemoteOp.addToQueue, song: song);
final RemoteOp op;
final int? index;
final int? oldIndex;
final int? newIndex;
final int? startIndex;
final int? positionMs;
final List<Song>? songs;
final Song? song;
Map<String, dynamic> toJson() => {
'op': op.name,
if (index != null) 'index': index,
if (oldIndex != null) 'oldIndex': oldIndex,
if (newIndex != null) 'newIndex': newIndex,
if (startIndex != null) 'startIndex': startIndex,
if (positionMs != null) 'positionMs': positionMs,
if (songs != null) 'songs': [for (final s in songs!) s.toJson()],
if (song != null) 'song': song!.toJson(),
};
factory RemoteCommand.fromJson(Map<String, dynamic> j) {
final op = RemoteOp.values.firstWhere(
(o) => o.name == j['op'],
orElse: () => throw const FormatException('unknown remote op'),
);
Song? song;
if (j['song'] is Map) {
song = Song.fromJson((j['song'] as Map).cast<String, dynamic>());
}
return RemoteCommand._(
op,
index: j['index'] as int?,
oldIndex: j['oldIndex'] as int?,
newIndex: j['newIndex'] as int?,
startIndex: j['startIndex'] as int?,
positionMs: j['positionMs'] as int?,
songs: j['songs'] is List
? (j['songs'] as List)
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.toList()
: null,
song: song,
);
}
}
// ---- Envelope -----------------------------------------------------------
/// Every frame on the wire is one JSON object tagged by `t`. Handshake order:
/// host → [ChallengeMessage], remote → [AuthMessage], host → [WelcomeMessage]
/// (then a stream of [SnapshotMessage]) or [DenyMessage] + close. After the
/// handshake the remote sends [CommandMessage]s.
sealed class RemoteMessage {
const RemoteMessage();
Map<String, dynamic> toJson();
String encode() => jsonEncode(toJson());
/// Parse a wire frame. Throws [FormatException] on anything unrecognized so
/// the transport can drop a bad/hostile peer rather than mis-handle it.
static RemoteMessage decode(String raw) {
final j = jsonDecode(raw);
if (j is! Map) throw const FormatException('frame is not an object');
final m = j.cast<String, dynamic>();
switch (m['t']) {
case 'challenge':
return ChallengeMessage(
version: (m['v'] as int?) ?? 0,
serverKey: m['serverKey'] as String? ?? '',
nonce: m['nonce'] as String? ?? '',
device: m['device'] as String? ?? '',
);
case 'auth':
return AuthMessage(
version: (m['v'] as int?) ?? 0,
serverKey: m['serverKey'] as String? ?? '',
proof: m['proof'] as String? ?? '',
device: m['device'] as String? ?? '',
);
case 'welcome':
return WelcomeMessage(device: m['device'] as String? ?? '');
case 'deny':
return DenyMessage(reason: m['reason'] as String? ?? '');
case 'state':
return SnapshotMessage(
StateSnapshot.fromJson((m['snapshot'] as Map).cast<String, dynamic>()),
);
case 'cmd':
return CommandMessage(
RemoteCommand.fromJson((m['cmd'] as Map).cast<String, dynamic>()),
);
default:
throw FormatException('unknown message type: ${m['t']}');
}
}
}
/// host → remote: identifies the host and carries the auth nonce.
class ChallengeMessage extends RemoteMessage {
const ChallengeMessage({
required this.version,
required this.serverKey,
required this.nonce,
required this.device,
});
final int version;
final String serverKey;
final String nonce;
final String device;
@override
Map<String, dynamic> toJson() => {
't': 'challenge',
'v': version,
'serverKey': serverKey,
'nonce': nonce,
'device': device,
};
}
/// remote → host: proves knowledge of the shared password and names itself.
class AuthMessage extends RemoteMessage {
const AuthMessage({
required this.version,
required this.serverKey,
required this.proof,
required this.device,
});
final int version;
final String serverKey;
final String proof;
final String device;
@override
Map<String, dynamic> toJson() => {
't': 'auth',
'v': version,
'serverKey': serverKey,
'proof': proof,
'device': device,
};
}
/// host → remote: handshake accepted; snapshots follow.
class WelcomeMessage extends RemoteMessage {
const WelcomeMessage({required this.device});
final String device;
@override
Map<String, dynamic> toJson() => {'t': 'welcome', 'device': device};
}
/// host → remote: handshake rejected (version / server / auth mismatch).
class DenyMessage extends RemoteMessage {
const DenyMessage({required this.reason});
final String reason;
@override
Map<String, dynamic> toJson() => {'t': 'deny', 'reason': reason};
}
/// host → remote: a full playback snapshot.
class SnapshotMessage extends RemoteMessage {
const SnapshotMessage(this.snapshot);
final StateSnapshot snapshot;
@override
Map<String, dynamic> toJson() => {'t': 'state', 'snapshot': snapshot.toJson()};
}
/// remote → host: one control command.
class CommandMessage extends RemoteMessage {
const CommandMessage(this.command);
final RemoteCommand command;
@override
Map<String, dynamic> toJson() => {'t': 'cmd', 'cmd': command.toJson()};
}