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
@@ -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;
}