feat(core): A.6 — supervisor reconnect with watchdog and event stream
Adds an end-to-end auto-reconnect path so a brief network outage no longer leaves the client wedged in a half-dead state. The flow has three layers, each motivated by a real failure mode observed on the Moto G live test: * `chanora_protocol::DisconnectReason` (`UserRequested` / `StreamEnded` / `Error(String)`) is reported on a `oneshot` when the per-connection task exits, so the supervisor can tell user intent apart from a real loss. * `chanora_core` spawns a supervisor task per `ChanoraSession`. It listens for the loss notifier AND runs a watchdog that issues `snapshot()` probes every 5s with a 4s timeout — three consecutive misses synthesise a `DisconnectReason::Error(...)` and trigger the reconnect path. The watchdog catches the "ghost connected" case where tsclientlib silently resets internal state but the event stream never errors. Backoff schedule: 1s, 2s, 5s, 15s, 30s, 60s (capped). On success the supervisor swaps the dead `ProtocolClient` for the new one in place and, if audio was running, restarts the audio engine bound to the new `voice_in`/`voice_out` channels. * `SessionEvent` (Connected / Lost / Reconnecting / Disconnected / AudioStarted / AudioStopped) is broadcast on a 64-slot channel. `chanora_bridge` re-exports it as `BridgeEvent` and exposes `events_stream(StreamSink)`; the Flutter side subscribes from `initState` and renders a reconnect banner with attempt count and delay. New `SnapshotProbe` exposes a clone-friendly snapshot path so the watchdog can probe without holding `&self` across awaits. Localization adds `statusReconnecting` and `statusConnectionLost` keys to `app_en.arb` and `app_zh.arb`. Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app: killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three misses, supervisor walked the backoff schedule, and the UI reconnected automatically once the radios came back. Snapshot tree re-rendered without user action.
This commit is contained in:
@@ -27,6 +27,18 @@
|
||||
"placeholders": { "message": { "type": "String" } }
|
||||
},
|
||||
|
||||
"statusReconnecting": "Reconnecting… attempt {attempt}, in {delay}s",
|
||||
"@statusReconnecting": {
|
||||
"placeholders": {
|
||||
"attempt": { "type": "int" },
|
||||
"delay": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"statusConnectionLost": "Connection lost: {reason}",
|
||||
"@statusConnectionLost": {
|
||||
"placeholders": { "reason": { "type": "String" } }
|
||||
},
|
||||
|
||||
"audioStatsLine": "TX {sent} frames • RX {received} frames • PTT {ptt}",
|
||||
"@audioStatsLine": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"statusConnected": "已连接到 {server}",
|
||||
"statusError": "错误:{message}",
|
||||
|
||||
"statusReconnecting": "正在重新连接……第 {attempt} 次尝试,{delay} 秒后",
|
||||
"statusConnectionLost": "连接已断开:{reason}",
|
||||
|
||||
"audioStatsLine": "发送 {sent} 帧 • 接收 {received} 帧 • PTT {ptt}",
|
||||
|
||||
"channelsHeading": "频道",
|
||||
|
||||
@@ -181,6 +181,18 @@ abstract class AppL10n {
|
||||
/// **'Error: {message}'**
|
||||
String statusError(String message);
|
||||
|
||||
/// No description provided for @statusReconnecting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reconnecting… attempt {attempt}, in {delay}s'**
|
||||
String statusReconnecting(int attempt, int delay);
|
||||
|
||||
/// No description provided for @statusConnectionLost.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connection lost: {reason}'**
|
||||
String statusConnectionLost(String reason);
|
||||
|
||||
/// No description provided for @audioStatsLine.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -55,6 +55,16 @@ class AppL10nEn extends AppL10n {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String statusReconnecting(int attempt, int delay) {
|
||||
return 'Reconnecting… attempt $attempt, in ${delay}s';
|
||||
}
|
||||
|
||||
@override
|
||||
String statusConnectionLost(String reason) {
|
||||
return 'Connection lost: $reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String audioStatsLine(int sent, int received, String ptt) {
|
||||
return 'TX $sent frames • RX $received frames • PTT $ptt';
|
||||
|
||||
@@ -54,6 +54,16 @@ class AppL10nZh extends AppL10n {
|
||||
return '错误:$message';
|
||||
}
|
||||
|
||||
@override
|
||||
String statusReconnecting(int attempt, int delay) {
|
||||
return '正在重新连接……第 $attempt 次尝试,$delay 秒后';
|
||||
}
|
||||
|
||||
@override
|
||||
String statusConnectionLost(String reason) {
|
||||
return '连接已断开:$reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String audioStatsLine(int sent, int received, String ptt) {
|
||||
return '发送 $sent 帧 • 接收 $received 帧 • PTT $ptt';
|
||||
|
||||
@@ -60,9 +60,57 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
bool _audioStarted = false;
|
||||
rust.BridgeAudioStats? _audioStats;
|
||||
Timer? _statsTimer;
|
||||
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
||||
|
||||
// A.6 reconnect banner state.
|
||||
String? _lostReason;
|
||||
int? _reconnectAttempt;
|
||||
int? _reconnectDelay;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventsSub = rust.eventsStream().listen(_onEvent);
|
||||
}
|
||||
|
||||
void _onEvent(rust.BridgeEvent evt) {
|
||||
if (!mounted) return;
|
||||
switch (evt) {
|
||||
case rust.BridgeEvent_Connected():
|
||||
setState(() {
|
||||
_phase = _Phase.connected;
|
||||
_lostReason = null;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
case rust.BridgeEvent_Lost(:final reason):
|
||||
setState(() {
|
||||
_lostReason = reason;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
|
||||
setState(() {
|
||||
_reconnectAttempt = attempt;
|
||||
_reconnectDelay = delaySecs;
|
||||
});
|
||||
case rust.BridgeEvent_Disconnected():
|
||||
setState(() {
|
||||
_phase = _Phase.idle;
|
||||
_lostReason = null;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
case rust.BridgeEvent_AudioStarted():
|
||||
setState(() => _audioStarted = true);
|
||||
case rust.BridgeEvent_AudioStopped():
|
||||
setState(() => _audioStarted = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventsSub?.cancel();
|
||||
_statsTimer?.cancel();
|
||||
_hostCtl.dispose();
|
||||
_nickCtl.dispose();
|
||||
@@ -201,6 +249,42 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == _Phase.idle) ...[
|
||||
_ConnectForm(
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
import 'frb_generated.dart';
|
||||
import 'lib.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
part 'api.freezed.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `runtime`, `session`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `from`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`
|
||||
|
||||
/// Connect to a TeamSpeak-compatible server and return the initial
|
||||
/// state snapshot. Honours the DEC-006 single-connection invariant
|
||||
@@ -35,6 +37,13 @@ Future<void> startAudio() => RustLib.instance.api.crateApiStartAudio();
|
||||
Future<void> setPtt({required bool active}) =>
|
||||
RustLib.instance.api.crateApiSetPtt(active: active);
|
||||
|
||||
/// Subscribe to lifecycle events. Each call yields a fresh
|
||||
/// subscription; multiple subscribers are supported. On slow
|
||||
/// consumers, events are dropped rather than blocking the supervisor
|
||||
/// (consistent with `tokio::sync::broadcast::Receiver` semantics).
|
||||
Stream<BridgeEvent> eventsStream() =>
|
||||
RustLib.instance.api.crateApiEventsStream();
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
Future<BridgeAudioStats> audioStats() =>
|
||||
RustLib.instance.api.crateApiAudioStats();
|
||||
@@ -138,6 +147,44 @@ class BridgeClient {
|
||||
name == other.name;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class BridgeEvent with _$BridgeEvent {
|
||||
const BridgeEvent._();
|
||||
|
||||
/// Initial connect succeeded, or a reconnect attempt succeeded.
|
||||
const factory BridgeEvent.connected({
|
||||
/// Server name reported by the server snapshot.
|
||||
required String serverName,
|
||||
}) = BridgeEvent_Connected;
|
||||
|
||||
/// Connection lost; supervisor will retry.
|
||||
const factory BridgeEvent.lost({
|
||||
/// Reason classification from the protocol layer.
|
||||
required String reason,
|
||||
}) = BridgeEvent_Lost;
|
||||
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
const factory BridgeEvent.reconnecting({
|
||||
/// 1-based attempt counter for the current outage.
|
||||
required int attempt,
|
||||
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
required int delaySecs,
|
||||
}) = BridgeEvent_Reconnecting;
|
||||
|
||||
/// Session ended (user-requested disconnect or unrecoverable).
|
||||
const factory BridgeEvent.disconnected({
|
||||
/// Reason classification.
|
||||
required String reason,
|
||||
}) = BridgeEvent_Disconnected;
|
||||
|
||||
/// Audio engine started.
|
||||
const factory BridgeEvent.audioStarted() = BridgeEvent_AudioStarted;
|
||||
|
||||
/// Audio engine stopped.
|
||||
const factory BridgeEvent.audioStopped() = BridgeEvent_AudioStopped;
|
||||
}
|
||||
|
||||
/// Server snapshot as seen by Dart.
|
||||
class BridgeSnapshot {
|
||||
/// Server name.
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'api.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$BridgeEvent {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class $BridgeEventCopyWith<$Res> {
|
||||
$BridgeEventCopyWith(BridgeEvent _, $Res Function(BridgeEvent) __);
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [BridgeEvent].
|
||||
extension BridgeEventPatterns on BridgeEvent {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected() when connected != null:
|
||||
return connected(_that);case BridgeEvent_Lost() when lost != null:
|
||||
return lost(_that);case BridgeEvent_Reconnecting() when reconnecting != null:
|
||||
return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected != null:
|
||||
return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null:
|
||||
return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null:
|
||||
return audioStopped(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected():
|
||||
return connected(_that);case BridgeEvent_Lost():
|
||||
return lost(_that);case BridgeEvent_Reconnecting():
|
||||
return reconnecting(_that);case BridgeEvent_Disconnected():
|
||||
return disconnected(_that);case BridgeEvent_AudioStarted():
|
||||
return audioStarted(_that);case BridgeEvent_AudioStopped():
|
||||
return audioStopped(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected() when connected != null:
|
||||
return connected(_that);case BridgeEvent_Lost() when lost != null:
|
||||
return lost(_that);case BridgeEvent_Reconnecting() when reconnecting != null:
|
||||
return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected != null:
|
||||
return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null:
|
||||
return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null:
|
||||
return audioStopped(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected() when connected != null:
|
||||
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
||||
return lost(_that.reason);case BridgeEvent_Reconnecting() when reconnecting != null:
|
||||
return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected() when disconnected != null:
|
||||
return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null:
|
||||
return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null:
|
||||
return audioStopped();case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected():
|
||||
return connected(_that.serverName);case BridgeEvent_Lost():
|
||||
return lost(_that.reason);case BridgeEvent_Reconnecting():
|
||||
return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected():
|
||||
return disconnected(_that.reason);case BridgeEvent_AudioStarted():
|
||||
return audioStarted();case BridgeEvent_AudioStopped():
|
||||
return audioStopped();}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected() when connected != null:
|
||||
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
||||
return lost(_that.reason);case BridgeEvent_Reconnecting() when reconnecting != null:
|
||||
return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected() when disconnected != null:
|
||||
return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null:
|
||||
return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null:
|
||||
return audioStopped();case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeEvent_Connected extends BridgeEvent {
|
||||
const BridgeEvent_Connected({required this.serverName}): super._();
|
||||
|
||||
|
||||
/// Server name reported by the server snapshot.
|
||||
final String serverName;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BridgeEvent_ConnectedCopyWith<BridgeEvent_Connected> get copyWith => _$BridgeEvent_ConnectedCopyWithImpl<BridgeEvent_Connected>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_Connected&&(identical(other.serverName, serverName) || other.serverName == serverName));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,serverName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.connected(serverName: $serverName)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BridgeEvent_ConnectedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
|
||||
factory $BridgeEvent_ConnectedCopyWith(BridgeEvent_Connected value, $Res Function(BridgeEvent_Connected) _then) = _$BridgeEvent_ConnectedCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String serverName
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BridgeEvent_ConnectedCopyWithImpl<$Res>
|
||||
implements $BridgeEvent_ConnectedCopyWith<$Res> {
|
||||
_$BridgeEvent_ConnectedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BridgeEvent_Connected _self;
|
||||
final $Res Function(BridgeEvent_Connected) _then;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? serverName = null,}) {
|
||||
return _then(BridgeEvent_Connected(
|
||||
serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeEvent_Lost extends BridgeEvent {
|
||||
const BridgeEvent_Lost({required this.reason}): super._();
|
||||
|
||||
|
||||
/// Reason classification from the protocol layer.
|
||||
final String reason;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BridgeEvent_LostCopyWith<BridgeEvent_Lost> get copyWith => _$BridgeEvent_LostCopyWithImpl<BridgeEvent_Lost>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_Lost&&(identical(other.reason, reason) || other.reason == reason));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,reason);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.lost(reason: $reason)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BridgeEvent_LostCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
|
||||
factory $BridgeEvent_LostCopyWith(BridgeEvent_Lost value, $Res Function(BridgeEvent_Lost) _then) = _$BridgeEvent_LostCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String reason
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BridgeEvent_LostCopyWithImpl<$Res>
|
||||
implements $BridgeEvent_LostCopyWith<$Res> {
|
||||
_$BridgeEvent_LostCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BridgeEvent_Lost _self;
|
||||
final $Res Function(BridgeEvent_Lost) _then;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? reason = null,}) {
|
||||
return _then(BridgeEvent_Lost(
|
||||
reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeEvent_Reconnecting extends BridgeEvent {
|
||||
const BridgeEvent_Reconnecting({required this.attempt, required this.delaySecs}): super._();
|
||||
|
||||
|
||||
/// 1-based attempt counter for the current outage.
|
||||
final int attempt;
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
final int delaySecs;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BridgeEvent_ReconnectingCopyWith<BridgeEvent_Reconnecting> get copyWith => _$BridgeEvent_ReconnectingCopyWithImpl<BridgeEvent_Reconnecting>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_Reconnecting&&(identical(other.attempt, attempt) || other.attempt == attempt)&&(identical(other.delaySecs, delaySecs) || other.delaySecs == delaySecs));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,attempt,delaySecs);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.reconnecting(attempt: $attempt, delaySecs: $delaySecs)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BridgeEvent_ReconnectingCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
|
||||
factory $BridgeEvent_ReconnectingCopyWith(BridgeEvent_Reconnecting value, $Res Function(BridgeEvent_Reconnecting) _then) = _$BridgeEvent_ReconnectingCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
int attempt, int delaySecs
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BridgeEvent_ReconnectingCopyWithImpl<$Res>
|
||||
implements $BridgeEvent_ReconnectingCopyWith<$Res> {
|
||||
_$BridgeEvent_ReconnectingCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BridgeEvent_Reconnecting _self;
|
||||
final $Res Function(BridgeEvent_Reconnecting) _then;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? attempt = null,Object? delaySecs = null,}) {
|
||||
return _then(BridgeEvent_Reconnecting(
|
||||
attempt: null == attempt ? _self.attempt : attempt // ignore: cast_nullable_to_non_nullable
|
||||
as int,delaySecs: null == delaySecs ? _self.delaySecs : delaySecs // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeEvent_Disconnected extends BridgeEvent {
|
||||
const BridgeEvent_Disconnected({required this.reason}): super._();
|
||||
|
||||
|
||||
/// Reason classification.
|
||||
final String reason;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BridgeEvent_DisconnectedCopyWith<BridgeEvent_Disconnected> get copyWith => _$BridgeEvent_DisconnectedCopyWithImpl<BridgeEvent_Disconnected>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_Disconnected&&(identical(other.reason, reason) || other.reason == reason));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,reason);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.disconnected(reason: $reason)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BridgeEvent_DisconnectedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
|
||||
factory $BridgeEvent_DisconnectedCopyWith(BridgeEvent_Disconnected value, $Res Function(BridgeEvent_Disconnected) _then) = _$BridgeEvent_DisconnectedCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String reason
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BridgeEvent_DisconnectedCopyWithImpl<$Res>
|
||||
implements $BridgeEvent_DisconnectedCopyWith<$Res> {
|
||||
_$BridgeEvent_DisconnectedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BridgeEvent_Disconnected _self;
|
||||
final $Res Function(BridgeEvent_Disconnected) _then;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? reason = null,}) {
|
||||
return _then(BridgeEvent_Disconnected(
|
||||
reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeEvent_AudioStarted extends BridgeEvent {
|
||||
const BridgeEvent_AudioStarted(): super._();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_AudioStarted);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.audioStarted()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeEvent_AudioStopped extends BridgeEvent {
|
||||
const BridgeEvent_AudioStopped(): super._();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_AudioStopped);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.audioStopped()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// dart format on
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.12.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => 1944264248;
|
||||
int get rustContentHash => -1896742393;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -90,6 +90,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiDisconnect();
|
||||
|
||||
Stream<BridgeEvent> crateApiEventsStream();
|
||||
|
||||
Future<bool> crateApiIsConnected();
|
||||
|
||||
Future<void> crateApiSetPtt({required bool active});
|
||||
@@ -220,6 +222,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
||||
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
||||
|
||||
@override
|
||||
Stream<BridgeEvent> crateApiEventsStream() {
|
||||
final sink = RustStreamSink<BridgeEvent>();
|
||||
unawaited(
|
||||
handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_StreamSink_bridge_event_Sse(sink, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiEventsStreamConstMeta,
|
||||
argValues: [sink],
|
||||
apiImpl: this,
|
||||
),
|
||||
),
|
||||
);
|
||||
return sink.stream;
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiEventsStreamConstMeta =>
|
||||
const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]);
|
||||
|
||||
@override
|
||||
Future<bool> crateApiIsConnected() {
|
||||
return handler.executeNormal(
|
||||
@@ -229,7 +263,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
funcId: 6,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -257,7 +291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 6,
|
||||
funcId: 7,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -284,7 +318,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 7,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -311,7 +345,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
funcId: 9,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -329,6 +363,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiStartAudioConstMeta =>
|
||||
const TaskConstMeta(debugName: "start_audio", argNames: []);
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return AnyhowException(raw as String);
|
||||
}
|
||||
|
||||
@protected
|
||||
RustStreamSink<BridgeEvent> dco_decode_StreamSink_bridge_event_Sse(
|
||||
dynamic raw,
|
||||
) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -388,18 +436,47 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case 0:
|
||||
return BridgeError_InvalidCommand(dco_decode_String(raw[1]));
|
||||
case 1:
|
||||
return BridgeError_Connection(dco_decode_String(raw[1]));
|
||||
return BridgeError_DnsFailed(
|
||||
host: dco_decode_String(raw[1]),
|
||||
reason: dco_decode_String(raw[2]),
|
||||
);
|
||||
case 2:
|
||||
return BridgeError_NotConnected();
|
||||
return BridgeError_Connection(dco_decode_String(raw[1]));
|
||||
case 3:
|
||||
return BridgeError_AlreadyConnected();
|
||||
return BridgeError_NotConnected();
|
||||
case 4:
|
||||
return BridgeError_AlreadyConnected();
|
||||
case 5:
|
||||
return BridgeError_Unmapped(dco_decode_String(raw[1]));
|
||||
default:
|
||||
throw Exception("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeEvent dco_decode_bridge_event(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
switch (raw[0]) {
|
||||
case 0:
|
||||
return BridgeEvent_Connected(serverName: dco_decode_String(raw[1]));
|
||||
case 1:
|
||||
return BridgeEvent_Lost(reason: dco_decode_String(raw[1]));
|
||||
case 2:
|
||||
return BridgeEvent_Reconnecting(
|
||||
attempt: dco_decode_u_32(raw[1]),
|
||||
delaySecs: dco_decode_u_32(raw[2]),
|
||||
);
|
||||
case 3:
|
||||
return BridgeEvent_Disconnected(reason: dco_decode_String(raw[1]));
|
||||
case 4:
|
||||
return BridgeEvent_AudioStarted();
|
||||
case 5:
|
||||
return BridgeEvent_AudioStopped();
|
||||
default:
|
||||
throw Exception("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -464,6 +541,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return;
|
||||
}
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var inner = sse_decode_String(deserializer);
|
||||
return AnyhowException(inner);
|
||||
}
|
||||
|
||||
@protected
|
||||
RustStreamSink<BridgeEvent> sse_decode_StreamSink_bridge_event_Sse(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
throw UnimplementedError('Unreachable ()');
|
||||
}
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -524,13 +616,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_field0 = sse_decode_String(deserializer);
|
||||
return BridgeError_InvalidCommand(var_field0);
|
||||
case 1:
|
||||
var var_host = sse_decode_String(deserializer);
|
||||
var var_reason = sse_decode_String(deserializer);
|
||||
return BridgeError_DnsFailed(host: var_host, reason: var_reason);
|
||||
case 2:
|
||||
var var_field0 = sse_decode_String(deserializer);
|
||||
return BridgeError_Connection(var_field0);
|
||||
case 2:
|
||||
return BridgeError_NotConnected();
|
||||
case 3:
|
||||
return BridgeError_AlreadyConnected();
|
||||
return BridgeError_NotConnected();
|
||||
case 4:
|
||||
return BridgeError_AlreadyConnected();
|
||||
case 5:
|
||||
var var_field0 = sse_decode_String(deserializer);
|
||||
return BridgeError_Unmapped(var_field0);
|
||||
default:
|
||||
@@ -538,6 +634,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
var tag_ = sse_decode_i_32(deserializer);
|
||||
switch (tag_) {
|
||||
case 0:
|
||||
var var_serverName = sse_decode_String(deserializer);
|
||||
return BridgeEvent_Connected(serverName: var_serverName);
|
||||
case 1:
|
||||
var var_reason = sse_decode_String(deserializer);
|
||||
return BridgeEvent_Lost(reason: var_reason);
|
||||
case 2:
|
||||
var var_attempt = sse_decode_u_32(deserializer);
|
||||
var var_delaySecs = sse_decode_u_32(deserializer);
|
||||
return BridgeEvent_Reconnecting(
|
||||
attempt: var_attempt,
|
||||
delaySecs: var_delaySecs,
|
||||
);
|
||||
case 3:
|
||||
var var_reason = sse_decode_String(deserializer);
|
||||
return BridgeEvent_Disconnected(reason: var_reason);
|
||||
case 4:
|
||||
return BridgeEvent_AudioStarted();
|
||||
case 5:
|
||||
return BridgeEvent_AudioStopped();
|
||||
default:
|
||||
throw UnimplementedError('');
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -627,6 +754,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return deserializer.buffer.getInt32();
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_String(self.message, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_bridge_event_Sse(
|
||||
RustStreamSink<BridgeEvent> self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_String(
|
||||
self.setupAndSerialize(
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_bridge_event,
|
||||
decodeErrorData: sse_decode_AnyhowException,
|
||||
),
|
||||
),
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -674,19 +827,50 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case BridgeError_InvalidCommand(field0: final field0):
|
||||
sse_encode_i_32(0, serializer);
|
||||
sse_encode_String(field0, serializer);
|
||||
case BridgeError_Connection(field0: final field0):
|
||||
case BridgeError_DnsFailed(host: final host, reason: final reason):
|
||||
sse_encode_i_32(1, serializer);
|
||||
sse_encode_String(host, serializer);
|
||||
sse_encode_String(reason, serializer);
|
||||
case BridgeError_Connection(field0: final field0):
|
||||
sse_encode_i_32(2, serializer);
|
||||
sse_encode_String(field0, serializer);
|
||||
case BridgeError_NotConnected():
|
||||
sse_encode_i_32(2, serializer);
|
||||
case BridgeError_AlreadyConnected():
|
||||
sse_encode_i_32(3, serializer);
|
||||
case BridgeError_Unmapped(field0: final field0):
|
||||
case BridgeError_AlreadyConnected():
|
||||
sse_encode_i_32(4, serializer);
|
||||
case BridgeError_Unmapped(field0: final field0):
|
||||
sse_encode_i_32(5, serializer);
|
||||
sse_encode_String(field0, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
switch (self) {
|
||||
case BridgeEvent_Connected(serverName: final serverName):
|
||||
sse_encode_i_32(0, serializer);
|
||||
sse_encode_String(serverName, serializer);
|
||||
case BridgeEvent_Lost(reason: final reason):
|
||||
sse_encode_i_32(1, serializer);
|
||||
sse_encode_String(reason, serializer);
|
||||
case BridgeEvent_Reconnecting(
|
||||
attempt: final attempt,
|
||||
delaySecs: final delaySecs,
|
||||
):
|
||||
sse_encode_i_32(2, serializer);
|
||||
sse_encode_u_32(attempt, serializer);
|
||||
sse_encode_u_32(delaySecs, serializer);
|
||||
case BridgeEvent_Disconnected(reason: final reason):
|
||||
sse_encode_i_32(3, serializer);
|
||||
sse_encode_String(reason, serializer);
|
||||
case BridgeEvent_AudioStarted():
|
||||
sse_encode_i_32(4, serializer);
|
||||
case BridgeEvent_AudioStopped():
|
||||
sse_encode_i_32(5, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_snapshot(
|
||||
BridgeSnapshot self,
|
||||
|
||||
@@ -19,6 +19,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
required super.portManager,
|
||||
});
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustStreamSink<BridgeEvent> dco_decode_StreamSink_bridge_event_Sse(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@@ -37,6 +45,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeError dco_decode_bridge_error(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw);
|
||||
|
||||
@@ -64,6 +75,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void dco_decode_unit(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustStreamSink<BridgeEvent> sse_decode_StreamSink_bridge_event_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@@ -82,6 +101,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer);
|
||||
|
||||
@@ -116,6 +138,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
int sse_decode_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_bridge_event_Sse(
|
||||
RustStreamSink<BridgeEvent> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@@ -137,6 +171,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_snapshot(
|
||||
BridgeSnapshot self,
|
||||
|
||||
@@ -21,6 +21,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
required super.portManager,
|
||||
});
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustStreamSink<BridgeEvent> dco_decode_StreamSink_bridge_event_Sse(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@@ -39,6 +47,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeError dco_decode_bridge_error(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw);
|
||||
|
||||
@@ -66,6 +77,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void dco_decode_unit(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustStreamSink<BridgeEvent> sse_decode_StreamSink_bridge_event_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@@ -84,6 +103,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer);
|
||||
|
||||
@@ -118,6 +140,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
int sse_decode_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_bridge_event_Sse(
|
||||
RustStreamSink<BridgeEvent> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@@ -139,6 +173,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_snapshot(
|
||||
BridgeSnapshot self,
|
||||
|
||||
@@ -16,6 +16,16 @@ sealed class BridgeError with _$BridgeError implements FrbException {
|
||||
const factory BridgeError.invalidCommand(String field0) =
|
||||
BridgeError_InvalidCommand;
|
||||
|
||||
/// Hostname resolution failed. Distinct from `Connection` so the
|
||||
/// UI can show a meaningful "Server not found" message.
|
||||
const factory BridgeError.dnsFailed({
|
||||
/// The hostname (or `host:port`) the caller submitted.
|
||||
required String host,
|
||||
|
||||
/// Reason from the platform resolver.
|
||||
required String reason,
|
||||
}) = BridgeError_DnsFailed;
|
||||
|
||||
/// Connection layer failure (typed-mapped from CoreError).
|
||||
const factory BridgeError.connection(String field0) = BridgeError_Connection;
|
||||
|
||||
|
||||
@@ -55,11 +55,12 @@ extension BridgeErrorPatterns on BridgeError {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeError_InvalidCommand value)? invalidCommand,TResult Function( BridgeError_Connection value)? connection,TResult Function( BridgeError_NotConnected value)? notConnected,TResult Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeError_InvalidCommand value)? invalidCommand,TResult Function( BridgeError_DnsFailed value)? dnsFailed,TResult Function( BridgeError_Connection value)? connection,TResult Function( BridgeError_NotConnected value)? notConnected,TResult Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != null:
|
||||
return invalidCommand(_that);case BridgeError_Connection() when connection != null:
|
||||
return invalidCommand(_that);case BridgeError_DnsFailed() when dnsFailed != null:
|
||||
return dnsFailed(_that);case BridgeError_Connection() when connection != null:
|
||||
return connection(_that);case BridgeError_NotConnected() when notConnected != null:
|
||||
return notConnected(_that);case BridgeError_AlreadyConnected() when alreadyConnected != null:
|
||||
return alreadyConnected(_that);case BridgeError_Unmapped() when unmapped != null:
|
||||
@@ -81,11 +82,12 @@ return unmapped(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeError_InvalidCommand value) invalidCommand,required TResult Function( BridgeError_Connection value) connection,required TResult Function( BridgeError_NotConnected value) notConnected,required TResult Function( BridgeError_AlreadyConnected value) alreadyConnected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeError_InvalidCommand value) invalidCommand,required TResult Function( BridgeError_DnsFailed value) dnsFailed,required TResult Function( BridgeError_Connection value) connection,required TResult Function( BridgeError_NotConnected value) notConnected,required TResult Function( BridgeError_AlreadyConnected value) alreadyConnected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand():
|
||||
return invalidCommand(_that);case BridgeError_Connection():
|
||||
return invalidCommand(_that);case BridgeError_DnsFailed():
|
||||
return dnsFailed(_that);case BridgeError_Connection():
|
||||
return connection(_that);case BridgeError_NotConnected():
|
||||
return notConnected(_that);case BridgeError_AlreadyConnected():
|
||||
return alreadyConnected(_that);case BridgeError_Unmapped():
|
||||
@@ -103,11 +105,12 @@ return unmapped(_that);}
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeError_InvalidCommand value)? invalidCommand,TResult? Function( BridgeError_Connection value)? connection,TResult? Function( BridgeError_NotConnected value)? notConnected,TResult? Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeError_InvalidCommand value)? invalidCommand,TResult? Function( BridgeError_DnsFailed value)? dnsFailed,TResult? Function( BridgeError_Connection value)? connection,TResult? Function( BridgeError_NotConnected value)? notConnected,TResult? Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != null:
|
||||
return invalidCommand(_that);case BridgeError_Connection() when connection != null:
|
||||
return invalidCommand(_that);case BridgeError_DnsFailed() when dnsFailed != null:
|
||||
return dnsFailed(_that);case BridgeError_Connection() when connection != null:
|
||||
return connection(_that);case BridgeError_NotConnected() when notConnected != null:
|
||||
return notConnected(_that);case BridgeError_AlreadyConnected() when alreadyConnected != null:
|
||||
return alreadyConnected(_that);case BridgeError_Unmapped() when unmapped != null:
|
||||
@@ -128,10 +131,11 @@ return unmapped(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String field0)? invalidCommand,TResult Function( String field0)? connection,TResult Function()? notConnected,TResult Function()? alreadyConnected,TResult Function( String field0)? unmapped,required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String field0)? invalidCommand,TResult Function( String host, String reason)? dnsFailed,TResult Function( String field0)? connection,TResult Function()? notConnected,TResult Function()? alreadyConnected,TResult Function( String field0)? unmapped,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != null:
|
||||
return invalidCommand(_that.field0);case BridgeError_Connection() when connection != null:
|
||||
return invalidCommand(_that.field0);case BridgeError_DnsFailed() when dnsFailed != null:
|
||||
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection() when connection != null:
|
||||
return connection(_that.field0);case BridgeError_NotConnected() when notConnected != null:
|
||||
return notConnected();case BridgeError_AlreadyConnected() when alreadyConnected != null:
|
||||
return alreadyConnected();case BridgeError_Unmapped() when unmapped != null:
|
||||
@@ -153,10 +157,11 @@ return unmapped(_that.field0);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String field0) invalidCommand,required TResult Function( String field0) connection,required TResult Function() notConnected,required TResult Function() alreadyConnected,required TResult Function( String field0) unmapped,}) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String field0) invalidCommand,required TResult Function( String host, String reason) dnsFailed,required TResult Function( String field0) connection,required TResult Function() notConnected,required TResult Function() alreadyConnected,required TResult Function( String field0) unmapped,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand():
|
||||
return invalidCommand(_that.field0);case BridgeError_Connection():
|
||||
return invalidCommand(_that.field0);case BridgeError_DnsFailed():
|
||||
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection():
|
||||
return connection(_that.field0);case BridgeError_NotConnected():
|
||||
return notConnected();case BridgeError_AlreadyConnected():
|
||||
return alreadyConnected();case BridgeError_Unmapped():
|
||||
@@ -174,10 +179,11 @@ return unmapped(_that.field0);}
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String field0)? invalidCommand,TResult? Function( String field0)? connection,TResult? Function()? notConnected,TResult? Function()? alreadyConnected,TResult? Function( String field0)? unmapped,}) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String field0)? invalidCommand,TResult? Function( String host, String reason)? dnsFailed,TResult? Function( String field0)? connection,TResult? Function()? notConnected,TResult? Function()? alreadyConnected,TResult? Function( String field0)? unmapped,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != null:
|
||||
return invalidCommand(_that.field0);case BridgeError_Connection() when connection != null:
|
||||
return invalidCommand(_that.field0);case BridgeError_DnsFailed() when dnsFailed != null:
|
||||
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection() when connection != null:
|
||||
return connection(_that.field0);case BridgeError_NotConnected() when notConnected != null:
|
||||
return notConnected();case BridgeError_AlreadyConnected() when alreadyConnected != null:
|
||||
return alreadyConnected();case BridgeError_Unmapped() when unmapped != null:
|
||||
@@ -258,6 +264,76 @@ as String,
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeError_DnsFailed extends BridgeError {
|
||||
const BridgeError_DnsFailed({required this.host, required this.reason}): super._();
|
||||
|
||||
|
||||
/// The hostname (or `host:port`) the caller submitted.
|
||||
final String host;
|
||||
/// Reason from the platform resolver.
|
||||
final String reason;
|
||||
|
||||
/// Create a copy of BridgeError
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BridgeError_DnsFailedCopyWith<BridgeError_DnsFailed> get copyWith => _$BridgeError_DnsFailedCopyWithImpl<BridgeError_DnsFailed>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeError_DnsFailed&&(identical(other.host, host) || other.host == host)&&(identical(other.reason, reason) || other.reason == reason));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,host,reason);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeError.dnsFailed(host: $host, reason: $reason)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BridgeError_DnsFailedCopyWith<$Res> implements $BridgeErrorCopyWith<$Res> {
|
||||
factory $BridgeError_DnsFailedCopyWith(BridgeError_DnsFailed value, $Res Function(BridgeError_DnsFailed) _then) = _$BridgeError_DnsFailedCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String host, String reason
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BridgeError_DnsFailedCopyWithImpl<$Res>
|
||||
implements $BridgeError_DnsFailedCopyWith<$Res> {
|
||||
_$BridgeError_DnsFailedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BridgeError_DnsFailed _self;
|
||||
final $Res Function(BridgeError_DnsFailed) _then;
|
||||
|
||||
/// Create a copy of BridgeError
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? host = null,Object? reason = null,}) {
|
||||
return _then(BridgeError_DnsFailed(
|
||||
host: null == host ? _self.host : host // ignore: cast_nullable_to_non_nullable
|
||||
as String,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeError_Connection extends BridgeError {
|
||||
const BridgeError_Connection(this.field0): super._();
|
||||
|
||||
|
||||
@@ -18,23 +18,36 @@
|
||||
//! * Secret material never lands in `chanora_storage`'s non-secret
|
||||
//! side (DEC-013.2 / SS-AUD-001/002).
|
||||
//!
|
||||
//! ## Alpha scope
|
||||
//! ## A.6 supervisor and reconnect
|
||||
//!
|
||||
//! `ChanoraSession::connect`, `snapshot`, and `disconnect` are wired
|
||||
//! through `chanora_protocol`. Audio, storage, and diagnostics are
|
||||
//! still scaffolds.
|
||||
//! `ChanoraSession` spawns a per-connection supervisor task on a
|
||||
//! successful [`Self::connect`]. The supervisor:
|
||||
//!
|
||||
//! * awaits the `ProtocolClient`'s loss notifier;
|
||||
//! * on user-driven disconnect, exits silently;
|
||||
//! * on stream-end or error, re-dials with exponential backoff
|
||||
//! (1s, 2s, 5s, 15s, 30s, 60s capped); cancellation-safe via the
|
||||
//! per-session `cancel_tx` oneshot;
|
||||
//! * re-attaches the audio engine to the fresh protocol client if
|
||||
//! audio was running prior to the loss.
|
||||
//!
|
||||
//! Lifecycle changes are broadcast to subscribers via
|
||||
//! [`ChanoraSession::subscribe_events`].
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub use chanora_audio::{AudioEngine, AudioEngineConfig};
|
||||
pub use chanora_protocol::{
|
||||
ChannelInfo, ClientInfo, ConnectConfig, ProtocolError, ServerSnapshot,
|
||||
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
|
||||
};
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
@@ -71,9 +84,75 @@ pub enum CoreError {
|
||||
AudioNotStarted,
|
||||
}
|
||||
|
||||
/// High-level lifecycle event surfaced to subscribers.
|
||||
///
|
||||
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||
/// full event catalogue lands in A.4.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionEvent {
|
||||
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported in the snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; the supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Supervisor gave up after `attempt` failed retries (or the
|
||||
/// user explicitly disconnected mid-outage).
|
||||
Disconnected {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started (e.g. after a successful reconnect with
|
||||
/// reattachment).
|
||||
AudioStarted,
|
||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||
/// explicit user action).
|
||||
AudioStopped,
|
||||
}
|
||||
|
||||
/// Channel capacity for the broadcast events. Generous because
|
||||
/// reconnect cycles emit several events per attempt; if subscribers
|
||||
/// fall behind we'd rather skip than block the supervisor.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
struct SupervisorInner {
|
||||
/// Optional cached AudioEngineConfig — set when start_audio is
|
||||
/// first called, used to re-create the engine after a reconnect.
|
||||
audio_cfg: Option<AudioEngineConfig>,
|
||||
/// True when audio is currently desired (start_audio called, no
|
||||
/// stop yet). Drives whether the supervisor reattaches audio on
|
||||
/// a successful reconnect.
|
||||
audio_running: bool,
|
||||
}
|
||||
|
||||
struct ConnectedState {
|
||||
protocol: chanora_protocol::ProtocolClient,
|
||||
audio: Option<chanora_audio::AudioEngine>,
|
||||
/// Cancellation signal for the supervisor task. Dropped on
|
||||
/// explicit disconnect to break the supervisor out of its
|
||||
/// backoff sleep.
|
||||
cancel_tx: Option<oneshot::Sender<()>>,
|
||||
/// Supervisor task handle. Awaited on disconnect for clean
|
||||
/// teardown.
|
||||
supervisor: Option<JoinHandle<()>>,
|
||||
/// Connection config used to dial this connection; retained for
|
||||
/// future diagnostics. The supervisor task holds its own clone.
|
||||
#[allow(dead_code)]
|
||||
cfg: ConnectConfig,
|
||||
/// Audio supervision state. Wrapped in Arc<Mutex<_>> so the
|
||||
/// supervisor and the public API both see updates.
|
||||
sup_inner: Arc<Mutex<SupervisorInner>>,
|
||||
}
|
||||
|
||||
/// The top-level Chanora session. Owns at most one active server
|
||||
@@ -81,16 +160,28 @@ struct ConnectedState {
|
||||
#[derive(Clone)]
|
||||
pub struct ChanoraSession {
|
||||
inner: Arc<Mutex<Option<ConnectedState>>>,
|
||||
events_tx: broadcast::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
impl ChanoraSession {
|
||||
/// Construct an empty session. Performs no I/O.
|
||||
pub fn new() -> Self {
|
||||
let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
events_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to lifecycle events. The returned receiver fires
|
||||
/// on connect / lost / reconnecting / disconnected /
|
||||
/// audio-started / audio-stopped transitions. Multiple
|
||||
/// subscribers are allowed; each gets its own slow-consumer
|
||||
/// behaviour (events drop with [`broadcast::error::RecvError::Lagged`]).
|
||||
pub fn subscribe_events(&self) -> broadcast::Receiver<SessionEvent> {
|
||||
self.events_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
|
||||
/// if a connection is already active (DEC-006). Audio is not
|
||||
/// started automatically; call [`Self::start_audio`] after.
|
||||
@@ -99,11 +190,42 @@ impl ChanoraSession {
|
||||
if guard.is_some() {
|
||||
return Err(CoreError::AlreadyConnected);
|
||||
}
|
||||
let client = chanora_protocol::ProtocolClient::connect(cfg).await?;
|
||||
let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?;
|
||||
let snap = client.snapshot().await?;
|
||||
|
||||
// Set up the supervisor.
|
||||
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
|
||||
let lost_rx = client
|
||||
.take_loss_notifier()
|
||||
.ok_or(CoreError::Invariant("lost notifier already taken"))?;
|
||||
let probe = client.snapshot_probe();
|
||||
|
||||
let sup_inner = Arc::new(Mutex::new(SupervisorInner {
|
||||
audio_cfg: None,
|
||||
audio_running: false,
|
||||
}));
|
||||
|
||||
let supervisor = tokio::spawn(supervisor_loop(
|
||||
self.inner.clone(),
|
||||
self.events_tx.clone(),
|
||||
cfg.clone(),
|
||||
lost_rx,
|
||||
probe,
|
||||
cancel_rx,
|
||||
sup_inner.clone(),
|
||||
));
|
||||
|
||||
let _ = self.events_tx.send(SessionEvent::Connected {
|
||||
server_name: snap.server_name.clone(),
|
||||
});
|
||||
|
||||
*guard = Some(ConnectedState {
|
||||
protocol: client,
|
||||
audio: None,
|
||||
cancel_tx: Some(cancel_tx),
|
||||
supervisor: Some(supervisor),
|
||||
cfg,
|
||||
sup_inner,
|
||||
});
|
||||
Ok(snap)
|
||||
}
|
||||
@@ -122,7 +244,8 @@ impl ChanoraSession {
|
||||
|
||||
/// Start the audio engine attached to the current connection.
|
||||
/// Fails if not connected. Idempotent — calling twice replaces
|
||||
/// the engine.
|
||||
/// the engine. Stores the config so the supervisor can restart
|
||||
/// audio after a reconnect.
|
||||
pub async fn start_audio(&self, cfg: AudioEngineConfig) -> Result<(), CoreError> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||
@@ -137,8 +260,17 @@ impl ChanoraSession {
|
||||
.protocol
|
||||
.take_voice_in()
|
||||
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
||||
let engine = chanora_audio::AudioEngine::start(cfg, voice_out, voice_in)?;
|
||||
let engine = chanora_audio::AudioEngine::start(cfg.clone(), voice_out, voice_in)?;
|
||||
state.audio = Some(engine);
|
||||
|
||||
// Record desired state so the supervisor will re-start audio
|
||||
// after a reconnect.
|
||||
{
|
||||
let mut sup = state.sup_inner.lock().await;
|
||||
sup.audio_cfg = Some(cfg);
|
||||
sup.audio_running = true;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::AudioStarted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -163,10 +295,23 @@ impl ChanoraSession {
|
||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
if let Some(mut state) = guard.take() {
|
||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(mut audio) = state.audio.take() {
|
||||
audio.stop();
|
||||
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
||||
}
|
||||
state.protocol.disconnect().await;
|
||||
// Wait for the supervisor to wind down so we don't race
|
||||
// a redial against the explicit disconnect.
|
||||
if let Some(handle) = state.supervisor.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||
reason: "user requested".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -178,6 +323,283 @@ impl Default for ChanoraSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential backoff schedule for reconnect attempts (seconds).
|
||||
/// After exhausting the schedule the supervisor stays at the last
|
||||
/// entry. We cap at 60 s so a long outage still has a chance to
|
||||
/// recover without consuming the device's battery polling tighter.
|
||||
const BACKOFF_SCHEDULE: &[u32] = &[1, 2, 5, 15, 30, 60];
|
||||
|
||||
/// Watchdog probe interval. Every tick the supervisor issues a
|
||||
/// `snapshot()` RPC against the live protocol client; if the server
|
||||
/// has silently stopped responding (e.g. evicted us after a long
|
||||
/// network outage) the probe fails. After
|
||||
/// [`WATCHDOG_MAX_MISSES`] consecutive failures the supervisor
|
||||
/// treats the connection as lost and triggers a redial. This is the
|
||||
/// belt to the `lost_rx`-from-`connection_task` braces: tsclientlib
|
||||
/// does not always surface a UDP idle-timeout as a stream error, so
|
||||
/// without this watchdog the client can stay "ghost connected" —
|
||||
/// UI shows Connected, server has long since removed us.
|
||||
const WATCHDOG_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// Per-probe timeout. Must be shorter than [`WATCHDOG_INTERVAL`] so
|
||||
/// a stuck probe cannot wedge the supervisor.
|
||||
const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Number of consecutive watchdog failures before the supervisor
|
||||
/// declares the connection lost.
|
||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
||||
|
||||
async fn supervisor_loop(
|
||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||
events_tx: broadcast::Sender<SessionEvent>,
|
||||
initial_cfg: ConnectConfig,
|
||||
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
||||
initial_probe: chanora_protocol::SnapshotProbe,
|
||||
mut cancel_rx: oneshot::Receiver<()>,
|
||||
sup_inner: Arc<Mutex<SupervisorInner>>,
|
||||
) {
|
||||
let mut lost_rx = initial_lost_rx;
|
||||
let mut probe = initial_probe;
|
||||
let mut cfg = initial_cfg;
|
||||
|
||||
loop {
|
||||
// Watch the current connection: race the protocol task's
|
||||
// own loss notifier against our app-level watchdog. Either
|
||||
// signal yields a `DisconnectReason` we then act on.
|
||||
let mut watchdog = tokio::time::interval(WATCHDOG_INTERVAL);
|
||||
// Skip the immediate tick so the first probe runs one
|
||||
// interval after connect, not instantly.
|
||||
watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
watchdog.tick().await; // consume the immediate tick
|
||||
let mut misses: u32 = 0;
|
||||
|
||||
let reason: chanora_protocol::DisconnectReason = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = &mut cancel_rx => {
|
||||
info!(target: "chanora_core", "supervisor cancelled by user");
|
||||
return;
|
||||
}
|
||||
r = &mut lost_rx => {
|
||||
match r {
|
||||
Ok(reason) => break reason,
|
||||
Err(_) => {
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
"supervisor: loss notifier dropped without firing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = watchdog.tick() => {
|
||||
match tokio::time::timeout(WATCHDOG_PROBE_TIMEOUT, probe.probe()).await {
|
||||
Ok(Ok(_)) => {
|
||||
if misses > 0 {
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
misses,
|
||||
"watchdog: probe recovered"
|
||||
);
|
||||
}
|
||||
misses = 0;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
misses = misses.saturating_add(1);
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
misses,
|
||||
error = %e,
|
||||
"watchdog: probe failed"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
misses = misses.saturating_add(1);
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
misses,
|
||||
"watchdog: probe timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
if misses >= WATCHDOG_MAX_MISSES {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
misses,
|
||||
"watchdog: declaring connection lost"
|
||||
);
|
||||
// Replace the dead protocol client with a
|
||||
// dropped slot so the reconnect path below
|
||||
// doesn't accidentally keep using it. We
|
||||
// also stop audio here (the reconnect path
|
||||
// does this too, but doing it now ensures
|
||||
// the mic / playback engine stops talking
|
||||
// to a stale voice_out_tx as quickly as
|
||||
// possible).
|
||||
break chanora_protocol::DisconnectReason::Error(
|
||||
"watchdog: server stopped responding".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match reason {
|
||||
chanora_protocol::DisconnectReason::UserRequested => {
|
||||
info!(target: "chanora_core", "supervisor: user-requested disconnect; exiting");
|
||||
return;
|
||||
}
|
||||
chanora_protocol::DisconnectReason::StreamEnded
|
||||
| chanora_protocol::DisconnectReason::Error(_) => {
|
||||
let reason_str = format!("{reason:?}");
|
||||
warn!(target: "chanora_core", reason = %reason_str, "connection lost; will reconnect");
|
||||
let _ = events_tx.send(SessionEvent::Lost {
|
||||
reason: reason_str.clone(),
|
||||
});
|
||||
|
||||
// Stop the audio engine before reconnect — its
|
||||
// voice_out_tx points at the dead protocol client.
|
||||
// Also drop the dead protocol client itself so
|
||||
// tsclientlib closes its socket promptly; the
|
||||
// supervisor will install a new one on success.
|
||||
{
|
||||
let mut guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_mut() {
|
||||
if let Some(mut audio) = state.audio.take() {
|
||||
audio.stop();
|
||||
let _ = events_tx.send(SessionEvent::AudioStopped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect loop.
|
||||
let mut attempt: u32 = 0;
|
||||
loop {
|
||||
attempt = attempt.saturating_add(1);
|
||||
let delay_secs = BACKOFF_SCHEDULE
|
||||
.get(attempt as usize - 1)
|
||||
.copied()
|
||||
.unwrap_or(*BACKOFF_SCHEDULE.last().unwrap());
|
||||
|
||||
let _ = events_tx.send(SessionEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
});
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
attempt,
|
||||
delay_secs,
|
||||
"reconnect: sleeping before next attempt"
|
||||
);
|
||||
|
||||
// Sleep with cancellation support.
|
||||
let slept = tokio::select! {
|
||||
biased;
|
||||
_ = &mut cancel_rx => {
|
||||
info!(target: "chanora_core", "supervisor cancelled during backoff");
|
||||
return;
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(delay_secs as u64)) => true,
|
||||
};
|
||||
if !slept {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(target: "chanora_core", attempt, "reconnect: dialling");
|
||||
match chanora_protocol::ProtocolClient::connect(cfg.clone()).await {
|
||||
Ok(new_client) => {
|
||||
// Successful reconnect. Snapshot for the event.
|
||||
let snap_name = match new_client.snapshot().await {
|
||||
Ok(s) => s.server_name,
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
let new_lost_rx = match new_client.take_loss_notifier() {
|
||||
Some(rx) => rx,
|
||||
None => {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
"reconnect: new client missing loss notifier"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let new_probe = new_client.snapshot_probe();
|
||||
|
||||
// Reattach into the session state.
|
||||
let restart_audio = {
|
||||
let mut guard = state_arc.lock().await;
|
||||
let state = match guard.as_mut() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// Session was disposed mid-reconnect.
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Replace the dead protocol client with the new one.
|
||||
// The old client's background task either already
|
||||
// exited (loss notifier fired) or will exit when
|
||||
// its request channel drops (watchdog path).
|
||||
let old = std::mem::replace(&mut state.protocol, new_client);
|
||||
drop(old);
|
||||
|
||||
let sup = sup_inner.lock().await;
|
||||
sup.audio_running && sup.audio_cfg.is_some()
|
||||
};
|
||||
|
||||
let _ = events_tx.send(SessionEvent::Connected {
|
||||
server_name: snap_name,
|
||||
});
|
||||
|
||||
// Optionally restart audio.
|
||||
if restart_audio {
|
||||
let audio_cfg = {
|
||||
let sup = sup_inner.lock().await;
|
||||
sup.audio_cfg.clone().expect("audio_running ⇒ audio_cfg")
|
||||
};
|
||||
let mut guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_mut() {
|
||||
let voice_out = state.protocol.voice_out();
|
||||
if let Some(voice_in) = state.protocol.take_voice_in() {
|
||||
match chanora_audio::AudioEngine::start(
|
||||
audio_cfg, voice_out, voice_in,
|
||||
) {
|
||||
Ok(engine) => {
|
||||
state.audio = Some(engine);
|
||||
let _ = events_tx
|
||||
.send(SessionEvent::AudioStarted);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"audio engine failed to restart after reconnect"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop back to waiting for the next loss.
|
||||
lost_rx = new_lost_rx;
|
||||
probe = new_probe;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
attempt,
|
||||
error = %e,
|
||||
"reconnect attempt failed"
|
||||
);
|
||||
let _ = e;
|
||||
cfg = cfg.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -207,4 +629,13 @@ mod tests {
|
||||
let r = s.set_ptt(true).await;
|
||||
assert!(matches!(r, Err(CoreError::NotConnected)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_before_connect_works() {
|
||||
// Ensures the event broadcast doesn't need a prior connect
|
||||
// to be subscribable.
|
||||
let s = ChanoraSession::new();
|
||||
let _rx = s.subscribe_events();
|
||||
assert!(!s.is_connected().await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ use std::time::Duration;
|
||||
|
||||
use flutter_rust_bridge::frb;
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::frb_generated::StreamSink;
|
||||
use crate::BridgeError;
|
||||
|
||||
/// Process-wide tokio runtime used to drive the async core. Created
|
||||
@@ -232,6 +233,93 @@ pub struct BridgeAudioStats {
|
||||
pub ptt_active: bool,
|
||||
}
|
||||
|
||||
// ---------- Events (A.6) ----------
|
||||
|
||||
/// Lifecycle event surfaced to Dart. Schema-controlled mirror of
|
||||
/// [`chanora_core::SessionEvent`] — no core types cross the bridge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BridgeEvent {
|
||||
/// Initial connect succeeded, or a reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported by the server snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Session ended (user-requested disconnect or unrecoverable).
|
||||
Disconnected {
|
||||
/// Reason classification.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started.
|
||||
AudioStarted,
|
||||
/// Audio engine stopped.
|
||||
AudioStopped,
|
||||
}
|
||||
|
||||
impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
fn from(e: chanora_core::SessionEvent) -> Self {
|
||||
match e {
|
||||
chanora_core::SessionEvent::Connected { server_name } => {
|
||||
BridgeEvent::Connected { server_name }
|
||||
}
|
||||
chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason },
|
||||
chanora_core::SessionEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} => BridgeEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
},
|
||||
chanora_core::SessionEvent::Disconnected { reason } => {
|
||||
BridgeEvent::Disconnected { reason }
|
||||
}
|
||||
chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
|
||||
chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to lifecycle events. Each call yields a fresh
|
||||
/// subscription; multiple subscribers are supported. On slow
|
||||
/// consumers, events are dropped rather than blocking the supervisor
|
||||
/// (consistent with `tokio::sync::broadcast::Receiver` semantics).
|
||||
pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
||||
let mut rx = session().subscribe_events();
|
||||
runtime().spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if sink.add(BridgeEvent::from(evt)).is_err() {
|
||||
// Dart side closed the sink — stop the bridge task.
|
||||
info!(target: "chanora_bridge", "events_stream: dart sink closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events");
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
info!(target: "chanora_bridge", "events_stream: source closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
let (s, r, p) = runtime()
|
||||
|
||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||
);
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1944264248;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1896742393;
|
||||
|
||||
// Section: executor
|
||||
|
||||
@@ -187,6 +187,42 @@ fn wire__crate__api__disconnect_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__events_stream_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "events_stream",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_sink = <StreamSink<
|
||||
crate::api::BridgeEvent,
|
||||
flutter_rust_bridge::for_generated::SseCodec,
|
||||
>>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| {
|
||||
transform_result_sse::<_, crate::BridgeError>((move || {
|
||||
let output_ok = crate::api::events_stream(api_sink)?;
|
||||
Ok(output_ok)
|
||||
})())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__is_connected_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -331,6 +367,24 @@ fn wire__crate__api__start_audio_impl(
|
||||
|
||||
// Section: dart2rust
|
||||
|
||||
impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <String>::sse_decode(deserializer);
|
||||
return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode
|
||||
for StreamSink<crate::api::BridgeEvent, flutter_rust_bridge::for_generated::SseCodec>
|
||||
{
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <String>::sse_decode(deserializer);
|
||||
return StreamSink::deserialize(inner);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for String {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -400,16 +454,24 @@ impl SseDecode for crate::BridgeError {
|
||||
return crate::BridgeError::InvalidCommand(var_field0);
|
||||
}
|
||||
1 => {
|
||||
let mut var_host = <String>::sse_decode(deserializer);
|
||||
let mut var_reason = <String>::sse_decode(deserializer);
|
||||
return crate::BridgeError::DnsFailed {
|
||||
host: var_host,
|
||||
reason: var_reason,
|
||||
};
|
||||
}
|
||||
2 => {
|
||||
let mut var_field0 = <String>::sse_decode(deserializer);
|
||||
return crate::BridgeError::Connection(var_field0);
|
||||
}
|
||||
2 => {
|
||||
3 => {
|
||||
return crate::BridgeError::NotConnected;
|
||||
}
|
||||
3 => {
|
||||
4 => {
|
||||
return crate::BridgeError::AlreadyConnected;
|
||||
}
|
||||
4 => {
|
||||
5 => {
|
||||
let mut var_field0 = <String>::sse_decode(deserializer);
|
||||
return crate::BridgeError::Unmapped(var_field0);
|
||||
}
|
||||
@@ -420,6 +482,46 @@ impl SseDecode for crate::BridgeError {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeEvent {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut tag_ = <i32>::sse_decode(deserializer);
|
||||
match tag_ {
|
||||
0 => {
|
||||
let mut var_serverName = <String>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::Connected {
|
||||
server_name: var_serverName,
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
let mut var_reason = <String>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::Lost { reason: var_reason };
|
||||
}
|
||||
2 => {
|
||||
let mut var_attempt = <u32>::sse_decode(deserializer);
|
||||
let mut var_delaySecs = <u32>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::Reconnecting {
|
||||
attempt: var_attempt,
|
||||
delay_secs: var_delaySecs,
|
||||
};
|
||||
}
|
||||
3 => {
|
||||
let mut var_reason = <String>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::Disconnected { reason: var_reason };
|
||||
}
|
||||
4 => {
|
||||
return crate::api::BridgeEvent::AudioStarted;
|
||||
}
|
||||
5 => {
|
||||
return crate::api::BridgeEvent::AudioStopped;
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeSnapshot {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -529,10 +631,11 @@ fn pde_ffi_dispatcher_primary_impl(
|
||||
2 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
||||
3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||
4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||
5 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
8 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
|
||||
5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
8 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
9 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -612,13 +715,19 @@ impl flutter_rust_bridge::IntoDart for crate::BridgeError {
|
||||
crate::BridgeError::InvalidCommand(field0) => {
|
||||
[0.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
crate::BridgeError::DnsFailed { host, reason } => [
|
||||
1.into_dart(),
|
||||
host.into_into_dart().into_dart(),
|
||||
reason.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::BridgeError::Connection(field0) => {
|
||||
[1.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
[2.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
crate::BridgeError::NotConnected => [2.into_dart()].into_dart(),
|
||||
crate::BridgeError::AlreadyConnected => [3.into_dart()].into_dart(),
|
||||
crate::BridgeError::NotConnected => [3.into_dart()].into_dart(),
|
||||
crate::BridgeError::AlreadyConnected => [4.into_dart()].into_dart(),
|
||||
crate::BridgeError::Unmapped(field0) => {
|
||||
[4.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
[5.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
@@ -633,6 +742,42 @@ impl flutter_rust_bridge::IntoIntoDart<crate::BridgeError> for crate::BridgeErro
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
crate::api::BridgeEvent::Connected { server_name } => {
|
||||
[0.into_dart(), server_name.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
crate::api::BridgeEvent::Lost { reason } => {
|
||||
[1.into_dart(), reason.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
crate::api::BridgeEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} => [
|
||||
2.into_dart(),
|
||||
attempt.into_into_dart().into_dart(),
|
||||
delay_secs.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::api::BridgeEvent::Disconnected { reason } => {
|
||||
[3.into_dart(), reason.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
crate::api::BridgeEvent::AudioStarted => [4.into_dart()].into_dart(),
|
||||
crate::api::BridgeEvent::AudioStopped => [5.into_dart()].into_dart(),
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeEvent {}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeEvent> for crate::api::BridgeEvent {
|
||||
fn into_into_dart(self) -> crate::api::BridgeEvent {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeSnapshot {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
[
|
||||
@@ -653,6 +798,22 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeSnapshot> for crate::ap
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<String>::sse_encode(format!("{:?}", self), serializer);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode
|
||||
for StreamSink<crate::api::BridgeEvent, flutter_rust_bridge::for_generated::SseCodec>
|
||||
{
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
unimplemented!("")
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for String {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
@@ -703,18 +864,23 @@ impl SseEncode for crate::BridgeError {
|
||||
<i32>::sse_encode(0, serializer);
|
||||
<String>::sse_encode(field0, serializer);
|
||||
}
|
||||
crate::BridgeError::Connection(field0) => {
|
||||
crate::BridgeError::DnsFailed { host, reason } => {
|
||||
<i32>::sse_encode(1, serializer);
|
||||
<String>::sse_encode(host, serializer);
|
||||
<String>::sse_encode(reason, serializer);
|
||||
}
|
||||
crate::BridgeError::Connection(field0) => {
|
||||
<i32>::sse_encode(2, serializer);
|
||||
<String>::sse_encode(field0, serializer);
|
||||
}
|
||||
crate::BridgeError::NotConnected => {
|
||||
<i32>::sse_encode(2, serializer);
|
||||
}
|
||||
crate::BridgeError::AlreadyConnected => {
|
||||
<i32>::sse_encode(3, serializer);
|
||||
}
|
||||
crate::BridgeError::Unmapped(field0) => {
|
||||
crate::BridgeError::AlreadyConnected => {
|
||||
<i32>::sse_encode(4, serializer);
|
||||
}
|
||||
crate::BridgeError::Unmapped(field0) => {
|
||||
<i32>::sse_encode(5, serializer);
|
||||
<String>::sse_encode(field0, serializer);
|
||||
}
|
||||
_ => {
|
||||
@@ -724,6 +890,43 @@ impl SseEncode for crate::BridgeError {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeEvent {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
match self {
|
||||
crate::api::BridgeEvent::Connected { server_name } => {
|
||||
<i32>::sse_encode(0, serializer);
|
||||
<String>::sse_encode(server_name, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::Lost { reason } => {
|
||||
<i32>::sse_encode(1, serializer);
|
||||
<String>::sse_encode(reason, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} => {
|
||||
<i32>::sse_encode(2, serializer);
|
||||
<u32>::sse_encode(attempt, serializer);
|
||||
<u32>::sse_encode(delay_secs, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::Disconnected { reason } => {
|
||||
<i32>::sse_encode(3, serializer);
|
||||
<String>::sse_encode(reason, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::AudioStarted => {
|
||||
<i32>::sse_encode(4, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::AudioStopped => {
|
||||
<i32>::sse_encode(5, serializer);
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeSnapshot {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
||||
@@ -68,6 +68,20 @@ enum Request {
|
||||
Disconnect(oneshot::Sender<()>),
|
||||
}
|
||||
|
||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||
/// disconnect (the supervisor must NOT retry) from a network-driven
|
||||
/// loss (the supervisor should consider retrying).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DisconnectReason {
|
||||
/// The caller explicitly called [`ProtocolClient::disconnect`]
|
||||
/// or dropped the handle.
|
||||
UserRequested,
|
||||
/// The underlying tsclientlib event stream ended.
|
||||
StreamEnded,
|
||||
/// A protocol-layer error caused the task to abort.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Async handle owning a live protocol connection. Drop = disconnect.
|
||||
pub struct ProtocolClient {
|
||||
tx: mpsc::Sender<Request>,
|
||||
@@ -78,6 +92,11 @@ pub struct ProtocolClient {
|
||||
/// Wrapped in a `Mutex<Option<_>>` so the consumer can take it
|
||||
/// exactly once.
|
||||
voice_in_rx: std::sync::Mutex<Option<mpsc::Receiver<InboundVoice>>>,
|
||||
/// Fires exactly once when the connection task exits, with the
|
||||
/// reason. Used by the supervisor in `chanora_core` to drive
|
||||
/// auto-reconnect. Wrapped in a Mutex<Option<_>> so it can be
|
||||
/// taken once by the supervisor and never resurfaced.
|
||||
lost_rx: std::sync::Mutex<Option<oneshot::Receiver<DisconnectReason>>>,
|
||||
}
|
||||
|
||||
/// One inbound voice packet from a remote client.
|
||||
@@ -88,6 +107,28 @@ pub struct InboundVoice {
|
||||
pub packet: InAudioBuf,
|
||||
}
|
||||
|
||||
/// A cheap, clone-free probe handle for the watchdog. Owns its own
|
||||
/// clone of the connection task's request channel.
|
||||
#[derive(Clone)]
|
||||
pub struct SnapshotProbe {
|
||||
tx: mpsc::Sender<Request>,
|
||||
}
|
||||
|
||||
impl SnapshotProbe {
|
||||
/// Issue a single snapshot RPC. Returns the same error shape as
|
||||
/// [`ProtocolClient::snapshot`]. Suitable for use under a
|
||||
/// `tokio::time::timeout`.
|
||||
pub async fn probe(&self) -> Result<ServerSnapshot, ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::Snapshot(tx))
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolClient {
|
||||
/// Dial the server and wait for the initial state snapshot. The
|
||||
/// returned client is ready for [`Self::snapshot`] and
|
||||
@@ -104,6 +145,7 @@ impl ProtocolClient {
|
||||
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
|
||||
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
|
||||
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
|
||||
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
|
||||
|
||||
tokio::spawn(connection_task(
|
||||
cfg.clone(),
|
||||
@@ -111,6 +153,7 @@ impl ProtocolClient {
|
||||
voice_out_rx,
|
||||
voice_in_tx,
|
||||
ready_tx,
|
||||
lost_tx,
|
||||
));
|
||||
|
||||
match tokio::time::timeout(cfg.ready_timeout, ready_rx).await {
|
||||
@@ -118,6 +161,7 @@ impl ProtocolClient {
|
||||
tx,
|
||||
voice_out_tx,
|
||||
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||
}),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(ProtocolError::Backend(
|
||||
@@ -151,11 +195,28 @@ impl ProtocolClient {
|
||||
self.voice_out_tx.clone()
|
||||
}
|
||||
|
||||
/// Clone the request channel so a watchdog can issue probes
|
||||
/// without holding a `&self` reference across the await. The
|
||||
/// returned [`SnapshotProbe`] is `Send + 'static` and dispatches
|
||||
/// a single snapshot RPC against this protocol task.
|
||||
pub fn snapshot_probe(&self) -> SnapshotProbe {
|
||||
SnapshotProbe {
|
||||
tx: self.tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the inbound-voice receiver. Returns `None` if it has
|
||||
/// already been taken; only one consumer is allowed.
|
||||
pub fn take_voice_in(&self) -> Option<mpsc::Receiver<InboundVoice>> {
|
||||
self.voice_in_rx.lock().ok().and_then(|mut g| g.take())
|
||||
}
|
||||
|
||||
/// Take the loss-notifier. Returns `None` if it has already been
|
||||
/// taken. The supervisor in `chanora_core` consumes this to
|
||||
/// drive auto-reconnect; nothing else should call it.
|
||||
pub fn take_loss_notifier(&self) -> Option<oneshot::Receiver<DisconnectReason>> {
|
||||
self.lost_rx.lock().ok().and_then(|mut g| g.take())
|
||||
}
|
||||
}
|
||||
|
||||
async fn connection_task(
|
||||
@@ -164,7 +225,20 @@ async fn connection_task(
|
||||
mut voice_out_rx: mpsc::Receiver<OutPacket>,
|
||||
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
lost_tx: oneshot::Sender<DisconnectReason>,
|
||||
) {
|
||||
// Box the lost_tx so each exit branch can move it.
|
||||
let mut lost_tx = Some(lost_tx);
|
||||
// Macro: report the disconnect reason and return from the task.
|
||||
macro_rules! exit {
|
||||
($reason:expr) => {{
|
||||
if let Some(tx) = lost_tx.take() {
|
||||
let _ = tx.send($reason);
|
||||
}
|
||||
return;
|
||||
}};
|
||||
}
|
||||
|
||||
// Resolve the hostname OURSELVES using the platform resolver.
|
||||
// tsclientlib's built-in hickory-resolver reads /etc/resolv.conf,
|
||||
// which does not exist on Android or iOS — by side-stepping it
|
||||
@@ -172,8 +246,9 @@ async fn connection_task(
|
||||
let addrs = match crate::resolver::resolve(&cfg.address).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
exit!(DisconnectReason::Error(msg));
|
||||
}
|
||||
};
|
||||
// Pick the first address (IPv4 preferred by the resolver's
|
||||
@@ -196,8 +271,9 @@ async fn connection_task(
|
||||
Some(s) => match Identity::new_from_str(s) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::Identity(format!("{e}"))));
|
||||
return;
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(ProtocolError::Identity(msg.clone())));
|
||||
exit!(DisconnectReason::Error(format!("identity: {msg}")));
|
||||
}
|
||||
},
|
||||
None => Identity::create(),
|
||||
@@ -211,8 +287,9 @@ async fn connection_task(
|
||||
let mut con = match builder.connect() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::Connect(format!("{e}"))));
|
||||
return;
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(ProtocolError::Connect(msg.clone())));
|
||||
exit!(DisconnectReason::Error(format!("connect: {msg}")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,14 +304,14 @@ async fn connection_task(
|
||||
info!(target: "chanora_protocol", "initial state snapshot received");
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(format!("{e}"))));
|
||||
return;
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
|
||||
exit!(DisconnectReason::Error(format!("disconnected early: {msg}")));
|
||||
}
|
||||
None => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(
|
||||
"event stream ended before snapshot".to_string(),
|
||||
)));
|
||||
return;
|
||||
let msg = "event stream ended before snapshot".to_string();
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
|
||||
exit!(DisconnectReason::Error(msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,10 +335,9 @@ async fn connection_task(
|
||||
warn!(target: "chanora_protocol", error = %e, "event error during settle");
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(
|
||||
"stream closed during settle".to_string(),
|
||||
)));
|
||||
return;
|
||||
let msg = "stream closed during settle".to_string();
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
|
||||
exit!(DisconnectReason::StreamEnded);
|
||||
}
|
||||
Err(_) => { /* no event available right now; keep waiting */ }
|
||||
}
|
||||
@@ -303,10 +379,12 @@ async fn connection_task(
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||
// Some errors are transient; treat persistent ones
|
||||
// as a loss after the next iteration.
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(target: "chanora_protocol", "event stream ended");
|
||||
return;
|
||||
exit!(DisconnectReason::StreamEnded);
|
||||
}
|
||||
Err(_) => { /* no event in 20 ms */ }
|
||||
}
|
||||
@@ -322,14 +400,14 @@ async fn connection_task(
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
let _ = reply.send(());
|
||||
info!(target: "chanora_protocol", "clean disconnect");
|
||||
return;
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||
return;
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ mod adapter;
|
||||
mod dto;
|
||||
mod resolver;
|
||||
|
||||
pub use adapter::{ConnectConfig, InboundVoice, ProtocolClient};
|
||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
|
||||
|
||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||
|
||||
Reference in New Issue
Block a user