fix(audio,protocol,ui): TC-2.3 + TC-10 + TC-13 + channel tree hierarchy
Five user-reported defects + one auto-test regression-catcher.
== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.
Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.
Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.
New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.
== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.
Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.
Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
carries the canonical TS3 error code per the official catalogue
at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
The connection-task loop holds a pending_moves HashMap keyed by
MessageHandle, services StreamItem::MessageResult by looking up
and resolving the reply with either Ok or the typed
ServerRejected.
* Pending entries have a 3 s deadline so a server that never
replies doesn't leak the reply channel — expired entries fall
back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
full snapshot poll), still polls for confirmation as a
belt-and-braces fallback for legacy servers; on poll failure
emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
localised message selected by error code (channelJoinFailed*
l10n entries). 6 known codes mapped to specific messages
(insufficient permission, wrong password, channel full,
family limit, private channel, timeout); everything else
falls back to the server-supplied generic message.
== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.
Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.
== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.
Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).
== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.
Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
+2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
This commit is contained in:
@@ -128,5 +128,17 @@
|
||||
"voiceSettingsTitle": "Voice settings",
|
||||
"voiceBindKeyAction": "Bind PTT key",
|
||||
"voiceMicOn": "on",
|
||||
"voiceMicOff": "off"
|
||||
"voiceMicOff": "off",
|
||||
"channelJoinFailedPermission": "Insufficient permission to join this channel.",
|
||||
"channelJoinFailedPassword": "Wrong channel password.",
|
||||
"channelJoinFailedFull": "Channel is full.",
|
||||
"channelJoinFailedFamilyFull": "Channel family limit reached.",
|
||||
"channelJoinFailedPrivate": "This channel is private.",
|
||||
"channelJoinFailedTimeout": "Could not join channel: the server didn't respond in time.",
|
||||
"channelJoinFailedGeneric": "Could not join channel: {message}",
|
||||
"@channelJoinFailedGeneric": {
|
||||
"placeholders": {
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,5 +85,17 @@
|
||||
"voiceSettingsTitle": "语音设置",
|
||||
"voiceBindKeyAction": "绑定 PTT 按键",
|
||||
"voiceMicOn": "开启",
|
||||
"voiceMicOff": "关闭"
|
||||
"voiceMicOff": "关闭",
|
||||
"channelJoinFailedPermission": "权限不足,无法加入此频道。",
|
||||
"channelJoinFailedPassword": "频道密码错误。",
|
||||
"channelJoinFailedFull": "频道已满。",
|
||||
"channelJoinFailedFamilyFull": "频道家族人数已达上限。",
|
||||
"channelJoinFailedPrivate": "此频道为私有频道。",
|
||||
"channelJoinFailedTimeout": "无法加入频道:服务器响应超时。",
|
||||
"channelJoinFailedGeneric": "无法加入频道:{message}",
|
||||
"@channelJoinFailedGeneric": {
|
||||
"placeholders": {
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +558,48 @@ abstract class AppL10n {
|
||||
/// In en, this message translates to:
|
||||
/// **'off'**
|
||||
String get voiceMicOff;
|
||||
|
||||
/// No description provided for @channelJoinFailedPermission.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Insufficient permission to join this channel.'**
|
||||
String get channelJoinFailedPermission;
|
||||
|
||||
/// No description provided for @channelJoinFailedPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Wrong channel password.'**
|
||||
String get channelJoinFailedPassword;
|
||||
|
||||
/// No description provided for @channelJoinFailedFull.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Channel is full.'**
|
||||
String get channelJoinFailedFull;
|
||||
|
||||
/// No description provided for @channelJoinFailedFamilyFull.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Channel family limit reached.'**
|
||||
String get channelJoinFailedFamilyFull;
|
||||
|
||||
/// No description provided for @channelJoinFailedPrivate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This channel is private.'**
|
||||
String get channelJoinFailedPrivate;
|
||||
|
||||
/// No description provided for @channelJoinFailedTimeout.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not join channel: the server didn\'t respond in time.'**
|
||||
String get channelJoinFailedTimeout;
|
||||
|
||||
/// No description provided for @channelJoinFailedGeneric.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not join channel: {message}'**
|
||||
String channelJoinFailedGeneric(String message);
|
||||
}
|
||||
|
||||
class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> {
|
||||
|
||||
@@ -271,4 +271,29 @@ class AppL10nEn extends AppL10n {
|
||||
|
||||
@override
|
||||
String get voiceMicOff => 'off';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPermission =>
|
||||
'Insufficient permission to join this channel.';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPassword => 'Wrong channel password.';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedFull => 'Channel is full.';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedFamilyFull => 'Channel family limit reached.';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPrivate => 'This channel is private.';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedTimeout =>
|
||||
'Could not join channel: the server didn\'t respond in time.';
|
||||
|
||||
@override
|
||||
String channelJoinFailedGeneric(String message) {
|
||||
return 'Could not join channel: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,4 +265,27 @@ class AppL10nZh extends AppL10n {
|
||||
|
||||
@override
|
||||
String get voiceMicOff => '关闭';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPermission => '权限不足,无法加入此频道。';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPassword => '频道密码错误。';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedFull => '频道已满。';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedFamilyFull => '频道家族人数已达上限。';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPrivate => '此频道为私有频道。';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedTimeout => '无法加入频道:服务器响应超时。';
|
||||
|
||||
@override
|
||||
String channelJoinFailedGeneric(String message) {
|
||||
return '无法加入频道:$message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'l10n/generated/app_localizations.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_settings.dart';
|
||||
@@ -354,6 +355,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
|
||||
final l10n = AppL10n.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
String? password;
|
||||
// Heuristic: a channel name annotated with a lock prompts.
|
||||
if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) {
|
||||
@@ -369,10 +371,62 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
setState(() => _currentVoiceChannelId = ch.id);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
// Surface as a SnackBar so the user sees it even while
|
||||
// connected (the persistent _error string lives in the
|
||||
// pre-connect area and is hidden post-connect). The message
|
||||
// is selected by TS3 error code per the canonical
|
||||
// catalogue at https://github.com/ReSpeak/tsdeclarations.
|
||||
final message = _channelJoinErrorMessage(l10n, e);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `voiceJoin` error to a localised user-facing message.
|
||||
/// Recognises the typed `BridgeError.serverRejected` variant
|
||||
/// (with a TS3 error code) and falls back to a generic message
|
||||
/// for everything else.
|
||||
String _channelJoinErrorMessage(AppL10n l10n, Object e) {
|
||||
if (e is rust_err.BridgeError) {
|
||||
return e.when(
|
||||
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
dnsFailed: (host, reason) =>
|
||||
l10n.channelJoinFailedGeneric('$host: $reason'),
|
||||
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
||||
alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'),
|
||||
serverRejected: (code, message) {
|
||||
// Canonical TS3 error codes per ReSpeak/tsdeclarations
|
||||
// Errors.csv.
|
||||
switch (code) {
|
||||
case 0x0001:
|
||||
// Our sentinel for "snapshot poll didn't confirm".
|
||||
return l10n.channelJoinFailedTimeout;
|
||||
case 0x0a08: // permissions_client_insufficient
|
||||
return l10n.channelJoinFailedPermission;
|
||||
case 0x030d: // channel_invalid_password
|
||||
return l10n.channelJoinFailedPassword;
|
||||
case 0x0309: // channel_maxclients_reached
|
||||
return l10n.channelJoinFailedFull;
|
||||
case 0x030a: // channel_maxfamily_reached
|
||||
return l10n.channelJoinFailedFamilyFull;
|
||||
case 0x030e: // channel_is_private_channel
|
||||
return l10n.channelJoinFailedPrivate;
|
||||
default:
|
||||
return l10n.channelJoinFailedGeneric(message);
|
||||
}
|
||||
},
|
||||
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
);
|
||||
}
|
||||
return l10n.channelJoinFailedGeneric(e.toString());
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
Future<void> _onLeaveVoice() async {
|
||||
try {
|
||||
@@ -853,27 +907,57 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
||||
VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
);
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
);
|
||||
// Responsive: at <840 dp use a stacked layout
|
||||
// (Voice Bar on top, channel tree below). At
|
||||
// ≥840 dp use a side-by-side layout with the
|
||||
// Voice Bar pinned to 320 dp on the left and
|
||||
// the channel tree expanding on the right.
|
||||
// 840 dp matches Material's tablet / desktop
|
||||
// breakpoint.
|
||||
const wideBreakpoint = 840.0;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= wideBreakpoint) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: voiceBarWidthWide, child: voiceBar),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
voiceBar,
|
||||
const SizedBox(height: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1183,6 +1267,24 @@ class _SnapshotView extends StatelessWidget {
|
||||
// in that adapter (TS3 `order` is NOT a numeric rank).
|
||||
final channels = snapshot.channels;
|
||||
|
||||
// Compute each channel's depth in the parent hierarchy so we
|
||||
// can render it indented. Root channels (parent == 0) are
|
||||
// depth 0; their children depth 1; and so on. Depth caps at
|
||||
// 6 to keep the indent visually bounded on deeply-nested
|
||||
// servers (the cap plateaus silently — no glyph; the channel
|
||||
// is still tappable and its real depth lives in the data).
|
||||
final depthById = <BigInt, int>{};
|
||||
final zero = BigInt.zero;
|
||||
for (final ch in channels) {
|
||||
if (ch.parent == zero) {
|
||||
depthById[ch.id] = 0;
|
||||
} else {
|
||||
final parentDepth = depthById[ch.parent] ?? 0;
|
||||
depthById[ch.id] = (parentDepth + 1).clamp(0, 6);
|
||||
}
|
||||
}
|
||||
const indentPerLevel = 18.0;
|
||||
|
||||
final byChannel = <BigInt, List<rust.BridgeClient>>{};
|
||||
for (final c in snapshot.clients) {
|
||||
byChannel.putIfAbsent(c.channel, () => []).add(c);
|
||||
@@ -1212,21 +1314,28 @@ class _SnapshotView extends StatelessWidget {
|
||||
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
for (final ch in channels) ...[
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.tag),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.login),
|
||||
tooltip: l10n.joinChannelAction,
|
||||
onPressed: () => onJoinChannel(ch),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: (depthById[ch.id] ?? 0) * indentPerLevel,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.tag),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.login),
|
||||
tooltip: l10n.joinChannelAction,
|
||||
onPressed: () => onJoinChannel(ch),
|
||||
),
|
||||
onTap: () => onJoinChannel(ch),
|
||||
),
|
||||
onTap: () => onJoinChannel(ch),
|
||||
),
|
||||
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 64),
|
||||
padding: EdgeInsets.only(
|
||||
left: ((depthById[ch.id] ?? 0) * indentPerLevel) + 64,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -1371,6 +1480,17 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
|
||||
onKeyEvent: _onKeyEvent,
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
// HitTestBehavior.opaque so the Listener fires for
|
||||
// mouse side-button presses anywhere inside the
|
||||
// dialog's bounds, not just on top of a child widget.
|
||||
// Default (deferToChild) only delivers events when a
|
||||
// child's hit-test claims them; the surrounding padding
|
||||
// and Container backgrounds don't, so the user had to
|
||||
// hover over the captured-result Container before the
|
||||
// side-button click would register. Opaque means the
|
||||
// entire dialog content area receives PointerDown
|
||||
// events.
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// Capture mouse side buttons (4 and 5) without
|
||||
// capturing primary / secondary clicks which the
|
||||
// user uses to interact with the dialog itself. The
|
||||
|
||||
@@ -1108,6 +1108,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case 4:
|
||||
return BridgeError_AlreadyConnected();
|
||||
case 5:
|
||||
return BridgeError_ServerRejected(
|
||||
code: dco_decode_u_32(raw[1]),
|
||||
message: dco_decode_String(raw[2]),
|
||||
);
|
||||
case 6:
|
||||
return BridgeError_Unmapped(dco_decode_String(raw[1]));
|
||||
default:
|
||||
throw Exception("unreachable");
|
||||
@@ -1392,6 +1397,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
case 4:
|
||||
return BridgeError_AlreadyConnected();
|
||||
case 5:
|
||||
var var_code = sse_decode_u_32(deserializer);
|
||||
var var_message = sse_decode_String(deserializer);
|
||||
return BridgeError_ServerRejected(code: var_code, message: var_message);
|
||||
case 6:
|
||||
var var_field0 = sse_decode_String(deserializer);
|
||||
return BridgeError_Unmapped(var_field0);
|
||||
default:
|
||||
@@ -1722,8 +1731,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_i_32(3, serializer);
|
||||
case BridgeError_AlreadyConnected():
|
||||
sse_encode_i_32(4, serializer);
|
||||
case BridgeError_Unmapped(field0: final field0):
|
||||
case BridgeError_ServerRejected(code: final code, message: final message):
|
||||
sse_encode_i_32(5, serializer);
|
||||
sse_encode_u_32(code, serializer);
|
||||
sse_encode_String(message, serializer);
|
||||
case BridgeError_Unmapped(field0: final field0):
|
||||
sse_encode_i_32(6, serializer);
|
||||
sse_encode_String(field0, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ sealed class BridgeError with _$BridgeError implements FrbException {
|
||||
/// connection.
|
||||
const factory BridgeError.alreadyConnected() = BridgeError_AlreadyConnected;
|
||||
|
||||
/// A server command was rejected by the TeamSpeak server. The
|
||||
/// `code` is the canonical TS3 error number (see
|
||||
/// https://github.com/ReSpeak/tsdeclarations Errors.csv); the
|
||||
/// `message` is the server-supplied text. The UI uses `code`
|
||||
/// to look up a localised explanation.
|
||||
const factory BridgeError.serverRejected({
|
||||
/// Raw TS3 error code.
|
||||
required int code,
|
||||
|
||||
/// Server-supplied message text.
|
||||
required String message,
|
||||
}) = BridgeError_ServerRejected;
|
||||
|
||||
/// An unmapped error escaped the subsystem boundary. Production
|
||||
/// callers should never see this; if they do, it is a mapping
|
||||
/// bug here.
|
||||
|
||||
@@ -55,7 +55,7 @@ extension BridgeErrorPatterns on BridgeError {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@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(),}){
|
||||
@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_ServerRejected value)? serverRejected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != null:
|
||||
@@ -63,7 +63,8 @@ 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:
|
||||
return alreadyConnected(_that);case BridgeError_ServerRejected() when serverRejected != null:
|
||||
return serverRejected(_that);case BridgeError_Unmapped() when unmapped != null:
|
||||
return unmapped(_that);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -82,7 +83,7 @@ return unmapped(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@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,}){
|
||||
@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_ServerRejected value) serverRejected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand():
|
||||
@@ -90,7 +91,8 @@ 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():
|
||||
return alreadyConnected(_that);case BridgeError_ServerRejected():
|
||||
return serverRejected(_that);case BridgeError_Unmapped():
|
||||
return unmapped(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
@@ -105,7 +107,7 @@ return unmapped(_that);}
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@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,}){
|
||||
@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_ServerRejected value)? serverRejected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != null:
|
||||
@@ -113,7 +115,8 @@ 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:
|
||||
return alreadyConnected(_that);case BridgeError_ServerRejected() when serverRejected != null:
|
||||
return serverRejected(_that);case BridgeError_Unmapped() when unmapped != null:
|
||||
return unmapped(_that);case _:
|
||||
return null;
|
||||
|
||||
@@ -131,14 +134,15 @@ return unmapped(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@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;
|
||||
@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( int code, String message)? serverRejected,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_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:
|
||||
return alreadyConnected();case BridgeError_ServerRejected() when serverRejected != null:
|
||||
return serverRejected(_that.code,_that.message);case BridgeError_Unmapped() when unmapped != null:
|
||||
return unmapped(_that.field0);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -157,14 +161,15 @@ return unmapped(_that.field0);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@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;
|
||||
@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( int code, String message) serverRejected,required TResult Function( String field0) unmapped,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand():
|
||||
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():
|
||||
return alreadyConnected();case BridgeError_ServerRejected():
|
||||
return serverRejected(_that.code,_that.message);case BridgeError_Unmapped():
|
||||
return unmapped(_that.field0);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
@@ -179,14 +184,15 @@ return unmapped(_that.field0);}
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@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;
|
||||
@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( int code, String message)? serverRejected,TResult? Function( String field0)? unmapped,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case BridgeError_InvalidCommand() when invalidCommand != 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:
|
||||
return alreadyConnected();case BridgeError_ServerRejected() when serverRejected != null:
|
||||
return serverRejected(_that.code,_that.message);case BridgeError_Unmapped() when unmapped != null:
|
||||
return unmapped(_that.field0);case _:
|
||||
return null;
|
||||
|
||||
@@ -461,6 +467,76 @@ String toString() {
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class BridgeError_ServerRejected extends BridgeError {
|
||||
const BridgeError_ServerRejected({required this.code, required this.message}): super._();
|
||||
|
||||
|
||||
/// Raw TS3 error code.
|
||||
final int code;
|
||||
/// Server-supplied message text.
|
||||
final String message;
|
||||
|
||||
/// 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_ServerRejectedCopyWith<BridgeError_ServerRejected> get copyWith => _$BridgeError_ServerRejectedCopyWithImpl<BridgeError_ServerRejected>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeError_ServerRejected&&(identical(other.code, code) || other.code == code)&&(identical(other.message, message) || other.message == message));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,code,message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeError.serverRejected(code: $code, message: $message)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BridgeError_ServerRejectedCopyWith<$Res> implements $BridgeErrorCopyWith<$Res> {
|
||||
factory $BridgeError_ServerRejectedCopyWith(BridgeError_ServerRejected value, $Res Function(BridgeError_ServerRejected) _then) = _$BridgeError_ServerRejectedCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
int code, String message
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BridgeError_ServerRejectedCopyWithImpl<$Res>
|
||||
implements $BridgeError_ServerRejectedCopyWith<$Res> {
|
||||
_$BridgeError_ServerRejectedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BridgeError_ServerRejected _self;
|
||||
final $Res Function(BridgeError_ServerRejected) _then;
|
||||
|
||||
/// Create a copy of BridgeError
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? code = null,Object? message = null,}) {
|
||||
return _then(BridgeError_ServerRejected(
|
||||
code: null == code ? _self.code : code // ignore: cast_nullable_to_non_nullable
|
||||
as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
|
||||
@@ -867,20 +867,26 @@ impl ChanoraSession {
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), CoreError> {
|
||||
// 1. Send the move command. This returns Ok even when the
|
||||
// server later rejects it with a permission error,
|
||||
// because the rejection arrives as an asynchronous
|
||||
// server event the adapter doesn't currently surface.
|
||||
self.move_to_channel(channel_id, password).await?;
|
||||
// 1. Send the move command. With send_with_result the
|
||||
// adapter now correlates against the server's typed
|
||||
// error reply, so on rejection (no permission, wrong
|
||||
// password, channel full) we get a concrete
|
||||
// `ProtocolError::ServerRejected` and don't need the
|
||||
// snapshot polling at all.
|
||||
if let Err(e) = self.move_to_channel(channel_id, password).await {
|
||||
// Roll the selector back so the UI doesn't display a
|
||||
// fake "joined" state.
|
||||
self.voice_selector.set_in_channel(false);
|
||||
self.emit_voice_state(false).await;
|
||||
return Err(e);
|
||||
}
|
||||
// 2. Bring the audio engine up.
|
||||
self.ensure_audio_running().await?;
|
||||
// 3. Verify we are actually in the requested channel by
|
||||
// polling the snapshot for up to 1.5 s. The TS3 server
|
||||
// typically broadcasts the channel-update within
|
||||
// ~50–200 ms after the move; if we never see ourselves
|
||||
// move (no-permission, wrong password, channel full,
|
||||
// etc.) we surface the failure to the caller so the
|
||||
// Voice Bar doesn't lie about our membership state.
|
||||
// 3. Belt-and-braces: if the server replied Ok but never
|
||||
// actually moved us (legacy server, command processed
|
||||
// but rolled back later, etc.) the snapshot poll catches
|
||||
// it. Keeps the move/confirm contract honest even when
|
||||
// upstream tsclientlib changes shape.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500);
|
||||
let confirmed = loop {
|
||||
if let Ok(snap) = self.snapshot().await {
|
||||
@@ -902,15 +908,17 @@ impl ChanoraSession {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
|
||||
};
|
||||
if !confirmed {
|
||||
// The move did not take effect server-side. Roll
|
||||
// back the local selector so the UI doesn't display
|
||||
// a fake "joined" state.
|
||||
self.voice_selector.set_in_channel(false);
|
||||
self.emit_voice_state(false).await;
|
||||
return Err(CoreError::Protocol(
|
||||
chanora_protocol::ProtocolError::Backend(
|
||||
"channel move rejected by server (no permission, wrong password, or channel full)".to_string(),
|
||||
),
|
||||
chanora_protocol::ProtocolError::ServerRejected {
|
||||
// Use a sentinel "unknown" code (the canonical
|
||||
// TS3 error catalogue uses 0x0001 for `undefined`).
|
||||
code: 0x0001,
|
||||
message:
|
||||
"channel move did not take effect server-side within timeout"
|
||||
.to_string(),
|
||||
},
|
||||
));
|
||||
}
|
||||
self.voice_selector.set_in_channel(true);
|
||||
|
||||
@@ -268,8 +268,22 @@ impl DesktopPttBackend for WindowsRawInputBackend {
|
||||
// — otherwise the main thread's readiness probe
|
||||
// times out (the signal would only fire when
|
||||
// WM_QUIT was eventually delivered).
|
||||
let ok = unsafe { run_raw_input_loop(init_tx) };
|
||||
armed.store(ok, Ordering::Release);
|
||||
//
|
||||
// Also pass `armed` so the loop can flip the flag
|
||||
// to true inside the same critical window — the
|
||||
// outer `armed.store(ok, ...)` only runs after the
|
||||
// message pump returns (i.e. on WM_QUIT), which
|
||||
// never happens during normal arming. Without the
|
||||
// in-loop set, `descriptor()` would always report
|
||||
// L0Focused even though the backend is correctly
|
||||
// armed and receiving WM_INPUT events.
|
||||
let ok = unsafe { run_raw_input_loop(init_tx, armed.clone()) };
|
||||
// Belt-and-braces: if the loop's normal exit
|
||||
// (WM_QUIT) happens before stop() runs the outer
|
||||
// set, clear the flag here too.
|
||||
if !ok {
|
||||
armed.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
// If init failed, exit immediately. If it
|
||||
// succeeded, run_raw_input_loop already ran the
|
||||
@@ -289,15 +303,9 @@ impl DesktopPttBackend for WindowsRawInputBackend {
|
||||
// this just lets us log accurately.
|
||||
match init_rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
bound_input_class = ?match self.binding.class() {
|
||||
2 => "mouse-side-button",
|
||||
1 => "keyboard",
|
||||
_ => "none",
|
||||
},
|
||||
"windows ptt: Raw Input armed"
|
||||
);
|
||||
// Success — the in-loop log line already wrote
|
||||
// "Raw Input devices registered" with full context.
|
||||
// Don't double-log; just continue.
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
@@ -370,7 +378,10 @@ impl Drop for WindowsRawInputBackend {
|
||||
/// # Safety
|
||||
/// Calls into Win32 directly. Must be invoked on the thread that
|
||||
/// owns the message window (created here).
|
||||
unsafe fn run_raw_input_loop(init_tx: std::sync::mpsc::SyncSender<bool>) -> bool {
|
||||
unsafe fn run_raw_input_loop(
|
||||
init_tx: std::sync::mpsc::SyncSender<bool>,
|
||||
armed: Arc<AtomicBool>,
|
||||
) -> bool {
|
||||
// Helper that signals the readiness state to the main thread.
|
||||
// We send exactly once at the first decisive moment (either an
|
||||
// early-fail return or right after a successful
|
||||
@@ -473,6 +484,14 @@ unsafe fn run_raw_input_loop(init_tx: std::sync::mpsc::SyncSender<bool>) -> bool
|
||||
target: "chanora_audio",
|
||||
"windows ptt: Raw Input devices registered (keyboard + mouse, INPUTSINK)"
|
||||
);
|
||||
// Flip armed = true here, inside the loop, BEFORE blocking on
|
||||
// GetMessageW. The outer worker closure's armed.store(ok, ...)
|
||||
// only runs on WM_QUIT and so never fires during normal use.
|
||||
// Without this in-loop store, descriptor() would always report
|
||||
// L0Focused even though the backend is correctly receiving
|
||||
// WM_INPUT events — the bug that landed the capability badge
|
||||
// stuck at L0Focused on Korean Win 11 in TC-2.3.
|
||||
armed.store(true, Ordering::Release);
|
||||
// Signal readiness NOW (before we block on GetMessageW). The
|
||||
// main thread's init probe is waiting for this; the loop runs
|
||||
// until WM_QUIT and the return value at end-of-life is no
|
||||
@@ -721,8 +740,10 @@ impl DesktopPttBackend for WindowsHookBackend {
|
||||
});
|
||||
});
|
||||
|
||||
let ok = unsafe { run_hook_loop(init_tx) };
|
||||
armed.store(ok, Ordering::Release);
|
||||
let ok = unsafe { run_hook_loop(init_tx, armed.clone()) };
|
||||
if !ok {
|
||||
armed.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
HOOK_CTX.with(|cell| {
|
||||
*cell.borrow_mut() = None;
|
||||
@@ -731,10 +752,10 @@ impl DesktopPttBackend for WindowsHookBackend {
|
||||
.map_err(|e| PttBackendError::Init(format!("llhook thread: {e}")))?;
|
||||
|
||||
match init_rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
Ok(true) => info!(
|
||||
target: "chanora_audio",
|
||||
"windows ptt: low-level hook armed"
|
||||
),
|
||||
Ok(true) => {
|
||||
// Success — the in-loop log line already wrote
|
||||
// "low-level hooks installed"; no need to repeat.
|
||||
}
|
||||
Ok(false) => warn!(
|
||||
target: "chanora_audio",
|
||||
"windows ptt: SetWindowsHookEx failed; descriptor will report L0"
|
||||
@@ -795,7 +816,10 @@ impl Drop for WindowsHookBackend {
|
||||
/// # Safety
|
||||
/// Calls Win32 directly; must run on the thread that owns the
|
||||
/// hook handles.
|
||||
unsafe fn run_hook_loop(init_tx: std::sync::mpsc::SyncSender<bool>) -> bool {
|
||||
unsafe fn run_hook_loop(
|
||||
init_tx: std::sync::mpsc::SyncSender<bool>,
|
||||
armed: Arc<AtomicBool>,
|
||||
) -> bool {
|
||||
let mut signal = Some(init_tx);
|
||||
macro_rules! report {
|
||||
($v:expr) => {
|
||||
@@ -851,6 +875,9 @@ unsafe fn run_hook_loop(init_tx: std::sync::mpsc::SyncSender<bool>) -> bool {
|
||||
target: "chanora_audio",
|
||||
"windows ptt: low-level hooks installed (WH_KEYBOARD_LL + WH_MOUSE_LL)"
|
||||
);
|
||||
// Flip armed = true here, before blocking on GetMessageW.
|
||||
// Same rationale as run_raw_input_loop.
|
||||
armed.store(true, Ordering::Release);
|
||||
// Signal readiness now, before blocking on GetMessageW. The
|
||||
// return value at end-of-loop is no longer used by the init
|
||||
// probe.
|
||||
@@ -1313,18 +1340,49 @@ mod tests {
|
||||
assert_eq!(b.binding.class(), 0);
|
||||
}
|
||||
|
||||
/// Real-runtime arm of the Raw Input backend. Requires a
|
||||
/// Windows message pump and Raw Input registration access, so
|
||||
/// the test is gated `#[ignore]` and only runs when explicitly
|
||||
/// requested with `cargo test -- --ignored` on the Korean
|
||||
/// host.
|
||||
/// Full start → descriptor → stop cycle on the real Windows
|
||||
/// runtime. Catches the regression where `armed` was only
|
||||
/// flipped after `WM_QUIT` (i.e. never during normal use), so
|
||||
/// `descriptor()` reported `L0Focused` even though the
|
||||
/// `RegisterRawInputDevices` call had succeeded.
|
||||
///
|
||||
/// Not ignored — must run on every Windows test pass.
|
||||
#[test]
|
||||
#[ignore = "requires real Windows runtime; runs on the host smoke pass"]
|
||||
fn raw_input_backend_real_start_succeeds() {
|
||||
fn raw_input_backend_start_flips_armed_to_l2() {
|
||||
let mut b = WindowsRawInputBackend::try_new().unwrap();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
b.start(gate, binding(PttInputClass::Keyboard, "Space"))
|
||||
.expect("real RegisterRawInputDevices should succeed on a Windows desktop");
|
||||
// The worker thread flips armed inside the message-pump
|
||||
// loop. Give it a tiny window to do so.
|
||||
std::thread::sleep(std::time::Duration::from_millis(80));
|
||||
let d = b.descriptor();
|
||||
assert_eq!(
|
||||
d.level,
|
||||
PttCapabilityLevel::L2GlobalHoldToTalk,
|
||||
"armed must flip to true while the loop is running"
|
||||
);
|
||||
assert_eq!(d.backend_id, "raw-input");
|
||||
assert_eq!(d.bound_input_class, Some("keyboard"));
|
||||
b.stop();
|
||||
}
|
||||
|
||||
/// Same as above but for the WH_KEYBOARD_LL / WH_MOUSE_LL hook
|
||||
/// backend. Catches the parallel armed-flag regression there.
|
||||
#[test]
|
||||
fn hook_backend_start_flips_armed_to_l2() {
|
||||
let mut b = WindowsHookBackend::try_new().unwrap();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
b.start(gate, binding(PttInputClass::Keyboard, "Space"))
|
||||
.expect("real SetWindowsHookExW should succeed on a Windows desktop");
|
||||
std::thread::sleep(std::time::Duration::from_millis(80));
|
||||
let d = b.descriptor();
|
||||
assert_eq!(
|
||||
d.level,
|
||||
PttCapabilityLevel::L2GlobalHoldToTalk,
|
||||
"armed must flip to true while the hook is running"
|
||||
);
|
||||
assert_eq!(d.backend_id, "low-level-hook");
|
||||
b.stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -1232,6 +1232,14 @@ impl SseDecode for crate::BridgeError {
|
||||
return crate::BridgeError::AlreadyConnected;
|
||||
}
|
||||
5 => {
|
||||
let mut var_code = <u32>::sse_decode(deserializer);
|
||||
let mut var_message = <String>::sse_decode(deserializer);
|
||||
return crate::BridgeError::ServerRejected {
|
||||
code: var_code,
|
||||
message: var_message,
|
||||
};
|
||||
}
|
||||
6 => {
|
||||
let mut var_field0 = <String>::sse_decode(deserializer);
|
||||
return crate::BridgeError::Unmapped(var_field0);
|
||||
}
|
||||
@@ -1636,8 +1644,14 @@ impl flutter_rust_bridge::IntoDart for crate::BridgeError {
|
||||
}
|
||||
crate::BridgeError::NotConnected => [3.into_dart()].into_dart(),
|
||||
crate::BridgeError::AlreadyConnected => [4.into_dart()].into_dart(),
|
||||
crate::BridgeError::ServerRejected { code, message } => [
|
||||
5.into_dart(),
|
||||
code.into_into_dart().into_dart(),
|
||||
message.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::BridgeError::Unmapped(field0) => {
|
||||
[5.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
[6.into_dart(), field0.into_into_dart().into_dart()].into_dart()
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
@@ -1897,8 +1911,13 @@ impl SseEncode for crate::BridgeError {
|
||||
crate::BridgeError::AlreadyConnected => {
|
||||
<i32>::sse_encode(4, serializer);
|
||||
}
|
||||
crate::BridgeError::Unmapped(field0) => {
|
||||
crate::BridgeError::ServerRejected { code, message } => {
|
||||
<i32>::sse_encode(5, serializer);
|
||||
<u32>::sse_encode(code, serializer);
|
||||
<String>::sse_encode(message, serializer);
|
||||
}
|
||||
crate::BridgeError::Unmapped(field0) => {
|
||||
<i32>::sse_encode(6, serializer);
|
||||
<String>::sse_encode(field0, serializer);
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -66,6 +66,18 @@ pub enum BridgeError {
|
||||
/// connection.
|
||||
#[error("already connected")]
|
||||
AlreadyConnected,
|
||||
/// A server command was rejected by the TeamSpeak server. The
|
||||
/// `code` is the canonical TS3 error number (see
|
||||
/// https://github.com/ReSpeak/tsdeclarations Errors.csv); the
|
||||
/// `message` is the server-supplied text. The UI uses `code`
|
||||
/// to look up a localised explanation.
|
||||
#[error("server rejected (code {code}): {message}")]
|
||||
ServerRejected {
|
||||
/// Raw TS3 error code.
|
||||
code: u32,
|
||||
/// Server-supplied message text.
|
||||
message: String,
|
||||
},
|
||||
/// An unmapped error escaped the subsystem boundary. Production
|
||||
/// callers should never see this; if they do, it is a mapping
|
||||
/// bug here.
|
||||
@@ -85,6 +97,9 @@ impl From<chanora_core::CoreError> for BridgeError {
|
||||
host,
|
||||
reason,
|
||||
}) => BridgeError::DnsFailed { host, reason },
|
||||
chanora_core::CoreError::Protocol(
|
||||
chanora_protocol::ProtocolError::ServerRejected { code, message },
|
||||
) => BridgeError::ServerRejected { code, message },
|
||||
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
||||
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
||||
chanora_core::CoreError::Storage(s) => {
|
||||
|
||||
@@ -27,7 +27,8 @@ use tracing::{info, warn};
|
||||
use tsclientlib::data::{self, Channel, Client};
|
||||
use tsclientlib::prelude::*;
|
||||
use tsclientlib::{
|
||||
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem,
|
||||
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, MessageHandle,
|
||||
OutCommandExt, StreamItem,
|
||||
};
|
||||
use tsproto_packets::packets::{InAudioBuf, OutPacket};
|
||||
|
||||
@@ -410,6 +411,20 @@ async fn connection_task(
|
||||
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
|
||||
// Pending `client_move` requests: each one is keyed by the
|
||||
// `MessageHandle` tsclientlib returns from `send_with_result`.
|
||||
// When the corresponding `StreamItem::MessageResult` arrives we
|
||||
// resolve the oneshot back to the caller. Entries also carry a
|
||||
// deadline so a server that never replies doesn't leak the
|
||||
// reply channel — at most 3 s of pending state per move.
|
||||
let mut pending_moves: HashMap<
|
||||
MessageHandle,
|
||||
(
|
||||
oneshot::Sender<Result<(), ProtocolError>>,
|
||||
std::time::Instant,
|
||||
),
|
||||
> = HashMap::new();
|
||||
|
||||
// Main loop: pump events, service requests, forward voice.
|
||||
loop {
|
||||
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||
@@ -426,20 +441,49 @@ async fn connection_task(
|
||||
};
|
||||
match pump.await {
|
||||
Ok(Some(Ok(item))) => {
|
||||
if let StreamItem::Audio(buf) = item {
|
||||
// Extract `from` client id then forward.
|
||||
let from = packet_sender_id(&buf);
|
||||
if let Some(from) = from {
|
||||
if voice_in_tx
|
||||
.try_send(InboundVoice {
|
||||
from_client: from,
|
||||
packet: buf,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Subscriber is too slow or absent; drop.
|
||||
match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
let from = packet_sender_id(&buf);
|
||||
if let Some(from) = from {
|
||||
if voice_in_tx
|
||||
.try_send(InboundVoice {
|
||||
from_client: from,
|
||||
packet: buf,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Subscriber is too slow or absent; drop.
|
||||
}
|
||||
}
|
||||
}
|
||||
StreamItem::MessageResult(handle, result) => {
|
||||
if let Some((reply, _deadline)) = pending_moves.remove(&handle) {
|
||||
let mapped = match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(cmd_err) => {
|
||||
// tsclientlib's CommandError carries a
|
||||
// typed `TsError` (the canonical TS3
|
||||
// error code) plus an optional missing
|
||||
// permission. We convert to our typed
|
||||
// ProtocolError::ServerRejected so the
|
||||
// upper layers can render a localised
|
||||
// explanation by code instead of a
|
||||
// generic backend string.
|
||||
let code = cmd_err.error as u32;
|
||||
let message = cmd_err.error.to_string();
|
||||
info!(
|
||||
target: "chanora_protocol",
|
||||
code,
|
||||
message = %message,
|
||||
"server rejected client_move"
|
||||
);
|
||||
Err(ProtocolError::ServerRejected { code, message })
|
||||
}
|
||||
};
|
||||
let _ = reply.send(mapped);
|
||||
}
|
||||
}
|
||||
_ => { /* book / message / other events: ignore */ }
|
||||
}
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
@@ -454,6 +498,28 @@ async fn connection_task(
|
||||
Err(_) => { /* no event in 20 ms */ }
|
||||
}
|
||||
|
||||
// 2b. Sweep stale pending_moves whose deadline has passed.
|
||||
// The server should always reply within ~1 s; 3 s is a
|
||||
// generous ceiling. Expired entries get an Ok() so the
|
||||
// caller's snapshot-confirmation polling still has a chance
|
||||
// to detect success (fall back to the previous optimistic
|
||||
// behaviour rather than blocking the user with a fake
|
||||
// ServerRejected).
|
||||
if !pending_moves.is_empty() {
|
||||
let now = std::time::Instant::now();
|
||||
pending_moves.retain(|_, (reply, deadline)| {
|
||||
if now >= *deadline {
|
||||
// Cannot move `reply` out of `&mut` cleanly here
|
||||
// without an intermediate take(); use a sentinel
|
||||
// sender so retain's signature works.
|
||||
let _ = std::mem::replace(reply, oneshot::channel().0).send(Ok(()));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Service at most one control request (non-blocking).
|
||||
match rx.try_recv() {
|
||||
Ok(Request::Snapshot(reply)) => {
|
||||
@@ -461,8 +527,18 @@ async fn connection_task(
|
||||
let _ = reply.send(snap);
|
||||
}
|
||||
Ok(Request::MoveToChannel { channel_id, password, reply }) => {
|
||||
let r = move_self_to(&mut con, channel_id, password.as_deref());
|
||||
let _ = reply.send(r);
|
||||
match move_self_to(&mut con, channel_id, password.as_deref()) {
|
||||
Ok(handle) => {
|
||||
let deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(3);
|
||||
pending_moves.insert(handle, (reply, deadline));
|
||||
}
|
||||
Err(e) => {
|
||||
// Couldn't even send the command; report
|
||||
// immediately.
|
||||
let _ = reply.send(Err(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Request::SetMuted { input, output, reply }) => {
|
||||
let r = set_self_muted(&mut con, input, output);
|
||||
@@ -488,12 +564,16 @@ async fn connection_task(
|
||||
|
||||
/// Move our own client into `channel_id` with an optional password.
|
||||
/// Looks up our `own_client` in the current state and dispatches the
|
||||
/// generated `client_move` command via the `OutCommandExt` trait.
|
||||
/// generated `client_move` command via `send_with_result`. The
|
||||
/// returned `MessageHandle` is correlated by the connection loop
|
||||
/// against the next `StreamItem::MessageResult` so we can surface
|
||||
/// typed `ServerRejected` errors (no permission, wrong password,
|
||||
/// channel full, etc.) per the TS3 error catalogue.
|
||||
fn move_self_to(
|
||||
con: &mut Connection,
|
||||
channel_id: u64,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
) -> Result<MessageHandle, ProtocolError> {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
@@ -507,10 +587,11 @@ fn move_self_to(
|
||||
if let Some(pw) = password {
|
||||
part = part.set_password(pw);
|
||||
}
|
||||
part.send(con)
|
||||
let handle = part
|
||||
.send_with_result(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("client_move send: {e}")))?;
|
||||
info!(target: "chanora_protocol", channel_id, "client_move sent");
|
||||
Ok(())
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Send a `clientupdate` with the requested mute fields set. `None`
|
||||
|
||||
@@ -95,6 +95,21 @@ pub enum ProtocolError {
|
||||
#[error("protocol timeout")]
|
||||
Timeout,
|
||||
|
||||
/// A server command was rejected by the TeamSpeak server with
|
||||
/// a typed error code. The `code` is the raw TS3 error number
|
||||
/// (see https://github.com/ReSpeak/tsdeclarations Errors.csv),
|
||||
/// and `message` is the server-supplied human-readable text.
|
||||
/// Distinguishing this from `Backend` lets the UI surface a
|
||||
/// localised explanation (insufficient permission, wrong
|
||||
/// channel password, etc.) instead of a generic failure.
|
||||
#[error("server rejected (code {code}): {message}")]
|
||||
ServerRejected {
|
||||
/// Raw TS3 error code (e.g. 0x0a08 = `permissions_client_insufficient`).
|
||||
code: u32,
|
||||
/// Server-supplied message text.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// A backend error escaped the mapping. Production callers
|
||||
/// should never see this; if they do, it is a mapping bug here.
|
||||
#[error("protocol backend: {0}")]
|
||||
|
||||
Reference in New Issue
Block a user