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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user