362 lines
11 KiB
Dart
362 lines
11 KiB
Dart
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()};
|
|
}
|