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:
Edison Jwa
2026-05-25 11:55:10 +09:00
parent 5c8c6df16b
commit b8df25a195
16 changed files with 1635 additions and 391 deletions
+275 -218
View File
@@ -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) {