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.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 queue; final int? currentIndex; final bool playing; final int positionMs; final int durationMs; final bool shuffle; final String loop; final int seq; Map 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 j) => StateSnapshot( queue: (j['queue'] as List? ?? const []) .whereType() .map((e) => Song.fromJson(e.cast())) .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 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? songs; final Song? song; Map 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 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()); } 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((e) => Song.fromJson(e.cast())) .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 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(); 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()), ); case 'cmd': return CommandMessage( RemoteCommand.fromJson((m['cmd'] as Map).cast()), ); 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 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 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 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 toJson() => {'t': 'deny', 'reason': reason}; } /// host → remote: a full playback snapshot. class SnapshotMessage extends RemoteMessage { const SnapshotMessage(this.snapshot); final StateSnapshot snapshot; @override Map toJson() => {'t': 'state', 'snapshot': snapshot.toJson()}; } /// remote → host: one control command. class CommandMessage extends RemoteMessage { const CommandMessage(this.command); final RemoteCommand command; @override Map toJson() => {'t': 'cmd', 'cmd': command.toJson()}; }