network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
362
lib/remote/messages.dart
Normal file
362
lib/remote/messages.dart
Normal 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()};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue