Reduce Linux setup ambiguity and surface desktop input/message failures honestly
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful. Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped Confidence: medium Scope-risk: moderate Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
This commit is contained in:
+275
-218
@@ -35,6 +35,7 @@ import 'widgets/chat_views.dart';
|
||||
import 'widgets/connect_widgets.dart';
|
||||
import 'widgets/input_dialogs.dart';
|
||||
import 'widgets/snapshot_view.dart';
|
||||
import 'widgets/startup_dependency_screen.dart';
|
||||
import 'widgets/voice_platform.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_compact.dart';
|
||||
@@ -121,7 +122,7 @@ class ChanoraApp extends StatelessWidget {
|
||||
navigatorKey: navigatorKey,
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: const _BetaHome(),
|
||||
home: const StartupDependencyGate(child: _BetaHome()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -204,7 +205,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
// only the platform-neutral label that already crossed into
|
||||
// the Rust side.
|
||||
String _pttBoundKeyLabel = '';
|
||||
final Set<LogicalKeyboardKey> _focusedPttHeldKeys = <LogicalKeyboardKey>{};
|
||||
final Set<String> _focusedPttHeldInputs = <String>{};
|
||||
|
||||
List<rust.BridgeBookmark> _bookmarks = const [];
|
||||
final List<ChatEntry> _chatMessages = [];
|
||||
@@ -426,24 +427,23 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
bool _handleFocusedPttKey(KeyEvent event) {
|
||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||
final isBoundKey =
|
||||
_pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
|
||||
if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
|
||||
if (_focusedPttHeldKeys.remove(event.logicalKey)) {
|
||||
unawaited(_setPtt(false));
|
||||
final platformKey =
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(event.logicalKey) ??
|
||||
pttDisplayLabelForKey(event.logicalKey);
|
||||
if (platformKey == null) {
|
||||
return false;
|
||||
}
|
||||
if (event is KeyUpEvent && _focusedPttHeldInputs.contains(platformKey)) {
|
||||
if (_focusedPttHeldInputs.remove(platformKey)) {
|
||||
unawaited(_setPtt(false, reportError: false));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_pttBackendId != 'focused' ||
|
||||
!_serverReachable ||
|
||||
!_inChannel ||
|
||||
_transmitMode != rust.BridgeTransmitMode.ptt ||
|
||||
!isBoundKey) {
|
||||
if (!_canHandleFocusedPttPlatformKey(platformKey)) {
|
||||
return false;
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
if (_focusedPttHeldKeys.add(event.logicalKey)) {
|
||||
if (_focusedPttHeldInputs.add(platformKey)) {
|
||||
unawaited(_setPtt(true));
|
||||
}
|
||||
return true;
|
||||
@@ -451,9 +451,38 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _canHandleFocusedPttPlatformKey(String platformKey) {
|
||||
return _pttBackendId == 'focused' &&
|
||||
_serverReachable &&
|
||||
_inChannel &&
|
||||
_transmitMode == rust.BridgeTransmitMode.ptt &&
|
||||
_pttBoundKeyLabel.isNotEmpty &&
|
||||
platformKey == _pttBoundKeyLabel;
|
||||
}
|
||||
|
||||
void _handleFocusedPttPointerDown(PointerDownEvent event) {
|
||||
final platformKey = pttMouseSideButtonPlatformKeyForButtons(event.buttons);
|
||||
if (platformKey == null || !_canHandleFocusedPttPlatformKey(platformKey)) {
|
||||
return;
|
||||
}
|
||||
if (_focusedPttHeldInputs.add(platformKey)) {
|
||||
unawaited(_setPtt(true));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleFocusedPttPointerRelease(PointerEvent event) {
|
||||
final boundKey = _pttBoundKeyLabel;
|
||||
if (!boundKey.startsWith('mouse-side-button:')) {
|
||||
return;
|
||||
}
|
||||
if (_focusedPttHeldInputs.remove(boundKey)) {
|
||||
unawaited(_setPtt(false, reportError: false));
|
||||
}
|
||||
}
|
||||
|
||||
void _releaseFocusedPttIfHeld() {
|
||||
if (_focusedPttHeldKeys.isEmpty) return;
|
||||
_focusedPttHeldKeys.clear();
|
||||
if (_focusedPttHeldInputs.isEmpty) return;
|
||||
_focusedPttHeldInputs.clear();
|
||||
unawaited(_setPtt(false, reportError: false));
|
||||
}
|
||||
|
||||
@@ -623,12 +652,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
// Skip echo of self-sent messages (already added locally).
|
||||
if (senderId == _snapshot?.ownClientId) return;
|
||||
final isPoke = target is rust.BridgeMessageTarget_Poke;
|
||||
final resolvedSenderName = _resolvedChatSenderName(
|
||||
senderId,
|
||||
senderName,
|
||||
);
|
||||
final receivedAt = DateTime.now();
|
||||
setState(() {
|
||||
_appendChatEntryUnlocked(
|
||||
ChatEntry(
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
senderName: resolvedSenderName,
|
||||
message: message,
|
||||
target: target,
|
||||
isSelf: senderId == _snapshot?.ownClientId,
|
||||
@@ -638,7 +671,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
if (isPoke) {
|
||||
_showPokeSnackBar(
|
||||
senderName: senderName,
|
||||
senderName: resolvedSenderName,
|
||||
message: message,
|
||||
receivedAt: receivedAt,
|
||||
);
|
||||
@@ -649,7 +682,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
unawaited(() async {
|
||||
if (!mounted) return;
|
||||
_showChatMessageSnackBar(
|
||||
senderName: senderName,
|
||||
senderName: resolvedSenderName,
|
||||
message: message,
|
||||
target: target,
|
||||
);
|
||||
@@ -1435,6 +1468,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
};
|
||||
}
|
||||
|
||||
String _resolvedChatSenderName(BigInt senderId, String fallback) {
|
||||
final snapshot = _snapshot;
|
||||
if (snapshot != null) {
|
||||
for (final client in snapshot.clients) {
|
||||
if (client.id == senderId && client.name.isNotEmpty) {
|
||||
return client.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void _applySnapshot(rust.BridgeSnapshot snap) {
|
||||
_snapshot = snap;
|
||||
final own = ownClientSnapshotState(snap);
|
||||
@@ -1473,6 +1518,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Future<void> _onShowDiagnostics(BuildContext context) async {
|
||||
if (!_phase.canOpenDiagnostics) {
|
||||
return;
|
||||
}
|
||||
final l10n = AppL10n.of(context);
|
||||
final rustText = rust.exportDiagnostics();
|
||||
final uiText = _uiDiagnostics.isEmpty
|
||||
@@ -1614,10 +1662,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await _onShowDiagnostics(context);
|
||||
},
|
||||
onPressed: _phase.canOpenDiagnostics
|
||||
? () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await _onShowDiagnostics(context);
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.diagnosticsAction),
|
||||
),
|
||||
TextButton(
|
||||
@@ -1806,223 +1856,230 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
reconnectDelay: _reconnectDelay,
|
||||
);
|
||||
|
||||
final bodyContent = LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
final isWideSnapshot =
|
||||
bodyConstraints.maxWidth >= _wideBreakpoint &&
|
||||
_serverReachable &&
|
||||
_snapshot != null;
|
||||
final banner = Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n.homeNotProductionReadyBanner,
|
||||
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
if (!_serverReachable)
|
||||
Row(
|
||||
children: [
|
||||
Icon(connTokens.icon, size: 18, color: connTokens.color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
softWrap: true,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
connTokens.background ?? theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
final bodyContent = Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: _handleFocusedPttPointerDown,
|
||||
onPointerUp: _handleFocusedPttPointerRelease,
|
||||
onPointerCancel: _handleFocusedPttPointerRelease,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
final isWideSnapshot =
|
||||
bodyConstraints.maxWidth >= _wideBreakpoint &&
|
||||
_serverReachable &&
|
||||
_snapshot != null;
|
||||
final banner = Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n.homeNotProductionReadyBanner,
|
||||
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
if (!_serverReachable)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Icon(connTokens.icon, size: 18, color: connTokens.color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
statusText,
|
||||
softWrap: true,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == ConnectionPhase.idle ||
|
||||
_phase == ConnectionPhase.disconnected) ...[
|
||||
Expanded(
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
connTokens.background ??
|
||||
theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
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 == ConnectionPhase.idle ||
|
||||
_phase == ConnectionPhase.disconnected) ...[
|
||||
Expanded(
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (_phase == ConnectionPhase.connecting) ...[
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
] else if (_phase == ConnectionPhase.connecting) ...[
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (_serverReachable && _snapshot != null) ...[
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
talkPowerBlocked: _hardMuteByTalkPower,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: snapshotChannelName(
|
||||
_snapshot,
|
||||
_currentVoiceChannelId,
|
||||
),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
);
|
||||
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
|
||||
// banner. Self-hides on granted / unknown.
|
||||
final permissionBanner =
|
||||
PermissionStateBanner.fromCallbacks(
|
||||
recordAudioState: _activeRecordAudioState,
|
||||
ensureRecordAudio: _ensureActiveRecordAudio,
|
||||
openAppSettings: _openActivePermissionSettings,
|
||||
] else if (_serverReachable && _snapshot != null) ...[
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
talkPowerBlocked: _hardMuteByTalkPower,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: snapshotChannelName(
|
||||
_snapshot,
|
||||
_currentVoiceChannelId,
|
||||
),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
);
|
||||
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
|
||||
// banner. Self-hides on granted / unknown.
|
||||
final permissionBanner =
|
||||
PermissionStateBanner.fromCallbacks(
|
||||
recordAudioState: _activeRecordAudioState,
|
||||
ensureRecordAudio: _ensureActiveRecordAudio,
|
||||
openAppSettings: _openActivePermissionSettings,
|
||||
);
|
||||
final snapshotView = SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
audioStats: _audioStats,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
pendingVoiceChannelId: _pendingVoiceChannelId,
|
||||
localInputMuted: _inputMuted || _hardMute,
|
||||
localOutputMuted: _outputMuted,
|
||||
hasJoinPending: _pendingVoiceChannelId != null,
|
||||
canJoinVoiceChannel: _canJoinVoiceChannel,
|
||||
onJoinChannel: (ch) => _onJoinChannel(ch),
|
||||
onJoinChannelWithPassword: (ch) =>
|
||||
_onJoinChannel(ch, askForPassword: true),
|
||||
onTs3ServerLink: _onTs3ServerLink,
|
||||
);
|
||||
final ownClientState = _ownClientState;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= _wideBreakpoint) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
permissionBanner,
|
||||
voiceBar,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
final snapshotView = SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
audioStats: _audioStats,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
pendingVoiceChannelId: _pendingVoiceChannelId,
|
||||
localInputMuted: _inputMuted || _hardMute,
|
||||
localOutputMuted: _outputMuted,
|
||||
hasJoinPending: _pendingVoiceChannelId != null,
|
||||
canJoinVoiceChannel: _canJoinVoiceChannel,
|
||||
onJoinChannel: (ch) => _onJoinChannel(ch),
|
||||
onJoinChannelWithPassword: (ch) =>
|
||||
_onJoinChannel(ch, askForPassword: true),
|
||||
onTs3ServerLink: _onTs3ServerLink,
|
||||
);
|
||||
final ownClientState = _ownClientState;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= _wideBreakpoint) {
|
||||
return Row(
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
permissionBanner,
|
||||
voiceBar,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
const SizedBox(height: 8),
|
||||
permissionBanner,
|
||||
VoiceStatusChip(
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
talkPower: ownClientState?.talkPower,
|
||||
neededTalkPower: ownClientState?.neededTalkPower,
|
||||
talkPowerGranted: ownClientState?.talkPowerGranted,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: snapshotView),
|
||||
const SizedBox(height: 8),
|
||||
permissionBanner,
|
||||
VoiceStatusChip(
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
talkPower: ownClientState?.talkPower,
|
||||
neededTalkPower: ownClientState?.neededTalkPower,
|
||||
talkPowerGranted: ownClientState?.talkPowerGranted,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (_isMacOS) {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
class StartupDependencyCheckResult {
|
||||
const StartupDependencyCheckResult({
|
||||
required this.issues,
|
||||
required this.platformLabel,
|
||||
});
|
||||
|
||||
final List<StartupDependencyIssue> issues;
|
||||
final String platformLabel;
|
||||
|
||||
bool get hasIssues => issues.isNotEmpty;
|
||||
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
|
||||
}
|
||||
|
||||
class StartupDependencyIssue {
|
||||
const StartupDependencyIssue({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.summary,
|
||||
required this.details,
|
||||
required this.severity,
|
||||
this.installHints = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String summary;
|
||||
final List<String> details;
|
||||
final StartupDependencySeverity severity;
|
||||
final List<StartupInstallHint> installHints;
|
||||
|
||||
bool get isRequired => severity == StartupDependencySeverity.required;
|
||||
}
|
||||
|
||||
class StartupInstallHint {
|
||||
const StartupInstallHint({required this.label, required this.command});
|
||||
|
||||
final String label;
|
||||
final String command;
|
||||
}
|
||||
|
||||
enum StartupDependencySeverity { required, recommended }
|
||||
|
||||
enum _LinuxDistro { debian, fedora, arch, other }
|
||||
|
||||
enum _LinuxArch { x64, arm64, other }
|
||||
|
||||
typedef _LibraryProbe = bool Function(String candidate);
|
||||
typedef _FileExists = Future<bool> Function(String path);
|
||||
typedef _ResolvedExecutableProvider = String Function();
|
||||
typedef _CurrentDirectoryProvider = String Function();
|
||||
typedef _OsReleaseProvider = Future<String?> Function();
|
||||
typedef _PlatformIsLinux = bool Function();
|
||||
typedef _LogFilePathProvider = String Function();
|
||||
typedef _CurrentAbiProvider = Abi Function();
|
||||
|
||||
_LibraryProbe _libraryProbe = _defaultLibraryProbe;
|
||||
_FileExists _fileExists = _defaultFileExists;
|
||||
_ResolvedExecutableProvider _resolvedExecutableProvider = () =>
|
||||
Platform.resolvedExecutable;
|
||||
_CurrentDirectoryProvider _currentDirectoryProvider = () =>
|
||||
Directory.current.path;
|
||||
_OsReleaseProvider _osReleaseProvider = _defaultOsReleaseProvider;
|
||||
_PlatformIsLinux _platformIsLinux = () => Platform.isLinux;
|
||||
_LogFilePathProvider _logFilePathProvider = rust.logFilePathStr;
|
||||
_CurrentAbiProvider _currentAbiProvider = Abi.current;
|
||||
|
||||
Future<void> logStartupDependencyIssues(
|
||||
StartupDependencyCheckResult result,
|
||||
) async {
|
||||
if (!result.hasIssues) return;
|
||||
|
||||
final path = _logFilePathProvider();
|
||||
if (path.isEmpty) return;
|
||||
|
||||
final issues = result.issues
|
||||
.map(
|
||||
(issue) =>
|
||||
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}:'
|
||||
'${_sanitizeLogValue(issue.title)}',
|
||||
)
|
||||
.join(', ');
|
||||
final line =
|
||||
'${DateTime.now().toUtc().toIso8601String()} '
|
||||
'[startup_dependency_check] '
|
||||
'platform="${result.platformLabel}" '
|
||||
'blocking=${result.hasBlockingIssues} '
|
||||
'issues=[$issues]';
|
||||
|
||||
try {
|
||||
await File(
|
||||
path,
|
||||
).writeAsString('$line\n', mode: FileMode.append, flush: true);
|
||||
} catch (_) {
|
||||
// Best-effort only: a missing/unwritable log file must not block app startup.
|
||||
}
|
||||
}
|
||||
|
||||
String _sanitizeLogValue(String value) => value.replaceAll('"', "'");
|
||||
|
||||
Future<StartupDependencyCheckResult> checkStartupDependencies() async {
|
||||
if (!_platformIsLinux()) {
|
||||
return const StartupDependencyCheckResult(
|
||||
issues: [],
|
||||
platformLabel: 'default',
|
||||
);
|
||||
}
|
||||
|
||||
final distro = await _detectLinuxDistro();
|
||||
final arch = _detectLinuxArch();
|
||||
final executableDir = File(_resolvedExecutableProvider()).parent.path;
|
||||
final issues = <StartupDependencyIssue>[];
|
||||
|
||||
if (!_hasAnyLoadableLibrary(const ['libSDL2.so', 'libSDL2-2.0.so.0'])) {
|
||||
issues.add(_buildSdlIssue(distro));
|
||||
}
|
||||
|
||||
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
|
||||
issues.add(_buildOnnxIssue(executableDir: executableDir, arch: arch));
|
||||
}
|
||||
|
||||
return StartupDependencyCheckResult(
|
||||
issues: issues,
|
||||
platformLabel: switch (distro) {
|
||||
_LinuxDistro.debian => 'Debian / Ubuntu',
|
||||
_LinuxDistro.fedora => 'Fedora',
|
||||
_LinuxDistro.arch => 'Arch Linux',
|
||||
_LinuxDistro.other => 'Linux',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _defaultLibraryProbe(String candidate) {
|
||||
try {
|
||||
DynamicLibrary.open(candidate);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _defaultFileExists(String path) => File(path).exists();
|
||||
|
||||
Future<String?> _defaultOsReleaseProvider() async {
|
||||
const path = '/etc/os-release';
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
return file.readAsString();
|
||||
}
|
||||
|
||||
bool _hasAnyLoadableLibrary(List<String> candidates) {
|
||||
for (final candidate in candidates) {
|
||||
if (_libraryProbe(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _hasOnnxRuntime({required String executableDir}) async {
|
||||
final envPath = Platform.environment['ORT_DYLIB_PATH'];
|
||||
if (envPath != null && envPath.isNotEmpty && await _fileExists(envPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final candidates = <String>{
|
||||
'$executableDir/lib/libonnxruntime.so',
|
||||
'$executableDir/libonnxruntime.so',
|
||||
'${_currentDirectoryProvider()}/libonnxruntime.so',
|
||||
'/usr/lib/libonnxruntime.so',
|
||||
'/usr/lib64/libonnxruntime.so',
|
||||
'/usr/local/lib/libonnxruntime.so',
|
||||
'/lib/x86_64-linux-gnu/libonnxruntime.so',
|
||||
'/usr/lib/x86_64-linux-gnu/libonnxruntime.so',
|
||||
'/lib/aarch64-linux-gnu/libonnxruntime.so',
|
||||
'/usr/lib/aarch64-linux-gnu/libonnxruntime.so',
|
||||
};
|
||||
|
||||
for (final candidate in candidates) {
|
||||
if (await _fileExists(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (final dir in const [
|
||||
'/usr/lib',
|
||||
'/usr/lib64',
|
||||
'/usr/local/lib',
|
||||
'/usr/lib/x86_64-linux-gnu',
|
||||
'/usr/lib/aarch64-linux-gnu',
|
||||
]) {
|
||||
final match = await _firstMatchingDirEntry(dir, 'libonnxruntime.so');
|
||||
if (match != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return _hasAnyLoadableLibrary(const ['libonnxruntime.so']);
|
||||
}
|
||||
|
||||
Future<String?> _firstMatchingDirEntry(String dir, String prefix) async {
|
||||
final directory = Directory(dir);
|
||||
if (!await directory.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
if (entity is! File) {
|
||||
continue;
|
||||
}
|
||||
final name = entity.uri.pathSegments.isEmpty
|
||||
? ''
|
||||
: entity.uri.pathSegments.last;
|
||||
if (name == prefix || name.startsWith('$prefix.')) {
|
||||
return entity.path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<_LinuxDistro> _detectLinuxDistro() async {
|
||||
final content = await _osReleaseProvider();
|
||||
if (content == null || content.isEmpty) {
|
||||
return _LinuxDistro.other;
|
||||
}
|
||||
|
||||
final lower = content.toLowerCase();
|
||||
if (lower.contains('id=fedora') ||
|
||||
lower.contains('id_like="fedora"') ||
|
||||
lower.contains('id_like=fedora')) {
|
||||
return _LinuxDistro.fedora;
|
||||
}
|
||||
if (lower.contains('id=ubuntu') ||
|
||||
lower.contains('id=debian') ||
|
||||
lower.contains('id_like=debian') ||
|
||||
lower.contains('id_like="ubuntu debian"')) {
|
||||
return _LinuxDistro.debian;
|
||||
}
|
||||
if (lower.contains('id=arch') || lower.contains('id_like=arch')) {
|
||||
return _LinuxDistro.arch;
|
||||
}
|
||||
return _LinuxDistro.other;
|
||||
}
|
||||
|
||||
_LinuxArch _detectLinuxArch() {
|
||||
return switch (_currentAbiProvider()) {
|
||||
Abi.linuxX64 => _LinuxArch.x64,
|
||||
Abi.linuxArm64 => _LinuxArch.arm64,
|
||||
_ => _LinuxArch.other,
|
||||
};
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildSdlIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libsdl2-2.0-0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(label: 'Fedora', command: 'sudo dnf install SDL2'),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(label: 'Arch Linux', command: 'sudo pacman -S sdl2'),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-sdl2-runtime',
|
||||
title: 'SDL2 runtime is missing',
|
||||
summary:
|
||||
'Chanora uses SDL2 for Linux audio playback. Without it, voice output will not work.',
|
||||
details: const [
|
||||
'Install the SDL2 runtime package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.required,
|
||||
installHints: hints,
|
||||
);
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildOnnxIssue({
|
||||
required String executableDir,
|
||||
required _LinuxArch arch,
|
||||
}) {
|
||||
final bundlePath = '$executableDir/lib/libonnxruntime.so';
|
||||
final archivePrefix = switch (arch) {
|
||||
_LinuxArch.x64 => 'onnxruntime-linux-x64-<version>.tgz',
|
||||
_LinuxArch.arm64 => 'onnxruntime-linux-aarch64-<version>.tgz',
|
||||
_LinuxArch.other => 'onnxruntime-linux-<arch>-<version>.tgz',
|
||||
};
|
||||
final archiveDescription = switch (arch) {
|
||||
_LinuxArch.x64 =>
|
||||
'This machine needs the Linux x64 CPU archive ($archivePrefix).',
|
||||
_LinuxArch.arm64 =>
|
||||
'This machine needs the Linux ARM64 / aarch64 CPU archive ($archivePrefix).',
|
||||
_LinuxArch.other =>
|
||||
'Download the Linux CPU archive that matches this machine ($archivePrefix).',
|
||||
};
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-onnxruntime',
|
||||
title: 'ONNX Runtime is not available',
|
||||
summary:
|
||||
'Silero voice activity detection needs libonnxruntime.so. Chanora can continue, but it will fall back to simpler voice detection.',
|
||||
details: [
|
||||
archiveDescription,
|
||||
'Open the official ONNX Runtime releases page, download the matching Linux CPU archive, then extract it.',
|
||||
'Inside the extracted archive, use the file in lib/ named libonnxruntime.so (or the versioned libonnxruntime.so.* file it points to).',
|
||||
'Use a Chanora build that already bundles ONNX Runtime, or place libonnxruntime.so in the app bundle lib/ directory.',
|
||||
'You can also point Chanora at an existing ONNX Runtime shared library with ORT_DYLIB_PATH.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: [
|
||||
StartupInstallHint(
|
||||
label: switch (arch) {
|
||||
_LinuxArch.x64 => 'Download Linux x64 archive',
|
||||
_LinuxArch.arm64 => 'Download Linux ARM64 archive',
|
||||
_LinuxArch.other => 'Open release downloads',
|
||||
},
|
||||
command: 'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Archive name to look for',
|
||||
command: archivePrefix,
|
||||
),
|
||||
const StartupInstallHint(
|
||||
label: 'Official install reference',
|
||||
command: 'https://onnxruntime.ai/docs/install/',
|
||||
),
|
||||
const StartupInstallHint(
|
||||
label: 'Temporary shell setup',
|
||||
command: 'export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Bundle into this app',
|
||||
command: 'cp /absolute/path/to/libonnxruntime.so $bundlePath',
|
||||
),
|
||||
StartupInstallHint(label: 'Bundled file location', command: bundlePath),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugResetStartupDependencyCheck({
|
||||
bool Function(String)? libraryProbe,
|
||||
Future<bool> Function(String path)? fileExists,
|
||||
String Function()? resolvedExecutableProvider,
|
||||
String Function()? currentDirectoryProvider,
|
||||
Future<String?> Function()? osReleaseProvider,
|
||||
bool Function()? platformIsLinux,
|
||||
String Function()? logFilePathProvider,
|
||||
Abi Function()? currentAbiProvider,
|
||||
}) {
|
||||
_libraryProbe = libraryProbe ?? _defaultLibraryProbe;
|
||||
_fileExists = fileExists ?? _defaultFileExists;
|
||||
_resolvedExecutableProvider =
|
||||
resolvedExecutableProvider ?? (() => Platform.resolvedExecutable);
|
||||
_currentDirectoryProvider =
|
||||
currentDirectoryProvider ?? (() => Directory.current.path);
|
||||
_osReleaseProvider = osReleaseProvider ?? _defaultOsReleaseProvider;
|
||||
_platformIsLinux = platformIsLinux ?? (() => Platform.isLinux);
|
||||
_logFilePathProvider = logFilePathProvider ?? rust.logFilePathStr;
|
||||
_currentAbiProvider = currentAbiProvider ?? Abi.current;
|
||||
}
|
||||
@@ -201,19 +201,26 @@ bool androidShowsLimiterControl(AudioProcessingConfigState state) {
|
||||
|
||||
/// Never leave the UI on the hidden disabled backend.
|
||||
///
|
||||
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
|
||||
/// available internally as a runtime fallback.
|
||||
/// Desktop keeps Silero as the default, but still allows a user-chosen
|
||||
/// WebRTC fallback when ONNX Runtime is unavailable.
|
||||
rust.BridgeVadBackend normalizedVadBackend(
|
||||
rust.BridgeVadBackend backend, {
|
||||
bool? isWindows,
|
||||
bool? isLinux,
|
||||
}) {
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
|
||||
return backend == rust.BridgeVadBackend.disabled
|
||||
final normalized = backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (!desktop) {
|
||||
return normalized;
|
||||
}
|
||||
return switch (normalized) {
|
||||
rust.BridgeVadBackend.webrtcVad ||
|
||||
rust.BridgeVadBackend.sileroOnnx => normalized,
|
||||
_ => rust.BridgeVadBackend.sileroOnnx,
|
||||
};
|
||||
}
|
||||
|
||||
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
@@ -11,7 +9,12 @@ import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const Color _chatSidebarSelectedTileColor = Color(0xFF415366);
|
||||
const double _chatSidebarIndicatorExtent = 76;
|
||||
const double _chatSidebarIconExtent = 24;
|
||||
const double _chatSidebarIconSize = 20;
|
||||
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
);
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
@@ -750,56 +753,64 @@ class _ChatSidebar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dividerColor = theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.45,
|
||||
);
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Column(
|
||||
children: [
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: privateChats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chat = privateChats[index];
|
||||
final selected = switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) =>
|
||||
field0 == chat.id,
|
||||
_ => false,
|
||||
};
|
||||
return _ChatSidebarItem(
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: selected,
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
),
|
||||
],
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: privateChats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chat = privateChats[index];
|
||||
final selected = switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) =>
|
||||
field0 == chat.id,
|
||||
_ => false,
|
||||
};
|
||||
return _ChatSidebarItem(
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: selected,
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 8, 12),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -821,48 +832,63 @@ class _ChatSidebarItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final fg = selected ? Colors.white : theme.colorScheme.onSurface;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
width: _chatSidebarTileExtent,
|
||||
height: _chatSidebarTileExtent,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? _chatSidebarSelectedTileColor
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [Icon(icon, size: 18, color: fg)],
|
||||
final scheme = theme.colorScheme;
|
||||
final indicatorColor = selected
|
||||
? scheme.primaryContainer
|
||||
: Colors.transparent;
|
||||
final contentColor = selected
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant;
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: contentColor,
|
||||
height: 1.15,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
height: _chatSidebarTileExtent,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _chatSidebarIndicatorExtent,
|
||||
height: _chatSidebarIndicatorExtent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: indicatorColor,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _chatSidebarIconExtent,
|
||||
height: _chatSidebarIconExtent,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: _chatSidebarIconSize,
|
||||
color: contentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
height: 1.1,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: _chatSidebarIndicatorExtent - 16,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1018,6 +1044,7 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
final _scrollCtl = ScrollController();
|
||||
int _lastRenderedMessageCount = -1;
|
||||
rust.BridgeMessageTarget? _lastRenderedTarget;
|
||||
bool _sending = false;
|
||||
|
||||
Iterable<ChatEntry> get _filtered {
|
||||
if (widget.target is rust.BridgeMessageTarget_Channel) {
|
||||
@@ -1047,30 +1074,51 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
Future<void> _send() async {
|
||||
final text = _textCtl.text.trim();
|
||||
if (text.isEmpty || !_canSend) return;
|
||||
if (text.isEmpty || !_canSend || _sending) return;
|
||||
_textCtl.clear();
|
||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
ChatEntry(
|
||||
senderId: ownId,
|
||||
senderName: widget.target is rust.BridgeMessageTarget_Poke
|
||||
? widget.clientName
|
||||
: 'You',
|
||||
message: text,
|
||||
target: widget.target,
|
||||
isSelf: true,
|
||||
timestamp: DateTime.now(),
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await rust.sendChatMessage(message: text, target: widget.target);
|
||||
if (!mounted) return;
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
ChatEntry(
|
||||
senderId: ownId,
|
||||
senderName: widget.target is rust.BridgeMessageTarget_Poke
|
||||
? widget.clientName
|
||||
: 'You',
|
||||
message: text,
|
||||
target: widget.target,
|
||||
isSelf: true,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
_textCtl.text = text;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
'Could not send message: $error',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _sending = false);
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
const int pttMouseBackButtonBitmask = 0x08;
|
||||
const int pttMouseForwardButtonBitmask = 0x10;
|
||||
|
||||
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
|
||||
/// stored by the PTT binding flow.
|
||||
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
@@ -46,6 +49,27 @@ String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String? pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey key) {
|
||||
return switch (key) {
|
||||
LogicalKeyboardKey.browserBack ||
|
||||
LogicalKeyboardKey.goBack => 'mouse-side-button:$pttMouseBackButtonBitmask',
|
||||
LogicalKeyboardKey.browserForward =>
|
||||
'mouse-side-button:$pttMouseForwardButtonBitmask',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String? pttMouseSideButtonPlatformKeyForButtons(int buttons) {
|
||||
if ((buttons & pttMouseBackButtonBitmask) == pttMouseBackButtonBitmask) {
|
||||
return 'mouse-side-button:$pttMouseBackButtonBitmask';
|
||||
}
|
||||
if ((buttons & pttMouseForwardButtonBitmask) ==
|
||||
pttMouseForwardButtonBitmask) {
|
||||
return 'mouse-side-button:$pttMouseForwardButtonBitmask';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Result of a successful PTT binding capture.
|
||||
class CapturedBinding {
|
||||
const CapturedBinding({required this.inputClass, required this.platformKey});
|
||||
@@ -181,6 +205,16 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final mouseSideButton = pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
event.logicalKey,
|
||||
);
|
||||
if (mouseSideButton != null) {
|
||||
setState(() {
|
||||
_captured = mouseSideButton;
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||
if (label == null) return KeyEventResult.ignored;
|
||||
setState(() {
|
||||
@@ -190,9 +224,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _captureMouseSideButton(int button) {
|
||||
void _captureMouseSideButton(String platformKey) {
|
||||
setState(() {
|
||||
_captured = 'mouse-side-button:$button';
|
||||
_captured = platformKey;
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
}
|
||||
@@ -212,10 +246,11 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
const int back = 0x08;
|
||||
const int forward = 0x10;
|
||||
if (e.buttons == back || e.buttons == forward) {
|
||||
_captureMouseSideButton(e.buttons);
|
||||
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
|
||||
e.buttons,
|
||||
);
|
||||
if (platformKey != null) {
|
||||
_captureMouseSideButton(platformKey);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/startup_dependency_check.dart';
|
||||
|
||||
class StartupDependencyGate extends StatefulWidget {
|
||||
const StartupDependencyGate({
|
||||
required this.child,
|
||||
this.checker = checkStartupDependencies,
|
||||
this.logger = logStartupDependencyIssues,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Future<StartupDependencyCheckResult> Function() checker;
|
||||
final Future<void> Function(StartupDependencyCheckResult result) logger;
|
||||
|
||||
@override
|
||||
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
|
||||
}
|
||||
|
||||
class _StartupDependencyGateState extends State<StartupDependencyGate> {
|
||||
late Future<StartupDependencyCheckResult> _future;
|
||||
bool _dismissedForSession = false;
|
||||
String? _lastLoggedIssueSignature;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.checker();
|
||||
}
|
||||
|
||||
void _recheck() {
|
||||
setState(() {
|
||||
_future = widget.checker();
|
||||
_dismissedForSession = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_dismissedForSession) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
return FutureBuilder<StartupDependencyCheckResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const _StartupCheckLoadingView();
|
||||
}
|
||||
|
||||
final result = snapshot.data;
|
||||
if (result == null || !result.hasIssues) {
|
||||
_lastLoggedIssueSignature = null;
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
_logIssueScreenShown(result);
|
||||
return StartupDependencyScreen(
|
||||
result: result,
|
||||
onContinue: () {
|
||||
setState(() {
|
||||
_dismissedForSession = true;
|
||||
});
|
||||
},
|
||||
onRecheck: _recheck,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _logIssueScreenShown(StartupDependencyCheckResult result) {
|
||||
final signature = [
|
||||
result.platformLabel,
|
||||
for (final issue in result.issues)
|
||||
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}',
|
||||
].join('|');
|
||||
if (_lastLoggedIssueSignature == signature) {
|
||||
return;
|
||||
}
|
||||
_lastLoggedIssueSignature = signature;
|
||||
unawaited(widget.logger(result));
|
||||
}
|
||||
}
|
||||
|
||||
class StartupDependencyScreen extends StatelessWidget {
|
||||
const StartupDependencyScreen({
|
||||
required this.result,
|
||||
required this.onContinue,
|
||||
required this.onRecheck,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StartupDependencyCheckResult result;
|
||||
final VoidCallback onContinue;
|
||||
final VoidCallback onRecheck;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasBlockingIssues = result.hasBlockingIssues;
|
||||
final content = Column(
|
||||
children: [
|
||||
Icon(
|
||||
hasBlockingIssues
|
||||
? Icons.warning_amber_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 52,
|
||||
color: hasBlockingIssues
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Finish Linux setup',
|
||||
style: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
hasBlockingIssues
|
||||
? 'Chanora started, but some Linux runtime packages are still missing. Install them, then recheck.'
|
||||
: 'Chanora started, but a few optional Linux runtime components are still missing. You can install them now or continue with limited functionality.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Detected platform: ${result.platformLabel}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...result.issues.map((issue) => _DependencyIssueCard(issue: issue)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onRecheck,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Recheck'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onContinue,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
label: Text(
|
||||
hasBlockingIssues
|
||||
? 'Continue with limited mode'
|
||||
: 'Continue anyway',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Scrollbar(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 880),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DependencyIssueCard extends StatelessWidget {
|
||||
const _DependencyIssueCard({required this.issue});
|
||||
|
||||
final StartupDependencyIssue issue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final containerColor = issue.isRequired
|
||||
? scheme.errorContainer
|
||||
: scheme.secondaryContainer;
|
||||
final onContainerColor = issue.isRequired
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
color: containerColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
issue.isRequired
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.settings_suggest_rounded,
|
||||
color: onContainerColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
issue.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
issue.summary,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SeverityBadge(issue: issue),
|
||||
],
|
||||
),
|
||||
if (issue.details.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
...issue.details.map(
|
||||
(detail) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'• $detail',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (issue.installHints.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Install help',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...issue.installHints.map(
|
||||
(hint) => _InstallHintTile(
|
||||
hint: hint,
|
||||
foregroundColor: onContainerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallHintTile extends StatelessWidget {
|
||||
const _InstallHintTile({required this.hint, required this.foregroundColor});
|
||||
|
||||
final StartupInstallHint hint;
|
||||
final Color foregroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final isUrl = _isWebUrl(hint.command);
|
||||
final VoidCallback? onTap = isUrl
|
||||
? () => unawaited(_openUrl(context, Uri.parse(hint.command)))
|
||||
: null;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: foregroundColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
hint.label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
if (isUrl) {
|
||||
await _openUrl(context, Uri.parse(hint.command));
|
||||
return;
|
||||
}
|
||||
await Clipboard.setData(ClipboardData(text: hint.command));
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Install command copied')),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
isUrl
|
||||
? Icons.open_in_new_rounded
|
||||
: Icons.content_copy_rounded,
|
||||
),
|
||||
label: Text(isUrl ? 'Open' : 'Copy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
hint.command,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: foregroundColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isWebUrl(String value) {
|
||||
final uri = Uri.tryParse(value);
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.hasAuthority;
|
||||
}
|
||||
|
||||
Future<void> _openUrl(BuildContext context, Uri uri) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened && context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not open ${uri.toString()}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SeverityBadge extends StatelessWidget {
|
||||
const _SeverityBadge({required this.issue});
|
||||
|
||||
final StartupDependencyIssue issue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final foregroundColor = issue.isRequired
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: foregroundColor.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: foregroundColor.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
issue.isRequired ? 'Required' : 'Recommended',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupCheckLoadingView extends StatelessWidget {
|
||||
const _StartupCheckLoadingView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Checking Linux runtime dependencies…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -786,6 +786,15 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (_isDesktopSileroVadHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Silero needs ONNX Runtime. WebRTC works without it.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Debug.
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -299,6 +299,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_isDesktopSileroVadHost)
|
||||
Text(
|
||||
'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_isDesktopSileroVadHost) const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
|
||||
@@ -59,15 +59,10 @@ const vadBackendSegments = [
|
||||
|
||||
/// Desktop VAD selector segments.
|
||||
///
|
||||
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
|
||||
/// internal runtime fallback when the model/runtime is unavailable.
|
||||
const desktopVadBackendSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
label: Text('Silero'),
|
||||
icon: Icon(Icons.psychology, size: 14),
|
||||
),
|
||||
];
|
||||
/// Desktop keeps Silero as the default, but WebRTC remains a supported
|
||||
/// manual fallback when ONNX Runtime is missing or when the user wants a
|
||||
/// smaller dependency surface.
|
||||
const desktopVadBackendSegments = vadBackendSegments;
|
||||
|
||||
/// Section subheader used by both voice settings surfaces.
|
||||
class VoiceSubHeader extends StatelessWidget {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/startup_dependency_check.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
debugResetStartupDependencyCheck();
|
||||
});
|
||||
|
||||
test('non-linux hosts skip startup dependency issues', () async {
|
||||
debugResetStartupDependencyCheck(platformIsLinux: () => false);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('missing SDL2 is reported as required on Debian-like systems', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => false,
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=ubuntu\nID_LIKE=debian\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.hasIssues, isTrue);
|
||||
final sdl = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-sdl2-runtime',
|
||||
);
|
||||
expect(sdl.isRequired, isTrue);
|
||||
expect(sdl.installHints.single.command, 'sudo apt install libsdl2-2.0-0');
|
||||
});
|
||||
|
||||
test(
|
||||
'missing ONNX runtime is reported as recommended when SDL2 is present',
|
||||
() async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => candidate.contains('SDL2'),
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=fedora\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
currentAbiProvider: () => Abi.linuxX64,
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, hasLength(1));
|
||||
final ort = result.issues.single;
|
||||
expect(ort.id, 'linux-onnxruntime');
|
||||
expect(ort.isRequired, isFalse);
|
||||
expect(
|
||||
ort.installHints.any(
|
||||
(hint) =>
|
||||
hint.command ==
|
||||
'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.installHints.any(
|
||||
(hint) => hint.command == 'onnxruntime-linux-x64-<version>.tgz',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.installHints.any((hint) => hint.command.contains('ORT_DYLIB_PATH')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.details,
|
||||
contains(
|
||||
'This machine needs the Linux x64 CPU archive (onnxruntime-linux-x64-<version>.tgz).',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('bundle-local ONNX runtime clears the recommendation', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => candidate.contains('SDL2'),
|
||||
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('logging missing startup dependencies appends a log line', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'chanora-startup-log',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final logFile = File('${tempDir.path}/chanora.log');
|
||||
|
||||
debugResetStartupDependencyCheck(logFilePathProvider: () => logFile.path);
|
||||
|
||||
const result = StartupDependencyCheckResult(
|
||||
platformLabel: 'Fedora',
|
||||
issues: [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-sdl2-runtime',
|
||||
title: 'SDL2 runtime is missing',
|
||||
summary: 'Audio playback needs SDL2.',
|
||||
details: ['Install SDL2 and recheck.'],
|
||||
severity: StartupDependencySeverity.required,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await logStartupDependencyIssues(result);
|
||||
|
||||
final text = await logFile.readAsString();
|
||||
expect(text, contains('[startup_dependency_check]'));
|
||||
expect(text, contains('platform="Fedora"'));
|
||||
expect(
|
||||
text,
|
||||
contains('issues=[linux-sdl2-runtime:required:SDL2 runtime is missing]'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -31,14 +31,14 @@ void main() {
|
||||
expect(state.agcEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('normalizes desktop VAD backend to Silero', () {
|
||||
test('desktop VAD normalization keeps explicit WebRTC selections', () {
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
@@ -46,6 +46,14 @@ void main() {
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.energyDebug,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
});
|
||||
@@ -103,6 +111,7 @@ void main() {
|
||||
|
||||
test('builds Windows/Linux software WebRTC APM config consistently', () {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig)
|
||||
..vadBackend = rust.BridgeVadBackend.webrtcVad
|
||||
..nsEnabled = true
|
||||
..aecEnabled = false
|
||||
..agcEnabled = true;
|
||||
@@ -127,7 +136,7 @@ void main() {
|
||||
|
||||
for (final config in [windowsConfig, linuxConfig]) {
|
||||
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||
expect(config.aec, rust.BridgeEffectOwner.off);
|
||||
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
|
||||
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/widgets/input_dialogs.dart';
|
||||
|
||||
void main() {
|
||||
test('browser back and forward logical keys map to mouse side bindings', () {
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
LogicalKeyboardKey.browserBack,
|
||||
),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey.goBack),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
LogicalKeyboardKey.browserForward,
|
||||
),
|
||||
'mouse-side-button:16',
|
||||
);
|
||||
});
|
||||
|
||||
test('pointer button bitmasks map to mouse side bindings', () {
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x08),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x10),
|
||||
'mouse-side-button:16',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x18),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(pttMouseSideButtonPlatformKeyForButtons(0x00), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/startup_dependency_check.dart';
|
||||
import 'package:chanora_flutter/widgets/startup_dependency_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('startup gate shows install-help screen and can continue', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 1800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final result = StartupDependencyCheckResult(
|
||||
platformLabel: 'Fedora',
|
||||
issues: const [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-sdl2-runtime',
|
||||
title: 'SDL2 runtime is missing',
|
||||
summary: 'Audio playback needs SDL2.',
|
||||
details: ['Install SDL2 and recheck.'],
|
||||
severity: StartupDependencySeverity.required,
|
||||
installHints: [
|
||||
StartupInstallHint(
|
||||
label: 'Release downloads',
|
||||
command: 'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install SDL2',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
final loggedResults = <StartupDependencyCheckResult>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StartupDependencyGate(
|
||||
checker: () async => result,
|
||||
logger: (value) async {
|
||||
loggedResults.add(value);
|
||||
},
|
||||
child: const Scaffold(body: Text('ready')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finish Linux setup'), findsOneWidget);
|
||||
expect(find.text('SDL2 runtime is missing'), findsOneWidget);
|
||||
expect(find.text('Continue with limited mode'), findsOneWidget);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
expect(find.text('Copy'), findsOneWidget);
|
||||
expect(loggedResults, [result]);
|
||||
|
||||
await tester.ensureVisible(find.text('Continue with limited mode'));
|
||||
await tester.tap(find.text('Continue with limited mode'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -24,8 +24,9 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
test('desktop VAD segments expose Silero as the primary backend', () {
|
||||
test('desktop VAD segments expose both Silero and WebRTC', () {
|
||||
expect(desktopVadBackendSegments.map((s) => s.value), [
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ fn is_gnome_on_wayland() -> bool {
|
||||
trait BlockingGlobalShortcuts {
|
||||
/// Version property (read on the blocking proxy as a fast
|
||||
/// reachability probe).
|
||||
#[zbus(property)]
|
||||
#[zbus(property, name = "version")]
|
||||
fn version(&self) -> zbus::Result<u32>;
|
||||
}
|
||||
|
||||
|
||||
@@ -618,6 +618,14 @@ async fn connection_task(
|
||||
std::time::Instant,
|
||||
),
|
||||
> = HashMap::new();
|
||||
let mut pending_text_messages: HashMap<
|
||||
MessageHandle,
|
||||
(
|
||||
MessageTarget,
|
||||
oneshot::Sender<Result<(), ProtocolError>>,
|
||||
std::time::Instant,
|
||||
),
|
||||
> = HashMap::new();
|
||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||
|
||||
// Main loop: pump events, service requests, forward voice.
|
||||
@@ -726,28 +734,7 @@ async fn connection_task(
|
||||
if let Some((_target_channel, 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 mapped = map_command_result(result, "client_move");
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(mapped);
|
||||
} else if let Err(err) = mapped {
|
||||
@@ -757,6 +744,11 @@ async fn connection_task(
|
||||
"client_move completed in background with error"
|
||||
);
|
||||
}
|
||||
} else if let Some((_target, reply, _deadline)) =
|
||||
pending_text_messages.remove(&handle)
|
||||
{
|
||||
let mapped = map_command_result(result, "text_message");
|
||||
let _ = reply.send(mapped);
|
||||
}
|
||||
}
|
||||
_ => { /* book / message / other events: ignore */ }
|
||||
@@ -801,6 +793,24 @@ async fn connection_task(
|
||||
}
|
||||
}
|
||||
}
|
||||
if !pending_text_messages.is_empty() {
|
||||
let now = std::time::Instant::now();
|
||||
let expired: Vec<MessageHandle> = pending_text_messages
|
||||
.iter()
|
||||
.filter_map(|(handle, (_, _, deadline))| {
|
||||
if now >= *deadline {
|
||||
Some(*handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for handle in expired {
|
||||
if let Some((_target, reply, _)) = pending_text_messages.remove(&handle) {
|
||||
let _ = reply.send(Err(ProtocolError::Timeout));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Service at most one control request (non-blocking).
|
||||
match rx.try_recv() {
|
||||
@@ -850,10 +860,15 @@ async fn connection_task(
|
||||
message,
|
||||
target,
|
||||
reply,
|
||||
}) => {
|
||||
let r = send_text_message(&mut con, &message, target);
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
}) => match send_text_message(&mut con, &message, target) {
|
||||
Ok(handle) => {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||
pending_text_messages.insert(handle, (target, reply, deadline));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(e));
|
||||
}
|
||||
},
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
@@ -957,43 +972,43 @@ fn send_text_message(
|
||||
con: &mut Connection,
|
||||
message: &str,
|
||||
target: MessageTarget,
|
||||
) -> Result<(), ProtocolError> {
|
||||
) -> Result<MessageHandle, ProtocolError> {
|
||||
use tsproto_types::TextMessageTargetMode;
|
||||
match target {
|
||||
MessageTarget::Server => {
|
||||
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")?;
|
||||
}
|
||||
MessageTarget::Server => send_text_to_mode(con, message, TextMessageTargetMode::Server, "server"),
|
||||
MessageTarget::Channel => {
|
||||
// Fix: previously channel messages were sent via
|
||||
// state.server.send_textmessage() which always uses
|
||||
// TextMessageTargetMode::Server. Now correctly uses
|
||||
// TextMessageTargetMode::Channel so the message is
|
||||
// scoped to the current channel, not server-wide.
|
||||
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")?;
|
||||
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")
|
||||
}
|
||||
MessageTarget::Client(client_id) => {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
let client = find_client_by_id(state.clients.values(), client_id)?;
|
||||
client
|
||||
let handle = client
|
||||
.send_textmessage(message)
|
||||
.send(con)
|
||||
.send_with_result(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
|
||||
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
|
||||
Ok(handle)
|
||||
}
|
||||
MessageTarget::Poke(client_id) => {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
let client = find_client_by_id(state.clients.values(), client_id)?;
|
||||
client
|
||||
let handle = client
|
||||
.poke(message)
|
||||
.send(con)
|
||||
.send_with_result(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
|
||||
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
|
||||
Ok(handle)
|
||||
}
|
||||
}
|
||||
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_text_to_mode(
|
||||
@@ -1001,7 +1016,7 @@ fn send_text_to_mode(
|
||||
message: &str,
|
||||
target: tsproto_types::TextMessageTargetMode,
|
||||
label: &str,
|
||||
) -> Result<(), ProtocolError> {
|
||||
) -> Result<MessageHandle, ProtocolError> {
|
||||
use ts_bookkeeping::messages::c2s;
|
||||
|
||||
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(c2s::OutSendTextMessagePart {
|
||||
@@ -1009,10 +1024,31 @@ fn send_text_to_mode(
|
||||
target_client_id: None,
|
||||
message: message.into(),
|
||||
}))
|
||||
.send(con)
|
||||
.send_with_result(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
|
||||
}
|
||||
|
||||
fn map_command_result(
|
||||
result: Result<(), tsclientlib::CommandError>,
|
||||
action: &str,
|
||||
) -> Result<(), ProtocolError> {
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(cmd_err) => {
|
||||
let code = cmd_err.error as u32;
|
||||
let message = cmd_err.error.to_string();
|
||||
info!(
|
||||
target: "chanora_protocol",
|
||||
action,
|
||||
code,
|
||||
message = %message,
|
||||
"server rejected command"
|
||||
);
|
||||
Err(ProtocolError::ServerRejected { code, message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_client_by_id<'a>(
|
||||
clients: impl IntoIterator<Item = &'a Client>,
|
||||
client_id: u64,
|
||||
|
||||
Reference in New Issue
Block a user