diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index af55d30..d9d521c 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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 _focusedPttHeldKeys = {}; + final Set _focusedPttHeldInputs = {}; List _bookmarks = const []; final List _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 _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) { diff --git a/apps/chanora_flutter/lib/services/startup_dependency_check.dart b/apps/chanora_flutter/lib/services/startup_dependency_check.dart new file mode 100644 index 0000000..16efbd4 --- /dev/null +++ b/apps/chanora_flutter/lib/services/startup_dependency_check.dart @@ -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 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 details; + final StartupDependencySeverity severity; + final List 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 Function(String path); +typedef _ResolvedExecutableProvider = String Function(); +typedef _CurrentDirectoryProvider = String Function(); +typedef _OsReleaseProvider = Future 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 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 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 = []; + + 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 _defaultFileExists(String path) => File(path).exists(); + +Future _defaultOsReleaseProvider() async { + const path = '/etc/os-release'; + final file = File(path); + if (!await file.exists()) { + return null; + } + return file.readAsString(); +} + +bool _hasAnyLoadableLibrary(List candidates) { + for (final candidate in candidates) { + if (_libraryProbe(candidate)) { + return true; + } + } + return false; +} + +Future _hasOnnxRuntime({required String executableDir}) async { + final envPath = Platform.environment['ORT_DYLIB_PATH']; + if (envPath != null && envPath.isNotEmpty && await _fileExists(envPath)) { + return true; + } + + final candidates = { + '$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 _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 hints = switch (distro) { + _LinuxDistro.debian => const [ + StartupInstallHint( + label: 'Debian / Ubuntu', + command: 'sudo apt install libsdl2-2.0-0', + ), + ], + _LinuxDistro.fedora => const [ + StartupInstallHint(label: 'Fedora', command: 'sudo dnf install SDL2'), + ], + _LinuxDistro.arch => const [ + StartupInstallHint(label: 'Arch Linux', command: 'sudo pacman -S sdl2'), + ], + _LinuxDistro.other => const [], + }; + + 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-.tgz', + _LinuxArch.arm64 => 'onnxruntime-linux-aarch64-.tgz', + _LinuxArch.other => 'onnxruntime-linux--.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 Function(String path)? fileExists, + String Function()? resolvedExecutableProvider, + String Function()? currentDirectoryProvider, + Future 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; +} diff --git a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart index 1ed4a77..72ccf32 100644 --- a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart +++ b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart @@ -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( diff --git a/apps/chanora_flutter/lib/widgets/chat_views.dart b/apps/chanora_flutter/lib/widgets/chat_views.dart index 953a205..2b91dbf 100644 --- a/apps/chanora_flutter/lib/widgets/chat_views.dart +++ b/apps/chanora_flutter/lib/widgets/chat_views.dart @@ -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 get _filtered { if (widget.target is rust.BridgeMessageTarget_Channel) { @@ -1047,30 +1074,51 @@ class _ChatDetailViewState extends State<_ChatDetailView> { super.dispose(); } - void _send() { + Future _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() { diff --git a/apps/chanora_flutter/lib/widgets/input_dialogs.dart b/apps/chanora_flutter/lib/widgets/input_dialogs.dart index 7187078..e37a6c9 100644 --- a/apps/chanora_flutter/lib/widgets/input_dialogs.dart +++ b/apps/chanora_flutter/lib/widgets/input_dialogs.dart @@ -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 { 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 { 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 { 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( diff --git a/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart b/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart new file mode 100644 index 0000000..c7972da --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart @@ -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 Function() checker; + final Future Function(StartupDependencyCheckResult result) logger; + + @override + State createState() => _StartupDependencyGateState(); +} + +class _StartupDependencyGateState extends State { + late Future _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( + 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 _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…'), + ], + ), + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 601c46e..2e0c71a 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -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), diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index 45cd4be..e07554d 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -299,6 +299,14 @@ class _VoiceSettingsDialogState extends State { 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 && diff --git a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart index 7c15441..f18fb21 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart @@ -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 { diff --git a/apps/chanora_flutter/test/services/startup_dependency_check_test.dart b/apps/chanora_flutter/test/services/startup_dependency_check_test.dart new file mode 100644 index 0000000..477387d --- /dev/null +++ b/apps/chanora_flutter/test/services/startup_dependency_check_test.dart @@ -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-.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-.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]'), + ); + }); +} diff --git a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart index bc3053a..a415974 100644 --- a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart +++ b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart @@ -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); diff --git a/apps/chanora_flutter/test/widgets/input_dialogs_test.dart b/apps/chanora_flutter/test/widgets/input_dialogs_test.dart new file mode 100644 index 0000000..553cb51 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/input_dialogs_test.dart @@ -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); + }); +} diff --git a/apps/chanora_flutter/test/widgets/startup_dependency_screen_test.dart b/apps/chanora_flutter/test/widgets/startup_dependency_screen_test.dart new file mode 100644 index 0000000..dda960d --- /dev/null +++ b/apps/chanora_flutter/test/widgets/startup_dependency_screen_test.dart @@ -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 = []; + + 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); + }); +} diff --git a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart index 9922be8..6f3f525 100644 --- a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart +++ b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart @@ -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, ]); }); diff --git a/crates/chanora_audio/src/ptt_backends/linux.rs b/crates/chanora_audio/src/ptt_backends/linux.rs index f2580e6..5de3492 100644 --- a/crates/chanora_audio/src/ptt_backends/linux.rs +++ b/crates/chanora_audio/src/ptt_backends/linux.rs @@ -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; } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 04f51ab..a7b4cb9 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -618,6 +618,14 @@ async fn connection_task( std::time::Instant, ), > = HashMap::new(); + let mut pending_text_messages: HashMap< + MessageHandle, + ( + MessageTarget, + oneshot::Sender>, + std::time::Instant, + ), + > = HashMap::new(); let mut voice_activity: HashMap = 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 = 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 { 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 { 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, client_id: u64,