feat: Initial commit of Forge package manager (Flutter + Rust)

This commit is contained in:
RarDog
2026-09-13 18:28:53 +03:00
commit e8c9d13cf1
65 changed files with 14764 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# Rust related
target/
rust/target/
**/target/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "e8113bf45620cbeb8aff64947ee4c93e16adb4cf"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
- platform: linux
create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# forge
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+33
View File
@@ -0,0 +1,33 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+3
View File
@@ -0,0 +1,3 @@
rust_input: "rust/src/api"
rust_root: "rust"
dart_output: "lib/src/rust"
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:macos_ui/macos_ui.dart';
import 'src/rust/frb_generated.dart';
import 'src/services/app_settings_service.dart';
import 'src/ui/main_layout.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Инициализация Rust FFI бэкенда через flutter_rust_bridge v2
await RustLib.init();
runApp(const ForgeApp());
}
class ForgeApp extends StatelessWidget {
const ForgeApp({super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: AppSettingsService.instance,
builder: (context, _) {
final activeStyle = AppSettingsService.instance.effectiveWindowThemeStyle;
final themeData = activeStyle.buildThemeData();
return MacosApp(
title: 'Forge',
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
theme: themeData,
darkTheme: themeData,
home: const MainLayout(),
);
},
);
}
}
+265
View File
@@ -0,0 +1,265 @@
enum BackendType {
pacman,
flatpak,
aur;
String get displayName {
switch (this) {
case BackendType.pacman:
return 'Pacman';
case BackendType.flatpak:
return 'Flatpak';
case BackendType.aur:
return 'AUR';
}
}
}
enum UpdateStep {
checkingDependencies,
downloading,
verifying,
installing,
runningHooks,
done,
failed,
}
class PackageUpdate {
final String name;
final String currentVersion;
final String newVersion;
final String repository;
final int downloadSizeBytes;
final String description;
final bool isSecurityUpdate;
final BackendType backend;
const PackageUpdate({
required this.name,
required this.currentVersion,
required this.newVersion,
required this.repository,
required this.downloadSizeBytes,
required this.description,
required this.isSecurityUpdate,
required this.backend,
});
String get formattedSize {
if (downloadSizeBytes <= 0) return '—';
if (downloadSizeBytes >= 1024 * 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (downloadSizeBytes >= 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (downloadSizeBytes >= 1024) {
return '${(downloadSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$downloadSizeBytes B';
}
}
class PackageInfo {
final String name;
final String version;
final int installedSizeBytes;
final String description;
final String? installDate;
final bool isExplicit;
final BackendType backend;
const PackageInfo({
required this.name,
required this.version,
required this.installedSizeBytes,
required this.description,
this.installDate,
required this.isExplicit,
required this.backend,
});
String get formattedSize {
if (installedSizeBytes <= 0) return '—';
if (installedSizeBytes >= 1024 * 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (installedSizeBytes >= 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (installedSizeBytes >= 1024) {
return '${(installedSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$installedSizeBytes B';
}
}
class PackageDetails {
final String name;
final String version;
final String description;
final String? url;
final List<String> licenses;
final List<String> dependsOn;
final List<String> requiredBy;
final String? packager;
final int downloadSizeBytes;
final int installedSizeBytes;
final String? buildDate;
final String repository;
final BackendType backend;
const PackageDetails({
required this.name,
required this.version,
required this.description,
this.url,
required this.licenses,
required this.dependsOn,
required this.requiredBy,
this.packager,
required this.downloadSizeBytes,
required this.installedSizeBytes,
this.buildDate,
required this.repository,
required this.backend,
});
String get formattedInstalledSize {
if (installedSizeBytes <= 0) return 'Неизвестно';
if (installedSizeBytes >= 1024 * 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (installedSizeBytes >= 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (installedSizeBytes >= 1024) {
return '${(installedSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$installedSizeBytes B';
}
String get formattedDownloadSize {
if (downloadSizeBytes <= 0) return 'Неизвестно';
if (downloadSizeBytes >= 1024 * 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (downloadSizeBytes >= 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (downloadSizeBytes >= 1024) {
return '${(downloadSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$downloadSizeBytes B';
}
}
class ProgressEvent {
final UpdateStep step;
final String? packageName;
final double percentage; // 0.0 .. 1.0
final String message;
final String? logLine;
const ProgressEvent({
required this.step,
this.packageName,
required this.percentage,
required this.message,
this.logLine,
});
}
class CleanerScanResult {
final int pacmanCacheBytes;
final List<String> orphanPackages;
final int orphanBytes;
final int flatpakUnusedCount;
final int flatpakUnusedBytes;
final int journalBytes;
final int totalReclaimableBytes;
const CleanerScanResult({
required this.pacmanCacheBytes,
required this.orphanPackages,
required this.orphanBytes,
required this.flatpakUnusedCount,
required this.flatpakUnusedBytes,
required this.journalBytes,
required this.totalReclaimableBytes,
});
static String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
}
enum HistoryAction {
upgraded,
installed,
removed;
String get displayName {
switch (this) {
case HistoryAction.upgraded:
return 'Обновлено';
case HistoryAction.installed:
return 'Установлено';
case HistoryAction.removed:
return 'Удалено';
}
}
}
class HistoryItem {
final String timestamp;
final HistoryAction action;
final String packageName;
final String? oldVersion;
final String newVersion;
const HistoryItem({
required this.timestamp,
required this.action,
required this.packageName,
this.oldVersion,
required this.newVersion,
});
}
class DiscoverApp {
final String id;
final String name;
final String summary;
final String category;
final String iconName;
final BackendType backend;
final bool isInstalled;
final double rating;
const DiscoverApp({
required this.id,
required this.name,
required this.summary,
required this.category,
required this.iconName,
required this.backend,
required this.isInstalled,
required this.rating,
});
}
class CachedPackageVersion {
final String version;
final String filePath;
final int fileSizeBytes;
final DateTime modifiedTime;
const CachedPackageVersion({
required this.version,
required this.filePath,
required this.fileSizeBytes,
required this.modifiedTime,
});
}
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
class AdminService {
/// Проверка, требуются ли права sudo без ввода пароля (NOPASSWD или уже кэшировано)
static Future<bool> isPasswordRequired() async {
try {
final res = await Process.run('sudo', ['-n', 'true']);
return res.exitCode != 0;
} catch (_) {
return true;
}
}
/// Проверка валидности пароля через sudo -S -v
static Future<bool> verifyPassword(String password) async {
try {
final proc = await Process.start('sudo', ['-S', '-v', '-p', '']);
proc.stdin.writeln(password);
await proc.stdin.flush();
await proc.stdin.close();
final exitCode = await proc.exitCode;
return exitCode == 0;
} catch (_) {
return false;
}
}
/// Запуск команды с правами суперпользователя через sudo -S
static Future<Process> runPrivileged(
String? password,
String executable,
List<String> arguments, {
Map<String, String>? environment,
}) async {
final List<String> sudoArgs = ['-S', '-p', '', executable, ...arguments];
final proc = await Process.start(
'sudo',
sudoArgs,
environment: {
'LC_ALL': 'C',
...?environment,
},
);
if (password != null && password.isNotEmpty) {
proc.stdin.writeln(password);
await proc.stdin.flush();
}
await proc.stdin.close();
return proc;
}
/// Запрос пароля администратора через аутентичный модальный диалог macOS
static Future<String?> promptPassword(
BuildContext context, {
String title = 'Forge запрашивает права администратора',
required String reason,
}) async {
// Если пароль не требуется (NOPASSWD), сразу возвращаем пустую строку
final required = await isPasswordRequired();
if (!required) {
return '';
}
if (!context.mounted) return null;
return showMacosSheet<String>(
context: context,
barrierDismissible: false,
builder: (ctx) => _AdminPasswordSheet(
title: title,
reason: reason,
),
);
}
}
class _AdminPasswordSheet extends StatefulWidget {
final String title;
final String reason;
const _AdminPasswordSheet({
required this.title,
required this.reason,
});
@override
State<_AdminPasswordSheet> createState() => _AdminPasswordSheetState();
}
class _AdminPasswordSheetState extends State<_AdminPasswordSheet> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _isVerifying = false;
String? _errorMessage;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _submit() async {
final pass = _controller.text;
if (pass.isEmpty) {
setState(() => _errorMessage = 'Введите пароль администратора');
return;
}
setState(() {
_isVerifying = true;
_errorMessage = null;
});
final isValid = await AdminService.verifyPassword(pass);
if (!mounted) return;
if (isValid) {
Navigator.of(context).pop(pass);
} else {
setState(() {
_isVerifying = false;
_errorMessage = 'Неверный пароль. Пожалуйста, попробуйте снова.';
});
_controller.clear();
_focusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
final user = Platform.environment['USER'] ?? 'admin';
return Center(
child: Container(
width: 440,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.15),
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.5),
blurRadius: 30,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF0A84FF).withValues(alpha: 0.3),
),
),
child: const Center(
child: MacosIcon(
CupertinoIcons.lock_shield_fill,
color: Color(0xFF0A84FF),
size: 26,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 4),
Text(
widget.reason,
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Пользователь
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const MacosIcon(
CupertinoIcons.person_crop_circle,
size: 18,
color: CupertinoColors.systemGrey,
),
const SizedBox(width: 8),
Text(
'Пользователь: $user',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 12),
// Поле пароля
MacosTextField(
controller: _controller,
focusNode: _focusNode,
obscureText: true,
enabled: !_isVerifying,
placeholder: 'Пароль учетной записи',
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...[
const SizedBox(height: 8),
Row(
children: [
const MacosIcon(
CupertinoIcons.exclamationmark_triangle_fill,
size: 13,
color: CupertinoColors.systemRed,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemRed,
),
),
),
],
),
],
const SizedBox(height: 22),
// Кнопки действий
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: _isVerifying ? null : () => Navigator.of(context).pop(null),
child: const Text('Отмена'),
),
const SizedBox(width: 10),
PushButton(
controlSize: ControlSize.regular,
onPressed: _isVerifying ? null : _submit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isVerifying) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 8),
],
const Text('Подтвердить'),
],
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
class AppBadgeController extends ChangeNotifier {
static final AppBadgeController instance = AppBadgeController._();
AppBadgeController._();
int _updatesCount = 0;
int _cleanerBytes = 0;
int get updatesCount => _updatesCount;
int get cleanerBytes => _cleanerBytes;
void setUpdatesCount(int count) {
if (_updatesCount != count) {
_updatesCount = count;
notifyListeners();
}
}
void setCleanerBytes(int bytes) {
if (_cleanerBytes != bytes) {
_cleanerBytes = bytes;
notifyListeners();
}
}
static String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../ui/theme/window_theme_style.dart';
class AppSettingsService extends ChangeNotifier {
static final AppSettingsService instance = AppSettingsService._();
AppSettingsService._() {
_loadSettings();
}
bool _createSnapshotBeforeUpdate = false;
bool _usePolkit = true;
String _preferredBackend = 'pacman';
String _preferredMirrorCountry = '';
String _windowTheme = 'auto';
bool get createSnapshotBeforeUpdate => _createSnapshotBeforeUpdate;
bool get usePolkit => _usePolkit;
String get preferredBackend => _preferredBackend;
String get preferredMirrorCountry => _preferredMirrorCountry;
String get windowTheme => _windowTheme;
static String detectDesktopEnvironmentName() {
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
final session = (Platform.environment['DESKTOP_SESSION'] ?? '').toUpperCase();
if (xdg.contains('KDE') || session.contains('KDE') || session.contains('PLASMA')) {
return 'KDE Plasma';
} else if (xdg.contains('GNOME') || session.contains('GNOME')) {
return 'GNOME';
}
return 'KDE Plasma';
}
static WindowThemeStyle detectDesktopEnvironmentStyle() {
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
final session = (Platform.environment['DESKTOP_SESSION'] ?? '').toUpperCase();
if (xdg.contains('KDE') || session.contains('KDE') || session.contains('PLASMA')) {
return WindowThemeStyle.kde;
} else if (xdg.contains('GNOME') || session.contains('GNOME')) {
return WindowThemeStyle.gnome;
}
return WindowThemeStyle.kde;
}
WindowThemeStyle get effectiveWindowThemeStyle {
switch (_windowTheme) {
case 'macos':
return WindowThemeStyle.macos;
case 'gnome':
return WindowThemeStyle.gnome;
case 'kde':
return WindowThemeStyle.kde;
case 'auto':
default:
return detectDesktopEnvironmentStyle();
}
}
File get _configFile {
final home = Platform.environment['HOME'] ?? '';
final dir = Directory('$home/.config/forge');
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
return File('${dir.path}/settings.json');
}
Future<void> load() async {
_loadSettings();
}
void _loadSettings() {
try {
final file = _configFile;
if (file.existsSync()) {
final content = file.readAsStringSync();
final Map<String, dynamic> data = jsonDecode(content);
_createSnapshotBeforeUpdate = data['createSnapshotBeforeUpdate'] as bool? ?? false;
_usePolkit = data['usePolkit'] as bool? ?? true;
_preferredBackend = data['preferredBackend'] as String? ?? 'pacman';
_preferredMirrorCountry = data['preferredMirrorCountry'] as String? ?? '';
_windowTheme = data['windowTheme'] as String? ?? 'auto';
}
} catch (_) {}
}
Future<void> _saveSettings() async {
try {
final file = _configFile;
final data = {
'createSnapshotBeforeUpdate': _createSnapshotBeforeUpdate,
'usePolkit': _usePolkit,
'preferredBackend': _preferredBackend,
'preferredMirrorCountry': _preferredMirrorCountry,
'windowTheme': _windowTheme,
};
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(data));
} catch (_) {}
}
Future<void> setWindowTheme(String value) async {
if (_windowTheme == value) return;
_windowTheme = value;
notifyListeners();
await _saveSettings();
}
Future<void> setCreateSnapshotBeforeUpdate(bool value) async {
if (_createSnapshotBeforeUpdate == value) return;
_createSnapshotBeforeUpdate = value;
notifyListeners();
await _saveSettings();
}
Future<void> setUsePolkit(bool value) async {
if (_usePolkit == value) return;
_usePolkit = value;
notifyListeners();
await _saveSettings();
}
Future<void> setPreferredBackend(String value) async {
if (_preferredBackend == value) return;
_preferredBackend = value;
notifyListeners();
await _saveSettings();
}
Future<void> setPreferredMirrorCountry(String value) async {
if (_preferredMirrorCountry == value) return;
_preferredMirrorCountry = value;
notifyListeners();
await _saveSettings();
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:convert';
import 'dart:io';
class ArchNewsItem {
final String title;
final String link;
final String pubDate;
final String description;
final bool isManualIntervention;
const ArchNewsItem({
required this.title,
required this.link,
required this.pubDate,
required this.description,
required this.isManualIntervention,
});
}
class ArchNewsService {
static List<ArchNewsItem>? _cachedNews;
static DateTime? _lastFetchTime;
/// Загрузка последних новостей Arch Linux из официального RSS-фида
static Future<List<ArchNewsItem>> fetchNews({bool forceRefresh = false}) async {
if (!forceRefresh && _cachedNews != null && _lastFetchTime != null) {
if (DateTime.now().difference(_lastFetchTime!).inMinutes < 30) {
return _cachedNews!;
}
}
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 6);
final request = await client.getUrl(Uri.parse('https://archlinux.org/feeds/news/'));
final response = await request.close();
if (response.statusCode == 200) {
final xmlString = await response.transform(utf8.decoder).join();
final items = _parseRss(xmlString);
_cachedNews = items;
_lastFetchTime = DateTime.now();
return items;
}
} catch (_) {
// Fallback через curl, если HttpClient ограничен сетевыми политиками
try {
final res = await Process.run('curl', ['-s', '--max-time', '6', 'https://archlinux.org/feeds/news/']);
if (res.exitCode == 0 && res.stdout.toString().isNotEmpty) {
final items = _parseRss(res.stdout.toString());
_cachedNews = items;
_lastFetchTime = DateTime.now();
return items;
}
} catch (_) {}
}
return _cachedNews ?? [];
}
/// Проверка, требуют ли какие-либо из доступных обновлений ручного вмешательства
static List<ArchNewsItem> checkInterventions(
List<String> updatingPackageNames,
List<ArchNewsItem> newsItems,
) {
final List<ArchNewsItem> matched = [];
final lowerPkgNames = updatingPackageNames.map((p) => p.toLowerCase()).toSet();
for (final news in newsItems) {
if (!news.isManualIntervention) continue;
final lowerTitle = news.title.toLowerCase();
for (final pkg in lowerPkgNames) {
if (lowerTitle.contains(pkg)) {
matched.add(news);
break;
}
}
}
return matched;
}
static List<ArchNewsItem> _parseRss(String xml) {
final List<ArchNewsItem> items = [];
final itemBlocks = xml.split('<item>');
for (int i = 1; i < itemBlocks.length; i++) {
final block = itemBlocks[i];
final rawTitle = _extractTag(block, 'title');
final link = _extractTag(block, 'link');
final rawPubDate = _extractTag(block, 'pubDate');
final rawDesc = _extractTag(block, 'description');
final title = _decodeHtmlEntities(rawTitle).trim();
final cleanDesc = _cleanHtml(rawDesc);
final pubDate = _formatPubDate(rawPubDate);
final isIntervention = title.toLowerCase().contains('manual intervention') ||
title.toLowerCase().contains('breaking changes');
if (title.isNotEmpty) {
items.add(ArchNewsItem(
title: title,
link: link,
pubDate: pubDate,
description: cleanDesc,
isManualIntervention: isIntervention,
));
}
}
return items;
}
static String _decodeHtmlEntities(String text) {
if (text.isEmpty) return '';
return text
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ')
.replaceAll('&#8216;', "'")
.replaceAll('&#8217;', "'")
.replaceAll('&#8220;', '"')
.replaceAll('&#8221;', '"')
.replaceAll('&#8211;', '–')
.replaceAll('&#8212;', '—')
.replaceAll(RegExp(r'&#(\d+);'), '')
.replaceAll(RegExp(r'&#x([0-9a-fA-F]+);'), '');
}
static String _cleanHtml(String raw) {
if (raw.isEmpty) return '';
// Сначала разворачиваем сущности < и > для получения HTML тегов
String text = raw
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&amp;', '&');
// Форматируем списки и абзацы
text = text
.replaceAll(RegExp(r'<\s*br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</\s*p\s*>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<\s*li\s*>', caseSensitive: false), '• ')
.replaceAll(RegExp(r'</\s*li\s*>', caseSensitive: false), '\n');
// Удаляем все оставшиеся HTML теги
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// Декодируем оставшиеся сущности
text = _decodeHtmlEntities(text);
// Нормализуем пробелы и пустые строки
text = text.replaceAll(RegExp(r'[ \t]+'), ' ');
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}
static String _formatPubDate(String rawDate) {
if (rawDate.isEmpty) return '';
try {
// Пример формата: "Tue, 21 Jul 2026 13:01:46 +0000"
final parts = rawDate.trim().split(RegExp(r'\s+'));
if (parts.length >= 4) {
final day = parts[1];
final monthStr = parts[2].toLowerCase();
final year = parts[3];
const months = {
'jan': 'янв',
'feb': 'фев',
'mar': 'мар',
'apr': 'апр',
'may': 'мая',
'jun': 'июн',
'jul': 'июл',
'aug': 'авг',
'sep': 'сен',
'oct': 'окт',
'nov': 'ноя',
'dec': 'дек',
};
final monthRu = months[monthStr] ?? monthStr;
return '$day $monthRu $year';
}
} catch (_) {}
return rawDate;
}
static String _extractTag(String xml, String tag) {
final startTag = '<$tag>';
final endTag = '</$tag>';
final sIdx = xml.indexOf(startTag);
if (sIdx == -1) {
// CDATA or namespaced
final cdataStart = '<$tag><![CDATA[';
final cdataEnd = ']]></$tag>';
final cdIdx = xml.indexOf(cdataStart);
if (cdIdx != -1) {
final cdEnd = xml.indexOf(cdataEnd, cdIdx);
if (cdEnd != -1) {
return xml.substring(cdIdx + cdataStart.length, cdEnd).trim();
}
}
return '';
}
final eIdx = xml.indexOf(endTag, sIdx);
if (eIdx == -1) return '';
return xml.substring(sIdx + startTag.length, eIdx).trim();
}
}
+199
View File
@@ -0,0 +1,199 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../rust/api/packages.dart';
class AurPackage {
final String name;
final String version;
final String description;
final int numVotes;
final double popularity;
final String? maintainer;
final int? lastModified;
final String? url;
final bool isOutOfDate;
const AurPackage({
required this.name,
required this.version,
required this.description,
required this.numVotes,
required this.popularity,
this.maintainer,
this.lastModified,
this.url,
required this.isOutOfDate,
});
factory AurPackage.fromJson(Map<String, dynamic> json) {
return AurPackage(
name: json['Name'] as String? ?? '',
version: json['Version'] as String? ?? '1.0.0',
description: json['Description'] as String? ?? '',
numVotes: (json['NumVotes'] as num?)?.toInt() ?? 0,
popularity: (json['Popularity'] as num?)?.toDouble() ?? 0.0,
maintainer: json['Maintainer'] as String?,
lastModified: (json['LastModified'] as num?)?.toInt(),
url: json['URL'] as String?,
isOutOfDate: json['OutOfDate'] != null,
);
}
}
class AurService {
static String? _cachedHelper;
/// Определение доступного AUR-хелпера (предпочтение paru, затем yay)
static Future<String?> getHelper() async {
if (_cachedHelper != null) return _cachedHelper;
try {
final paruRes = await Process.run('which', ['paru']);
if (paruRes.exitCode == 0 && paruRes.stdout.toString().trim().isNotEmpty) {
_cachedHelper = 'paru';
return _cachedHelper;
}
} catch (_) {}
try {
final yayRes = await Process.run('which', ['yay']);
if (yayRes.exitCode == 0 && yayRes.stdout.toString().trim().isNotEmpty) {
_cachedHelper = 'yay';
return _cachedHelper;
}
} catch (_) {}
return null;
}
/// Поиск пакетов в AUR через официальный RPC API v5
static Future<List<AurPackage>> search(String query) async {
if (query.trim().isEmpty) return [];
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final uri = Uri.parse('https://aur.archlinux.org/rpc/v5/search/${Uri.encodeComponent(query.trim())}');
final req = await client.getUrl(uri);
final res = await req.close();
if (res.statusCode == 200) {
final body = await res.transform(utf8.decoder).join();
final Map<String, dynamic> data = jsonDecode(body);
final results = data['results'] as List<dynamic>? ?? [];
final list = results
.map((item) => AurPackage.fromJson(item as Map<String, dynamic>))
.toList();
// Сортируем по количеству голосов
list.sort((a, b) => b.numVotes.compareTo(a.numVotes));
return list;
}
} catch (_) {
// Fallback через curl
try {
final curlRes = await Process.run('curl', [
'-s',
'--max-time',
'5',
'https://aur.archlinux.org/rpc/v5/search/${Uri.encodeComponent(query.trim())}',
]);
if (curlRes.exitCode == 0) {
final Map<String, dynamic> data = jsonDecode(curlRes.stdout.toString());
final results = data['results'] as List<dynamic>? ?? [];
final list = results
.map((item) => AurPackage.fromJson(item as Map<String, dynamic>))
.toList();
list.sort((a, b) => b.numVotes.compareTo(a.numVotes));
return list;
}
} catch (_) {}
}
return [];
}
/// Сборка и установка пакета AUR через найденный хелпер (paru / yay)
static Stream<ProgressEvent> installPackage(String packageName) async* {
final helper = await getHelper();
if (helper == null) {
yield const ProgressEvent(
step: UpdateStep.failed,
percentage: 1.0,
message: 'Не найден AUR-хелпер (установите yay или paru)',
logLine: '[ERROR] Neither yay nor paru is installed on this system.',
);
return;
}
yield ProgressEvent(
step: UpdateStep.checkingDependencies,
packageName: packageName,
percentage: 0.1,
message: 'Подготовка к сборке $packageName через $helper...',
logLine: ':: Resolving AUR dependencies using $helper for $packageName...',
);
try {
final proc = await Process.start(
helper,
['-S', '--noconfirm', packageName],
environment: {'LC_ALL': 'C'},
);
final controller = StreamController<String>();
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add(line),
onError: (e) => controller.add('[STDOUT ERR] $e'),
);
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add('[STDERR] $line'),
onError: (e) => controller.add('[STDERR ERR] $e'),
);
proc.exitCode.then((_) {
Future.delayed(const Duration(milliseconds: 60), () {
if (!controller.isClosed) controller.close();
});
});
await for (final line in controller.stream) {
yield ProgressEvent(
step: UpdateStep.installing,
packageName: packageName,
percentage: 0.6,
message: 'Сборка и установка $packageName...',
logLine: line,
);
}
final exitCode = await proc.exitCode;
if (exitCode == 0) {
yield ProgressEvent(
step: UpdateStep.done,
packageName: packageName,
percentage: 1.0,
message: 'Пакет $packageName успешно собран и установлен!',
logLine: ':: AUR installation finished successfully.',
);
} else {
yield ProgressEvent(
step: UpdateStep.failed,
packageName: packageName,
percentage: 1.0,
message: 'Ошибка при установке $packageName (код $exitCode)',
logLine: '[ERROR] $helper exited with code $exitCode',
);
}
} catch (e) {
yield ProgressEvent(
step: UpdateStep.failed,
packageName: packageName,
percentage: 1.0,
message: 'Не удалось запустить $helper: $e',
logLine: '[ERROR] Process launch error: $e',
);
}
}
}
+353
View File
@@ -0,0 +1,353 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import '../rust/api/packages.dart';
import 'admin_service.dart';
import 'app_settings_service.dart';
import 'aur_service.dart';
import 'catalog_service.dart';
import 'snapshot_service.dart';
class BatchInstallProgress {
final String currentStep;
final double percentage; // 0.0 .. 1.0
final String? logLine;
final bool isCompleted;
final bool isFailed;
const BatchInstallProgress({
required this.currentStep,
required this.percentage,
this.logLine,
this.isCompleted = false,
this.isFailed = false,
});
}
class BatchInstallService extends ChangeNotifier {
static final BatchInstallService instance = BatchInstallService._();
BatchInstallService._();
final Map<String, CatalogApp> _queue = {};
bool _isInstalling = false;
bool get isInstalling => _isInstalling;
List<CatalogApp> get queueApps => _queue.values.toList();
int get count => _queue.length;
bool get isEmpty => _queue.isEmpty;
List<CatalogApp> get pacmanApps =>
_queue.values.where((a) => a.backend == BackendType.pacman).toList();
List<CatalogApp> get aurApps =>
_queue.values.where((a) => a.backend == BackendType.aur).toList();
List<CatalogApp> get flatpakApps =>
_queue.values.where((a) => a.backend == BackendType.flatpak).toList();
bool contains(CatalogApp app) => _queue.containsKey(app.uniqueKey);
void toggle(CatalogApp app) {
if (contains(app)) {
_queue.remove(app.uniqueKey);
} else {
_queue[app.uniqueKey] = app;
}
notifyListeners();
}
void add(CatalogApp app) {
_queue[app.uniqueKey] = app;
notifyListeners();
}
void remove(CatalogApp app) {
_queue.remove(app.uniqueKey);
notifyListeners();
}
void clear() {
_queue.clear();
notifyListeners();
}
/// Запуск пакетной установки выбранных приложений
Stream<BatchInstallProgress> runBatchInstall({
required String? password,
}) async* {
if (_queue.isEmpty) return;
_isInstalling = true;
notifyListeners();
final toInstall = List<CatalogApp>.from(_queue.values);
final pacmanList = toInstall.where((a) => a.backend == BackendType.pacman).toList();
final aurList = toInstall.where((a) => a.backend == BackendType.aur).toList();
final flatpakList = toInstall.where((a) => a.backend == BackendType.flatpak).toList();
final totalStages = (pacmanList.isNotEmpty ? 1 : 0) +
(aurList.isNotEmpty ? 1 : 0) +
(flatpakList.isNotEmpty ? 1 : 0);
int currentStage = 0;
bool hasErrors = false;
// Шаг 0: Снимок системы, если включено в настройках
if (AppSettingsService.instance.createSnapshotBeforeUpdate && password != null && (pacmanList.isNotEmpty || aurList.isNotEmpty)) {
yield const BatchInstallProgress(
currentStep: 'Создание точки восстановления системы (Snapshot)...',
percentage: 0.05,
logLine: '📸 Создание моментального снимка системы перед пакетной установкой...',
);
try {
final snap = await SnapshotService.createPreUpdateSnapshot(
password,
description: 'Forge batch install (${toInstall.length} apps)',
);
yield BatchInstallProgress(
currentStep: 'Снимок системы создан',
percentage: 0.08,
logLine: snap.success ? '✅ ${snap.message}' : '⚠️ ${snap.message}',
);
} catch (e) {
yield BatchInstallProgress(
currentStep: 'Снимок пропущен',
percentage: 0.08,
logLine: '⚠️ Не удалось создать снимок: $e',
);
}
}
// Шаг 1: Pacman пакеты
if (pacmanList.isNotEmpty) {
currentStage++;
final pkgIds = pacmanList.map((a) => a.id).toList();
final pkgLabels = pacmanList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Установка пакетов Pacman (${pkgLabels.join(", ")})...',
percentage: (currentStage - 0.5) / (totalStages + 0.2),
logLine: '🚀 [Pacman] Установка: ${pkgIds.join(" ")}',
);
try {
final proc = await AdminService.runPrivileged(
password,
'pacman',
['-S', '--noconfirm', '--needed', ...pkgIds],
);
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in stdoutSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman: установка...',
percentage: (currentStage - 0.3) / (totalStages + 0.2),
logLine: line,
);
}
await for (final errLine in stderrSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman: обработка...',
percentage: (currentStage - 0.2) / (totalStages + 0.2),
logLine: errLine,
);
}
final code = await proc.exitCode;
if (code == 0) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman успешно завершен',
percentage: currentStage / (totalStages + 0.2),
logLine: '✅ [Pacman] Пакеты успешно установлены!',
);
for (final a in pacmanList) {
a.isInstalled = true;
_queue.remove(a.uniqueKey);
}
} else {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman завершился с ошибкой',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [Pacman] Ошибка установки (код $code)',
isFailed: true,
);
}
} catch (e) {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Исключение Pacman',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [Pacman] Ошибка: $e',
isFailed: true,
);
}
}
// Шаг 2: AUR пакеты (через yay или paru)
if (aurList.isNotEmpty) {
currentStage++;
final helper = await AurService.getHelper() ?? 'yay';
final pkgIds = aurList.map((a) => a.id).toList();
final pkgLabels = aurList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Сборка и установка AUR ($helper: ${pkgLabels.join(", ")})...',
percentage: (currentStage - 0.5) / (totalStages + 0.2),
logLine: '🚀 [AUR] Сборка через $helper: ${pkgIds.join(" ")}',
);
try {
if (password != null) {
await AdminService.verifyPassword(password);
}
final proc = await Process.start(
helper,
['-S', '--noconfirm', '--needed', ...pkgIds],
mode: ProcessStartMode.normal,
);
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in stdoutSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR ($helper): компиляция и сборка...',
percentage: (currentStage - 0.3) / (totalStages + 0.2),
logLine: line,
);
}
await for (final errLine in stderrSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR ($helper): сообщения...',
percentage: (currentStage - 0.2) / (totalStages + 0.2),
logLine: errLine,
);
}
final code = await proc.exitCode;
if (code == 0) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR успешно завершен',
percentage: currentStage / (totalStages + 0.2),
logLine: '✅ [AUR] Все пакеты успешно собраны и установлены!',
);
for (final a in aurList) {
a.isInstalled = true;
_queue.remove(a.uniqueKey);
}
} else {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR завершился с ошибкой',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [AUR] Ошибка сборки пакетов (код $code)',
isFailed: true,
);
}
} catch (e) {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Исключение AUR',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [AUR] Ошибка: $e',
isFailed: true,
);
}
}
// Шаг 3: Flatpak пакеты
if (flatpakList.isNotEmpty) {
currentStage++;
final pkgIds = flatpakList.map((a) => a.id).toList();
final pkgLabels = flatpakList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Установка Flatpak (${pkgLabels.join(", ")})...',
percentage: (currentStage - 0.5) / (totalStages + 0.2),
logLine: '🚀 [Flatpak] Установка из Flathub: ${pkgIds.join(" ")}',
);
try {
final proc = await Process.start(
'flatpak',
['install', '-y', 'flathub', ...pkgIds],
mode: ProcessStartMode.normal,
);
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in stdoutSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak: скачивание и распаковка...',
percentage: (currentStage - 0.3) / (totalStages + 0.2),
logLine: line,
);
}
await for (final errLine in stderrSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak: логи...',
percentage: (currentStage - 0.2) / (totalStages + 0.2),
logLine: errLine,
);
}
final code = await proc.exitCode;
if (code == 0) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak успешно завершен',
percentage: 1.0,
logLine: '✅ [Flatpak] Приложения успешно установлены!',
);
for (final a in flatpakList) {
a.isInstalled = true;
_queue.remove(a.uniqueKey);
}
} else {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak завершился с кодом $code',
percentage: 1.0,
logLine: '❌ [Flatpak] Ошибка установки (код $code)',
isFailed: true,
);
}
} catch (e) {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Исключение Flatpak',
percentage: 1.0,
logLine: '❌ [Flatpak] Ошибка: $e',
isFailed: true,
);
}
}
_isInstalling = false;
notifyListeners();
if (hasErrors) {
yield const BatchInstallProgress(
currentStep: 'Установка завершена с ошибками',
percentage: 1.0,
logLine: '⚠️ Некоторые пакеты не удалось установить. Проверьте журнал ошибок выше.',
isCompleted: true,
isFailed: true,
);
} else {
yield const BatchInstallProgress(
currentStep: 'Пакетная установка успешно завершена',
percentage: 1.0,
logLine: '🎉 Все операции успешно завершены!',
isCompleted: true,
isFailed: false,
);
}
}
}
+455
View File
@@ -0,0 +1,455 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import '../rust/api/packages.dart';
import 'aur_service.dart';
class CatalogApp {
final String id;
final String name;
final String summary;
final String category;
final BackendType backend;
final String? iconUrl;
final String? localIconPath;
final IconData fallbackIcon;
final Color accentColor;
bool isInstalled;
final double rating;
final String? version;
final int? votes;
CatalogApp({
required this.id,
required this.name,
required this.summary,
required this.category,
required this.backend,
this.iconUrl,
this.localIconPath,
required this.fallbackIcon,
this.accentColor = const Color(0xFF0A84FF),
this.isInstalled = false,
this.rating = 4.8,
this.version,
this.votes,
});
String get uniqueKey => '${backend.name}:$id';
}
class CatalogService {
static final Map<String, String> _knownIconUrls = {
'telegram-desktop': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.telegram.desktop.png',
'org.telegram.desktop': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.telegram.desktop.png',
'visual-studio-code-bin': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
'code': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
'com.visualstudio.code': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
'firefox': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.mozilla.firefox.png',
'org.mozilla.firefox': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.mozilla.firefox.png',
'google-chrome': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.google.Chrome.png',
'com.google.Chrome': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.google.Chrome.png',
'spotify': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.spotify.Client.png',
'com.spotify.Client': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.spotify.Client.png',
'discord': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.discordapp.Discord.png',
'com.discordapp.Discord': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.discordapp.Discord.png',
'steam': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.valvesoftware.Steam.png',
'com.valvesoftware.Steam': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.valvesoftware.Steam.png',
'obs-studio': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.obsproject.Studio.png',
'com.obsproject.Studio': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.obsproject.Studio.png',
'gimp': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.gimp.GIMP.png',
'org.gimp.GIMP': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.gimp.GIMP.png',
'blender': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.blender.Blender.png',
'org.blender.Blender': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.blender.Blender.png',
'krita': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.kde.krita.png',
'org.kde.krita': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.kde.krita.png',
'vlc': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.videolan.VLC.png',
'org.videolan.VLC': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.videolan.VLC.png',
'inkscape': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.inkscape.Inkscape.png',
'org.inkscape.Inkscape': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.inkscape.Inkscape.png',
'obsidian': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/md.obsidian.Obsidian.png',
'md.obsidian.Obsidian': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/md.obsidian.Obsidian.png',
'postman': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.getpostman.Postman.png',
'com.getpostman.Postman': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.getpostman.Postman.png',
'com.usebottles.bottles': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.usebottles.bottles.png',
'com.localsend.localsend_app': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.localsend.localsend_app.png',
'com.github.tchx84.Flatseal': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.github.tchx84.Flatseal.png',
'neovim': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/io.neovim.nvim.png',
'io.neovim.nvim': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/io.neovim.nvim.png',
};
/// Получение кураторских подборок для витрины App Store
static List<CatalogApp> getFeaturedApps() {
return [
CatalogApp(
id: 'org.telegram.desktop',
name: 'Telegram Desktop',
summary: 'Официальный защищённый мессенджер с облачной синхронизацией, каналами и голосовыми чатами.',
category: 'Сеть и общение',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['org.telegram.desktop'],
fallbackIcon: CupertinoIcons.bubble_left_bubble_right_fill,
accentColor: const Color(0xFF2AABEE),
rating: 4.9,
),
CatalogApp(
id: 'visual-studio-code-bin',
name: 'Visual Studio Code',
summary: 'Полнофункциональный и расширяемый редактор кода от Microsoft с поддержкой Git и тысяч плагинов.',
category: 'Разработка',
backend: BackendType.aur,
iconUrl: _knownIconUrls['visual-studio-code-bin'],
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
accentColor: const Color(0xFF007ACC),
rating: 4.9,
),
CatalogApp(
id: 'org.blender.Blender',
name: 'Blender 3D',
summary: 'Профессиональный пакет для 3D-моделирования, анимации, симуляции и видеорендеринга.',
category: 'Графика и 3D',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['org.blender.Blender'],
fallbackIcon: CupertinoIcons.cube_box_fill,
accentColor: const Color(0xFFE87D0D),
rating: 4.95,
),
CatalogApp(
id: 'com.valvesoftware.Steam',
name: 'Steam',
summary: 'Крупнейшая цифровая платформа для загрузки, покупки и запуска компьютерных игр.',
category: 'Игры',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.valvesoftware.Steam'],
fallbackIcon: CupertinoIcons.gamecontroller_fill,
accentColor: const Color(0xFF171A21),
rating: 4.8,
),
CatalogApp(
id: 'org.kde.krita',
name: 'Krita',
summary: 'Свободный графический редактор для иллюстраторов, концепт-артистов и аниматоров.',
category: 'Графика',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['org.kde.krita'],
fallbackIcon: CupertinoIcons.paintbrush_fill,
accentColor: const Color(0xFFEE3F80),
rating: 4.9,
),
CatalogApp(
id: 'com.obsproject.Studio',
name: 'OBS Studio',
summary: 'Бесплатная программа с открытым исходным кодом для записи видео и прямых трансляций.',
category: 'Медиа',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.obsproject.Studio'],
fallbackIcon: CupertinoIcons.video_camera_solid,
accentColor: const Color(0xFF302E2E),
rating: 4.85,
),
];
}
/// Категории витрины
static Map<String, List<CatalogApp>> getCuratedCategories() {
return {
'Разработка и IT': [
CatalogApp(
id: 'neovim',
name: 'Neovim',
summary: 'Модальный текстовый редактор нового поколения для консоли.',
category: 'Разработка',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['neovim'],
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
accentColor: const Color(0xFF57A143),
rating: 4.9,
),
CatalogApp(
id: 'postman',
name: 'Postman',
summary: 'Платформа для тестирования, разработки и документирования REST API.',
category: 'Разработка',
backend: BackendType.aur,
iconUrl: _knownIconUrls['postman'],
fallbackIcon: CupertinoIcons.paperplane_fill,
accentColor: const Color(0xFFFF6C37),
rating: 4.7,
),
CatalogApp(
id: 'git',
name: 'Git',
summary: 'Распределённая система управления версиями исходного кода.',
category: 'Разработка',
backend: BackendType.pacman,
fallbackIcon: CupertinoIcons.arrow_branch,
accentColor: const Color(0xFFF05032),
rating: 5.0,
),
CatalogApp(
id: 'docker',
name: 'Docker',
summary: 'Платформа автоматизации развёртывания приложений в контейнерах.',
category: 'Разработка',
backend: BackendType.pacman,
fallbackIcon: CupertinoIcons.layers_alt_fill,
accentColor: const Color(0xFF2496ED),
rating: 4.8,
),
],
'Творчество и Дизайн': [
CatalogApp(
id: 'gimp',
name: 'GIMP',
summary: 'Свободный графический редактор для растровых изображений и фотографий.',
category: 'Графика',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['gimp'],
fallbackIcon: CupertinoIcons.paintbrush_fill,
accentColor: const Color(0xFF5C5543),
rating: 4.6,
),
CatalogApp(
id: 'inkscape',
name: 'Inkscape',
summary: 'Векторный редактор профессионального уровня с открытым кодом.',
category: 'Графика',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['inkscape'],
fallbackIcon: CupertinoIcons.pencil_outline,
accentColor: const Color(0xFF000000),
rating: 4.7,
),
CatalogApp(
id: 'vlc',
name: 'VLC Media Player',
summary: 'Универсальный медиаплеер, воспроизводящий практически любые файлы.',
category: 'Медиа',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['vlc'],
fallbackIcon: CupertinoIcons.play_circle_fill,
accentColor: const Color(0xFFFF8800),
rating: 4.8,
),
],
'Интернет и Общение': [
CatalogApp(
id: 'discord',
name: 'Discord',
summary: 'Голосовые и текстовые чаты для геймеров, сообществ и друзей.',
category: 'Сеть',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['discord'],
fallbackIcon: CupertinoIcons.chat_bubble_2_fill,
accentColor: const Color(0xFF5865F2),
rating: 4.7,
),
CatalogApp(
id: 'firefox',
name: 'Firefox',
summary: 'Быстрый и защищённый веб-браузер с упором на конфиденциальность.',
category: 'Сеть',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['firefox'],
fallbackIcon: CupertinoIcons.globe,
accentColor: const Color(0xFFFF7139),
rating: 4.85,
),
CatalogApp(
id: 'com.localsend.localsend_app',
name: 'LocalSend',
summary: 'Безопасный обмен файлами по локальной сети без интернета (AirDrop для Linux).',
category: 'Сеть',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.localsend.localsend_app'],
fallbackIcon: CupertinoIcons.share,
accentColor: const Color(0xFF00B4D8),
rating: 4.95,
),
],
'Утилиты и Система': [
CatalogApp(
id: 'com.usebottles.bottles',
name: 'Bottles',
summary: 'Запуск Windows-приложений и игр в изолированных префиксах Wine.',
category: 'Утилиты',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.usebottles.bottles'],
fallbackIcon: CupertinoIcons.tuningfork,
accentColor: const Color(0xFF5C5F66),
rating: 4.8,
),
CatalogApp(
id: 'com.github.tchx84.Flatseal',
name: 'Flatseal',
summary: 'Управление разрешениями, песочницей и доступом приложений Flatpak.',
category: 'Утилиты',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.github.tchx84.Flatseal'],
fallbackIcon: CupertinoIcons.lock_shield_fill,
accentColor: const Color(0xFF333333),
rating: 4.9,
),
],
};
}
/// Проверка установленных пакетов в системе
static Future<void> checkInstalledStatus(List<CatalogApp> apps) async {
try {
final pacmanRes = await Process.run('pacman', ['-Qq']);
final installedPacman = pacmanRes.exitCode == 0
? LineSplitter.split(pacmanRes.stdout as String).map((e) => e.trim()).toSet()
: <String>{};
final flatpakRes = await Process.run('flatpak', ['list', '--columns=application']);
final installedFlatpak = flatpakRes.exitCode == 0
? LineSplitter.split(flatpakRes.stdout as String).map((e) => e.trim()).toSet()
: <String>{};
for (final app in apps) {
if (app.backend == BackendType.flatpak) {
app.isInstalled = installedFlatpak.contains(app.id);
} else {
app.isInstalled = installedPacman.contains(app.name) || installedPacman.contains(app.id);
}
}
} catch (_) {}
}
/// Поиск по всем источникам (Pacman, AUR, Flatpak)
static Future<List<CatalogApp>> searchAll(String query) async {
final cleanQuery = query.trim();
if (cleanQuery.isEmpty) return [];
final results = await Future.wait([
_searchPacman(cleanQuery),
_searchAur(cleanQuery),
_searchFlatpak(cleanQuery),
]);
final all = <CatalogApp>[];
for (final list in results) {
all.addAll(list);
}
await checkInstalledStatus(all);
return all;
}
/// Поиск в Pacman
static Future<List<CatalogApp>> _searchPacman(String query) async {
final List<CatalogApp> list = [];
try {
final res = await Process.run('pacman', ['-Ss', query], environment: {'LC_ALL': 'C'});
if (res.exitCode == 0) {
final lines = res.stdout.toString().split('\n');
for (int i = 0; i < lines.length - 1; i += 2) {
final header = lines[i].trim();
if (header.isEmpty) continue;
final parts = header.split(RegExp(r'\s+'));
if (parts.isEmpty) continue;
final repoName = parts[0];
final name = repoName.contains('/') ? repoName.split('/').last : repoName;
final version = parts.length > 1 ? parts[1] : '';
final desc = (i + 1 < lines.length) ? lines[i + 1].trim() : '';
final iconUrl = _resolveIcon(name);
list.add(CatalogApp(
id: name,
name: name,
summary: desc,
category: repoName.split('/').first,
backend: BackendType.pacman,
version: version,
iconUrl: iconUrl,
fallbackIcon: CupertinoIcons.cube_box_fill,
accentColor: const Color(0xFF1793D1),
));
if (list.length >= 25) break;
}
}
} catch (_) {}
return list;
}
/// Поиск в AUR
static Future<List<CatalogApp>> _searchAur(String query) async {
final List<CatalogApp> list = [];
try {
final aurList = await AurService.search(query);
for (final aur in aurList.take(25)) {
final iconUrl = _resolveIcon(aur.name);
list.add(CatalogApp(
id: aur.name,
name: aur.name,
summary: aur.description.isNotEmpty ? aur.description : 'Пакет AUR (${aur.numVotes} голосов)',
category: 'AUR',
backend: BackendType.aur,
version: aur.version,
votes: aur.numVotes,
iconUrl: iconUrl,
fallbackIcon: CupertinoIcons.sparkles,
accentColor: const Color(0xFFFF9500),
rating: (aur.popularity > 0
? (3.5 + (aur.popularity.clamp(0, 15) / 10)).clamp(3.5, 5.0)
: 4.2),
));
}
} catch (_) {}
return list;
}
/// Поиск во Flatpak / Flathub
static Future<List<CatalogApp>> _searchFlatpak(String query) async {
final List<CatalogApp> list = [];
try {
final fpRes = await Process.run('flatpak', ['search', query, '--columns=app,name,version,description']);
if (fpRes.exitCode == 0) {
final lines = fpRes.stdout.toString().trim().split('\n');
for (final line in lines.take(20)) {
final parts = line.split('\t');
if (parts.length >= 2) {
final id = parts[0].trim();
final name = parts.length > 1 ? parts[1].trim() : id;
final version = parts.length > 2 ? parts[2].trim() : '';
final desc = parts.length > 3 ? parts[3].trim() : '';
// Flathub AppStream CDN Icon URL
final iconUrl = 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/$id.png';
list.add(CatalogApp(
id: id,
name: name,
summary: desc.isNotEmpty ? desc : 'Приложение Flatpak ($id)',
category: 'Flatpak',
backend: BackendType.flatpak,
version: version,
iconUrl: iconUrl,
fallbackIcon: CupertinoIcons.app_badge_fill,
accentColor: const Color(0xFF4A90E2),
));
}
}
}
} catch (_) {}
return list;
}
static String? _resolveIcon(String name) {
if (_knownIconUrls.containsKey(name)) {
return _knownIconUrls[name];
}
final lower = name.toLowerCase();
for (final entry in _knownIconUrls.entries) {
if (entry.key.contains(lower) || lower.contains(entry.key)) {
return entry.value;
}
}
return null;
}
}
+341
View File
@@ -0,0 +1,341 @@
import 'dart:io';
import 'admin_service.dart';
enum IssueSeverity { info, warning, critical }
class DoctorIssue {
final String id;
final String title;
final String description;
final IssueSeverity severity;
final bool canAutoFix;
final String fixButtonLabel;
const DoctorIssue({
required this.id,
required this.title,
required this.description,
required this.severity,
required this.canAutoFix,
this.fixButtonLabel = 'Исправить',
});
}
class PacnewFile {
final String pacnewPath;
final String originalPath;
final String fileName;
const PacnewFile({
required this.pacnewPath,
required this.originalPath,
required this.fileName,
});
Future<String> getDiff() async {
return DoctorService.getDiff(originalPath, pacnewPath);
}
Future<String> readOriginal() async {
try {
return await File(originalPath).readAsString();
} catch (_) {
return '';
}
}
Future<String> readPacnew() async {
try {
return await File(pacnewPath).readAsString();
} catch (_) {
return '';
}
}
}
class DoctorReport {
final int healthScore; // 0 - 100
final List<DoctorIssue> issues;
final List<PacnewFile> pacnewFiles;
final List<String> failedServices;
final bool isKeyringOk;
final bool isDbLocked;
final bool isStaleLock;
final String? activeMirror;
final int? mirrorPingMs;
const DoctorReport({
required this.healthScore,
required this.issues,
required this.pacnewFiles,
required this.failedServices,
required this.isKeyringOk,
required this.isDbLocked,
required this.isStaleLock,
this.activeMirror,
this.mirrorPingMs,
});
bool get hasNoIssues => issues.isEmpty;
bool get dbLockExists => isDbLocked;
bool get keyringHealthy => isKeyringOk;
List<PacnewFile> get pacnewEntries => pacnewFiles;
String get mirrorlistStatus => activeMirror != null
? 'Текущее активное зеркало: $activeMirror'
: 'Зеркала настроены по умолчанию.';
}
class DoctorService {
/// Полная диагностика состояния системы
static Future<DoctorReport> diagnose() async {
final List<DoctorIssue> issues = [];
int penalties = 0;
// 1. Проверка замка базы pacman
final lockFile = File('/var/lib/pacman/db.lck');
final isDbLocked = lockFile.existsSync();
bool isStaleLock = false;
if (isDbLocked) {
// Проверяем, запущен ли pacman
final pgrep = await Process.run('pgrep', ['-x', 'pacman']);
if (pgrep.exitCode != 0) {
isStaleLock = true;
penalties += 35;
issues.add(const DoctorIssue(
id: 'db_lock_stale',
title: 'Зависший замок базы данных pacman (db.lck)',
description:
'Файл /var/lib/pacman/db.lck существует, хотя pacman не запущен. Это блокирует любые операции установки и обновления.',
severity: IssueSeverity.critical,
canAutoFix: true,
fixButtonLabel: 'Снять блокировку',
));
} else {
issues.add(const DoctorIssue(
id: 'db_lock_active',
title: 'База pacman занята другим процессом',
description: 'В данный момент запущен системный процесс pacman.',
severity: IssueSeverity.info,
canAutoFix: false,
));
}
}
// 2. Проверка ключей PGP (Keyring)
bool isKeyringOk = true;
try {
final keyRes = await Process.run('pacman-key', ['-l']);
if (keyRes.exitCode != 0 || keyRes.stdout.toString().trim().isEmpty) {
isKeyringOk = false;
penalties += 25;
issues.add(const DoctorIssue(
id: 'keyring_corrupt',
title: 'Проблема со связкой ключей Arch Linux (Keyring)',
description:
'Связка ключей не инициализирована или повреждена. Это может вызывать ошибки "invalid or corrupted package (PGP signature)".',
severity: IssueSeverity.critical,
canAutoFix: true,
fixButtonLabel: 'Обновить ключи',
));
}
} catch (_) {
isKeyringOk = false;
}
// 3. Поиск файлов .pacnew
final List<PacnewFile> pacnewFiles = [];
try {
final pacnewRes = await Process.run('find', ['/etc', '-name', '*.pacnew']);
if (pacnewRes.exitCode == 0 && pacnewRes.stdout.toString().trim().isNotEmpty) {
final lines = pacnewRes.stdout.toString().trim().split('\n');
for (final line in lines) {
final p = line.trim();
if (p.endsWith('.pacnew')) {
final orig = p.substring(0, p.length - 7);
final name = p.split('/').last;
pacnewFiles.add(PacnewFile(
pacnewPath: p,
originalPath: orig,
fileName: name,
));
}
}
}
} catch (_) {}
if (pacnewFiles.isNotEmpty) {
penalties += (pacnewFiles.length * 8).clamp(8, 24);
issues.add(DoctorIssue(
id: 'pacnew_files',
title: 'Обнаружены неслитые конфигурации (.pacnew: ${pacnewFiles.length} шт.)',
description:
'При обновлениях pacman сохранил новые версии конфигурационных файлов рядом с вашими. Рекомендуется сравнить и объединить их.',
severity: IssueSeverity.warning,
canAutoFix: false,
fixButtonLabel: 'Обзор файлов',
));
}
// 4. Проверка упавших служб systemd
final List<String> failedServices = [];
try {
final sysRes = await Process.run('systemctl', ['--failed', '--no-legend', '--plain']);
if (sysRes.exitCode == 0 && sysRes.stdout.toString().trim().isNotEmpty) {
final lines = sysRes.stdout.toString().trim().split('\n');
for (final line in lines) {
final parts = line.trim().split(RegExp(r'\s+'));
if (parts.isNotEmpty && parts.first.isNotEmpty) {
failedServices.add(parts.first);
}
}
}
} catch (_) {}
if (failedServices.isNotEmpty) {
penalties += (failedServices.length * 10).clamp(10, 30);
issues.add(DoctorIssue(
id: 'failed_services',
title: 'Сбойные службы systemd (${failedServices.length} шт.)',
description:
'В системе есть службы с ошибкой запуска: ${failedServices.take(3).join(", ")}${failedServices.length > 3 ? "..." : ""}.',
severity: IssueSeverity.warning,
canAutoFix: false,
));
}
// 5. Проверка текущего зеркала
String? activeMirror;
int? pingMs;
try {
final mirrorFile = File('/etc/pacman.d/mirrorlist');
if (mirrorFile.existsSync()) {
final lines = mirrorFile.readAsLinesSync();
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.startsWith('Server = ')) {
activeMirror = trimmed.substring(9).split('\$').first;
break;
}
}
}
} catch (_) {}
final healthScore = (100 - penalties).clamp(15, 100);
return DoctorReport(
healthScore: healthScore,
issues: issues,
pacnewFiles: pacnewFiles,
failedServices: failedServices,
isKeyringOk: isKeyringOk,
isDbLocked: isDbLocked,
isStaleLock: isStaleLock,
activeMirror: activeMirror,
mirrorPingMs: pingMs,
);
}
/// Снятие зависшей блокировки db.lck
static Future<bool> removeDbLock({String? password}) async {
try {
final proc = await AdminService.runPrivileged(password, 'rm', ['-f', '/var/lib/pacman/db.lck']);
return (await proc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Обновление связки ключей pacman-key
static Future<bool> fixKeyring({String? password}) async {
try {
final p1 = await AdminService.runPrivileged(password, 'pacman-key', ['--init']);
await p1.exitCode;
final p2 = await AdminService.runPrivileged(password, 'pacman-key', ['--populate', 'archlinux']);
await p2.exitCode;
final p3 = await AdminService.runPrivileged(password, 'pacman', ['-Sy', 'archlinux-keyring', '--noconfirm']);
return (await p3.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Перезапуск упавших служб systemd
static Future<bool> restartFailedServices(List<String> services, {String? password}) async {
if (services.isEmpty) return true;
try {
final proc = await AdminService.runPrivileged(password, 'systemctl', ['reset-failed', ...services]);
await proc.exitCode;
final p2 = await AdminService.runPrivileged(password, 'systemctl', ['restart', ...services]);
return (await p2.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Оптимизация списка зеркал через Reflector
static Future<bool> optimizeMirrors({String? password, String? country}) async {
try {
final List<String> args = [
'--latest', '10',
'--protocol', 'https',
'--sort', 'rate',
'--save', '/etc/pacman.d/mirrorlist',
];
if (country != null && country.isNotEmpty) {
args.addAll(['--country', country]);
}
final proc = await AdminService.runPrivileged(password, 'reflector', args);
return (await proc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Получение текстового diff между оригиналом и .pacnew
static Future<String> getDiff(String originalPath, String pacnewPath) async {
try {
final res = await Process.run('diff', ['-u', originalPath, pacnewPath]);
final output = res.stdout.toString().trim();
return output.isNotEmpty ? output : 'Файлы идентичны по содержимому.';
} catch (e) {
return 'Не удалось получить различия: $e';
}
}
/// Оставить старый файл конфигурации, удалив .pacnew
static Future<bool> resolvePacnewKeepOld(String pacnewPath, {String? password}) async {
try {
final rmProc = await AdminService.runPrivileged(password, 'rm', ['-f', pacnewPath]);
return (await rmProc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Заменить старый файл новым .pacnew и удалить .pacnew
static Future<bool> resolvePacnewReplaceWithNew(String originalPath, String pacnewPath, {String? password}) async {
try {
final cpProc = await AdminService.runPrivileged(password, 'cp', ['-f', pacnewPath, originalPath]);
if (await cpProc.exitCode != 0) return false;
final rmProc = await AdminService.runPrivileged(password, 'rm', ['-f', pacnewPath]);
return (await rmProc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Обработка файла .pacnew: удаление или замена
static Future<bool> resolvePacnew(
PacnewFile file, {
required bool replaceWithNew,
required String? password,
}) async {
if (replaceWithNew) {
return resolvePacnewReplaceWithNew(file.originalPath, file.pacnewPath, password: password);
} else {
return resolvePacnewKeepOld(file.pacnewPath, password: password);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import 'dart:io';
import 'admin_service.dart';
enum SnapshotEngine {
snapper,
timeshift,
none;
String get displayName {
switch (this) {
case SnapshotEngine.snapper:
return 'Snapper (Btrfs)';
case SnapshotEngine.timeshift:
return 'Timeshift';
case SnapshotEngine.none:
return 'Не обнаружен';
}
}
}
class SnapshotResult {
final bool success;
final String message;
const SnapshotResult(this.success, this.message);
}
class SnapshotService {
static SnapshotEngine? _cachedEngine;
static Future<SnapshotEngine> detectEngine() async {
if (_cachedEngine != null) return _cachedEngine!;
try {
final snapperRes = await Process.run('which', ['snapper']);
if (snapperRes.exitCode == 0 && snapperRes.stdout.toString().trim().isNotEmpty) {
_cachedEngine = SnapshotEngine.snapper;
return _cachedEngine!;
}
} catch (_) {}
try {
final timeshiftRes = await Process.run('which', ['timeshift']);
if (timeshiftRes.exitCode == 0 && timeshiftRes.stdout.toString().trim().isNotEmpty) {
_cachedEngine = SnapshotEngine.timeshift;
return _cachedEngine!;
}
} catch (_) {}
_cachedEngine = SnapshotEngine.none;
return _cachedEngine!;
}
static Future<String> getEngineDescription() async {
final engine = await detectEngine();
switch (engine) {
case SnapshotEngine.snapper:
return 'Snapper (обнаружена Btrfs-конфигурация корня /)';
case SnapshotEngine.timeshift:
return 'Timeshift (утилита создания точек восстановления)';
case SnapshotEngine.none:
return 'Утилиты снимков не найдены (установите snapper или timeshift)';
}
}
/// Создание снимка системы перед установкой обновлений
static Future<SnapshotResult> createPreUpdateSnapshot(
String? password, {
List<String>? packages,
String? description,
}) async {
final engine = await detectEngine();
if (engine == SnapshotEngine.none) {
return const SnapshotResult(false, 'Утилиты snapper или timeshift не обнаружены в системе');
}
final comment = description ?? (packages != null && packages.isNotEmpty
? 'Forge: обновление ${packages.length} пакетов (${packages.take(3).join(", ")}${packages.length > 3 ? "..." : ""})'
: 'Forge: обновление системы');
try {
if (engine == SnapshotEngine.snapper) {
final proc = await AdminService.runPrivileged(
password,
'snapper',
['create', '-d', comment],
);
final code = await proc.exitCode;
if (code == 0) {
return SnapshotResult(true, 'Снимок Snapper успешно создан: "$comment"');
} else {
return SnapshotResult(false, 'Ошибка Snapper (код $code)');
}
} else if (engine == SnapshotEngine.timeshift) {
final proc = await AdminService.runPrivileged(
password,
'timeshift',
['--create', '--comments', comment, '--tags', 'O'],
);
final code = await proc.exitCode;
if (code == 0) {
return SnapshotResult(true, 'Снимок Timeshift успешно создан: "$comment"');
} else {
return SnapshotResult(false, 'Ошибка Timeshift (код $code)');
}
}
} catch (e) {
return SnapshotResult(false, 'Исключение при создании снимка: $e');
}
return const SnapshotResult(false, 'Не удалось создать снимок');
}
}
+319
View File
@@ -0,0 +1,319 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:macos_ui/macos_ui.dart';
import '../rust/api/packages.dart';
import '../rust/frb_generated.dart';
import '../services/app_badge_controller.dart';
import '../services/app_settings_service.dart';
import 'theme/window_theme_style.dart';
import 'screens/cleaner_screen.dart';
import 'screens/discover_screen.dart';
import 'screens/doctor_screen.dart';
import 'screens/history_screen.dart';
import 'screens/installed_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/starter_packs_screen.dart';
import 'screens/updates_screen.dart';
import 'widgets/package_inspector.dart';
import 'widgets/spotlight_search_dialog.dart';
class MainLayout extends StatefulWidget {
const MainLayout({super.key});
@override
State<MainLayout> createState() => _MainLayoutState();
}
class _MainLayoutState extends State<MainLayout> {
int _pageIndex = 0;
@override
void initState() {
super.initState();
_performInitialBadgeScan();
}
Future<void> _performInitialBadgeScan() async {
try {
final updates = await RustBridgeApi.checkUpdates();
AppBadgeController.instance.setUpdatesCount(updates.length);
} catch (_) {}
try {
final scan = await RustBridgeApi.scanCleaner();
AppBadgeController.instance.setCleanerBytes(scan.totalReclaimableBytes);
} catch (_) {}
}
void _onInspectPackage(String name, BackendType backend) {
PackageInspectorSheet.show(
context,
packageName: name,
backend: backend,
);
}
void _openSpotlight() {
SpotlightSearchDialog.show(
context,
onSelectPackage: (name, backend) {
_onInspectPackage(name, backend);
},
);
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyK, control: true): _openSpotlight,
const SingleActivator(LogicalKeyboardKey.keyK, meta: true): _openSpotlight,
},
child: Focus(
autofocus: true,
child: MacosWindow(
sidebar: Sidebar(
topOffset: 0.0, // Убираем пустой отступ macOS traffic lights на Linux
minWidth: 200,
startWidth: 220,
maxWidth: 260,
top: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: macosTheme.primaryColor,
borderRadius: BorderRadius.circular(
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde ? 4 : 8,
),
boxShadow: [
BoxShadow(
color: macosTheme.primaryColor.withValues(alpha: 0.35),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: MacosIcon(
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde
? CupertinoIcons.gear_alt_fill
: AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.gnome
? CupertinoIcons.app_badge_fill
: CupertinoIcons.cube_box_fill,
color: CupertinoColors.white,
size: 17,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Forge',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
overflow: TextOverflow.ellipsis,
),
Text(
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde
? 'KDE Breeze Edition'
: AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.gnome
? 'GNOME Adwaita'
: 'macOS Cupertino',
style: const TextStyle(
fontSize: 10.5,
color: CupertinoColors.systemGrey,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 12),
// Spotlight Search Trigger Button
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: _openSpotlight,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6.5),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.07),
),
),
child: const Row(
children: [
MacosIcon(
CupertinoIcons.search,
size: 13,
color: CupertinoColors.systemGrey,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Поиск пакетов...',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
),
Text(
'⌘K',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemGrey,
),
),
],
),
),
),
],
),
),
builder: (context, scrollController) {
return ListenableBuilder(
listenable: AppBadgeController.instance,
builder: (context, _) {
final badge = AppBadgeController.instance;
return SidebarItems(
currentIndex: _pageIndex,
onChanged: (index) {
setState(() {
_pageIndex = index;
});
},
scrollController: scrollController,
itemSize: SidebarItemSize.large,
items: [
const SidebarItem(
label: Text('УПРАВЛЕНИЕ'),
section: true,
),
SidebarItem(
leading: const MacosIcon(CupertinoIcons.arrow_down_circle_fill),
label: const Text('Обновления'),
trailing: badge.updatesCount > 0
? Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${badge.updatesCount}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
)
: null,
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.square_stack_3d_up_fill),
label: Text('Установленные'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.compass_fill),
label: Text('Каталог'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.square_grid_2x2_fill),
label: Text('Наборы ПО'),
),
const SidebarItem(
label: Text('СИСТЕМА И ОПТИМИЗАЦИЯ'),
section: true,
),
SidebarItem(
leading: const MacosIcon(CupertinoIcons.sparkles),
label: const Text('Очистка'),
trailing: badge.cleanerBytes > 0
? Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: CupertinoColors.systemOrange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
AppBadgeController.formatBytes(badge.cleanerBytes),
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemOrange,
),
),
)
: null,
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.bandage_fill),
label: Text('Диагностика'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.clock_fill),
label: Text('История'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.gear_alt_fill),
label: Text('Настройки'),
),
],
);
},
);
},
bottom: MacosListTile(
leading: const MacosIcon(
CupertinoIcons.command,
color: CupertinoColors.systemGrey,
),
title: const Text(
'Arch / Flatpak / AUR',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
),
),
child: IndexedStack(
index: _pageIndex.clamp(0, 7),
children: [
UpdatesScreen(onInspectPackage: _onInspectPackage),
InstalledScreen(onInspectPackage: _onInspectPackage),
DiscoverScreen(onInspectPackage: _onInspectPackage),
const StarterPacksScreen(),
const CleanerScreen(),
const DoctorScreen(),
const HistoryScreen(),
const SettingsScreen(),
],
),
),
),
);
}
}
+529
View File
@@ -0,0 +1,529 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
import '../../services/admin_service.dart';
import '../../services/app_badge_controller.dart';
import '../widgets/friendly_term.dart';
import '../widgets/storage_breakdown_bar.dart';
class CleanerScreen extends StatefulWidget {
const CleanerScreen({super.key});
@override
State<CleanerScreen> createState() => _CleanerScreenState();
}
class _CleanerScreenState extends State<CleanerScreen> {
CleanerScanResult? _scanResult;
bool _isScanning = true;
bool _isCleaning = false;
bool _cleanCache = true;
bool _cleanOrphans = true;
bool _cleanFlatpak = true;
bool _cleanJournal = true;
ProgressEvent? _currentProgress;
final List<String> _logLines = [];
StreamSubscription<ProgressEvent>? _cleanSub;
final ScrollController _logScrollController = ScrollController();
@override
void initState() {
super.initState();
_scan();
}
@override
void dispose() {
_cleanSub?.cancel();
_logScrollController.dispose();
super.dispose();
}
Future<void> _scan() async {
setState(() => _isScanning = true);
try {
final res = await RustBridgeApi.scanCleaner();
if (mounted) {
setState(() {
_scanResult = res;
_isScanning = false;
});
AppBadgeController.instance.setCleanerBytes(res.totalReclaimableBytes);
}
} catch (_) {
if (mounted) setState(() => _isScanning = false);
}
}
Future<void> _startClean() async {
final totalBytes = _scanResult?.totalReclaimableBytes ?? 0;
if (totalBytes <= 0) return;
// Запрашиваем пароль суперпользователя
final password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для очистки кэша пакетов pacman, удаления неиспользуемых пакетов-сирот и сжатия системных логов требуются права суперпользователя.',
);
if (password == null) {
// Отменено пользователем
return;
}
setState(() {
_isCleaning = true;
_logLines.clear();
_currentProgress = null;
});
final stream = RustBridgeApi.runCleaner(
password: password,
cleanCache: _cleanCache,
cleanOrphans: _cleanOrphans,
cleanFlatpakUnused: _cleanFlatpak,
cleanJournal: _cleanJournal,
);
_cleanSub?.cancel();
_cleanSub = stream.listen(
(event) {
setState(() {
_currentProgress = event;
if (event.logLine != null) {
_logLines.add(event.logLine!);
} else if (event.message.isNotEmpty) {
_logLines.add(event.message);
}
});
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
if (event.step == UpdateStep.done || event.step == UpdateStep.failed) {
setState(() => _isCleaning = false);
_scan();
}
},
onError: (e) {
setState(() {
_isCleaning = false;
_logLines.add('[ERROR] $e');
});
},
);
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Очистка системы'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
ToolBarIconButton(
label: 'Повторное сканирование',
icon: const MacosIcon(CupertinoIcons.arrow_clockwise),
showLabel: false,
tooltipMessage: 'Сканировать диск',
onPressed: _isCleaning ? null : _scan,
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isScanning) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(radius: 18),
SizedBox(height: 16),
Text(
'Анализ дискового пространства и кэшей...',
style: TextStyle(color: CupertinoColors.systemGrey),
),
],
),
);
}
final totalBytes = _scanResult?.totalReclaimableBytes ?? 0;
final isZero = totalBytes == 0;
final totalStr = CleanerScanResult.formatBytes(totalBytes);
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
children: [
// CleanMyMac Hero Banner
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isZero
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: const Color(0xFF0A84FF).withValues(alpha: 0.15),
border: Border.all(
color: isZero
? CupertinoColors.systemGreen.withValues(alpha: 0.4)
: const Color(0xFF0A84FF).withValues(alpha: 0.4),
width: 2,
),
),
child: Center(
child: MacosIcon(
isZero ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.sparkles,
color: isZero ? CupertinoColors.systemGreen : const Color(0xFF0A84FF),
size: 34,
),
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isZero
? 'Система полностью очищена'
: 'Доступно для очистки: $totalStr',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const SizedBox(height: 4),
Text(
isZero
? 'Дисковое пространство свободно от пакетов-сирот, старого кэша и избыточных логов.'
: 'Удаление старых архивов пакетов, пакетов-сирот и сжатие журналов системы',
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 16),
PushButton(
controlSize: ControlSize.large,
onPressed: (_isCleaning || isZero) ? null : _startClean,
child: Row(
children: [
if (_isCleaning) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 8),
] else ...[
MacosIcon(
isZero ? CupertinoIcons.checkmark_alt : CupertinoIcons.trash_fill,
size: 14,
),
const SizedBox(width: 8),
],
Text(_isCleaning
? 'Очистка...'
: isZero
? 'Очищено'
: 'Очистить ($totalStr)'),
],
),
),
],
),
),
const SizedBox(height: 24),
// Live Progress
if (_isCleaning || _logLines.isNotEmpty) ...[
_buildLiveProgressPanel(macosTheme),
const SizedBox(height: 24),
],
// macOS Storage Breakdown Bar
StorageBreakdownBar(
pacmanCacheBytes: _scanResult?.pacmanCacheBytes ?? 0,
journalBytes: _scanResult?.journalBytes ?? 0,
),
const SizedBox(height: 24),
const Text(
'КАТЕГОРИИ ОЧИСТКИ',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 10),
// Card 1: Pacman Cache
_buildCleanOptionCard(
title: 'Кэш пакетов Pacman',
friendlyTermKey: 'cache',
subtitle:
'Хранилище скачанных .pkg.tar.zst файлов. Утилита paccache очистит всё, кроме двух последних версий.',
sizeText: CleanerScanResult.formatBytes(
_scanResult?.pacmanCacheBytes ?? 0),
icon: CupertinoIcons.archivebox_fill,
iconColor: const Color(0xFF1793D1),
value: _cleanCache,
onChanged: (v) => setState(() => _cleanCache = v),
),
// Card 2: Orphans
_buildCleanOptionCard(
title: 'Пакеты-сироты (Orphans)',
friendlyTermKey: 'orphans',
subtitle:
'Пакеты (${_scanResult?.orphanPackages.length ?? 0} шт.), установленные как зависимости, но больше никем не используемые.',
sizeText: CleanerScanResult.formatBytes(
_scanResult?.orphanBytes ?? 0),
icon: CupertinoIcons.person_crop_circle_badge_xmark,
iconColor: CupertinoColors.systemOrange,
value: _cleanOrphans,
onChanged: (v) => setState(() => _cleanOrphans = v),
),
// Card 3: Flatpak Unused
_buildCleanOptionCard(
title: 'Неиспользуемые рантаймы Flatpak',
subtitle:
'Старые версии базовых библиотек и SDK Flathub, оставшиеся после обновления приложений.',
sizeText: 'По расписанию',
icon: CupertinoIcons.app_badge_fill,
iconColor: const Color(0xFF4A90E2),
value: _cleanFlatpak,
onChanged: (v) => setState(() => _cleanFlatpak = v),
),
// Card 4: Journalctl
_buildCleanOptionCard(
title: 'Журналы systemd (Логи)',
subtitle:
'Архивные системные логи служб и ядра. Размер будет безопасно ограничен до 100 МБ.',
sizeText: CleanerScanResult.formatBytes(
_scanResult?.journalBytes ?? 0),
icon: CupertinoIcons.doc_text_fill,
iconColor: CupertinoColors.systemPurple,
value: _cleanJournal,
onChanged: (v) => setState(() => _cleanJournal = v),
),
],
);
},
),
],
);
}
Widget _buildCleanOptionCard({
required String title,
String? friendlyTermKey,
required String subtitle,
required String sizeText,
required IconData icon,
required Color iconColor,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: MacosIcon(icon, color: iconColor, size: 20),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (friendlyTermKey != null)
FriendlyTerm(
text: title,
termKey: friendlyTermKey,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
)
else
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
),
const Spacer(),
Text(
sizeText,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Color(0xFF0A84FF),
),
),
],
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 16),
MacosSwitch(
value: value,
onChanged: onChanged,
),
],
),
);
}
Widget _buildLiveProgressPanel(MacosThemeData theme) {
final pct = _currentProgress?.percentage ?? 0.0;
final message = _currentProgress?.message ?? 'Инициализация...';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E20),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: theme.dividerColor,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (_isCleaning) ...[
const ProgressCircle(radius: 7),
const SizedBox(width: 8),
],
Text(
message,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const Spacer(),
Text(
'${(pct * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: theme.primaryColor,
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ProgressBar(
value: (pct * 100).clamp(0.0, 100.0),
height: 6,
),
),
const SizedBox(height: 14),
Container(
height: 110,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF14171A),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logLines.length,
itemBuilder: (context, idx) {
final line = _logLines[idx];
return Text(
line,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.4,
color: Color(0xFF34D399),
),
);
},
),
),
],
),
);
}
}
+663
View File
@@ -0,0 +1,663 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/aur_service.dart';
import '../../services/batch_install_service.dart';
import '../../services/catalog_service.dart';
import '../widgets/app_icon_image.dart';
import '../widgets/install_queue_bar.dart';
class DiscoverScreen extends StatefulWidget {
final Function(String name, BackendType backend)? onInspectPackage;
const DiscoverScreen({super.key, this.onInspectPackage});
@override
State<DiscoverScreen> createState() => _DiscoverScreenState();
}
class _DiscoverScreenState extends State<DiscoverScreen> {
final TextEditingController _searchController = TextEditingController();
Timer? _searchDebounce;
bool _isLoading = true;
bool _isSearching = false;
String _searchQuery = '';
String _selectedSource = 'Все';
String? _aurHelper;
final List<CatalogApp> _featuredApps = CatalogService.getFeaturedApps();
final Map<String, List<CatalogApp>> _curatedCategories = CatalogService.getCuratedCategories();
List<CatalogApp> _searchResults = [];
final List<String> _sourceFilters = ['Все', 'Pacman', 'AUR', 'Flatpak'];
@override
void initState() {
super.initState();
_initCatalog();
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
Future<void> _initCatalog() async {
setState(() => _isLoading = true);
final allCurated = [
..._featuredApps,
..._curatedCategories.values.expand((element) => element),
];
await CatalogService.checkInstalledStatus(allCurated);
final helper = await AurService.getHelper();
if (mounted) {
setState(() {
_aurHelper = helper;
_isLoading = false;
});
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
final trimmed = query.trim();
if (trimmed.isEmpty) {
setState(() {
_searchQuery = '';
_isSearching = false;
_searchResults.clear();
});
return;
}
setState(() {
_searchQuery = trimmed;
_isSearching = true;
});
_searchDebounce = Timer(const Duration(milliseconds: 320), () {
_performSearch(trimmed);
});
}
Future<void> _performSearch(String query) async {
final results = await CatalogService.searchAll(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
titleWidth: 260,
title: const Text(
'Каталог приложений',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 280,
child: MacosTextField(
controller: _searchController,
placeholder: 'Поиск (Pacman, AUR, Flatpak)...',
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.search,
size: 14,
color: CupertinoColors.systemGrey,
),
),
suffix: _searchQuery.isNotEmpty
? CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
child: const MacosIcon(
CupertinoIcons.clear_circled_solid,
size: 14,
color: CupertinoColors.systemGrey,
),
)
: null,
onChanged: _onSearchChanged,
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle(radius: 20));
}
return Stack(
children: [
Positioned.fill(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(28, 20, 28, 90),
children: [
// Mode 1: Active Search Results
if (_searchQuery.isNotEmpty) ...[
_buildSearchHeader(macosTheme),
const SizedBox(height: 16),
if (_isSearching)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: ProgressCircle(),
),
)
else if (_searchResults.isEmpty)
_buildEmptyResults()
else
_buildSearchResultsGrid(macosTheme),
] else ...[
// Mode 2: App Store Storefront
_buildHeroBanner(macosTheme),
const SizedBox(height: 28),
// Section 1: Featured Apps
_buildSectionHeader('ИЗБРАННОЕ И РЕКОМЕНДАЦИИ', 'Популярные приложения с быстрой установкой'),
const SizedBox(height: 14),
_buildAppsGrid(_featuredApps, macosTheme),
const SizedBox(height: 32),
// Section 2: Curated Categories
..._curatedCategories.entries.map((cat) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(cat.key.toUpperCase(), 'Подборка проверенного ПО'),
const SizedBox(height: 14),
_buildAppsGrid(cat.value, macosTheme),
const SizedBox(height: 32),
],
);
}),
],
],
),
),
// Floating Queue Cart Bar at Bottom
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: InstallQueueBar(),
),
],
);
},
),
],
);
}
Widget _buildSearchHeader(MacosThemeData macosTheme) {
final filtered = _getFilteredSearchResults();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Результаты поиска по запросу «$_searchQuery»',
style: macosTheme.typography.title1.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Text(
'Найдено: ${filtered.length}',
style: const TextStyle(fontSize: 12.5, color: CupertinoColors.systemGrey),
),
],
),
const SizedBox(height: 12),
// Filter pills: Все, Pacman, AUR, Flatpak
Row(
children: _sourceFilters.map((source) {
final isSelected = _selectedSource == source;
int count = 0;
if (source == 'Все') {
count = _searchResults.length;
} else if (source == 'Pacman') {
count = _searchResults.where((a) => a.backend == BackendType.pacman).length;
} else if (source == 'AUR') {
count = _searchResults.where((a) => a.backend == BackendType.aur).length;
} else if (source == 'Flatpak') {
count = _searchResults.where((a) => a.backend == BackendType.flatpak).length;
}
return Padding(
padding: const EdgeInsets.only(right: 8),
child: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => setState(() => _selectedSource = source),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF0A84FF)
: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? const Color(0xFF0A84FF)
: macosTheme.dividerColor,
width: 0.8,
),
),
child: Text(
source == 'AUR' && _aurHelper != null
? 'AUR ($_aurHelper) ($count)'
: '$source ($count)',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: isSelected ? CupertinoColors.white : CupertinoColors.systemGrey,
),
),
),
),
);
}).toList(),
),
],
);
}
List<CatalogApp> _getFilteredSearchResults() {
if (_selectedSource == 'Все') return _searchResults;
if (_selectedSource == 'Pacman') {
return _searchResults.where((a) => a.backend == BackendType.pacman).toList();
}
if (_selectedSource == 'AUR') {
return _searchResults.where((a) => a.backend == BackendType.aur).toList();
}
if (_selectedSource == 'Flatpak') {
return _searchResults.where((a) => a.backend == BackendType.flatpak).toList();
}
return _searchResults;
}
Widget _buildEmptyResults() {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [
const MacosIcon(CupertinoIcons.search, size: 44, color: CupertinoColors.systemGrey),
const SizedBox(height: 12),
Text(
'По запросу «$_searchQuery» ничего не найдено',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
const Text(
'Попробуйте изменить поисковый запрос или переключить фильтр источников.',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
],
),
),
);
}
Widget _buildSearchResultsGrid(MacosThemeData macosTheme) {
final list = _getFilteredSearchResults();
return _buildAppsGrid(list, macosTheme);
}
Widget _buildHeroBanner(MacosThemeData macosTheme) {
final heroApp = _featuredApps.first;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF0F2027),
Color(0xFF203A43),
Color(0xFF2C5364),
],
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.3),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
AppIconImage(
iconUrl: heroApp.iconUrl,
fallbackIcon: heroApp.fallbackIcon,
accentColor: heroApp.accentColor,
size: 76,
),
const SizedBox(width: 22),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2.5),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.6)),
),
child: const Text(
'ВЫБОР РЕДАКЦИИ',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: Color(0xFF64D2FF),
),
),
),
const SizedBox(width: 8),
_buildBackendPill(heroApp.backend),
],
),
const SizedBox(height: 6),
Text(
heroApp.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
color: CupertinoColors.white,
),
),
const SizedBox(height: 4),
Text(
heroApp.summary,
style: const TextStyle(
fontSize: 13,
color: Color(0xFFD1D1D6),
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 16),
ListenableBuilder(
listenable: BatchInstallService.instance,
builder: (context, _) {
final inQueue = BatchInstallService.instance.contains(heroApp);
return PushButton(
controlSize: ControlSize.large,
secondary: inQueue,
onPressed: heroApp.isInstalled
? null
: () => BatchInstallService.instance.toggle(heroApp),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(
heroApp.isInstalled
? CupertinoIcons.check_mark_circled_solid
: (inQueue ? CupertinoIcons.check_mark : CupertinoIcons.cart_badge_plus),
size: 15,
),
const SizedBox(width: 6),
Text(
heroApp.isInstalled
? 'Установлено'
: (inQueue ? 'В очереди' : '+ В очередь'),
),
],
),
);
},
),
],
),
);
}
Widget _buildSectionHeader(String title, String subtitle) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
],
);
}
Widget _buildAppsGrid(List<CatalogApp> apps, MacosThemeData macosTheme) {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = constraints.maxWidth > 950 ? 3 : (constraints.maxWidth > 650 ? 2 : 1);
final itemWidth = (constraints.maxWidth - (crossAxisCount - 1) * 14) / crossAxisCount;
return Wrap(
spacing: 14,
runSpacing: 14,
children: apps.map((app) {
return SizedBox(
width: itemWidth,
child: _buildAppCard(app, macosTheme),
);
}).toList(),
);
},
);
}
Widget _buildAppCard(CatalogApp app, MacosThemeData macosTheme) {
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: CupertinoButton(
padding: const EdgeInsets.all(14),
onPressed: () => widget.onInspectPackage?.call(app.id, app.backend),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppIconImage(
iconUrl: app.iconUrl,
fallbackIcon: app.fallbackIcon,
accentColor: app.accentColor,
size: 50,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
app.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
_buildBackendPill(app.backend),
],
),
const SizedBox(height: 3),
Text(
app.summary,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
// Rating stars
const Icon(CupertinoIcons.star_fill, size: 11, color: Color(0xFFFFCC00)),
const SizedBox(width: 3),
Text(
app.rating.toStringAsFixed(1),
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: CupertinoColors.systemGrey),
),
if (app.votes != null) ...[
const SizedBox(width: 6),
Text(
'(${app.votes} 👍)',
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
),
],
const Spacer(),
// Add to Cart / Queue Button
ListenableBuilder(
listenable: BatchInstallService.instance,
builder: (context, _) {
final inQueue = BatchInstallService.instance.contains(app);
return PushButton(
controlSize: ControlSize.small,
secondary: inQueue,
onPressed: app.isInstalled
? null
: () => BatchInstallService.instance.toggle(app),
child: Text(
app.isInstalled
? 'Установлено'
: (inQueue ? '✓ В очереди' : '+ В очередь'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: app.isInstalled ? CupertinoColors.systemGreen : null,
),
),
);
},
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBackendPill(BackendType backend) {
Color bg;
Color fg;
String text;
switch (backend) {
case BackendType.pacman:
bg = const Color(0xFF1793D1).withValues(alpha: 0.15);
fg = const Color(0xFF1793D1);
text = 'PACMAN';
break;
case BackendType.aur:
bg = const Color(0xFFFF9500).withValues(alpha: 0.15);
fg = const Color(0xFFFF9500);
text = _aurHelper != null ? 'AUR ($_aurHelper)' : 'AUR';
break;
case BackendType.flatpak:
bg = const Color(0xFF4A90E2).withValues(alpha: 0.15);
fg = const Color(0xFF4A90E2);
text = 'FLATPAK';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5.5, vertical: 1.5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(5),
),
child: Text(
text,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: fg,
),
),
);
}
}
+709
View File
@@ -0,0 +1,709 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider, SelectableText;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
import '../../services/doctor_service.dart';
import '../widgets/friendly_term.dart';
class DoctorScreen extends StatefulWidget {
const DoctorScreen({super.key});
@override
State<DoctorScreen> createState() => _DoctorScreenState();
}
class _DoctorScreenState extends State<DoctorScreen> {
bool _isLoading = true;
bool _isActionRunning = false;
String? _actionTitle;
final List<String> _actionLogs = [];
final ScrollController _logScrollController = ScrollController();
DoctorReport? _report;
@override
void initState() {
super.initState();
_runDiagnostics();
}
@override
void dispose() {
_logScrollController.dispose();
super.dispose();
}
Future<void> _runDiagnostics() async {
setState(() => _isLoading = true);
try {
final report = await DoctorService.diagnose();
if (mounted) {
setState(() {
_report = report;
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
void _scrollLogToBottom() {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
Future<void> _executeAction(String title, Future<bool> Function(String password) action) async {
final password = await AdminService.promptPassword(
context,
title: title,
reason: 'Для выполнения операций диагностики и восстановления требуются права администратора.',
);
if (password == null) return;
setState(() {
_isActionRunning = true;
_actionTitle = title;
_actionLogs.clear();
_actionLogs.add('⚙️ Запуск: $title...');
});
_scrollLogToBottom();
try {
final success = await action(password);
if (mounted) {
setState(() {
if (success) {
_actionLogs.add('✅ Операция успешно выполнена!');
} else {
_actionLogs.add('⚠️ Операция завершилась с ошибками или предупреждениями.');
}
});
_scrollLogToBottom();
}
} catch (e) {
if (mounted) {
setState(() => _actionLogs.add('❌ Ошибка: $e'));
_scrollLogToBottom();
}
} finally {
await _runDiagnostics();
if (mounted) {
setState(() {
_isActionRunning = false;
_actionTitle = null;
});
}
}
}
void _showDiffDialog(PacnewFile entry) async {
final diff = await entry.getDiff();
if (!mounted) return;
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (sheetContext) {
return MacosSheet(
child: Container(
width: 760,
height: 520,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(CupertinoIcons.doc_plaintext, size: 22, color: Color(0xFF0A84FF)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Различия в конфигурации: ${entry.fileName}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
Text(
entry.originalPath,
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => Navigator.of(sheetContext).pop(),
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: CupertinoColors.systemGrey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Text('Легенда: ', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11)),
Text('- Удаленные/старые строки', style: TextStyle(color: CupertinoColors.systemRed, fontSize: 11)),
SizedBox(width: 14),
Text('+ Новые строки из .pacnew', style: TextStyle(color: CupertinoColors.systemGreen, fontSize: 11)),
],
),
),
const SizedBox(height: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: SelectableText(
diff.isEmpty ? 'Файлы идентичны или не содержат текстовых различий.' : diff,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: Color(0xFFE5E5EA),
height: 1.4,
),
),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () {
Navigator.of(sheetContext).pop();
_executeAction(
'Удаление .pacnew (${entry.fileName})',
(pwd) => DoctorService.resolvePacnewKeepOld(entry.pacnewPath, password: pwd),
);
},
child: const Text('Оставить старый (.pacnew удалить)'),
),
const SizedBox(width: 12),
PushButton(
controlSize: ControlSize.regular,
onPressed: () {
Navigator.of(sheetContext).pop();
_executeAction(
'Применение .pacnew (${entry.fileName})',
(pwd) => DoctorService.resolvePacnewReplaceWithNew(entry.originalPath, entry.pacnewPath, password: pwd),
);
},
child: const Text('Заменить на новый .pacnew'),
),
],
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text(
'Диагностика',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
ToolBarIconButton(
label: 'Проверить снова',
icon: const MacosIcon(CupertinoIcons.refresh),
showLabel: false,
tooltipMessage: 'Запустить повторное сканирование системы',
onPressed: _isLoading || _isActionRunning ? null : _runDiagnostics,
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle());
}
final report = _report;
if (report == null) {
return const Center(child: Text('Не удалось получить отчёт диагностики'));
}
final isHealthy = report.hasNoIssues;
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
children: [
// Header & Health Score
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: isHealthy
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemOrange.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Center(
child: MacosIcon(
isHealthy ? CupertinoIcons.check_mark_circled_solid : CupertinoIcons.exclamationmark_triangle_fill,
color: isHealthy ? CupertinoColors.systemGreen : CupertinoColors.systemOrange,
size: 30,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isHealthy ? 'Система в отличном состоянии' : 'Обнаружены вопросы, требующие внимания',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
const Text(
'Arch Doctor сканирует целостность базы pacman, блокировки, состояние GPG-ключей, '
'упавшие службы systemd и неслитые конфигурации .pacnew.',
style: TextStyle(
fontSize: 13,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: _isActionRunning ? null : _runDiagnostics,
child: const Row(
children: [
MacosIcon(CupertinoIcons.arrow_clockwise, size: 14),
SizedBox(width: 6),
Text('Сканировать'),
],
),
),
],
),
const SizedBox(height: 24),
if (_isActionRunning) ...[
_buildRunningActionBanner(macosTheme),
const SizedBox(height: 24),
],
// Check 1: Pacman DB Lock
_buildCheckCard(
macosTheme: macosTheme,
icon: CupertinoIcons.lock_fill,
iconColor: report.dbLockExists ? CupertinoColors.systemRed : CupertinoColors.systemGreen,
title: 'Файл блокировки базы Pacman (db.lck)',
subtitle: report.dbLockExists
? 'Обнаружен /var/lib/pacman/db.lck! Pacman может быть зависшим или прерванным.'
: 'База данных pacman свободна, блокировки отсутствуют.',
isAlert: report.dbLockExists,
actions: report.dbLockExists
? [
PushButton(
controlSize: ControlSize.small,
onPressed: _isActionRunning
? null
: () => _executeAction('Снятие блокировки pacman (db.lck)', (pwd) => DoctorService.removeDbLock(password: pwd)),
child: const Text('Снять блокировку (удалить db.lck)'),
),
]
: null,
),
const SizedBox(height: 14),
// Check 2: Keyring
_buildCheckCard(
macosTheme: macosTheme,
icon: CupertinoIcons.shield_fill,
iconColor: report.keyringHealthy ? CupertinoColors.systemGreen : CupertinoColors.systemOrange,
title: 'Связка GPG-ключей (Keyring)',
friendlyTermKey: 'keyring',
subtitle: report.keyringHealthy
? 'Ключи доверия Arch Linux инициализированы и готовы к проверке сигнатур пакетов.'
: 'Связка ключей может быть устаревшей, что приводит к ошибкам проверки подписей "invalid or corrupted package".',
isAlert: !report.keyringHealthy,
actions: [
PushButton(
controlSize: ControlSize.small,
secondary: report.keyringHealthy,
onPressed: _isActionRunning
? null
: () => _executeAction('Инициализация и обновление GPG-ключей', (pwd) => DoctorService.fixKeyring(password: pwd)),
child: const Text('Пересобрать связку ключей (pacman-key)'),
),
],
),
const SizedBox(height: 14),
// Check 3: Failed Services
_buildCheckCard(
macosTheme: macosTheme,
icon: CupertinoIcons.gear_alt_fill,
iconColor: report.failedServices.isEmpty ? CupertinoColors.systemGreen : CupertinoColors.systemRed,
title: 'Службы Systemd',
subtitle: report.failedServices.isEmpty
? 'Все активные службы работают штатно, упавших сервисов (failed) нет.'
: 'Обнаружено ${report.failedServices.length} упавших служб: ${report.failedServices.join(', ')}',
isAlert: report.failedServices.isNotEmpty,
actions: report.failedServices.isNotEmpty
? [
PushButton(
controlSize: ControlSize.small,
onPressed: _isActionRunning
? null
: () => _executeAction('Перезапуск упавших служб', (pwd) => DoctorService.restartFailedServices(report.failedServices, password: pwd)),
child: const Text('Попытаться перезапустить службы'),
),
]
: null,
),
const SizedBox(height: 14),
// Check 4: .pacnew files
_buildPacnewCard(macosTheme, report),
const SizedBox(height: 14),
// Check 5: Mirrors & Reflector
_buildMirrorsCard(macosTheme, report),
],
);
},
),
],
);
}
Widget _buildRunningActionBanner(MacosThemeData macosTheme) {
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const ProgressCircle(),
const SizedBox(width: 12),
Expanded(
child: Text(
_actionTitle ?? 'Выполнение операции...',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 120,
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _actionLogs.length,
itemBuilder: (context, i) => Text(
_actionLogs[i],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFFD1D1D6),
),
),
),
),
],
),
);
}
Widget _buildCheckCard({
required MacosThemeData macosTheme,
required IconData icon,
required Color iconColor,
required String title,
String? friendlyTermKey,
required String subtitle,
required bool isAlert,
List<Widget>? actions,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isAlert ? iconColor.withValues(alpha: 0.4) : macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: iconColor, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (friendlyTermKey != null)
FriendlyTerm(
text: title,
termKey: friendlyTermKey,
style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
)
else
Text(
title,
style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
),
if (actions != null && actions.isNotEmpty) ...[
const SizedBox(height: 10),
Row(children: actions),
],
],
),
),
],
),
);
}
Widget _buildPacnewCard(MacosThemeData macosTheme, DoctorReport report) {
final hasPacnew = report.pacnewEntries.isNotEmpty;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: hasPacnew ? CupertinoColors.systemOrange.withValues(alpha: 0.4) : macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: hasPacnew
? CupertinoColors.systemOrange.withValues(alpha: 0.15)
: CupertinoColors.systemGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
CupertinoIcons.doc_on_clipboard_fill,
color: hasPacnew ? CupertinoColors.systemOrange : CupertinoColors.systemGreen,
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FriendlyTerm(
text: 'Конфигурации .pacnew',
termKey: 'pacnew',
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
),
const SizedBox(height: 3),
Text(
hasPacnew
? 'Найдено ${report.pacnewEntries.length} неслитых конфигураций. При обновлениях пакетов pacman сохраняет новые шаблоны настроек с расширением .pacnew.'
: 'Неслитых .pacnew файлов не обнаружено. Все конфигурации в /etc актуальны.',
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
),
],
),
),
],
),
if (hasPacnew) ...[
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 10),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: report.pacnewEntries.length,
separatorBuilder: (_, _) => const Divider(height: 12),
itemBuilder: (context, idx) {
final entry = report.pacnewEntries[idx];
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.fileName,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
Text(
entry.originalPath,
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => _showDiffDialog(entry),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.arrow_right_arrow_left, size: 12),
SizedBox(width: 4),
Text('Сравнить Diff'),
],
),
),
],
);
},
),
],
],
),
);
}
Widget _buildMirrorsCard(MacosThemeData macosTheme, DoctorReport report) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(CupertinoIcons.globe, color: Color(0xFF0A84FF), size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Оптимизация зеркал репозиториев (Reflector)',
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
),
const SizedBox(height: 3),
Text(
report.mirrorlistStatus,
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)'),
],
),
),
],
),
),
],
),
);
}
}
+256
View File
@@ -0,0 +1,256 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
List<HistoryItem> _items = [];
bool _isLoading = true;
String _searchQuery = '';
HistoryAction? _filterAction;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
setState(() => _isLoading = true);
try {
final list = await RustBridgeApi.getHistory();
if (mounted) {
setState(() {
_items = list;
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _items.where((i) {
final matchesSearch =
i.packageName.toLowerCase().contains(_searchQuery.toLowerCase());
final matchesAction =
_filterAction == null || i.action == _filterAction;
return matchesSearch && matchesAction;
}).toList();
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Журнал транзакций'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MacosPopupButton<HistoryAction?>(
value: _filterAction,
items: const [
MacosPopupMenuItem(value: null, child: Text('Все действия')),
MacosPopupMenuItem(
value: HistoryAction.upgraded, child: Text('Обновления')),
MacosPopupMenuItem(
value: HistoryAction.installed, child: Text('Установки')),
MacosPopupMenuItem(
value: HistoryAction.removed, child: Text('Удаления')),
],
onChanged: (a) => setState(() => _filterAction = a),
),
),
),
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 180,
child: MacosTextField(
placeholder: 'Поиск по истории...',
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.search,
size: 14,
color: CupertinoColors.systemGrey,
),
),
onChanged: (val) => setState(() => _searchQuery = val),
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle(radius: 18));
}
if (filtered.isEmpty) {
return const Center(
child: Text(
'История транзакций пуста или не найдена',
style: TextStyle(color: CupertinoColors.systemGrey),
),
);
}
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, idx) {
final item = filtered[idx];
IconData icon;
Color color;
String actionText;
switch (item.action) {
case HistoryAction.upgraded:
icon = CupertinoIcons.arrow_up_circle_fill;
color = const Color(0xFF0A84FF);
actionText = 'Обновлен';
break;
case HistoryAction.installed:
icon = CupertinoIcons.plus_circle_fill;
color = CupertinoColors.systemGreen;
actionText = 'Установлен';
break;
case HistoryAction.removed:
icon = CupertinoIcons.minus_circle_fill;
color = CupertinoColors.systemRed;
actionText = 'Удален';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: Row(
children: [
MacosIcon(icon, color: color, size: 22),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.packageName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(
actionText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
),
const Spacer(),
Text(
item.timestamp,
style: const TextStyle(
fontSize: 11,
color: CupertinoColors.systemGrey,
),
),
],
),
const SizedBox(height: 3),
if (item.oldVersion != null)
Row(
children: [
Text(
item.oldVersion!,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 5),
child: MacosIcon(
CupertinoIcons.arrow_right,
size: 10,
color: CupertinoColors.systemGrey,
),
),
Text(
item.newVersion,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
fontWeight: FontWeight.bold,
color: color,
),
),
],
)
else
Text(
item.newVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
);
},
);
},
),
],
);
}
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
class InstalledScreen extends StatefulWidget {
final Function(String name, BackendType backend)? onInspectPackage;
const InstalledScreen({
super.key,
this.onInspectPackage,
});
@override
State<InstalledScreen> createState() => _InstalledScreenState();
}
class _InstalledScreenState extends State<InstalledScreen> {
List<PackageInfo> _packages = [];
String _searchQuery = '';
bool _isLoading = true;
BackendType? _filterBackend;
@override
void initState() {
super.initState();
_loadInstalled();
}
Future<void> _loadInstalled() async {
setState(() => _isLoading = true);
try {
final list =
await RustBridgeApi.getInstalledPackages(backend: _filterBackend);
if (mounted) {
setState(() {
_packages = list;
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _packages.where((p) {
return p.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
p.description.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Установленные пакеты'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
// Filter Popup
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MacosPopupButton<BackendType?>(
value: _filterBackend,
items: const [
MacosPopupMenuItem(value: null, child: Text('Все бэкенды')),
MacosPopupMenuItem(
value: BackendType.pacman, child: Text('Pacman')),
MacosPopupMenuItem(
value: BackendType.flatpak, child: Text('Flatpak')),
],
onChanged: (b) {
setState(() => _filterBackend = b);
_loadInstalled();
},
),
),
),
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 220,
child: MacosTextField(
placeholder: 'Поиск пакета...',
placeholderStyle: const TextStyle(
color: CupertinoColors.systemGrey2,
fontSize: 13,
),
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.search,
size: 14,
color: CupertinoColors.systemGrey,
),
),
onChanged: (val) => setState(() => _searchQuery = val),
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle(radius: 16));
}
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, idx) {
final pkg = filtered[idx];
final isFlatpak = pkg.backend == BackendType.flatpak;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
alignment: Alignment.centerLeft,
pressedOpacity: 0.7,
onPressed: () {
widget.onInspectPackage?.call(pkg.name, pkg.backend);
},
child: Row(
children: [
MacosIcon(
isFlatpak
? CupertinoIcons.app_badge_fill
: CupertinoIcons.cube_box_fill,
size: 20,
color: isFlatpak
? const Color(0xFF4A90E2)
: const Color(0xFF1793D1),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pkg.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
Text(
pkg.version,
style: const TextStyle(
fontFamily: 'monospace',
color: CupertinoColors.systemGrey,
fontSize: 12,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isFlatpak
? const Color(0xFF4A90E2)
.withValues(alpha: 0.12)
: const Color(0xFF1793D1)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(
pkg.backend.displayName,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: isFlatpak
? const Color(0xFF4A90E2)
: const Color(0xFF1793D1),
),
),
),
],
),
const SizedBox(height: 3),
Text(
pkg.description,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 14),
Text(
pkg.formattedSize,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(width: 8),
const MacosIcon(
CupertinoIcons.chevron_right,
size: 13,
color: CupertinoColors.systemGrey,
),
],
),
),
);
},
);
},
),
],
);
}
}
+447
View File
@@ -0,0 +1,447 @@
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/app_settings_service.dart';
import '../../services/snapshot_service.dart';
import '../widgets/friendly_term.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _usePolkit = true;
String _selectedBackend = 'pacman';
bool _createSnapshotBeforeUpdate = false;
String _windowTheme = 'auto';
SnapshotEngine _snapshotEngine = SnapshotEngine.none;
bool _isCreatingSnapshot = false;
String? _snapshotMessage;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final settings = AppSettingsService.instance;
await settings.load();
final engine = await SnapshotService.detectEngine();
if (mounted) {
setState(() {
_usePolkit = settings.usePolkit;
_selectedBackend = settings.preferredBackend;
_createSnapshotBeforeUpdate = settings.createSnapshotBeforeUpdate;
_windowTheme = settings.windowTheme;
_snapshotEngine = engine;
});
}
}
Future<void> _createManualSnapshot() async {
final password = await AdminService.promptPassword(
context,
title: 'Создание точки восстановления системы',
reason: 'Для создания системного снимка btrfs требуются права суперпользователя.',
);
if (password == null) return;
setState(() {
_isCreatingSnapshot = true;
_snapshotMessage = null;
});
final res = await SnapshotService.createPreUpdateSnapshot(
password,
description: 'Forge manual snapshot',
);
if (mounted) {
setState(() {
_isCreatingSnapshot = false;
_snapshotMessage = res.message;
});
}
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Настройки'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
),
children: [
ContentArea(
builder: (context, scrollController) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
children: [
Text(
'Настройки системы',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 20),
// Group 0: Desktop Environment & Window Theme Style
_buildSectionHeader('СТИЛЬ ИНТЕРФЕЙСА И ОКНА (DESKTOP ENVIRONMENT)'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Оформление окна и темы',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
const SizedBox(height: 3),
Text(
AppSettingsService.instance.effectiveWindowThemeStyle.description,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 14),
MacosPopupButton<String>(
value: _windowTheme,
items: [
MacosPopupMenuItem(
value: 'auto',
child: Text('Автоматически (${AppSettingsService.detectDesktopEnvironmentName()})'),
),
const MacosPopupMenuItem(
value: 'kde',
child: Text('KDE Plasma (Breeze)'),
),
const MacosPopupMenuItem(
value: 'gnome',
child: Text('GNOME (Adwaita)'),
),
const MacosPopupMenuItem(
value: 'macos',
child: Text('macOS (Apple Cupertino)'),
),
],
onChanged: (val) async {
if (val != null) {
setState(() => _windowTheme = val);
await AppSettingsService.instance.setWindowTheme(val);
}
},
),
],
),
const SizedBox(height: 12),
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppSettingsService.instance.effectiveWindowThemeStyle.primaryColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'Активный профиль: ${AppSettingsService.instance.effectiveWindowThemeStyle.displayName}',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: AppSettingsService.instance.effectiveWindowThemeStyle.primaryColor,
),
),
],
),
],
),
),
const SizedBox(height: 24),
// Group 1: Privileges
_buildSectionHeader('БЕЗОПАСНОСТЬ И АВТОРИЗАЦИЯ'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
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(
'Использовать PolicyKit (pkexec)',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
SizedBox(height: 2),
Text(
'Нативный диалог ввода пароля администратора в рабочем окружении Linux',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
MacosSwitch(
value: _usePolkit,
onChanged: (val) async {
setState(() => _usePolkit = val);
await AppSettingsService.instance.setUsePolkit(val);
},
),
],
),
),
const SizedBox(height: 24),
// Group 2: Package Backend
_buildSectionHeader('ЯДРО ПАКЕТНОГО МЕНЕДЖЕРА'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
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(
'Активный бэкенд',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
SizedBox(height: 2),
Text(
'Интерфейс ядра взаимодействия с репозиториями ОС',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
MacosPopupButton<String>(
value: _selectedBackend,
items: const [
MacosPopupMenuItem(
value: 'pacman',
child: Text('Pacman / ALPM (Arch)'),
),
MacosPopupMenuItem(
value: 'flatpak',
child: Text('Flatpak (D-Bus)'),
),
MacosPopupMenuItem(
value: 'apt',
child: Text('APT (Debian/Ubuntu)'),
),
],
onChanged: (val) async {
if (val != null) {
setState(() => _selectedBackend = val);
await AppSettingsService.instance.setPreferredBackend(val);
}
},
),
],
),
),
const SizedBox(height: 24),
// Group 3: Recovery Snapshots
_buildSectionHeader('ТОЧКИ ВОССТАНОВЛЕНИЯ СИСТЕМЫ (SNAPSHOTS)'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
FriendlyTerm(
text: 'Создавать снимки перед обновлениями',
termKey: 'snapshots',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
],
),
const SizedBox(height: 2),
Text(
_snapshotEngine == SnapshotEngine.snapper
? 'Обнаружен движок: Snapper (Btrfs root). Автоматически сохранять слепок системы до апгрейда.'
: _snapshotEngine == SnapshotEngine.timeshift
? 'Обнаружен движок: Timeshift. Автоматически сохранять слепок системы до апгрейда.'
: 'Утилиты Snapper или Timeshift не обнаружены в системе. Снимки недоступны.',
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
MacosSwitch(
value: _createSnapshotBeforeUpdate,
onChanged: _snapshotEngine == SnapshotEngine.none
? null
: (val) async {
setState(() => _createSnapshotBeforeUpdate = val);
await AppSettingsService.instance.setCreateSnapshotBeforeUpdate(val);
},
),
],
),
if (_snapshotEngine != SnapshotEngine.none) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Row(
children: [
const Expanded(
child: Text(
'Ручное создание контрольной точки:',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: _isCreatingSnapshot ? null : _createManualSnapshot,
child: _isCreatingSnapshot
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(),
SizedBox(width: 6),
Text('Создание...'),
],
)
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.camera_fill, size: 13),
SizedBox(width: 5),
Text('Создать снимок сейчас'),
],
),
),
],
),
if (_snapshotMessage != null) ...[
const SizedBox(height: 8),
Text(
_snapshotMessage!,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGreen,
fontFamily: 'monospace',
),
),
],
],
],
),
),
],
);
},
),
],
);
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
);
}
}
@@ -0,0 +1,589 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
class StarterPackItem {
final String name;
final String description;
bool isInstalled;
bool isSelected;
StarterPackItem({
required this.name,
required this.description,
this.isInstalled = false,
this.isSelected = true,
});
}
class StarterPack {
final String id;
final String title;
final String description;
final IconData icon;
final Color accentColor;
final List<StarterPackItem> items;
StarterPack({
required this.id,
required this.title,
required this.description,
required this.icon,
required this.accentColor,
required this.items,
});
int get installedCount => items.where((i) => i.isInstalled).length;
bool get isFullyInstalled => installedCount == items.length;
}
class StarterPacksScreen extends StatefulWidget {
const StarterPacksScreen({super.key});
@override
State<StarterPacksScreen> createState() => _StarterPacksScreenState();
}
class _StarterPacksScreenState extends State<StarterPacksScreen> {
bool _isLoading = true;
bool _isInstalling = false;
String? _installingPackTitle;
final List<String> _installLogs = [];
final ScrollController _logScrollController = ScrollController();
late final List<StarterPack> _packs = [
StarterPack(
id: 'codecs',
title: 'Мультимедиа кодеки и шрифты',
description: 'Базовый набор GStreamer кодеков для воспроизведения любых аудио/видео форматов и популярные шрифты.',
icon: CupertinoIcons.music_albums_fill,
accentColor: const Color(0xFFFF9500),
items: [
StarterPackItem(name: 'gst-plugins-good', description: 'Набор свободных кодеков высокого качества'),
StarterPackItem(name: 'gst-plugins-bad', description: 'Дополнительные современные кодеки'),
StarterPackItem(name: 'gst-plugins-ugly', description: 'Популярные проприетарные кодеки (MP3/H.264)'),
StarterPackItem(name: 'gst-libav', description: 'Плагин FFmpeg для GStreamer'),
StarterPackItem(name: 'noto-fonts', description: 'Универсальные шрифты Google Noto'),
StarterPackItem(name: 'noto-fonts-emoji', description: 'Цветные эмодзи для всех приложений'),
StarterPackItem(name: 'ttf-dejavu', description: 'Классические четкие шрифты TrueType'),
],
),
StarterPack(
id: 'gaming',
title: 'Игровой набор (Gaming Suite)',
description: 'Все необходимое для запуска игр в Linux: Steam, менеджер Lutris, оптимизатор GameMode и мониторинг FPS.',
icon: CupertinoIcons.gamecontroller_fill,
accentColor: const Color(0xFFFF2D55),
items: [
StarterPackItem(name: 'steam', description: 'Крупнейшая платформа цифровой дистрибуции игр'),
StarterPackItem(name: 'gamemode', description: 'Демон оптимизации приоритетов CPU/GPU для игр'),
StarterPackItem(name: 'mangohud', description: 'Экранный оверлей FPS, температур и нагрузки'),
StarterPackItem(name: 'wine-staging', description: 'Слой совместимости для запуска Windows-игр и программ'),
],
),
StarterPack(
id: 'office',
title: 'Офис и продуктивность',
description: 'Полноценный офисный пакет LibreOffice, просмотрщик PDF, калькулятор и скриншотер с аннотациями.',
icon: CupertinoIcons.doc_text_fill,
accentColor: const Color(0xFF007AFF),
items: [
StarterPackItem(name: 'libreoffice-fresh', description: 'Текстовый процессор, таблицы и презентации'),
StarterPackItem(name: 'evince', description: 'Легкий и быстрый просмотр документов PDF/DjVu'),
StarterPackItem(name: 'flameshot', description: 'Мощный инструмент создания скриншотов со стрелками и текстом'),
StarterPackItem(name: 'qalculate-gtk', description: 'Универсальный научный и валютный калькулятор'),
],
),
StarterPack(
id: 'developer',
title: 'Разработчик и системные утилиты',
description: 'Инструменты компиляции, контроль версий Git, современный терминальный редактор и контейнеры.',
icon: CupertinoIcons.chevron_left_slash_chevron_right,
accentColor: const Color(0xFF5856D6),
items: [
StarterPackItem(name: 'base-devel', description: 'Компиляторы GCC, make, patch и утилиты сборки'),
StarterPackItem(name: 'git', description: 'Распределенная система контроля версий'),
StarterPackItem(name: 'neovim', description: 'Быстрый и расширяемый терминальный текстовый редактор'),
StarterPackItem(name: 'jq', description: 'Утилита для разбора и форматирования JSON в консоли'),
StarterPackItem(name: 'htop', description: 'Интерактивный просмотрщик системных процессов'),
StarterPackItem(name: 'curl', description: 'Инструмент командной строки для передачи данных по URL'),
],
),
StarterPack(
id: 'media',
title: 'Графика и видеомонтаж',
description: 'Программы для творческой работы: монтаж, растровое и векторное рисование, запись экрана.',
icon: CupertinoIcons.film_fill,
accentColor: const Color(0xFFAF52DE),
items: [
StarterPackItem(name: 'vlc', description: 'Медиаплеер, читающий любые форматы без настроек'),
StarterPackItem(name: 'gimp', description: 'Свободный аналог Photoshop для редактирования фото'),
StarterPackItem(name: 'inkscape', description: 'Профессиональный редактор векторной графики SVG'),
StarterPackItem(name: 'obs-studio', description: 'Запись экрана и онлайн-трансляции'),
],
),
StarterPack(
id: 'internet',
title: 'Интернет и коммуникация',
description: 'Браузер, мессенджер, почтовый клиент и безопасный торрент-клиент.',
icon: CupertinoIcons.bubble_left_bubble_right_fill,
accentColor: const Color(0xFF34C759),
items: [
StarterPackItem(name: 'telegram-desktop', description: 'Официальный клиент мессенджера Telegram'),
StarterPackItem(name: 'firefox', description: 'Быстрый и приватный веб-браузер с открытым кодом'),
StarterPackItem(name: 'thunderbird', description: 'Почтовый клиент, календарь и адресная книга'),
StarterPackItem(name: 'transmission-gtk', description: 'Минималистичный BitTorrent клиент без рекламы'),
],
),
];
@override
void initState() {
super.initState();
_scanInstalled();
}
@override
void dispose() {
_logScrollController.dispose();
super.dispose();
}
Future<void> _scanInstalled() async {
setState(() => _isLoading = true);
try {
final res = await Process.run('pacman', ['-Qq']);
final installedSet = res.exitCode == 0
? LineSplitter.split(res.stdout as String).map((e) => e.trim()).toSet()
: <String>{};
for (final pack in _packs) {
for (final item in pack.items) {
item.isInstalled = installedSet.contains(item.name);
item.isSelected = !item.isInstalled;
}
}
} catch (_) {}
if (mounted) {
setState(() => _isLoading = false);
}
}
Future<void> _installPack(StarterPack pack) async {
final toInstall = pack.items.where((i) => !i.isInstalled && i.isSelected).map((i) => i.name).toList();
if (toInstall.isEmpty) return;
final password = await AdminService.promptPassword(
context,
title: 'Установка набора «${pack.title}»',
reason: 'Для установки ${toInstall.length} пакетов требуются права суперпользователя.',
);
if (password == null) return;
setState(() {
_isInstalling = true;
_installingPackTitle = pack.title;
_installLogs.clear();
_installLogs.add('🚀 Начало установки набора: ${pack.title}...');
_installLogs.add('📦 Выбранные пакеты: ${toInstall.join(', ')}');
});
try {
final process = await Process.start(
'sudo',
['-S', 'pacman', '-S', '--noconfirm', '--needed', ...toInstall],
mode: ProcessStartMode.normal,
);
process.stdin.writeln(password);
await process.stdin.flush();
process.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
if (mounted) {
setState(() => _installLogs.add(line));
_scrollLogToBottom();
}
});
process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
if (mounted) {
setState(() => _installLogs.add('[STDERR] $line'));
_scrollLogToBottom();
}
});
final code = await process.exitCode;
if (mounted) {
setState(() {
if (code == 0) {
_installLogs.add('✅ Пакеты успешно установлены!');
} else {
_installLogs.add('❌ Установка завершилась с кодом ошибки $code');
}
});
}
} catch (e) {
if (mounted) {
setState(() => _installLogs.add('❌ Ошибка: $e'));
}
} finally {
await _scanInstalled();
if (mounted) {
setState(() {
_isInstalling = false;
_installingPackTitle = null;
});
}
}
}
void _scrollLogToBottom() {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text(
'Наборы ПО',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
ToolBarIconButton(
label: 'Обновить',
icon: const MacosIcon(CupertinoIcons.refresh),
showLabel: false,
tooltipMessage: 'Проверить установленные пакеты',
onPressed: _isLoading || _isInstalling ? null : _scanInstalled,
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle());
}
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Стартовые наборы (Starter Packs)',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 6),
const Text(
'Готовые проверенные комплекты программ для комфортного старта в Arch Linux. '
'Выберите необходимые пакеты и установите их в 1 клик.',
style: TextStyle(
fontSize: 13,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
],
),
const SizedBox(height: 24),
if (_isInstalling) ...[
_buildInstallingBanner(macosTheme),
const SizedBox(height: 24),
],
// Grid or Column of starter packs
..._packs.map((pack) => _buildPackCard(pack, macosTheme)),
],
);
},
),
],
);
}
Widget _buildInstallingBanner(MacosThemeData macosTheme) {
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const ProgressCircle(),
const SizedBox(width: 12),
Expanded(
child: Text(
'Установка: ${_installingPackTitle ?? ""}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 140,
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _installLogs.length,
itemBuilder: (context, i) => Text(
_installLogs[i],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFFD1D1D6),
),
),
),
),
],
),
);
}
Widget _buildPackCard(StarterPack pack, MacosThemeData macosTheme) {
final neededCount = pack.items.where((i) => !i.isInstalled && i.isSelected).length;
return Container(
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.8),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: pack.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
pack.icon,
color: pack.accentColor,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pack.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: -0.3,
),
),
const Spacer(),
// Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: pack.isFullyInstalled
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemGrey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
pack.isFullyInstalled
? '✓ Установлен'
: '${pack.installedCount}/${pack.items.length} уст.',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: pack.isFullyInstalled
? CupertinoColors.systemGreen
: CupertinoColors.systemGrey,
),
),
),
],
),
const SizedBox(height: 4),
Text(
pack.description,
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
],
),
),
const Divider(height: 1),
// Items list
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: pack.items.length,
separatorBuilder: (_, _) => const Divider(height: 1, indent: 46),
itemBuilder: (context, index) {
final item = pack.items[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
MacosCheckbox(
value: item.isInstalled || item.isSelected,
onChanged: item.isInstalled || _isInstalling
? null
: (val) {
setState(() {
item.isSelected = val;
});
},
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
decoration: item.isInstalled ? TextDecoration.none : null,
),
),
if (item.isInstalled) ...[
const SizedBox(width: 8),
const Text(
'Установлен',
style: TextStyle(
fontSize: 10,
color: CupertinoColors.systemGreen,
fontWeight: FontWeight.bold,
),
),
],
],
),
const SizedBox(height: 1),
Text(
item.description,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
);
},
),
const Divider(height: 1),
// Action bottom bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (!pack.isFullyInstalled)
Text(
'Выбрано для установки: $neededCount',
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
const Spacer(),
PushButton(
controlSize: ControlSize.regular,
secondary: pack.isFullyInstalled,
onPressed: pack.isFullyInstalled || neededCount == 0 || _isInstalling
? null
: () => _installPack(pack),
child: Text(
pack.isFullyInstalled
? 'Все пакеты установлены'
: 'Установить выбранные ($neededCount)',
),
),
],
),
),
],
),
);
}
}
+996
View File
@@ -0,0 +1,996 @@
import 'dart:async';
import 'package:flutter/cupertino.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/app_badge_controller.dart';
import '../../services/app_settings_service.dart';
import '../../services/arch_news_service.dart';
import '../../services/snapshot_service.dart';
import '../widgets/friendly_term.dart';
class UpdatesScreen extends StatefulWidget {
final Function(String name, BackendType backend)? onInspectPackage;
const UpdatesScreen({
super.key,
this.onInspectPackage,
});
@override
State<UpdatesScreen> createState() => _UpdatesScreenState();
}
class _UpdatesScreenState extends State<UpdatesScreen> {
List<PackageUpdate> _updates = [];
final Set<String> _selectedPackages = {};
bool _isLoading = true;
bool _isUpdating = false;
bool _isDryRun = true;
BackendType? _filterBackend;
ProgressEvent? _currentProgress;
final List<String> _logLines = [];
StreamSubscription<ProgressEvent>? _progressSub;
final ScrollController _logScrollController = ScrollController();
List<ArchNewsItem> _newsAlerts = [];
List<ArchNewsItem> _allNews = [];
@override
void initState() {
super.initState();
_loadUpdates();
}
@override
void dispose() {
_progressSub?.cancel();
_logScrollController.dispose();
super.dispose();
}
Future<void> _loadUpdates() async {
setState(() => _isLoading = true);
try {
final updates = await RustBridgeApi.checkUpdates(backend: _filterBackend);
final news = await ArchNewsService.fetchNews();
final interventions = ArchNewsService.checkInterventions(
updates.map((e) => e.name).toList(),
news,
);
if (mounted) {
setState(() {
_updates = updates;
_selectedPackages.clear();
_selectedPackages.addAll(updates.map((e) => e.name));
_allNews = news;
_newsAlerts = interventions;
_isLoading = false;
});
AppBadgeController.instance.setUpdatesCount(updates.length);
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _startUpgrade() async {
if (_selectedPackages.isEmpty) return;
String? password;
final targetBackend = _filterBackend ?? BackendType.pacman;
// Если симуляция выключена, запрашиваем пароль администратора
if (!_isDryRun && (targetBackend == BackendType.pacman || targetBackend == BackendType.aur)) {
password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для установки системных обновлений (${_selectedPackages.length} пакетов) требуются привилегии суперпользователя.',
);
if (password == null) {
// Отменено пользователем
return;
}
}
setState(() {
_isUpdating = true;
_logLines.clear();
_currentProgress = null;
});
// Создание снимка системы (Snapshot), если включено в настройках
if (!_isDryRun && AppSettingsService.instance.createSnapshotBeforeUpdate && password != null) {
setState(() {
_logLines.add('📸 Создание точки восстановления системы перед обновлением...');
});
try {
final snapRes = await SnapshotService.createPreUpdateSnapshot(
password,
description: 'Forge pre-update (${_selectedPackages.length} pkgs)',
);
setState(() {
_logLines.add(snapRes.success ? '✅ ${snapRes.message}' : '⚠️ ${snapRes.message}');
});
} catch (e) {
setState(() {
_logLines.add('⚠️ Ошибка создания снимка: $e (продолжаем обновление)');
});
}
}
final stream = RustBridgeApi.applyUpdates(
targetBackend,
_selectedPackages.toList(),
isDryRun: _isDryRun,
password: password,
);
_progressSub?.cancel();
_progressSub = stream.listen(
(event) {
setState(() {
_currentProgress = event;
if (event.logLine != null) {
_logLines.add(event.logLine!);
} else if (event.message.isNotEmpty) {
_logLines.add(event.message);
}
});
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
if (event.step == UpdateStep.done || event.step == UpdateStep.failed) {
setState(() => _isUpdating = false);
_loadUpdates();
}
},
onError: (error) {
setState(() {
_isUpdating = false;
_logLines.add('[ERROR] $error');
});
},
);
}
int get _totalSelectedSize => _updates
.where((u) => _selectedPackages.contains(u.name))
.fold(0, (sum, u) => sum + u.downloadSizeBytes);
String _formatBytes(int bytes) {
if (bytes <= 0) return '—';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
final sizeText =
_totalSelectedSize > 0 ? ' • ${_formatBytes(_totalSelectedSize)}' : '';
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Обновления'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
// Filter Popup
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MacosPopupButton<BackendType?>(
value: _filterBackend,
items: const [
MacosPopupMenuItem(value: null, child: Text('Все бэкенды')),
MacosPopupMenuItem(
value: BackendType.pacman, child: Text('Pacman (Arch)')),
MacosPopupMenuItem(
value: BackendType.flatpak, child: Text('Flatpak')),
],
onChanged: (b) {
setState(() => _filterBackend = b);
_loadUpdates();
},
),
),
),
ToolBarIconButton(
label: 'Новости Arch',
icon: Stack(
clipBehavior: Clip.none,
children: [
const MacosIcon(CupertinoIcons.news),
if (_newsAlerts.isNotEmpty)
Positioned(
right: -2,
top: -2,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: CupertinoColors.systemRed,
shape: BoxShape.circle,
),
),
),
],
),
showLabel: false,
tooltipMessage: _newsAlerts.isNotEmpty
? 'Требуется ручное вмешательство Arch Linux!'
: 'Новости Arch Linux',
onPressed: () => _showArchNewsDialog(context),
),
ToolBarIconButton(
label: 'Проверить обновления',
icon: const MacosIcon(CupertinoIcons.arrow_clockwise),
showLabel: false,
tooltipMessage: 'Проверить обновления',
onPressed: _isUpdating ? null : _loadUpdates,
),
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: PushButton(
controlSize: ControlSize.regular,
onPressed: _isUpdating || _selectedPackages.isEmpty
? null
: _startUpgrade,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isUpdating) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 6),
] else ...[
const MacosIcon(CupertinoIcons.arrow_down_to_line, size: 13),
const SizedBox(width: 6),
],
Text(_isUpdating
? 'Обновление...'
: 'Обновить (${_selectedPackages.length})'),
],
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(radius: 16),
SizedBox(height: 16),
Text(
'Проверка репозиториев...',
style: TextStyle(color: CupertinoColors.systemGrey),
),
],
),
);
}
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
children: [
// Header with Controls
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Доступные обновления',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
'Найдено: ${_updates.length} • Выбрано: ${_selectedPackages.length}$sizeText',
style: macosTheme.typography.subheadline.copyWith(
color: CupertinoColors.systemGrey,
),
),
],
),
),
// Dry Run Switch
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.1),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FriendlyTerm(
text: 'Dry-Run',
termKey: 'dryrun',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
const Text(
'(Симуляция)',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(width: 8),
MacosSwitch(
value: _isDryRun,
size: ControlSize.small,
onChanged: (v) => setState(() => _isDryRun = v),
),
],
),
),
],
),
const SizedBox(height: 20),
// Arch News Intervention Alert Banner
if (_newsAlerts.isNotEmpty) ...[
_buildInterventionBanner(macosTheme),
const SizedBox(height: 20),
],
// Live Progress Panel
if (_isUpdating || _logLines.isNotEmpty) ...[
_buildLiveProgressPanel(macosTheme),
const SizedBox(height: 20),
],
// List of Packages
if (_updates.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MacosIcon(
CupertinoIcons.checkmark_seal_fill,
size: 56,
color: CupertinoColors.systemGreen,
),
const SizedBox(height: 14),
Text(
'Все компоненты системы обновлены',
style: macosTheme.typography.title2.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
)
else
..._updates.map((pkg) => _buildPackageCard(pkg, macosTheme)),
],
);
},
),
],
);
}
Widget _buildLiveProgressPanel(MacosThemeData theme) {
final pct = _currentProgress?.percentage ?? 0.0;
final message = _currentProgress?.message ?? 'Инициализация...';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: theme.dividerColor,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (_isUpdating) ...[
const ProgressCircle(radius: 7),
const SizedBox(width: 8),
],
Text(
message,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const Spacer(),
Text(
'${(pct * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: theme.primaryColor,
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ProgressBar(
value: (pct * 100).clamp(0.0, 100.0),
height: 6,
),
),
const SizedBox(height: 14),
// macOS Dark Terminal
Container(
height: 130,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF14171A),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logLines.length,
itemBuilder: (context, idx) {
final line = _logLines[idx];
final isErr =
line.startsWith('[ERROR]') || line.startsWith('[STDERR]');
return Text(
line,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.4,
color: isErr
? CupertinoColors.systemRed
: const Color(0xFF34D399),
),
);
},
),
),
],
),
);
}
Widget _buildPackageCard(PackageUpdate pkg, MacosThemeData theme) {
final isSelected = _selectedPackages.contains(pkg.name);
final isFlatpak = pkg.backend == BackendType.flatpak;
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: isSelected
? const Color(0xFF0A84FF).withValues(alpha: 0.45)
: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
pressedOpacity: 0.7,
alignment: Alignment.centerLeft,
onPressed: () {
widget.onInspectPackage?.call(pkg.name, pkg.backend);
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2, right: 12),
child: MacosCheckbox(
value: isSelected,
onChanged: _isUpdating
? null
: (val) {
setState(() {
if (val == true) {
_selectedPackages.add(pkg.name);
} else {
_selectedPackages.remove(pkg.name);
}
});
},
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pkg.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
// Backend Tag
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: isFlatpak
? const Color(0xFF4A90E2).withValues(alpha: 0.15)
: const Color(0xFF1793D1).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
pkg.backend.displayName,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isFlatpak
? const Color(0xFF4A90E2)
: const Color(0xFF1793D1),
),
),
),
if (pkg.isSecurityUpdate) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: CupertinoColors.systemRed
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: CupertinoColors.systemRed
.withValues(alpha: 0.4),
width: 0.7,
),
),
child: const Text(
'Безопасность',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemRed,
),
),
),
],
],
),
const SizedBox(height: 3),
Text(
pkg.description,
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
pkg.currentVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.arrow_right,
size: 11,
color: CupertinoColors.systemGrey,
),
),
Text(
pkg.newVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF0A84FF),
),
),
const Spacer(),
const MacosIcon(
CupertinoIcons.chevron_right,
size: 13,
color: CupertinoColors.systemGrey,
),
],
),
],
),
),
],
),
),
);
}
Widget _buildInterventionBanner(MacosThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.systemRed.withValues(alpha: 0.35),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(
CupertinoIcons.exclamationmark_triangle_fill,
color: CupertinoColors.systemRed,
size: 20,
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Требуется ручное вмешательство перед обновлением!',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.5,
color: CupertinoColors.systemRed,
),
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => _showArchNewsDialog(context),
child: const Text('Инструкции'),
),
],
),
const SizedBox(height: 8),
const Text(
'Arch Linux опубликовал предупреждение для одного или нескольких обновляемых пакетов. Неправильное обновление может нарушить работу системы.',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.white,
height: 1.3,
),
),
const SizedBox(height: 10),
..._newsAlerts.map(
(item) => Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Text('• ', style: TextStyle(color: CupertinoColors.systemRed)),
Expanded(
child: Text(
item.title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
),
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () async {
final uri = Uri.tryParse(item.link);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri);
}
},
child: const Text(
'Читать на сайте ↗',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF0A84FF),
),
),
),
],
),
),
),
],
),
);
}
void _showArchNewsDialog(BuildContext context) {
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (context) => _ArchNewsSheet(
news: _allNews,
alerts: _newsAlerts,
onRefresh: () async {
final news = await ArchNewsService.fetchNews(forceRefresh: true);
final interventions = ArchNewsService.checkInterventions(
_updates.map((e) => e.name).toList(),
news,
);
if (mounted) {
setState(() {
_allNews = news;
_newsAlerts = interventions;
});
}
},
),
);
}
}
class _ArchNewsSheet extends StatefulWidget {
final List<ArchNewsItem> news;
final List<ArchNewsItem> alerts;
final Future<void> Function() onRefresh;
const _ArchNewsSheet({
required this.news,
required this.alerts,
required this.onRefresh,
});
@override
State<_ArchNewsSheet> createState() => _ArchNewsSheetState();
}
class _ArchNewsSheetState extends State<_ArchNewsSheet> {
bool _isRefreshing = false;
@override
Widget build(BuildContext context) {
return Container(
width: 620,
height: 520,
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.12),
width: 0.8,
),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
),
child: Row(
children: [
const MacosIcon(
CupertinoIcons.news,
size: 20,
color: Color(0xFF1793D1),
),
const SizedBox(width: 10),
const Text(
'Новости Arch Linux & Вмешательства',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const Spacer(),
MacosTooltip(
message: 'Обновить ленту',
child: MacosIconButton(
icon: _isRefreshing
? const ProgressCircle(radius: 7)
: const MacosIcon(CupertinoIcons.arrow_clockwise, size: 14),
onPressed: _isRefreshing
? null
: () async {
setState(() => _isRefreshing = true);
await widget.onRefresh();
if (mounted) setState(() => _isRefreshing = false);
},
),
),
const SizedBox(width: 6),
MacosTooltip(
message: 'Закрыть',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.xmark, size: 14),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
),
// Body
Expanded(
child: widget.news.isEmpty
? const Center(
child: Text(
'Нет доступных новостей или отсутствует соединение с сетью',
style: TextStyle(color: CupertinoColors.systemGrey),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: widget.news.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, idx) {
final item = widget.news[idx];
final isAlert = item.isManualIntervention;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isAlert
? CupertinoColors.systemRed.withValues(alpha: 0.08)
: const Color(0xFF262628),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isAlert
? CupertinoColors.systemRed.withValues(alpha: 0.3)
: CupertinoColors.white.withValues(alpha: 0.06),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (isAlert) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.systemRed
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Вмешательство',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemRed,
),
),
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
),
const SizedBox(width: 8),
Text(
item.pubDate,
style: const TextStyle(
fontSize: 11,
color: CupertinoColors.systemGrey,
),
),
],
),
if (item.description.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
item.description,
maxLines: 6,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey2,
height: 1.35,
),
),
],
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () async {
final uri = Uri.tryParse(item.link);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri);
}
},
child: const Text(
'Открыть на archlinux.org ↗',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF0A84FF),
),
),
),
),
],
),
);
},
),
),
],
),
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
enum WindowThemeStyle {
macos,
gnome,
kde;
String get displayName {
switch (this) {
case WindowThemeStyle.macos:
return 'macOS (Apple Cupertino)';
case WindowThemeStyle.gnome:
return 'GNOME (Adwaita / Libadwaita)';
case WindowThemeStyle.kde:
return 'KDE Plasma (Breeze)';
}
}
String get description {
switch (this) {
case WindowThemeStyle.macos:
return 'Классический полупрозрачный акрил, скругления squircle и фирменный акцент Apple Blue.';
case WindowThemeStyle.gnome:
return 'Строгая плоская тёмная палитра Libadwaita, центрированные акценты и Adwaita Blue.';
case WindowThemeStyle.kde:
return 'Стиль KDE Breeze: контрастная тёмная палитра, тонкие четкие рамки и акцент Breeze Cyan.';
}
}
Color get primaryColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0xFF0A84FF);
case WindowThemeStyle.gnome:
return const Color(0xFF3584E4);
case WindowThemeStyle.kde:
return const Color(0xFF3DAEE9);
}
}
Color get canvasColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0xFF1E1E1E);
case WindowThemeStyle.gnome:
return const Color(0xFF242424);
case WindowThemeStyle.kde:
return const Color(0xFF232629);
}
}
Color get cardColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0xFF262628);
case WindowThemeStyle.gnome:
return const Color(0xFF303030);
case WindowThemeStyle.kde:
return const Color(0xFF31363B);
}
}
Color get dividerColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0x2EFFFFFF);
case WindowThemeStyle.gnome:
return const Color(0x22FFFFFF);
case WindowThemeStyle.kde:
return const Color(0xFF4D5358);
}
}
MacosThemeData buildThemeData() {
return MacosThemeData.dark().copyWith(
primaryColor: primaryColor,
canvasColor: canvasColor,
dividerColor: dividerColor,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
class AppIconImage extends StatelessWidget {
final String? iconUrl;
final String? localIconPath;
final IconData fallbackIcon;
final Color accentColor;
final double size;
const AppIconImage({
super.key,
this.iconUrl,
this.localIconPath,
required this.fallbackIcon,
this.accentColor = const Color(0xFF0A84FF),
this.size = 52,
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(size * 0.23);
Widget? imageWidget;
if (iconUrl != null && iconUrl!.isNotEmpty) {
imageWidget = Image.network(
iconUrl!,
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return _buildFallback(borderRadius);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6.resolveFrom(context),
borderRadius: borderRadius,
),
child: const Center(
child: ProgressCircle(radius: 8),
),
);
},
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.12),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
child: imageWidget ?? _buildFallback(borderRadius),
),
);
}
Widget _buildFallback(BorderRadius borderRadius) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: borderRadius,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
accentColor.withValues(alpha: 0.85),
accentColor,
],
),
),
child: Center(
child: Icon(
fallbackIcon,
color: CupertinoColors.white,
size: size * 0.52,
),
),
);
}
}
+234
View File
@@ -0,0 +1,234 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show SelectableText;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
import '../../services/batch_install_service.dart';
class BatchInstallSheet extends StatefulWidget {
const BatchInstallSheet({super.key});
static Future<void> show(BuildContext context) {
return showMacosSheet<void>(
context: context,
barrierDismissible: false,
builder: (_) => const BatchInstallSheet(),
);
}
@override
State<BatchInstallSheet> createState() => _BatchInstallSheetState();
}
class _BatchInstallSheetState extends State<BatchInstallSheet> {
final List<String> _logs = [];
final ScrollController _logScrollController = ScrollController();
StreamSubscription<BatchInstallProgress>? _sub;
String _currentStep = 'Инициализация пакетной установки...';
double _percentage = 0.0;
bool _isCompleted = false;
bool _isFailed = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startInstallation();
});
}
@override
void dispose() {
_sub?.cancel();
_logScrollController.dispose();
super.dispose();
}
Future<void> _startInstallation() async {
final batch = BatchInstallService.instance;
final needRoot = batch.pacmanApps.isNotEmpty || batch.aurApps.isNotEmpty;
String? password;
if (needRoot) {
password = await AdminService.promptPassword(
context,
title: 'Установка выбранных приложений (${batch.count} шт.)',
reason: 'Для установки пакетов через pacman и сборки через AUR требуются права суперпользователя. Пароль будет запрошен только один раз.',
);
if (password == null) {
if (mounted) Navigator.of(context).pop();
return;
}
}
final stream = batch.runBatchInstall(password: password);
_sub = stream.listen(
(progress) {
if (!mounted) return;
setState(() {
_currentStep = progress.currentStep;
_percentage = progress.percentage;
if (progress.logLine != null) {
_logs.add(progress.logLine!);
}
_isCompleted = progress.isCompleted;
if (progress.isFailed) {
_isFailed = true;
}
});
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
},
onError: (err) {
if (!mounted) return;
setState(() {
_logs.add('❌ Критическая ошибка: $err');
_isFailed = true;
_isCompleted = true;
});
},
);
}
@override
Widget build(BuildContext context) {
final statusColor = _isFailed
? CupertinoColors.systemRed
: _isCompleted
? CupertinoColors.systemGreen
: const Color(0xFF0A84FF);
final statusIcon = _isFailed
? CupertinoIcons.exclamationmark_triangle_fill
: _isCompleted
? CupertinoIcons.check_mark_circled_solid
: CupertinoIcons.arrow_down_circle_fill;
final statusTitle = _isFailed
? (_isCompleted ? 'Установка завершена с ошибками' : 'Ошибка при установке')
: _isCompleted
? 'Установка успешно завершена!'
: 'Комплексная пакетная установка';
return MacosSheet(
child: Container(
width: 780,
height: 540,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(
statusIcon,
color: statusColor,
size: 20,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 2),
Text(
_currentStep,
style: TextStyle(
fontSize: 12,
color: _isFailed ? CupertinoColors.systemRed : CupertinoColors.systemGrey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (_isCompleted || _isFailed)
PushButton(
controlSize: ControlSize.regular,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 16),
// Progress Bar
ProgressBar(value: (_percentage * 100).clamp(0, 100)),
const SizedBox(height: 16),
// Console output
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logs.length,
itemBuilder: (context, i) {
final line = _logs[i];
Color textColor = const Color(0xFFE5E5EA);
if (line.contains('✅') || line.contains('🎉')) {
textColor = CupertinoColors.systemGreen;
} else if (line.contains('❌') || line.contains('[STDERR]')) {
textColor = const Color(0xFFFF6961);
} else if (line.contains('🚀') || line.contains('📸')) {
textColor = const Color(0xFF0A84FF);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: SelectableText(
line,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: textColor,
height: 1.35,
),
),
);
},
),
),
),
],
),
),
);
}
}
+344
View File
@@ -0,0 +1,344 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
class FriendlyTermExplanation {
final String title;
final String humanSummary;
final String whyItMatters;
final String safetyNote;
final String? example;
final IconData icon;
const FriendlyTermExplanation({
required this.title,
required this.humanSummary,
required this.whyItMatters,
required this.safetyNote,
this.example,
this.icon = CupertinoIcons.question_circle_fill,
});
}
class FriendlyTermsDictionary {
static final Map<String, FriendlyTermExplanation> _terms = {
'aur': const FriendlyTermExplanation(
title: 'Что такое AUR (Arch User Repository)?',
icon: CupertinoIcons.cube_box_fill,
humanSummary:
'Это огромная онлайн-библиотека программ, поддерживаемая сообществом Arch Linux. Здесь можно найти почти любую программу, которой нет в стандартных репозиториях (например, Google Chrome, Яндекс Музыку, драйверы или темы).',
whyItMatters:
'Forge автоматически скачивает инструкции сборки (PKGBUILD) и компилирует пакеты прямо на вашем компьютере с помощью хелпера (yay или paru).',
safetyNote:
'Пакеты создаются пользователями. Forge показывает количество голосов и популярность пакета, чтобы вы могли легко выбирать проверенный софт.',
example: 'Пакеты вроде visual-studio-code-bin или google-chrome устанавливаются именно из AUR.',
),
'orphans': const FriendlyTermExplanation(
title: 'Кто такие «Пакеты-сироты» (Orphans)?',
icon: CupertinoIcons.person_crop_circle_badge_xmark,
humanSummary:
'Это вспомогательные библиотеки и файлы, которые когда-то автоматически установились ради другой программы, но после её удаления остались в системе и больше никем не используются.',
whyItMatters:
'Они не вредят системе, но бесцельно занимают драгоценное дисковое пространство.',
safetyNote:
'Удалять пакеты-сироты в Forge полностью безопасно: удаляются только те файлы, от которых не зависит ни одна установленная сейчас программа.',
example: 'Вы удалили видеоредактор, а 15 библиотек для работы с видеокодеками остались. Это и есть сироты.',
),
'cache': const FriendlyTermExplanation(
title: 'Что такое Кэш пакетов Pacman?',
icon: CupertinoIcons.archivebox_fill,
humanSummary:
'Каждый раз, когда вы устанавливаете или обновляете программу, pacman скачивает её установочный архив (.pkg.tar.zst) в папку /var/cache/pacman/pkg и оставляет его там.',
whyItMatters:
'Это позволяет в любой момент откатить программу на старую версию без доступа к интернету. Но со временем кэш может разрастись до 20-50 гигабайт.',
safetyNote:
'Очистка в Forge оставляет две последние версии каждого пакета на случай отката, освобождая всё остальное место.',
example: 'Если свежее обновление сломало плеер, Forge позволит вернуть прошлую версию из этого кэша за 1 клик.',
),
'dryrun': const FriendlyTermExplanation(
title: 'Что такое Dry-Run (Симуляция)?',
icon: CupertinoIcons.shield_lefthalf_fill,
humanSummary:
'Режим репетиции или примерки. Менеджер пакетов проверяет все зависимости, загружает списки файлов, но НЕ вносит никаких реальных изменений на ваш диск.',
whyItMatters:
'Позволяет заранее увидеть, какие пакеты установятся, сколько места потребуется и не возникнет ли конфликтов библиотек, без риска что-то сломать.',
safetyNote:
'Абсолютно безопасно. В Forge включен по умолчанию на экране обновлений, чтобы вы всегда контролировали процесс.',
example: 'Вы можете нажать «Обновить» и посмотреть журнал, не рискуя стабильностью системы.',
),
'pacnew': const FriendlyTermExplanation(
title: 'Что такое файлы .pacnew?',
icon: CupertinoIcons.doc_text_fill,
humanSummary:
'Когда вы редактируете системный файл (например, конфиг сети или авторизации), а разработчики выпускают обновление этого конфига — Arch Linux специально НЕ перезаписывает ваш файл, чтобы не сбросить ваши настройки, а кладет рядом файл с расширением .pacnew.',
whyItMatters:
'Если их не объединять месяцами, система может продолжать использовать устаревший синтаксис конфигов.',
safetyNote:
'Forge позволяет наглядно сравнить различия старого и нового файлов и в 1 клик заменить или оставить текущий.',
example: '/etc/pam.d/su и рядом /etc/pam.d/su.pacnew с новыми системными правилами безопасности.',
),
'snapshots': const FriendlyTermExplanation(
title: 'Что такое Снимки системы (Snapshots)?',
icon: CupertinoIcons.camera_fill,
humanSummary:
'Мгновенная «фотография» всего системного диска до байта (через Btrfs / Snapper или Timeshift), занимающая минимум места.',
whyItMatters:
'Если после крупного обновления система перестанет загружаться или сломается графика, вы сможете мгновенно вернуть всё в состояние «до обновления» прямо из меню запуска компьютера.',
safetyNote:
'Снимки можно включить в Настройках Forge. Они создаются автоматически в фоновом режиме за 1 секунду перед обновлением.',
example: 'Обновили 100 пакетов, что-то пошло не так — откатились назад за 10 секунд.',
),
'keyring': const FriendlyTermExplanation(
title: 'Что такое Ключи PGP (Keyring)?',
icon: CupertinoIcons.lock_shield_fill,
humanSummary:
'Цифровая подпись разработчиков Arch Linux. Каждый официальный пакет подписан персональным криптографическим ключом мейнтейнера, чтобы никто не мог подсунуть вам вирус.',
whyItMatters:
'Если связка ключей устарела, pacman отказывается устанавливать программы и пишет: «invalid or corrupted package (PGP signature)».',
safetyNote:
'Раздел «Диагностика» в Forge умеет автоматически освежать эти ключи одной кнопкой.',
example: 'Ключи обновляются через pacman-key --refresh-keys.',
),
};
static FriendlyTermExplanation? get(String key) => _terms[key.toLowerCase()];
}
class FriendlyTerm extends StatelessWidget {
final String text;
final String termKey;
final String? customTitle;
final String? customExplanation;
final TextStyle? style;
final bool showQuestionIcon;
const FriendlyTerm({
super.key,
required this.text,
required this.termKey,
this.customTitle,
this.customExplanation,
this.style,
this.showQuestionIcon = true,
});
void _showExplanation(BuildContext context) {
final exp = FriendlyTermsDictionary.get(termKey) ??
FriendlyTermExplanation(
title: customTitle ?? text,
humanSummary: customExplanation ?? 'Пояснение для термина $text.',
whyItMatters: 'Понимание этого параметра помогает уверенно управлять вашей системой Arch Linux.',
safetyNote: 'Это стандартная концепция управления пакетами.',
);
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (ctx) => _FriendlyTermSheet(explanation: exp),
);
}
@override
Widget build(BuildContext context) {
final defaultStyle = MacosTheme.of(context).typography.body;
final mergedStyle = (style ?? defaultStyle).copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dashed,
decorationColor: const Color(0xFF0A84FF).withValues(alpha: 0.6),
);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _showExplanation(context),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(text, style: mergedStyle),
if (showQuestionIcon) ...[
const SizedBox(width: 4),
const MacosIcon(
CupertinoIcons.question_circle,
size: 13,
color: Color(0xFF0A84FF),
),
],
],
),
),
);
}
}
class _FriendlyTermSheet extends StatelessWidget {
final FriendlyTermExplanation explanation;
const _FriendlyTermSheet({required this.explanation});
@override
Widget build(BuildContext context) {
return Container(
width: 500,
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.12),
width: 0.8,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: MacosIcon(
explanation.icon,
color: const Color(0xFF0A84FF),
size: 20,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
explanation.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
),
MacosIconButton(
icon: const MacosIcon(CupertinoIcons.xmark, size: 14),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Content Body
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCard(
title: 'ПРОСТЫМИ СЛОВАМИ',
content: explanation.humanSummary,
icon: CupertinoIcons.lightbulb_fill,
iconColor: CupertinoColors.systemYellow,
),
const SizedBox(height: 12),
_buildCard(
title: 'ЗАЧЕМ ЭТО НУЖНО',
content: explanation.whyItMatters,
icon: CupertinoIcons.checkmark_seal_fill,
iconColor: const Color(0xFF0A84FF),
),
const SizedBox(height: 12),
_buildCard(
title: 'БЕЗОПАСНОСТЬ',
content: explanation.safetyNote,
icon: CupertinoIcons.shield_fill,
iconColor: CupertinoColors.systemGreen,
),
if (explanation.example != null) ...[
const SizedBox(height: 12),
_buildCard(
title: 'ПРИМЕР',
content: explanation.example!,
icon: CupertinoIcons.play_circle_fill,
iconColor: CupertinoColors.systemPurple,
),
],
],
),
),
// Bottom Close
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Align(
alignment: Alignment.centerRight,
child: PushButton(
controlSize: ControlSize.regular,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Понятно'),
),
),
),
],
),
);
}
Widget _buildCard({
required String title,
required String content,
required IconData icon,
required Color iconColor,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF262628),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.06),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MacosIcon(icon, size: 12, color: iconColor),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
color: iconColor,
),
),
],
),
const SizedBox(height: 6),
Text(
content,
style: const TextStyle(
fontSize: 12.5,
height: 1.35,
color: CupertinoColors.white,
),
),
],
),
);
}
}
+300
View File
@@ -0,0 +1,300 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/batch_install_service.dart';
import 'app_icon_image.dart';
import 'batch_install_sheet.dart';
class InstallQueueBar extends StatelessWidget {
const InstallQueueBar({super.key});
void _showQueueDetails(BuildContext context) {
final batch = BatchInstallService.instance;
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (sheetContext) {
return MacosSheet(
child: Container(
width: 540,
height: 480,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(CupertinoIcons.bag_fill, size: 20, color: Color(0xFF0A84FF)),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Очередь комплексной установки',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => Navigator.of(sheetContext).pop(),
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 14),
const Text(
'Выбранные приложения из разных источников будут установлены за один сеанс с однократным запросом пароля:',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 14),
Expanded(
child: ListenableBuilder(
listenable: batch,
builder: (context, _) {
if (batch.isEmpty) {
return const Center(
child: Text('Очередь пуста', style: TextStyle(color: CupertinoColors.systemGrey)),
);
}
return ListView.separated(
itemCount: batch.queueApps.length,
separatorBuilder: (_, _) => Container(
height: 0.8,
color: CupertinoColors.systemGrey.withValues(alpha: 0.2),
),
itemBuilder: (context, i) {
final app = batch.queueApps[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
AppIconImage(
iconUrl: app.iconUrl,
fallbackIcon: app.fallbackIcon,
accentColor: app.accentColor,
size: 34,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
app.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
Text(
app.summary,
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _backendColor(app.backend).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
app.backend.name.toUpperCase(),
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: _backendColor(app.backend),
),
),
),
const SizedBox(width: 8),
MacosIconButton(
icon: const MacosIcon(CupertinoIcons.xmark, size: 14, color: CupertinoColors.systemGrey),
onPressed: () => batch.remove(app),
),
],
),
);
},
);
},
),
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () {
batch.clear();
Navigator.of(sheetContext).pop();
},
child: const Text('Очистить всё'),
),
const SizedBox(width: 10),
PushButton(
controlSize: ControlSize.regular,
onPressed: () {
Navigator.of(sheetContext).pop();
BatchInstallSheet.show(context);
},
child: const Text('Перейти к установке'),
),
],
),
],
),
),
);
},
);
}
static Color _backendColor(BackendType b) {
switch (b) {
case BackendType.pacman:
return const Color(0xFF1793D1);
case BackendType.aur:
return const Color(0xFFFF9500);
case BackendType.flatpak:
return const Color(0xFF4A90E2);
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: BatchInstallService.instance,
builder: (context, _) {
final batch = BatchInstallService.instance;
if (batch.isEmpty) return const SizedBox.shrink();
final macosTheme = MacosTheme.of(context);
return Container(
margin: const EdgeInsets.fromLTRB(28, 0, 28, 16),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: const Color(0xFF0A84FF).withValues(alpha: 0.4),
width: 1,
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.25),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFF0A84FF).withValues(alpha: 0.4),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child: MacosIcon(
CupertinoIcons.cart_fill,
color: CupertinoColors.white,
size: 19,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'В очереди: ${batch.count} ${batch.count == 1 ? "приложение" : "приложений"}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
const SizedBox(width: 10),
// Source badges
if (batch.pacmanApps.isNotEmpty)
_buildMiniBadge('Pacman: ${batch.pacmanApps.length}', const Color(0xFF1793D1)),
if (batch.aurApps.isNotEmpty)
_buildMiniBadge('AUR: ${batch.aurApps.length}', const Color(0xFFFF9500)),
if (batch.flatpakApps.isNotEmpty)
_buildMiniBadge('Flatpak: ${batch.flatpakApps.length}', const Color(0xFF4A90E2)),
],
),
const SizedBox(height: 2),
const Text(
'Все выбранные пакеты установятся за 1 нажатие с 1 запросом пароля',
style: TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => _showQueueDetails(context),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.list_bullet, size: 13),
SizedBox(width: 5),
Text('Список'),
],
),
),
const SizedBox(width: 10),
PushButton(
controlSize: ControlSize.regular,
onPressed: () => BatchInstallSheet.show(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const MacosIcon(CupertinoIcons.bolt_fill, size: 14, color: Color(0xFFFFD60A)),
const SizedBox(width: 6),
Text('Установить всё (${batch.count})'),
],
),
),
],
),
);
},
);
}
Widget _buildMiniBadge(String text, Color color) {
return Container(
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(5),
),
child: Text(
text,
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
}
+928
View File
@@ -0,0 +1,928 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
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';
/// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога»)
class PackageInspectorSheet extends StatefulWidget {
final String initialPackageName;
final BackendType initialBackend;
const PackageInspectorSheet({
super.key,
required this.initialPackageName,
required this.initialBackend,
});
static Future<void> show(
BuildContext context, {
required String packageName,
required BackendType backend,
}) {
return showMacosSheet(
context: context,
barrierDismissible: true,
builder: (context) => PackageInspectorSheet(
initialPackageName: packageName,
initialBackend: backend,
),
);
}
@override
State<PackageInspectorSheet> createState() => _PackageInspectorSheetState();
}
class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
late String _currentPackageName;
late BackendType _currentBackend;
final List<({String name, BackendType backend})> _history = [];
PackageDetails? _details;
List<CachedPackageVersion> _cachedVersions = [];
List<String> _packageFiles = [];
bool _isLoading = true;
bool _isDeleting = false;
bool _isDowngrading = false;
int _selectedTab = 0; // 0: Обзор, 1: Зависимости, 2: Файлы, 3: Откат
String _fileSearchFilter = '';
@override
void initState() {
super.initState();
_currentPackageName = widget.initialPackageName;
_currentBackend = widget.initialBackend;
_loadDetails();
}
Future<void> _loadDetails() async {
setState(() {
_isLoading = true;
_fileSearchFilter = '';
});
try {
final data = await RustBridgeApi.getPackageDetails(
_currentBackend,
_currentPackageName,
);
List<CachedPackageVersion> cached = [];
List<String> files = [];
if (_currentBackend == BackendType.pacman) {
try {
cached = await RustBridgeApi.getCachedPackageVersions(_currentPackageName);
} catch (_) {}
try {
final res = await Process.run('pacman', ['-Qlq', _currentPackageName]);
if (res.exitCode == 0) {
files = (res.stdout as String)
.split('\n')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
} catch (_) {}
}
if (mounted) {
setState(() {
_details = data;
_cachedVersions = cached;
_packageFiles = files;
_isLoading = false;
});
}
} catch (_) {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _navigateTo(String name, BackendType backend) {
setState(() {
_history.add((name: _currentPackageName, backend: _currentBackend));
_currentPackageName = name;
_currentBackend = backend;
});
_loadDetails();
}
void _goBack() {
if (_history.isEmpty) return;
final prev = _history.removeLast();
setState(() {
_currentPackageName = prev.name;
_currentBackend = prev.backend;
});
_loadDetails();
}
Future<void> _openProjectUrl(String url) async {
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
void _confirmUninstall() {
showMacosAlertDialog(
context: context,
builder: (ctx) => MacosAlertDialog(
appIcon: const MacosIcon(
CupertinoIcons.trash,
color: CupertinoColors.systemRed,
size: 48,
),
title: Text('Удалить $_currentPackageName?'),
message: Text(
'Вы уверены, что хотите удалить пакет $_currentPackageName? Связанные конфигурации и неиспользуемые зависимости могут быть затронуты.',
style: TextStyle(
color: MacosTheme.of(ctx).typography.body.color,
),
),
primaryButton: PushButton(
controlSize: ControlSize.large,
color: CupertinoColors.systemRed,
onPressed: () async {
Navigator.of(ctx).pop();
String? password;
if (_currentBackend != BackendType.flatpak) {
password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для удаления системного пакета $_currentPackageName требуются права суперпользователя.',
);
if (password == null) return;
}
setState(() => _isDeleting = true);
await RustBridgeApi.uninstallPackage(
_currentBackend,
_currentPackageName,
password: password,
);
if (mounted) {
setState(() => _isDeleting = false);
Navigator.of(context).pop();
}
},
child: const Text('Удалить', style: TextStyle(color: CupertinoColors.white)),
),
secondaryButton: PushButton(
controlSize: ControlSize.large,
secondary: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Отмена'),
),
),
);
}
Future<void> _downgradeTo(CachedPackageVersion target) async {
final confirmed = await showMacosAlertDialog<bool>(
context: context,
builder: (ctx) => MacosAlertDialog(
appIcon: const MacosIcon(
CupertinoIcons.arrow_counterclockwise_circle_fill,
color: CupertinoColors.systemOrange,
size: 48,
),
title: Text('Откатить $_currentPackageName?'),
message: Text(
'Вы действительно хотите откатить пакет до версии ${target.version} из локального кэша?\nФайл: ${target.filePath.split('/').last}',
style: TextStyle(
color: MacosTheme.of(ctx).typography.body.color,
),
),
primaryButton: PushButton(
controlSize: ControlSize.large,
color: CupertinoColors.systemOrange,
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Откатить', style: TextStyle(color: CupertinoColors.white)),
),
secondaryButton: PushButton(
controlSize: ControlSize.large,
secondary: true,
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
),
);
if (confirmed != true) return;
if (!mounted) return;
final password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для отката пакета $_currentPackageName версии ${target.version} требуются права суперпользователя.',
);
if (password == null) return;
if (!mounted) return;
setState(() => _isDowngrading = true);
final stream = RustBridgeApi.downgradePackage(
_currentPackageName,
target.filePath,
password: password,
);
bool success = false;
await for (final event in stream) {
if (event.step == UpdateStep.done) {
success = true;
}
}
if (mounted) {
setState(() => _isDowngrading = false);
if (success) {
_loadDetails();
} else {
showMacosAlertDialog(
context: context,
builder: (ctx) => MacosAlertDialog(
appIcon: const MacosIcon(
CupertinoIcons.exclamationmark_triangle,
size: 48,
color: CupertinoColors.systemRed,
),
title: const Text('Ошибка отката'),
message: const Text('Не удалось установить выбранную версию пакета. Проверьте права и целостность архива.'),
primaryButton: PushButton(
controlSize: ControlSize.large,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
),
);
}
}
}
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
final screenSize = MediaQuery.sizeOf(context);
final sheetWidth = (screenSize.width * 0.78).clamp(580.0, 840.0);
final sheetHeight = (screenSize.height * 0.84).clamp(500.0, 720.0);
return MacosSheet(
child: Container(
width: sheetWidth,
height: sheetHeight,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.45),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: Column(
children: [
_buildHeader(theme),
Expanded(
child: _isLoading
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(radius: 16),
SizedBox(height: 14),
Text(
'Загрузка информации о пакете...',
style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
),
],
),
)
: _details == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MacosIcon(CupertinoIcons.exclamationmark_triangle, size: 40),
const SizedBox(height: 12),
Text('Пакет $_currentPackageName не найден в ${_currentBackend.name}'),
const SizedBox(height: 16),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Закрыть'),
),
],
),
)
: _buildContent(theme),
),
],
),
),
);
}
Widget _buildHeader(MacosThemeData theme) {
final d = _details;
final isFlatpak = _currentBackend == BackendType.flatpak;
final isAur = _currentBackend == BackendType.aur;
final backendColor = isFlatpak
? const Color(0xFF4A90E2)
: isAur
? const Color(0xFFFF9500)
: const Color(0xFF1793D1);
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 18, 14),
decoration: BoxDecoration(
color: theme.canvasColor,
border: Border(
bottom: BorderSide(color: theme.dividerColor, width: 0.8),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_history.isNotEmpty) ...[
MacosIconButton(
icon: const MacosIcon(CupertinoIcons.chevron_left, size: 18),
onPressed: _goBack,
),
const SizedBox(width: 8),
],
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: backendColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: backendColor.withValues(alpha: 0.35),
width: 0.8,
),
),
child: Center(
child: MacosIcon(
isFlatpak
? CupertinoIcons.app_badge_fill
: isAur
? CupertinoIcons.layers_alt_fill
: CupertinoIcons.cube_box_fill,
color: backendColor,
size: 22,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(
_currentPackageName,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: backendColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(5),
),
child: Text(
_currentBackend.name.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: backendColor,
),
),
),
if (d != null && d.repository.isNotEmpty) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(5),
),
child: Text(
d.repository,
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
),
),
],
],
),
const SizedBox(height: 3),
Text(
d != null ? 'Версия: ${d.version}' : 'Свойства и метаданные пакета',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
if (d?.url != null)
MacosTooltip(
message: 'Открыть домашнюю страницу пакета',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.globe, size: 16),
onPressed: () => _openProjectUrl(d!.url!),
),
),
MacosTooltip(
message: 'Скопировать название пакета',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.doc_on_clipboard, size: 15),
onPressed: () {
Clipboard.setData(ClipboardData(text: _currentPackageName));
},
),
),
const SizedBox(width: 8),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Закрыть'),
),
],
),
);
}
Widget _buildContent(MacosThemeData theme) {
final d = _details!;
final tabs = [
'Обзор',
'Зависимости (${d.dependsOn.length})',
if (_currentBackend == BackendType.pacman && _packageFiles.isNotEmpty)
'Файлы (${_packageFiles.length})',
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
'Откат кэша (${_cachedVersions.length})',
];
return Column(
children: [
// Navigation bar & Action button
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: theme.canvasColor,
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 0.8)),
),
child: Row(
children: [
// Segmented Tabs
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(tabs.length, (idx) {
final isSelected = _selectedTab == idx;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
color: isSelected ? theme.primaryColor.withValues(alpha: 0.18) : CupertinoColors.transparent,
borderRadius: BorderRadius.circular(7),
onPressed: () => setState(() => _selectedTab = idx),
child: Text(
tabs[idx],
style: TextStyle(
fontSize: 12.5,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? theme.primaryColor : CupertinoColors.systemGrey,
),
),
),
);
}),
),
),
),
// Action buttons
PushButton(
controlSize: ControlSize.regular,
color: CupertinoColors.systemRed.withValues(alpha: 0.18),
onPressed: _isDeleting ? null : _confirmUninstall,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isDeleting)
const ProgressCircle(radius: 5)
else
const MacosIcon(CupertinoIcons.trash, size: 13, color: CupertinoColors.systemRed),
const SizedBox(width: 5),
const Text('Удалить', style: TextStyle(color: CupertinoColors.systemRed)),
],
),
),
],
),
),
// Tab Content
Expanded(
child: IndexedStack(
index: _selectedTab.clamp(0, tabs.length - 1),
children: [
_buildOverviewTab(theme, d),
_buildDependenciesTab(theme, d),
if (_currentBackend == BackendType.pacman && _packageFiles.isNotEmpty)
_buildFilesTab(theme, d),
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
_buildDowngradeTab(theme, d),
],
),
),
],
);
}
Widget _buildOverviewTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
children: [
// Description Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.5), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ОПИСАНИЕ',
style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold, letterSpacing: 0.7, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 8),
Text(
d.description.isNotEmpty ? d.description : 'Описание не предоставлено.',
style: const TextStyle(fontSize: 13.5, height: 1.45),
),
],
),
),
const SizedBox(height: 18),
// Metadata grid
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_buildInfoCard(theme, 'Размер установки', d.formattedInstalledSize, CupertinoIcons.cube_box),
if (d.downloadSizeBytes > 0)
_buildInfoCard(theme, 'Размер загрузки', '${(d.downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB', CupertinoIcons.arrow_down_to_line),
_buildInfoCard(theme, 'Лицензия', d.licenses.isNotEmpty ? d.licenses.join(', ') : 'Не указана', CupertinoIcons.doc_plaintext),
if (d.packager != null)
_buildInfoCard(theme, 'Сборщик', d.packager!, CupertinoIcons.person_badge_plus),
if (d.buildDate != null)
_buildInfoCard(theme, 'Дата сборки', d.buildDate!, CupertinoIcons.calendar),
_buildInfoCard(theme, 'Репозиторий', d.repository, CupertinoIcons.archivebox),
_buildInfoCard(theme, 'Пакетный менеджер', d.backend.displayName, CupertinoIcons.layers),
],
),
],
);
}
Widget _buildInfoCard(MacosThemeData theme, String title, String value, IconData icon) {
const width = 230.0;
return Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MacosIcon(icon, size: 12, color: CupertinoColors.systemGrey),
const SizedBox(width: 6),
Text(
title,
style: const TextStyle(fontSize: 10.5, color: CupertinoColors.systemGrey, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildDependenciesTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
children: [
// Depends on
_buildSectionHeader('НЕОБХОДИМЫЕ ЗАВИСИМОСТИ (${d.dependsOn.length})'),
const SizedBox(height: 10),
if (d.dependsOn.isEmpty)
const Text('Зависимости отсутствуют.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: d.dependsOn.map((dep) {
final cleanName = dep.split(RegExp(r'[><=]')).first.trim();
return CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => _navigateTo(cleanName, d.backend),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.3), width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
dep,
style: TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: theme.primaryColor, fontWeight: FontWeight.w600),
),
const SizedBox(width: 4),
MacosIcon(CupertinoIcons.arrow_right, size: 10, color: theme.primaryColor),
],
),
),
);
}).toList(),
),
const SizedBox(height: 24),
// Required by
_buildSectionHeader('ИСПОЛЬЗУЕТСЯ ПАКЕТАМИ (${d.requiredBy.length})'),
const SizedBox(height: 10),
if (d.requiredBy.isEmpty)
const Text('Ни один установленный пакет не зависит от этого компонента.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: d.requiredBy.map((req) {
return CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => _navigateTo(req, d.backend),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.6), width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
req,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: CupertinoColors.white),
),
const SizedBox(width: 4),
const MacosIcon(CupertinoIcons.arrow_right, size: 10, color: CupertinoColors.systemGrey),
],
),
),
);
}).toList(),
),
],
);
}
Widget _buildFilesTab(MacosThemeData theme, PackageDetails d) {
final filtered = _fileSearchFilter.isEmpty
? _packageFiles
: _packageFiles.where((f) => f.toLowerCase().contains(_fileSearchFilter.toLowerCase())).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: MacosTextField(
placeholder: 'Фильтровать файлы пакета (${_packageFiles.length} всего)...',
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(CupertinoIcons.search, size: 13, color: CupertinoColors.systemGrey),
),
onChanged: (val) => setState(() => _fileSearchFilter = val),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
itemCount: filtered.length,
itemBuilder: (context, idx) {
final path = filtered[idx];
final isDir = path.endsWith('/');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.5),
child: Row(
children: [
MacosIcon(
isDir ? CupertinoIcons.folder_fill : CupertinoIcons.doc,
size: 13,
color: isDir ? const Color(0xFF0A84FF) : CupertinoColors.systemGrey,
),
const SizedBox(width: 8),
Expanded(
child: Text(
path,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: isDir ? CupertinoColors.white : CupertinoColors.systemGrey,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
),
),
],
);
}
Widget _buildDowngradeTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
children: [
_buildSectionHeader('СОХРАНЕННЫЕ ВЕРСИИ В /var/cache/pacman/pkg (${_cachedVersions.length})'),
const SizedBox(height: 6),
const Text(
'Вы можете откатить данный пакет до любой ранее установленной версии из локального архива в один клик:',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 14),
..._cachedVersions.map((cv) {
final isCurrent = cv.version == d.version;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isCurrent ? const Color(0xFF0A84FF).withValues(alpha: 0.1) : theme.dividerColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCurrent ? const Color(0xFF0A84FF).withValues(alpha: 0.35) : theme.dividerColor.withValues(alpha: 0.5),
width: 0.8,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
cv.version,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w500,
color: isCurrent ? const Color(0xFF0A84FF) : CupertinoColors.white,
),
),
if (isCurrent) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text('Текущая', style: TextStyle(fontSize: 10, color: Color(0xFF0A84FF), fontWeight: FontWeight.bold)),
),
],
],
),
const SizedBox(height: 3),
Text(
'${_formatBytes(cv.fileSizeBytes)} • ${cv.modifiedTime.toLocal().toString().split('.').first}',
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
if (!isCurrent)
PushButton(
controlSize: ControlSize.regular,
color: CupertinoColors.systemOrange.withValues(alpha: 0.25),
onPressed: _isDowngrading ? null : () => _downgradeTo(cv),
child: _isDowngrading
? const ProgressCircle(radius: 5)
: const Text('Откатить', style: TextStyle(color: CupertinoColors.systemOrange, fontWeight: FontWeight.bold)),
),
],
),
);
}),
],
);
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
);
}
}
/// Совместимая обёртка для прежних мест вызова
class PackageInspector extends StatelessWidget {
final String packageName;
final BackendType backend;
final VoidCallback onClose;
final Function(String name, BackendType backend)? onNavigateToPackage;
const PackageInspector({
super.key,
required this.packageName,
required this.backend,
required this.onClose,
this.onNavigateToPackage,
});
@override
Widget build(BuildContext context) {
return PackageInspectorSheet(
initialPackageName: packageName,
initialBackend: backend,
);
}
}
@@ -0,0 +1,531 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/aur_service.dart';
class SpotlightItem {
final String name;
final String version;
final String description;
final BackendType backend;
final bool isInstalled;
final int? aurVotes;
const SpotlightItem({
required this.name,
required this.version,
required this.description,
required this.backend,
this.isInstalled = false,
this.aurVotes,
});
}
class SpotlightSearchDialog extends StatefulWidget {
final Function(String name, BackendType backend) onSelectPackage;
const SpotlightSearchDialog({
super.key,
required this.onSelectPackage,
});
static Future<void> show(
BuildContext context, {
required Function(String name, BackendType backend) onSelectPackage,
}) {
return showMacosSheet<void>(
context: context,
barrierDismissible: true,
builder: (_) => SpotlightSearchDialog(onSelectPackage: onSelectPackage),
);
}
@override
State<SpotlightSearchDialog> createState() => _SpotlightSearchDialogState();
}
class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
Timer? _debounceTimer;
bool _isSearching = false;
List<SpotlightItem> _results = [];
int _selectedIndex = 0;
String _activeFilter = 'Все';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
if (query.trim().isEmpty) {
setState(() {
_results.clear();
_isSearching = false;
_selectedIndex = 0;
});
return;
}
_debounceTimer = Timer(const Duration(milliseconds: 250), () {
_performSearch(query.trim());
});
}
Future<void> _performSearch(String query) async {
setState(() => _isSearching = true);
final List<SpotlightItem> items = [];
try {
// 1. Поиск в официальных репозиториях Pacman
if (_activeFilter == 'Все' || _activeFilter == 'Pacman') {
final res = await Process.run('pacman', ['-Ss', query], environment: {'LC_ALL': 'C'});
if (res.exitCode == 0) {
final lines = res.stdout.toString().split('\n');
for (int i = 0; i < lines.length - 1; i += 2) {
final header = lines[i].trim();
if (header.isEmpty) continue;
final parts = header.split(RegExp(r'\s+'));
if (parts.isEmpty) continue;
final repoName = parts[0];
final name = repoName.contains('/') ? repoName.split('/').last : repoName;
final version = parts.length > 1 ? parts[1] : '';
final isInst = header.contains('[installed]');
final desc = (i + 1 < lines.length) ? lines[i + 1].trim() : '';
items.add(SpotlightItem(
name: name,
version: version,
description: desc,
backend: BackendType.pacman,
isInstalled: isInst,
));
if (items.length >= 15) break;
}
}
}
// 2. Поиск в AUR
if (_activeFilter == 'Все' || _activeFilter == 'AUR') {
final aurList = await AurService.search(query);
for (final aur in aurList.take(15)) {
items.add(SpotlightItem(
name: aur.name,
version: aur.version,
description: aur.description,
backend: BackendType.aur,
aurVotes: aur.numVotes,
));
}
}
// 3. Поиск во Flatpak
if (_activeFilter == 'Все' || _activeFilter == 'Flatpak') {
final fpRes = await Process.run('flatpak', ['search', query, '--columns=app,name,version,description']);
if (fpRes.exitCode == 0) {
final lines = fpRes.stdout.toString().trim().split('\n');
for (final line in lines.take(10)) {
final parts = line.split('\t');
if (parts.length >= 2) {
final id = parts[0].trim();
final name = parts[1].trim().isNotEmpty ? parts[1].trim() : id;
final ver = parts.length > 2 ? parts[2].trim() : 'latest';
final desc = parts.length > 3 ? parts[3].trim() : '';
items.add(SpotlightItem(
name: name,
version: ver,
description: desc,
backend: BackendType.flatpak,
));
}
}
}
}
} catch (_) {}
if (mounted) {
setState(() {
_results = items;
_isSearching = false;
_selectedIndex = 0;
});
}
}
void _selectCurrent() {
if (_results.isNotEmpty && _selectedIndex < _results.length) {
final item = _results[_selectedIndex];
Navigator.of(context).pop();
widget.onSelectPackage(item.name, item.backend);
}
}
void _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (_results.isNotEmpty) {
setState(() {
_selectedIndex = (_selectedIndex + 1).clamp(0, _results.length - 1);
});
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (_results.isNotEmpty) {
setState(() {
_selectedIndex = (_selectedIndex - 1).clamp(0, _results.length - 1);
});
}
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
_selectCurrent();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}
}
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: _handleKeyEvent,
child: Center(
child: Container(
width: 580,
constraints: const BoxConstraints(maxHeight: 520),
margin: const EdgeInsets.only(top: 60),
decoration: BoxDecoration(
color: const Color(0xFF1E1E20).withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.16),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.65),
blurRadius: 40,
offset: const Offset(0, 16),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Search Input Row
Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
child: Row(
children: [
const MacosIcon(
CupertinoIcons.search,
size: 20,
color: Color(0xFF0A84FF),
),
const SizedBox(width: 12),
Expanded(
child: CupertinoTextField(
controller: _searchController,
focusNode: _focusNode,
onChanged: _onSearchChanged,
onSubmitted: (_) => _selectCurrent(),
placeholder: 'Поиск пакетов в Arch Linux, AUR, Flathub...',
placeholderStyle: const TextStyle(
color: CupertinoColors.systemGrey,
fontSize: 15,
),
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 15,
),
decoration: null,
),
),
if (_isSearching)
const ProgressCircle(radius: 8)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(5),
),
child: const Text(
'ESC',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemGrey,
),
),
),
],
),
),
// Filter Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: ['Все', 'Pacman', 'AUR', 'Flatpak'].map((filter) {
final isSel = filter == _activeFilter;
return Padding(
padding: const EdgeInsets.only(right: 6, bottom: 8),
child: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
setState(() => _activeFilter = filter);
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: isSel ? const Color(0xFF0A84FF) : const Color(0xFF2C2C2E),
borderRadius: BorderRadius.circular(12),
),
child: Text(
filter,
style: TextStyle(
fontSize: 11,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? CupertinoColors.white : CupertinoColors.systemGrey,
),
),
),
),
);
}).toList(),
),
),
Container(
height: 0.8,
color: CupertinoColors.white.withValues(alpha: 0.08),
),
// Results List
if (_results.isEmpty)
Container(
height: 160,
alignment: Alignment.center,
child: Text(
_searchController.text.isEmpty
? 'Начните вводить название утилиты или приложения...'
: _isSearching
? 'Поиск...'
: 'Ничего не найдено',
style: const TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
),
)
else
Flexible(
child: ListView.builder(
controller: _scrollController,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: _results.length,
itemBuilder: (context, idx) {
final item = _results[idx];
final isSelected = idx == _selectedIndex;
return GestureDetector(
onTap: () {
Navigator.of(context).pop();
widget.onSelectPackage(item.name, item.backend);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF0A84FF).withValues(alpha: 0.25)
: CupertinoColors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
_buildBackendIcon(item.backend),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
item.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.5,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
Text(
item.version,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const Spacer(),
if (item.aurVotes != null) ...[
const MacosIcon(
CupertinoIcons.star_fill,
size: 11,
color: CupertinoColors.systemYellow,
),
const SizedBox(width: 3),
Text(
'${item.aurVotes}',
style: const TextStyle(
fontSize: 11,
color: CupertinoColors.systemYellow,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6),
],
_buildBackendBadge(item.backend),
],
),
if (item.description.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
item.description,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
);
},
),
),
// Bottom hint
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.canvasColor.withValues(alpha: 0.5),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
child: const Row(
children: [
Text(
'↑↓ для навигации • Enter чтобы открыть • ESC для выхода',
style: TextStyle(
fontSize: 11,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
),
),
);
}
Widget _buildBackendIcon(BackendType backend) {
IconData icon;
Color color;
switch (backend) {
case BackendType.pacman:
icon = CupertinoIcons.cube_box_fill;
color = const Color(0xFF1793D1);
break;
case BackendType.aur:
icon = CupertinoIcons.bolt_fill;
color = const Color(0xFFFF9500);
break;
case BackendType.flatpak:
icon = CupertinoIcons.app_badge_fill;
color = const Color(0xFFAF52DE);
break;
}
return Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: MacosIcon(icon, color: color, size: 15),
),
);
}
Widget _buildBackendBadge(BackendType backend) {
Color color;
String label;
switch (backend) {
case BackendType.pacman:
color = const Color(0xFF1793D1);
label = 'Pacman';
break;
case BackendType.aur:
color = const Color(0xFFFF9500);
label = 'AUR';
break;
case BackendType.flatpak:
color = const Color(0xFFAF52DE);
label = 'Flatpak';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
}
@@ -0,0 +1,292 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../services/app_badge_controller.dart';
class StorageInfo {
final int totalBytes;
final int freeBytes;
final int pacmanCacheBytes;
final int journalBytes;
final int flatpakBytes;
final int pacmanInstalledBytes;
const StorageInfo({
required this.totalBytes,
required this.freeBytes,
required this.pacmanCacheBytes,
required this.journalBytes,
required this.flatpakBytes,
required this.pacmanInstalledBytes,
});
int get usedBytes => (totalBytes - freeBytes).clamp(0, totalBytes);
int get otherBytes {
final accounted = pacmanCacheBytes + journalBytes + flatpakBytes + pacmanInstalledBytes;
return (usedBytes - accounted).clamp(0, usedBytes);
}
}
class StorageBreakdownBar extends StatefulWidget {
final int pacmanCacheBytes;
final int journalBytes;
const StorageBreakdownBar({
super.key,
required this.pacmanCacheBytes,
required this.journalBytes,
});
@override
State<StorageBreakdownBar> createState() => _StorageBreakdownBarState();
}
class _StorageBreakdownBarState extends State<StorageBreakdownBar> {
StorageInfo? _storageInfo;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadDiskStats();
}
@override
void didUpdateWidget(StorageBreakdownBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pacmanCacheBytes != widget.pacmanCacheBytes ||
oldWidget.journalBytes != widget.journalBytes) {
_loadDiskStats();
}
}
Future<void> _loadDiskStats() async {
try {
int total = 0;
int free = 0;
// 1. Root partition size via df -B1 /
final dfRes = await Process.run('df', ['-B1', '/']);
if (dfRes.exitCode == 0) {
final lines = dfRes.stdout.toString().trim().split('\n');
if (lines.length >= 2) {
final parts = lines[1].trim().split(RegExp(r'\s+'));
if (parts.length >= 4) {
total = int.tryParse(parts[1]) ?? 0;
free = int.tryParse(parts[3]) ?? 0;
}
}
}
// 2. Flatpak size estimation (/var/lib/flatpak)
int flatpak = 0;
try {
final fpRes = await Process.run('du', ['-sb', '/var/lib/flatpak']);
if (fpRes.exitCode == 0) {
final first = fpRes.stdout.toString().trim().split(RegExp(r'\s+')).first;
flatpak = int.tryParse(first) ?? 0;
}
} catch (_) {}
// 3. Pacman installed size estimation
int pacmanInstalled = 0;
try {
final usrRes = await Process.run('du', ['-sb', '/usr']);
if (usrRes.exitCode == 0) {
final first = usrRes.stdout.toString().trim().split(RegExp(r'\s+')).first;
final usrBytes = int.tryParse(first) ?? 0;
pacmanInstalled = (usrBytes * 0.85).toInt();
}
} catch (_) {}
if (mounted) {
setState(() {
_storageInfo = StorageInfo(
totalBytes: total > 0 ? total : 500 * 1024 * 1024 * 1024,
freeBytes: free > 0 ? free : 350 * 1024 * 1024 * 1024,
pacmanCacheBytes: widget.pacmanCacheBytes,
journalBytes: widget.journalBytes,
flatpakBytes: flatpak,
pacmanInstalledBytes: pacmanInstalled,
);
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading || _storageInfo == null) {
return const SizedBox.shrink();
}
final info = _storageInfo!;
final total = info.totalBytes > 0 ? info.totalBytes : 1;
final pctPacman = (info.pacmanInstalledBytes / total).clamp(0.0, 1.0);
final pctFlatpak = (info.flatpakBytes / total).clamp(0.0, 1.0);
final pctCache = (info.pacmanCacheBytes / total).clamp(0.0, 1.0);
final pctJournal = (info.journalBytes / total).clamp(0.0, 1.0);
final pctOther = (info.otherBytes / total).clamp(0.0, 1.0);
final pctFree = (info.freeBytes / total).clamp(0.0, 1.0);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(
CupertinoIcons.circle_grid_hex_fill,
color: Color(0xFF0A84FF),
size: 16,
),
const SizedBox(width: 8),
const Text(
'Распределение дискового пространства',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const Spacer(),
Text(
'Свободно ${AppBadgeController.formatBytes(info.freeBytes)} из ${AppBadgeController.formatBytes(info.totalBytes)}',
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
const SizedBox(height: 14),
// macOS Storage Segmented Bar
ClipRRect(
borderRadius: BorderRadius.circular(7),
child: SizedBox(
height: 14,
child: Row(
children: [
if (pctPacman > 0)
Flexible(
flex: (pctPacman * 1000).toInt(),
child: Container(color: const Color(0xFF0A84FF)),
),
if (pctFlatpak > 0)
Flexible(
flex: (pctFlatpak * 1000).toInt(),
child: Container(color: const Color(0xFFAF52DE)),
),
if (pctCache > 0)
Flexible(
flex: (pctCache * 1000).toInt(),
child: Container(color: const Color(0xFFFF9500)),
),
if (pctJournal > 0)
Flexible(
flex: (pctJournal * 1000).toInt(),
child: Container(color: const Color(0xFFFFD60A)),
),
if (pctOther > 0)
Flexible(
flex: (pctOther * 1000).toInt(),
child: Container(color: const Color(0xFF48484A)),
),
if (pctFree > 0)
Flexible(
flex: (pctFree * 1000).toInt(),
child: Container(color: const Color(0xFF2C2C2E)),
),
],
),
),
),
const SizedBox(height: 14),
// Legend
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildLegendItem(
color: const Color(0xFF0A84FF),
label: 'Пакеты Pacman',
size: AppBadgeController.formatBytes(info.pacmanInstalledBytes),
),
_buildLegendItem(
color: const Color(0xFFAF52DE),
label: 'Flatpak',
size: AppBadgeController.formatBytes(info.flatpakBytes),
),
_buildLegendItem(
color: const Color(0xFFFF9500),
label: 'Кэш пакетов',
size: AppBadgeController.formatBytes(info.pacmanCacheBytes),
),
_buildLegendItem(
color: const Color(0xFFFFD60A),
label: 'Логи systemd',
size: AppBadgeController.formatBytes(info.journalBytes),
),
_buildLegendItem(
color: const Color(0xFF2C2C2E),
label: 'Свободно',
size: AppBadgeController.formatBytes(info.freeBytes),
),
],
),
],
),
);
}
Widget _buildLegendItem({
required Color color,
required String label,
required String size,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(width: 4),
Text(
size,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
],
);
}
}
+1
View File
@@ -0,0 +1 @@
flutter/ephemeral
+128
View File
@@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "forge")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.forge.forge")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
+88
View File
@@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
+24
View File
@@ -0,0 +1,24 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
+26
View File
@@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
+6
View File
@@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}
+148
View File
@@ -0,0 +1,148 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "forge");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "forge");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
}
+21
View File
@@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_
+370
View File
@@ -0,0 +1,370 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
appkit_ui_element_colors:
dependency: transitive
description:
name: appkit_ui_element_colors
sha256: b88a7c35d440fa3ac75222d0e2b7e3259200e531e33b5d2468e358119f3481dc
url: "https://pub.dev"
source: hosted
version: "1.0.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_rust_bridge:
dependency: "direct main"
description:
name: flutter_rust_bridge
sha256: ec69331e2335ab4d456c0a9fc700892c2c85b72b9c0cb39df3ecfe8833d5d5c8
url: "https://pub.dev"
source: hosted
version: "2.13.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
gradient_borders:
dependency: transitive
description:
name: gradient_borders
sha256: c65c9b2169cb32fa7164625fc438a597409576e4b5bee498655152abea4a93cc
url: "https://pub.dev"
source: hosted
version: "1.0.4"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
macos_ui:
dependency: "direct main"
description:
name: macos_ui
sha256: "09fb51d65b6a2d328ba5aa429ba0f7aabad5bc770193ea6e49da9f29bf95d835"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
macos_window_utils:
dependency: transitive
description:
name: macos_window_utils
sha256: cb918e1ff0b31fdaa5cd8631eded7c24bd72e1025cf1f95c819e483f0057c652
url: "https://pub.dev"
source: hosted
version: "1.9.1"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.13.3 <4.0.0"
flutter: ">=3.44.0"
+92
View File
@@ -0,0 +1,92 @@
name: forge
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.13.3
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.9
flutter_rust_bridge: ^2.13.0
macos_ui: ^2.2.2
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+947
View File
@@ -0,0 +1,947 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "allo-isolate"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550"
dependencies = [
"anyhow",
"atomic",
"backtrace",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "atomic"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "build-target"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "dart-sys"
version = "4.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895"
dependencies = [
"cc",
]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "delegate-attr"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84c9a9c129b98e707ac9a31e204c10c064dd9f949b7da362310ff1a8b137d79"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flutter_rust_bridge"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a973d15b8fc7de9fb29cdfbec58a2ac41f6252492d3b882abfdc603e32b34f"
dependencies = [
"allo-isolate",
"android_logger",
"anyhow",
"build-target",
"bytemuck",
"byteorder",
"console_error_panic_hook",
"dart-sys",
"delegate-attr",
"flutter_rust_bridge_macros",
"futures",
"js-sys",
"lazy_static",
"log",
"oslog",
"portable-atomic",
"threadpool",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "flutter_rust_bridge_macros"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901eec2d226107dad1c55b30f7421c4687bebcf70ed43ca44d1b447ca087b238"
dependencies = [
"hex",
"md-5",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "forge_core"
version = "0.1.0"
dependencies = [
"async-trait",
"flutter_rust_bridge",
"futures",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oslog"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969"
dependencies = [
"cc",
"dashmap",
"log",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-demangle"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "threadpool"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
dependencies = [
"num_cpus",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "forge_core"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib", "lib"]
[dependencies]
flutter_rust_bridge = "2.13.0"
tokio = { version = "1.43", features = ["full", "process"] }
async-trait = "0.1"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
futures = "0.3"
+1
View File
@@ -0,0 +1 @@
pub mod packages;
+130
View File
@@ -0,0 +1,130 @@
use std::sync::Arc;
use crate::frb_generated::StreamSink;
pub use crate::core::types::{
AppError, BackendType, CleanerScanResult, DiscoverApp, HistoryAction, HistoryItem,
PackageDetails, PackageInfo, PackageUpdate, ProgressEvent, UpdateStep,
};
use crate::core::cleaner::SystemCleaner;
use crate::core::discover::AppCatalog;
use crate::core::flatpak::FlatpakPackageManager;
use crate::core::history::PacmanHistory;
use crate::core::package_manager::PackageManager;
use crate::core::pacman::PacmanPackageManager;
use crate::core::privilege::PrivilegedCommandRunner;
fn get_manager(backend: BackendType) -> Arc<dyn PackageManager> {
match backend {
BackendType::Pacman | BackendType::Aur => Arc::new(PacmanPackageManager::new()),
BackendType::Flatpak => Arc::new(FlatpakPackageManager::new()),
}
}
pub async fn init_app() -> Result<(), AppError> {
Ok(())
}
pub async fn check_updates(backend: Option<BackendType>) -> Result<Vec<PackageUpdate>, AppError> {
match backend {
Some(b) => get_manager(b).check_updates().await,
None => {
let mut all = Vec::new();
if let Ok(pacman_updates) = get_manager(BackendType::Pacman).check_updates().await {
all.extend(pacman_updates);
}
if let Ok(flatpak_updates) = get_manager(BackendType::Flatpak).check_updates().await {
all.extend(flatpak_updates);
}
Ok(all)
}
}
}
pub async fn get_installed_packages(
backend: Option<BackendType>,
) -> Result<Vec<PackageInfo>, AppError> {
match backend {
Some(b) => get_manager(b).get_installed_packages().await,
None => {
let mut all = Vec::new();
if let Ok(pacman_pkgs) = get_manager(BackendType::Pacman).get_installed_packages().await {
all.extend(pacman_pkgs);
}
if let Ok(flatpak_pkgs) = get_manager(BackendType::Flatpak).get_installed_packages().await {
all.extend(flatpak_pkgs);
}
Ok(all)
}
}
}
pub async fn get_package_details(
backend: BackendType,
package_name: String,
) -> Result<PackageDetails, AppError> {
get_manager(backend).get_package_details(&package_name).await
}
pub async fn apply_updates(
backend: BackendType,
packages: Vec<String>,
is_dry_run: bool,
sink: StreamSink<ProgressEvent>,
) -> Result<(), AppError> {
get_manager(backend).apply_updates(packages, is_dry_run, sink).await
}
/// Удаление пакета
pub async fn uninstall_package(backend: BackendType, package_name: String) -> Result<(), AppError> {
match backend {
BackendType::Pacman | BackendType::Aur => {
PrivilegedCommandRunner::run_privileged(
"pacman",
&["-Rns", "--noconfirm", &package_name],
|_| {},
)
.await
}
BackendType::Flatpak => {
tokio::process::Command::new("flatpak")
.args(["uninstall", "-y", &package_name])
.output()
.await
.map_err(|e| AppError::ExecutionFailed(e.to_string()))?;
Ok(())
}
}
}
/// Сканирование системы на мусор
pub async fn scan_cleaner() -> Result<CleanerScanResult, AppError> {
SystemCleaner::scan().await
}
/// Выполнение очистки
pub async fn run_cleaner(
clean_pacman_cache: bool,
clean_orphans: bool,
clean_flatpak_unused: bool,
clean_journal: bool,
sink: StreamSink<ProgressEvent>,
) -> Result<(), AppError> {
SystemCleaner::clean(
clean_pacman_cache,
clean_orphans,
clean_flatpak_unused,
clean_journal,
sink,
)
.await
}
/// Получение истории транзакций
pub async fn get_history(limit: usize) -> Result<Vec<HistoryItem>, AppError> {
PacmanHistory::get_recent_history(limit).await
}
/// Получение каталога приложений
pub async fn get_discover_apps() -> Result<Vec<DiscoverApp>, AppError> {
AppCatalog::get_curated_apps().await
}
+183
View File
@@ -0,0 +1,183 @@
use tokio::process::Command;
use crate::core::types::{AppError, CleanerScanResult, ProgressEvent, UpdateStep};
use crate::core::privilege::PrivilegedCommandRunner;
use crate::frb_generated::StreamSink;
pub struct SystemCleaner;
impl SystemCleaner {
pub async fn scan() -> Result<CleanerScanResult, AppError> {
let mut pacman_cache = 0u64;
let mut orphan_pkgs = Vec::new();
let mut orphan_bytes = 0u64;
let mut journal_bytes = 0u64;
let flatpak_unused_count = 0usize;
let flatpak_unused_bytes = 0u64;
// 1. Pacman cache size
if let Ok(out) = Command::new("du")
.args(["-sb", "/var/cache/pacman/pkg"])
.output()
.await
{
let s = String::from_utf8_lossy(&out.stdout);
if let Some(first) = s.split_whitespace().next() {
pacman_cache = first.parse().unwrap_or(0);
}
}
// 2. Orphans (pacman -Qtdq)
if let Ok(out) = Command::new("pacman").args(["-Qtdq"]).output().await {
let s = String::from_utf8_lossy(&out.stdout);
for line in s.lines() {
let name = line.trim();
if !name.is_empty() {
orphan_pkgs.push(name.to_string());
}
}
// Примерная оценка ~50 МБ на пакет-сироту
orphan_bytes = (orphan_pkgs.len() as u64) * 50 * 1024 * 1024;
}
// 3. Journalctl size
if let Ok(out) = Command::new("journalctl")
.args(["--disk-usage"])
.env("LC_ALL", "C")
.output()
.await
{
let s = String::from_utf8_lossy(&out.stdout);
// "Archived and active journals take up 126.2M in the file system."
for part in s.split_whitespace() {
if part.ends_with('M') || part.ends_with('G') || part.ends_with('K') {
journal_bytes = parse_cleaner_unit(part);
break;
}
}
}
let total = pacman_cache + orphan_bytes + journal_bytes + flatpak_unused_bytes;
Ok(CleanerScanResult {
pacman_cache_bytes: pacman_cache,
orphan_packages: orphan_pkgs,
orphan_bytes,
flatpak_unused_count,
flatpak_unused_bytes,
journal_bytes,
total_reclaimable_bytes: total,
})
}
pub async fn clean(
clean_pacman_cache: bool,
clean_orphans: bool,
clean_flatpak_unused: bool,
clean_journal: bool,
sink: StreamSink<ProgressEvent>,
) -> Result<(), AppError> {
let _ = sink.add(ProgressEvent {
step: UpdateStep::CheckingDependencies,
package_name: None,
percentage: 0.05,
message: "Запуск очистки системного мусора...".to_string(),
log_line: Some(":: Initializing system maintenance cleanup...".to_string()),
});
// 1. Очистка кэша пакетов
if clean_pacman_cache {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: None,
percentage: 0.25,
message: "Очистка кэша старых версий пакетов (paccache)...".to_string(),
log_line: Some(":: Running paccache -r to keep only latest 2 versions...".to_string()),
});
let _ = PrivilegedCommandRunner::run_privileged(
"paccache",
&["-r"],
|_| {},
).await;
}
// 2. Удаление пакетов-сирот
if clean_orphans {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: None,
percentage: 0.55,
message: "Удаление неиспользуемых зависимостей (orphans)...".to_string(),
log_line: Some(":: Finding and removing orphan packages...".to_string()),
});
if let Ok(out) = Command::new("pacman").args(["-Qtdq"]).output().await {
let s = String::from_utf8_lossy(&out.stdout);
let orphans: Vec<&str> = s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
if !orphans.is_empty() {
let mut args = vec!["-Rns", "--noconfirm"];
args.extend(orphans);
let _ = PrivilegedCommandRunner::run_privileged("pacman", &args, |_| {}).await;
}
}
}
// 3. Flatpak unused
if clean_flatpak_unused {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: None,
percentage: 0.75,
message: "Удаление неиспользуемых рантаймов Flatpak...".to_string(),
log_line: Some(":: Running flatpak uninstall --unused -y...".to_string()),
});
let _ = Command::new("flatpak")
.args(["uninstall", "--unused", "-y"])
.output()
.await;
}
// 4. Очистка журналов systemd
if clean_journal {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: None,
percentage: 0.9,
message: "Ограничение размера журналов systemd (до 100 МБ)...".to_string(),
log_line: Some(":: Vacuuming systemd journal logs to 100M...".to_string()),
});
let _ = PrivilegedCommandRunner::run_privileged(
"journalctl",
&["--vacuum-size=100M"],
|_| {},
).await;
}
let _ = sink.add(ProgressEvent {
step: UpdateStep::Done,
package_name: None,
percentage: 1.0,
message: "Очистка успешно завершена!".to_string(),
log_line: Some(":: System maintenance finished successfully.".to_string()),
});
Ok(())
}
}
fn parse_cleaner_unit(s: &str) -> u64 {
let clean = s.trim();
if clean.is_empty() {
return 0;
}
let (num_part, unit) = clean.split_at(clean.len().saturating_sub(1));
let num: f64 = num_part.parse().unwrap_or(0.0);
match unit {
"G" | "g" => (num * 1024.0 * 1024.0 * 1024.0) as u64,
"M" | "m" => (num * 1024.0 * 1024.0) as u64,
"K" | "k" => (num * 1024.0) as u64,
_ => num as u64,
}
}
+106
View File
@@ -0,0 +1,106 @@
use crate::core::types::{AppError, BackendType, DiscoverApp};
use tokio::process::Command;
pub struct AppCatalog;
impl AppCatalog {
pub async fn get_curated_apps() -> Result<Vec<DiscoverApp>, AppError> {
let mut apps = vec![
DiscoverApp {
id: "org.kde.krita".to_string(),
name: "Krita".to_string(),
summary: "Профессиональный графический редактор для цифрового рисования".to_string(),
category: "Графика".to_string(),
icon_name: "paintbrush_fill".to_string(),
backend: BackendType::Flatpak,
is_installed: false,
rating: 4.9,
},
DiscoverApp {
id: "com.usebottles.bottles".to_string(),
name: "Bottles".to_string(),
summary: "Запуск Windows-приложений и игр в изолированных префиксах Wine".to_string(),
category: "Утилиты".to_string(),
icon_name: "tuningfork".to_string(),
backend: BackendType::Flatpak,
is_installed: false,
rating: 4.8,
},
DiscoverApp {
id: "com.localsend.localsend_app".to_string(),
name: "LocalSend".to_string(),
summary: "Безопасный обмен файлами по локальной сети без интернета (AirDrop для Linux)".to_string(),
category: "Сеть".to_string(),
icon_name: "paperplane_fill".to_string(),
backend: BackendType::Flatpak,
is_installed: false,
rating: 4.9,
},
DiscoverApp {
id: "com.github.tchx84.Flatseal".to_string(),
name: "Flatseal".to_string(),
summary: "Управление разрешениями, песочницей и доступом приложений Flatpak".to_string(),
category: "Утилиты".to_string(),
icon_name: "lock_shield_fill".to_string(),
backend: BackendType::Flatpak,
is_installed: false,
rating: 4.9,
},
DiscoverApp {
id: "com.obsproject.Studio".to_string(),
name: "OBS Studio".to_string(),
summary: "Запись видео с экрана и прямые трансляции профессионального уровня".to_string(),
category: "Медиа".to_string(),
icon_name: "video_fill".to_string(),
backend: BackendType::Flatpak,
is_installed: false,
rating: 4.8,
},
DiscoverApp {
id: "neovim".to_string(),
name: "Neovim".to_string(),
summary: "Быстрый, расширяемый модальный текстовый редактор для терминала".to_string(),
category: "Разработка".to_string(),
icon_name: "chevron_left_slash_chevron_right".to_string(),
backend: BackendType::Pacman,
is_installed: false,
rating: 4.9,
},
DiscoverApp {
id: "telegram-desktop".to_string(),
name: "Telegram".to_string(),
summary: "Быстрый и защищённый мессенджер с облачной синхронизацией".to_string(),
category: "Сеть".to_string(),
icon_name: "bubble_left_bubble_right_fill".to_string(),
backend: BackendType::Pacman,
is_installed: false,
rating: 4.8,
},
DiscoverApp {
id: "vlc".to_string(),
name: "VLC Media Player".to_string(),
summary: "Универсальный медиаплеер с поддержкой всех современных форматов и кодеков".to_string(),
category: "Медиа".to_string(),
icon_name: "play_circle_fill".to_string(),
backend: BackendType::Pacman,
is_installed: false,
rating: 4.7,
},
];
// Проверяем, установлены ли приложения
for app in &mut apps {
if app.backend == BackendType::Flatpak {
if let Ok(out) = Command::new("flatpak").args(["info", &app.id]).output().await {
app.is_installed = out.status.success();
}
} else if app.backend == BackendType::Pacman {
if let Ok(out) = Command::new("pacman").args(["-Q", &app.id]).output().await {
app.is_installed = out.status.success();
}
}
}
Ok(apps)
}
}
+286
View File
@@ -0,0 +1,286 @@
use std::process::Stdio;
use async_trait::async_trait;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::time::sleep;
use std::time::Duration;
use crate::core::package_manager::PackageManager;
use crate::core::types::{
AppError, BackendType, PackageDetails, PackageInfo, PackageUpdate, ProgressEvent, UpdateStep,
};
use crate::frb_generated::StreamSink;
pub struct FlatpakPackageManager;
impl FlatpakPackageManager {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl PackageManager for FlatpakPackageManager {
async fn check_updates(&self) -> Result<Vec<PackageUpdate>, AppError> {
let output = Command::new("flatpak")
.args(["remote-ls", "--updates", "--columns=app,name,version,origin,description"])
.env("LC_ALL", "C")
.output()
.await;
let stdout = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return Ok(Vec::new()),
};
let mut updates = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 4 {
let id = parts[0].trim().to_string();
let name = if parts[1].trim().is_empty() {
id.clone()
} else {
parts[1].trim().to_string()
};
let new_version = parts[2].trim().to_string();
let origin = parts[3].trim().to_string();
let desc = if parts.len() >= 5 {
parts[4].trim().to_string()
} else {
"Flatpak Application".to_string()
};
updates.push(PackageUpdate {
name,
current_version: "installed".to_string(),
new_version,
repository: format!("flatpak / {}", origin),
download_size_bytes: 0,
description: desc,
is_security_update: false,
backend: BackendType::Flatpak,
});
}
}
Ok(updates)
}
async fn get_installed_packages(&self) -> Result<Vec<PackageInfo>, AppError> {
let output = Command::new("flatpak")
.args(["list", "--app", "--columns=app,name,version,size,origin,description"])
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| AppError::ExecutionFailed(format!("Failed to run flatpak: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut list = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let id = parts[0].trim().to_string();
let name = if parts[1].trim().is_empty() {
id.clone()
} else {
parts[1].trim().to_string()
};
let version = if parts.len() >= 3 && !parts[2].trim().is_empty() {
parts[2].trim().to_string()
} else {
"stable".to_string()
};
let size_str = if parts.len() >= 4 { parts[3].trim() } else { "0" };
let desc = if parts.len() >= 6 {
parts[5].trim().to_string()
} else {
format!("Flatpak application ({})", id)
};
list.push(PackageInfo {
name,
version,
installed_size_bytes: parse_flatpak_size(size_str),
description: desc,
install_date: None,
is_explicit: true,
backend: BackendType::Flatpak,
});
}
}
Ok(list)
}
async fn get_package_details(&self, package_name: &str) -> Result<PackageDetails, AppError> {
let output = Command::new("flatpak")
.args(["info", package_name])
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| AppError::NotFound(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Err(AppError::NotFound(format!("Flatpak {} not found", package_name)));
}
let mut desc = String::new();
let mut version = "latest".to_string();
let mut license = Vec::new();
let mut origin = "flathub".to_string();
let mut installed_size = 0;
let mut runtime = Vec::new();
// Первая строка часто содержит: "Name - Summary"
if let Some(first_line) = stdout.lines().next() {
if first_line.contains('-') {
let parts: Vec<&str> = first_line.splitn(2, '-').collect();
desc = parts[1].trim().to_string();
}
}
for line in stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() != 2 {
continue;
}
let key = parts[0].trim();
let val = parts[1].trim();
match key {
"Version" => version = val.to_string(),
"License" => license = vec![val.to_string()],
"Origin" => origin = val.to_string(),
"Installed Size" | "Installed size" => installed_size = parse_flatpak_size(val),
"Runtime" => runtime.push(val.to_string()),
_ => {}
}
}
Ok(PackageDetails {
name: package_name.to_string(),
version,
description: desc,
url: Some(format!("https://flathub.org/apps/{}", package_name)),
licenses: license,
depends_on: runtime,
required_by: Vec::new(),
packager: Some("Flathub Community".to_string()),
download_size_bytes: 0,
installed_size_bytes: installed_size,
build_date: None,
repository: format!("flatpak / {}", origin),
backend: BackendType::Flatpak,
})
}
async fn apply_updates(
&self,
packages: Vec<String>,
is_dry_run: bool,
sink: StreamSink<ProgressEvent>,
) -> Result<(), AppError> {
if is_dry_run {
let _ = sink.add(ProgressEvent {
step: UpdateStep::CheckingDependencies,
package_name: None,
percentage: 0.2,
message: "[DRY-RUN] Симуляция обновления Flatpak...".to_string(),
log_line: Some(":: [DRY-RUN] flatpak update --dry-run".to_string()),
});
sleep(Duration::from_millis(600)).await;
let _ = sink.add(ProgressEvent {
step: UpdateStep::Done,
package_name: None,
percentage: 1.0,
message: "[DRY-RUN] Симуляция Flatpak завершена!".to_string(),
log_line: Some(":: [DRY-RUN] All runtimes and apps simulated.".to_string()),
});
return Ok(());
}
// Flatpak обновляется БЕЗ root прав!
let _ = sink.add(ProgressEvent {
step: UpdateStep::CheckingDependencies,
package_name: None,
percentage: 0.1,
message: "Подключение к Flathub репозиторию...".to_string(),
log_line: Some(":: Looking for Flatpak updates...".to_string()),
});
let mut cmd = Command::new("flatpak");
cmd.args(["update", "-y"]);
for pkg in &packages {
cmd.arg(pkg);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
AppError::ExecutionFailed(format!("Failed to spawn flatpak: {}", e))
})?;
if let Some(stdout) = child.stdout.take() {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: None,
percentage: 0.6,
message: "Загрузка и установка слоев Flatpak...".to_string(),
log_line: Some(line),
});
}
}
let status = child.wait().await.map_err(|e| AppError::ExecutionFailed(e.to_string()))?;
if !status.success() {
return Err(AppError::ExecutionFailed(format!("Flatpak exited with: {:?}", status.code())));
}
let _ = sink.add(ProgressEvent {
step: UpdateStep::Done,
package_name: None,
percentage: 1.0,
message: "Приложения Flatpak успешно обновлены!".to_string(),
log_line: Some(":: Flatpak update completed.".to_string()),
});
Ok(())
}
}
fn parse_flatpak_size(text: &str) -> u64 {
// "1.4 MB", "708.6 kB", "3.7 GB"
let clean = text.replace(',', ".");
let parts: Vec<&str> = clean.split_whitespace().collect();
if parts.is_empty() {
return 0;
}
let number: f64 = parts[0].parse().unwrap_or(0.0);
if parts.len() > 1 {
let unit = parts[1].to_lowercase();
if unit.contains('k') {
(number * 1000.0) as u64
} else if unit.contains('m') {
(number * 1000.0 * 1000.0) as u64
} else if unit.contains('g') {
(number * 1000.0 * 1000.0 * 1000.0) as u64
} else {
number as u64
}
} else {
number as u64
}
}
+84
View File
@@ -0,0 +1,84 @@
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use crate::core::types::{AppError, HistoryAction, HistoryItem};
pub struct PacmanHistory;
impl PacmanHistory {
pub async fn get_recent_history(limit: usize) -> Result<Vec<HistoryItem>, AppError> {
let path = "/var/log/pacman.log";
let file = match File::open(path).await {
Ok(f) => f,
Err(_) => return Ok(Vec::new()),
};
let mut reader = BufReader::new(file).lines();
let mut all_matches = Vec::new();
while let Ok(Some(line)) = reader.next_line().await {
if !line.contains("[ALPM]") {
continue;
}
// Примеры:
// [2026-09-13T17:04:47+0300] [ALPM] upgraded libgcrypt (1.12.3-1 -> 1.12.4-1)
// [2026-09-13T17:04:47+0300] [ALPM] installed neovim (0.10.1-1)
// [2026-09-13T17:04:47+0300] [ALPM] removed pkg (1.0-1)
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
let raw_time = parts[0].trim_start_matches('[').trim_end_matches(']');
let time_formatted = if raw_time.len() >= 16 {
raw_time[..16].replace('T', " ")
} else {
raw_time.to_string()
};
let action_str = parts[2];
let action = match action_str {
"upgraded" => HistoryAction::Upgraded,
"installed" => HistoryAction::Installed,
"removed" => HistoryAction::Removed,
_ => continue,
};
let pkg_name = parts[3].to_string();
let mut old_ver = None;
let mut new_ver = String::new();
// Версии внутри скобок
if let Some(start_idx) = line.find('(') {
if let Some(end_idx) = line.rfind(')') {
let ver_str = &line[start_idx + 1..end_idx];
if ver_str.contains("->") {
let ver_parts: Vec<&str> = ver_str.split("->").collect();
if ver_parts.len() >= 2 {
old_ver = Some(ver_parts[0].trim().to_string());
new_ver = ver_parts[1].trim().to_string();
}
} else {
new_ver = ver_str.trim().to_string();
}
}
}
all_matches.push(HistoryItem {
timestamp: time_formatted,
action,
package_name: pkg_name,
old_version: old_ver,
new_version: new_ver,
});
}
// Берем последние записи с конца
all_matches.reverse();
if all_matches.len() > limit {
all_matches.truncate(limit);
}
Ok(all_matches)
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod cleaner;
pub mod discover;
pub mod flatpak;
pub mod history;
pub mod package_manager;
pub mod pacman;
pub mod privilege;
pub mod types;
+25
View File
@@ -0,0 +1,25 @@
use async_trait::async_trait;
use crate::frb_generated::StreamSink;
use crate::core::types::{AppError, PackageDetails, PackageInfo, PackageUpdate, ProgressEvent};
/// Абстракция пакетного менеджера (Pacman, Flatpak и т.д.)
#[async_trait]
pub trait PackageManager: Send + Sync {
/// Проверка наличия обновлений
async fn check_updates(&self) -> Result<Vec<PackageUpdate>, AppError>;
/// Получение списка установленных пакетов
async fn get_installed_packages(&self) -> Result<Vec<PackageInfo>, AppError>;
/// Получение подробной информации о конкретном пакете (зависимости, ссылки, лицензии)
async fn get_package_details(&self, package_name: &str) -> Result<PackageDetails, AppError>;
/// Применение обновлений с флагом безопасного режима (is_dry_run) и стримингом логов
async fn apply_updates(
&self,
packages: Vec<String>,
is_dry_run: bool,
sink: StreamSink<ProgressEvent>,
) -> Result<(), AppError>;
}
+309
View File
@@ -0,0 +1,309 @@
use async_trait::async_trait;
use tokio::process::Command;
use tokio::time::sleep;
use std::time::Duration;
use crate::core::package_manager::PackageManager;
use crate::core::privilege::PrivilegedCommandRunner;
use crate::core::types::{
AppError, BackendType, PackageDetails, PackageInfo, PackageUpdate, ProgressEvent, UpdateStep,
};
use crate::frb_generated::StreamSink;
pub struct PacmanPackageManager;
impl PacmanPackageManager {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl PackageManager for PacmanPackageManager {
async fn check_updates(&self) -> Result<Vec<PackageUpdate>, AppError> {
// Попытка запустить системную утилиту `checkupdates`
let output = Command::new("checkupdates")
.env("LC_ALL", "C")
.output()
.await;
let stdout = match output {
Ok(o) => {
// Код 2 у checkupdates означает "нет обновлений"
if o.status.code() == Some(2) {
return Ok(Vec::new());
}
String::from_utf8_lossy(&o.stdout).to_string()
}
Err(_) => {
// Fallback: pacman -Qu
let fallback = Command::new("pacman")
.args(["-Qu"])
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| AppError::ExecutionFailed(e.to_string()))?;
String::from_utf8_lossy(&fallback.stdout).to_string()
}
};
let mut updates = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Формат checkupdates: "name 1.0-1 -> 2.0-1"
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 && parts[2] == "->" {
let name = parts[0].to_string();
let current_ver = parts[1].to_string();
let new_ver = parts[3].to_string();
let is_security = name.contains("linux")
|| name.contains("crypt")
|| name.contains("ssl")
|| name.contains("firefox")
|| name.contains("sudo")
|| name.contains("glibc");
updates.push(PackageUpdate {
name,
current_version: current_ver,
new_version: new_ver,
repository: "arch / pacman".to_string(),
download_size_bytes: 0, // Уточняется при инспекции
description: "System package from Arch Linux repository".to_string(),
is_security_update: is_security,
backend: BackendType::Pacman,
});
}
}
Ok(updates)
}
async fn get_installed_packages(&self) -> Result<Vec<PackageInfo>, AppError> {
// Получаем явно установленные пакеты: pacman -Qe
let output = Command::new("pacman")
.args(["-Qe"])
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| AppError::ExecutionFailed(format!("Failed to run pacman -Qe: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
packages.push(PackageInfo {
name: parts[0].to_string(),
version: parts[1].to_string(),
installed_size_bytes: 0,
description: "Explicitly installed system package".to_string(),
install_date: None,
is_explicit: true,
backend: BackendType::Pacman,
});
}
}
Ok(packages)
}
async fn get_package_details(&self, package_name: &str) -> Result<PackageDetails, AppError> {
// Сначала пробуем получить данные из базы репозиториев (pacman -Si), затем из локальной (pacman -Qi)
let output = Command::new("pacman")
.args(["-Si", package_name])
.env("LC_ALL", "C")
.output()
.await;
let stdout = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => {
let local_out = Command::new("pacman")
.args(["-Qi", package_name])
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| AppError::NotFound(e.to_string()))?;
String::from_utf8_lossy(&local_out.stdout).to_string()
}
};
if stdout.trim().is_empty() {
return Err(AppError::NotFound(format!("Package {} not found", package_name)));
}
let mut version = String::new();
let mut description = String::new();
let mut url = None;
let mut licenses = Vec::new();
let mut depends_on = Vec::new();
let mut required_by = Vec::new();
let mut packager = None;
let mut repository = "extra".to_string();
let mut build_date = None;
let mut download_size: u64 = 0;
let mut installed_size: u64 = 0;
for line in stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() != 2 {
continue;
}
let key = parts[0].trim();
let val = parts[1].trim();
match key {
"Version" => version = val.to_string(),
"Description" => description = val.to_string(),
"URL" => url = Some(val.to_string()),
"Licenses" => licenses = val.split_whitespace().map(|s| s.to_string()).collect(),
"Depends On" => {
depends_on = val
.split_whitespace()
.filter(|&s| s != "None")
.map(|s| s.to_string())
.collect();
}
"Required By" => {
required_by = val
.split_whitespace()
.filter(|&s| s != "None")
.map(|s| s.to_string())
.collect();
}
"Packager" => packager = Some(val.to_string()),
"Repository" => repository = val.to_string(),
"Build Date" => build_date = Some(val.to_string()),
"Download Size" => download_size = parse_pacman_size(val),
"Installed Size" => installed_size = parse_pacman_size(val),
_ => {}
}
}
Ok(PackageDetails {
name: package_name.to_string(),
version,
description,
url,
licenses,
depends_on,
required_by,
packager,
download_size_bytes: download_size,
installed_size_bytes: installed_size,
build_date,
repository,
backend: BackendType::Pacman,
})
}
async fn apply_updates(
&self,
packages: Vec<String>,
is_dry_run: bool,
sink: StreamSink<ProgressEvent>,
) -> Result<(), AppError> {
let total = packages.len();
if total == 0 {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Done,
package_name: None,
percentage: 1.0,
message: "Список пакетов пуст".to_string(),
log_line: None,
});
return Ok(());
}
if is_dry_run {
// Безопасный тестовый режим без изменения системы
let _ = sink.add(ProgressEvent {
step: UpdateStep::CheckingDependencies,
package_name: None,
percentage: 0.1,
message: "[DRY-RUN] Симуляция обновления системы...".to_string(),
log_line: Some(":: [DRY-RUN] Synchronizing package databases...".to_string()),
});
sleep(Duration::from_millis(600)).await;
for (idx, pkg) in packages.iter().enumerate() {
let pct = 0.2 + (idx as f32 / total as f32) * 0.7;
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: Some(pkg.clone()),
percentage: pct,
message: format!("[DRY-RUN] Обновление {} ({}/{})", pkg, idx + 1, total),
log_line: Some(format!(":: [DRY-RUN] upgrading {} (simulation mode)", pkg)),
});
sleep(Duration::from_millis(400)).await;
}
let _ = sink.add(ProgressEvent {
step: UpdateStep::Done,
package_name: None,
percentage: 1.0,
message: "[DRY-RUN] Симуляция успешно завершена! Изменения не применены.".to_string(),
log_line: Some(":: [DRY-RUN] Transaction simulated successfully.".to_string()),
});
return Ok(());
}
// Боевое обновление через PolicyKit (pkexec)
let _ = sink.add(ProgressEvent {
step: UpdateStep::CheckingDependencies,
package_name: None,
percentage: 0.05,
message: "Запрос привилегий администратора через Polkit...".to_string(),
log_line: Some(":: Waiting for Polkit authorization...".to_string()),
});
let mut args = vec!["-S", "--noconfirm", "--needed"];
for pkg in &packages {
args.push(pkg.as_str());
}
PrivilegedCommandRunner::run_privileged("pacman", &args, move |line| {
let _ = sink.add(ProgressEvent {
step: UpdateStep::Installing,
package_name: None,
percentage: 0.5,
message: "Установка системных пакетов...".to_string(),
log_line: Some(line),
});
})
.await?;
Ok(())
}
}
fn parse_pacman_size(text: &str) -> u64 {
// "1728.88 KiB" или "5.01 MiB" или "1024 B"
let parts: Vec<&str> = text.split_whitespace().collect();
if parts.len() < 2 {
return 0;
}
let number: f64 = parts[0].parse().unwrap_or(0.0);
let unit = parts[1].to_lowercase();
if unit.contains("k") {
(number * 1024.0) as u64
} else if unit.contains("m") {
(number * 1024.0 * 1024.0) as u64
} else if unit.contains("g") {
(number * 1024.0 * 1024.0 * 1024.0) as u64
} else {
number as u64
}
}
+104
View File
@@ -0,0 +1,104 @@
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::core::types::AppError;
pub struct PrivilegedCommandRunner;
impl PrivilegedCommandRunner {
/// Проверка, запущен ли процесс уже с root-правами
pub fn is_root() -> bool {
// Проверка через EUID (в Linux root = 0)
unsafe { libc_geteuid() == 0 }
}
/// Асинхронный запуск команды с повышением привилегий через pkexec (Polkit).
/// Вывод процесса построчно передается в callback `on_output_line`.
/// UI-поток Flutter не блокируется, так как выполнение происходит в Tokio runtime.
pub async fn run_privileged<F>(
program: &str,
args: &[&str],
mut on_output_line: F,
) -> Result<(), AppError>
where
F: FnMut(String) + Send + 'static,
{
let mut cmd = if Self::is_root() {
let mut c = Command::new(program);
c.args(args);
c
} else {
// Запуск через PolKit (pkexec) вызывает нативный GUI-диалог авторизации Linux
let mut c = Command::new("pkexec");
c.arg(program).args(args);
c
};
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
AppError::ExecutionFailed(format!("Failed to spawn privileged command {}: {}", program, e))
})?;
// Читаем stdout асинхронно построчно
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_handle = tokio::spawn(async move {
let mut lines_vec = Vec::new();
if let Some(stdout) = stdout {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
lines_vec.push(line);
}
}
lines_vec
});
let stderr_handle = tokio::spawn(async move {
let mut err_vec = Vec::new();
if let Some(stderr) = stderr {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
err_vec.push(line);
}
}
err_vec
});
// Ожидаем завершения процесса
let status = child.wait().await.map_err(|e| {
AppError::ExecutionFailed(format!("Failed waiting for child process: {}", e))
})?;
if let Ok(lines) = stdout_handle.await {
for line in lines {
on_output_line(line);
}
}
if let Ok(err_lines) = stderr_handle.await {
for line in err_lines {
on_output_line(format!("[STDERR] {}", line));
}
}
match status.code() {
Some(0) => Ok(()),
Some(126) => Err(AppError::PrivilegeDenied("Polkit authorization was cancelled or dismissed by user".into())),
Some(127) => Err(AppError::PrivilegeDenied("Polkit authorization failed or pkexec not found".into())),
Some(code) => Err(AppError::ExecutionFailed(format!("Process exited with error code: {}", code))),
None => Err(AppError::ExecutionFailed("Process terminated by signal".into())),
}
}
}
// Fallback функция получения euid без лишней зависимости от nix
unsafe fn libc_geteuid() -> u32 {
extern "C" {
fn geteuid() -> u32;
}
geteuid()
}
+129
View File
@@ -0,0 +1,129 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackendType {
Pacman,
Flatpak,
Aur,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageUpdate {
pub name: String,
pub current_version: String,
pub new_version: String,
pub repository: String,
pub download_size_bytes: u64,
pub description: String,
pub is_security_update: bool,
pub backend: BackendType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageInfo {
pub name: String,
pub version: String,
pub installed_size_bytes: u64,
pub description: String,
pub install_date: Option<String>,
pub is_explicit: bool,
pub backend: BackendType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageDetails {
pub name: String,
pub version: String,
pub description: String,
pub url: Option<String>,
pub licenses: Vec<String>,
pub depends_on: Vec<String>,
pub required_by: Vec<String>,
pub packager: Option<String>,
pub download_size_bytes: u64,
pub installed_size_bytes: u64,
pub build_date: Option<String>,
pub repository: String,
pub backend: BackendType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpdateStep {
CheckingDependencies,
Downloading,
Verifying,
Installing,
RunningHooks,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEvent {
pub step: UpdateStep,
pub package_name: Option<String>,
pub percentage: f32, // 0.0 .. 1.0
pub message: String,
pub log_line: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CleanerScanResult {
pub pacman_cache_bytes: u64,
pub orphan_packages: Vec<String>,
pub orphan_bytes: u64,
pub flatpak_unused_count: usize,
pub flatpak_unused_bytes: u64,
pub journal_bytes: u64,
pub total_reclaimable_bytes: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HistoryAction {
Upgraded,
Installed,
Removed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryItem {
pub timestamp: String,
pub action: HistoryAction,
pub package_name: String,
pub old_version: Option<String>,
pub new_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoverApp {
pub id: String,
pub name: String,
pub summary: String,
pub category: String,
pub icon_name: String,
pub backend: BackendType,
pub is_installed: bool,
pub rating: f32,
}
#[derive(Debug, Error)]
pub enum AppError {
#[error("Command execution failed: {0}")]
ExecutionFailed(String),
#[error("Authentication/Privilege denied: {0}")]
PrivilegeDenied(String),
#[error("Parsing output failed: {0}")]
ParsingError(String),
#[error("Network error: {0}")]
NetworkError(String),
#[error("Package not found: {0}")]
NotFound(String),
#[error("Internal error: {0}")]
Internal(String),
}
+13
View File
@@ -0,0 +1,13 @@
// Placeholder for frb_generated.rs until `flutter_rust_bridge_codegen generate` is executed.
// When codegen runs, it replaces this file with the complete FFI bindings and StreamSink implementation.
pub struct StreamSink<T> {
_marker: std::marker::PhantomData<T>,
}
impl<T> StreamSink<T> {
#[allow(dead_code)]
pub fn add(&self, _value: T) -> Result<(), ()> {
Ok(())
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod api;
pub mod core;
// Файл создается автоматически через flutter_rust_bridge_codegen
#[rustfmt::skip]
pub mod frb_generated;
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forge/src/rust/api/packages.dart';
import 'package:forge/src/services/batch_install_service.dart';
import 'package:forge/src/services/catalog_service.dart';
void main() {
group('BatchInstallService Tests', () {
test('Correctly identifies package IDs and separates backends', () {
final batch = BatchInstallService.instance;
batch.clear();
final vlc = CatalogApp(
id: 'vlc',
name: 'VLC Media Player',
summary: 'Media player',
category: 'Media',
backend: BackendType.pacman,
fallbackIcon: CupertinoIcons.play,
);
final vscode = CatalogApp(
id: 'visual-studio-code-bin',
name: 'Visual Studio Code',
summary: 'IDE',
category: 'Development',
backend: BackendType.aur,
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
);
final bottles = CatalogApp(
id: 'com.usebottles.bottles',
name: 'Bottles',
summary: 'Wine prefix manager',
category: 'Utilities',
backend: BackendType.flatpak,
fallbackIcon: CupertinoIcons.tuningfork,
);
batch.add(vlc);
batch.add(vscode);
batch.add(bottles);
expect(batch.count, 3);
expect(batch.pacmanApps.first.id, 'vlc');
expect(batch.aurApps.first.id, 'visual-studio-code-bin');
expect(batch.flatpakApps.first.id, 'com.usebottles.bottles');
batch.clear();
expect(batch.isEmpty, isTrue);
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forge/src/rust/api/packages.dart';
import 'package:forge/src/ui/widgets/package_inspector.dart';
import 'package:macos_ui/macos_ui.dart';
void main() {
testWidgets('PackageInspectorSheet builds and displays details', (tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.runAsync(() async {
await tester.pumpWidget(
MacosApp(
home: MacosScaffold(
children: [
ContentArea(
builder: (context, scrollController) {
return const PackageInspectorSheet(
initialPackageName: 'vlc',
initialBackend: BackendType.pacman,
);
},
),
],
),
),
);
await Future.delayed(const Duration(milliseconds: 300));
await tester.pump();
});
expect(find.text('vlc'), findsWidgets);
expect(find.text('Закрыть'), findsOneWidget);
});
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forge/main.dart';
void main() {
testWidgets('App loads smoke test', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.runAsync(() async {
await tester.pumpWidget(const ForgeApp());
await Future.delayed(const Duration(milliseconds: 200));
await tester.pump();
});
expect(find.text('Forge'), findsWidgets);
// Click on 'Каталог' sidebar item
await tester.runAsync(() async {
final catalogItem = find.text('Каталог');
expect(catalogItem, findsOneWidget);
await tester.tap(catalogItem);
await tester.pump();
await Future.delayed(const Duration(milliseconds: 200));
await tester.pump();
});
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forge/src/services/app_settings_service.dart';
import 'package:forge/src/ui/theme/window_theme_style.dart';
void main() {
group('WindowThemeStyle Tests', () {
test('Palettes and properties have correct values', () {
expect(WindowThemeStyle.macos.primaryColor, const Color(0xFF0A84FF));
expect(WindowThemeStyle.gnome.primaryColor, const Color(0xFF3584E4));
expect(WindowThemeStyle.kde.primaryColor, const Color(0xFF3DAEE9));
expect(WindowThemeStyle.kde.displayName, contains('KDE Plasma'));
expect(WindowThemeStyle.gnome.displayName, contains('GNOME'));
expect(WindowThemeStyle.macos.displayName, contains('macOS'));
final kdeTheme = WindowThemeStyle.kde.buildThemeData();
expect(kdeTheme.primaryColor, const Color(0xFF3DAEE9));
expect(kdeTheme.canvasColor, const Color(0xFF232629));
expect(kdeTheme.dividerColor, const Color(0xFF4D5358));
});
test('AppSettingsService theme switching', () async {
final service = AppSettingsService.instance;
await service.setWindowTheme('macos');
expect(service.windowTheme, 'macos');
expect(service.effectiveWindowThemeStyle, WindowThemeStyle.macos);
await service.setWindowTheme('gnome');
expect(service.windowTheme, 'gnome');
expect(service.effectiveWindowThemeStyle, WindowThemeStyle.gnome);
await service.setWindowTheme('kde');
expect(service.windowTheme, 'kde');
expect(service.effectiveWindowThemeStyle, WindowThemeStyle.kde);
await service.setWindowTheme('auto');
expect(service.windowTheme, 'auto');
// On host machine or test environment, effective should match detectDesktopEnvironmentStyle()
expect(service.effectiveWindowThemeStyle, AppSettingsService.detectDesktopEnvironmentStyle());
});
});
}