chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -18,19 +18,17 @@ Future<void>? _storageInitFuture;
|
||||
Future<void>? _vadBootstrapFuture;
|
||||
StorageDirectoryProvider _storageDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageDirectoryProvider _vadModelDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||
|
||||
Future<void> _defaultStorageInitializer(String dir) {
|
||||
return rust.initStorage(dir: dir);
|
||||
}
|
||||
|
||||
Future<File> _copyBundledAssetToManagedDirectory({
|
||||
Future<File> _copyBundledAssetToDocuments({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await _vadModelDirectoryProvider();
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
@@ -61,7 +59,7 @@ Future<void> configureBundledVadModels() async {
|
||||
}
|
||||
|
||||
Future<void> _configureBundledVadModelsImpl() async {
|
||||
final silero = await _copyBundledAssetToManagedDirectory(
|
||||
final silero = await _copyBundledAssetToDocuments(
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
@@ -126,15 +124,12 @@ Future<void> _wireStorageImpl() async {
|
||||
@visibleForTesting
|
||||
void debugResetStorageBootstrap({
|
||||
StorageDirectoryProvider? storageDirectoryProvider,
|
||||
StorageDirectoryProvider? vadModelDirectoryProvider,
|
||||
StorageInitializer? storageInitializer,
|
||||
}) {
|
||||
_storageInitFuture = null;
|
||||
_vadBootstrapFuture = null;
|
||||
_storageDirectoryProvider =
|
||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_vadModelDirectoryProvider =
|
||||
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ extension ConnectionPhaseState on ConnectionPhase {
|
||||
|
||||
bool get canDisconnect => canOpenChat;
|
||||
|
||||
bool shouldShowSnapshotLoading({required bool hasSnapshot}) =>
|
||||
isServerReachable && !hasSnapshot;
|
||||
|
||||
bool canOpenChatWithSnapshot({required bool hasSnapshot}) =>
|
||||
canOpenChat && hasSnapshot;
|
||||
|
||||
ConnectionTokens tokens(ColorScheme colorScheme) {
|
||||
switch (this) {
|
||||
case ConnectionPhase.idle:
|
||||
@@ -52,6 +58,15 @@ extension ConnectionPhaseState on ConnectionPhase {
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionPhase phaseAfterConnectedEvent(
|
||||
ConnectionPhase phase, {
|
||||
required bool hasSnapshot,
|
||||
}) {
|
||||
return phase == ConnectionPhase.connected && hasSnapshot
|
||||
? ConnectionPhase.connected
|
||||
: ConnectionPhase.synchronizing;
|
||||
}
|
||||
|
||||
String connectionStatusText({
|
||||
required ConnectionPhase phase,
|
||||
required AppL10n l10n,
|
||||
@@ -77,3 +92,9 @@ String connectionStatusText({
|
||||
return l10n.statusIdle;
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionPhase phaseAfterSnapshotApplied(ConnectionPhase phase) {
|
||||
return phase == ConnectionPhase.synchronizing
|
||||
? ConnectionPhase.connected
|
||||
: phase;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LinkTrustService extends ChangeNotifier {
|
||||
@@ -54,42 +56,45 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Open external link?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('You are about to open a link to:\n\n$domain'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: remember,
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => remember = v ?? false),
|
||||
builder: (ctx, setDialogState) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.linkTrustTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.linkTrustBody(domain)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: remember,
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => remember = v ?? false),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(child: Text('Trust all links from this domain')),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(l10n.linkTrustRememberDomain)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(null),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(remember),
|
||||
child: Text(l10n.openAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(remember),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
class PrefetchDebouncer {
|
||||
PrefetchDebouncer({
|
||||
required this.onPrefetch,
|
||||
this.delay = const Duration(milliseconds: 700),
|
||||
});
|
||||
|
||||
final Future<void> Function(String host) onPrefetch;
|
||||
final Duration delay;
|
||||
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
|
||||
void schedule(String rawHost) {
|
||||
if (_disposed) return;
|
||||
_timer?.cancel();
|
||||
final host = rawHost.trim();
|
||||
if (host.isEmpty) return;
|
||||
_timer = Timer(delay, () {
|
||||
if (_disposed) return;
|
||||
unawaited(onPrefetch(host));
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
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);
|
||||
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
|
||||
}
|
||||
|
||||
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 [
|
||||
'libpipewire-0.3.so.0',
|
||||
'libpipewire-0.3.so',
|
||||
])) {
|
||||
issues.add(_buildPipeWireIssue(distro));
|
||||
}
|
||||
|
||||
if (!_hasAnyLoadableLibrary(const [
|
||||
'libpulse.so.0',
|
||||
'libpulse.so',
|
||||
'libpulse-simple.so.0',
|
||||
'libpulse-simple.so',
|
||||
])) {
|
||||
issues.add(_buildPulseAudioIssue(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 fields = _parseOsRelease(content);
|
||||
final id = fields['id'] ?? '';
|
||||
final idLike = (fields['id_like'] ?? '').split(RegExp(r'\s+'));
|
||||
final ids = {id, ...idLike};
|
||||
if (ids.contains('fedora')) {
|
||||
return _LinuxDistro.fedora;
|
||||
}
|
||||
if (ids.contains('ubuntu') || ids.contains('debian')) {
|
||||
return _LinuxDistro.debian;
|
||||
}
|
||||
if (ids.contains('arch')) {
|
||||
return _LinuxDistro.arch;
|
||||
}
|
||||
return _LinuxDistro.other;
|
||||
}
|
||||
|
||||
Map<String, String> _parseOsRelease(String content) {
|
||||
final fields = <String, String>{};
|
||||
for (final line in content.split('\n')) {
|
||||
final separator = line.indexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
final key = line.substring(0, separator).trim().toLowerCase();
|
||||
var value = line.substring(separator + 1).trim().toLowerCase();
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
_LinuxArch _detectLinuxArch() {
|
||||
return switch (_currentAbiProvider()) {
|
||||
Abi.linuxX64 => _LinuxArch.x64,
|
||||
Abi.linuxArm64 => _LinuxArch.arm64,
|
||||
_ => _LinuxArch.other,
|
||||
};
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libpipewire-0.3-0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pipewire-libs',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Arch Linux',
|
||||
command: 'sudo pacman -S pipewire',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary:
|
||||
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
|
||||
details: const [
|
||||
'Install the PipeWire runtime package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: hints,
|
||||
);
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libpulse0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pulseaudio-libs',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Arch Linux',
|
||||
command: 'sudo pacman -S libpulse',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-pulseaudio-runtime',
|
||||
title: 'PulseAudio runtime is missing',
|
||||
summary:
|
||||
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
|
||||
details: const [
|
||||
'Install the PulseAudio client library package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
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;
|
||||
}
|
||||
@@ -1,53 +1,35 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum UiThemeMode {
|
||||
system,
|
||||
light,
|
||||
dark;
|
||||
|
||||
static UiThemeMode fromStorage(String? value) {
|
||||
return UiThemeMode.values.firstWhere(
|
||||
(mode) => mode.name == value,
|
||||
orElse: () => UiThemeMode.system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UiSettings {
|
||||
const UiSettings({this.host = '', this.nickname = ''});
|
||||
const UiSettings({
|
||||
this.host = '',
|
||||
this.nickname = '',
|
||||
this.themeMode = UiThemeMode.system,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final String nickname;
|
||||
}
|
||||
|
||||
class ClientPlaybackPreference {
|
||||
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
|
||||
|
||||
final double volume;
|
||||
final bool muted;
|
||||
|
||||
double get appliedVolume => muted ? 0.0 : volume;
|
||||
|
||||
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
|
||||
return ClientPlaybackPreference(
|
||||
volume: _clampVolume(volume ?? this.volume),
|
||||
muted: muted ?? this.muted,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'volume': _clampVolume(volume),
|
||||
'muted': muted,
|
||||
};
|
||||
|
||||
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
|
||||
final volume = json['volume'];
|
||||
return ClientPlaybackPreference(
|
||||
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
|
||||
muted: json['muted'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static double _clampVolume(double volume) {
|
||||
if (!volume.isFinite) return 1.0;
|
||||
return volume.clamp(0.0, 4.0);
|
||||
}
|
||||
final UiThemeMode themeMode;
|
||||
}
|
||||
|
||||
class UiPreferencesService {
|
||||
static const _hostKey = 'ui.host';
|
||||
static const _nicknameKey = 'ui.nickname';
|
||||
static const _themeModeKey = 'ui.theme_mode';
|
||||
static const _permissionsExplainedKey = 'perms_explained';
|
||||
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
|
||||
|
||||
const UiPreferencesService();
|
||||
|
||||
@@ -56,6 +38,7 @@ class UiPreferencesService {
|
||||
return UiSettings(
|
||||
host: prefs.getString(_hostKey) ?? '',
|
||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +48,11 @@ class UiPreferencesService {
|
||||
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
|
||||
}
|
||||
|
||||
Future<void> saveThemeMode(UiThemeMode themeMode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_themeModeKey, themeMode.name);
|
||||
}
|
||||
|
||||
Future<bool> hasExplainedPermissions() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
||||
@@ -74,80 +62,4 @@ class UiPreferencesService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_permissionsExplainedKey, true);
|
||||
}
|
||||
|
||||
Future<Map<String, ClientPlaybackPreference>>
|
||||
loadClientPlaybackPreferencesForServer(String serverHost) async {
|
||||
final normalizedHost = _normalizeServerHost(serverHost);
|
||||
if (normalizedHost.isEmpty) return const {};
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||
if (raw == null || raw.isEmpty) return const {};
|
||||
|
||||
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||
|
||||
final result = <String, ClientPlaybackPreference>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
final (host, uid) = _splitCompositeKey(key);
|
||||
if (host != normalizedHost ||
|
||||
uid.isEmpty ||
|
||||
value is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
result[uid] = ClientPlaybackPreference.fromJson(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> saveClientPlaybackPreference({
|
||||
required String serverHost,
|
||||
required String userUid,
|
||||
double? volume,
|
||||
bool? muted,
|
||||
}) async {
|
||||
final normalizedHost = _normalizeServerHost(serverHost);
|
||||
final normalizedUid = userUid.trim();
|
||||
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final current = await loadClientPlaybackPreferencesForServer(serverHost);
|
||||
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
|
||||
final updated = previous.copyWith(volume: volume, muted: muted);
|
||||
|
||||
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
|
||||
|
||||
if (updated.volume == 1.0 && !updated.muted) {
|
||||
decoded.remove(compositeKey);
|
||||
} else {
|
||||
decoded[compositeKey] = updated.toJson();
|
||||
}
|
||||
|
||||
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
|
||||
}
|
||||
|
||||
String _compositeKey(String normalizedHost, String normalizedUid) {
|
||||
return '$normalizedHost|$normalizedUid';
|
||||
}
|
||||
|
||||
(String, String) _splitCompositeKey(String key) {
|
||||
final index = key.indexOf('|');
|
||||
if (index == -1) return ('', '');
|
||||
return (key.substring(0, index), key.substring(index + 1));
|
||||
}
|
||||
|
||||
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
|
||||
|
||||
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return <String, dynamic>{};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||
} catch (_) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user