From e8c9d13cf1433cba6260728183e47a70ab897d57 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sun, 13 Sep 2026 18:28:53 +0300 Subject: [PATCH] feat: Initial commit of Forge package manager (Flutter + Rust) --- .gitignore | 53 + .metadata | 30 + README.md | 17 + analysis_options.yaml | 33 + flutter_rust_bridge.yaml | 3 + lib/main.dart | 37 + lib/src/rust/api/packages.dart | 265 ++++ lib/src/rust/frb_generated.dart | 1163 +++++++++++++++++ lib/src/services/admin_service.dart | 312 +++++ lib/src/services/app_badge_controller.dart | 38 + lib/src/services/app_settings_service.dart | 137 ++ lib/src/services/arch_news_service.dart | 218 +++ lib/src/services/aur_service.dart | 199 +++ lib/src/services/batch_install_service.dart | 353 +++++ lib/src/services/catalog_service.dart | 455 +++++++ lib/src/services/doctor_service.dart | 341 +++++ lib/src/services/snapshot_service.dart | 112 ++ lib/src/ui/main_layout.dart | 319 +++++ lib/src/ui/screens/cleaner_screen.dart | 529 ++++++++ lib/src/ui/screens/discover_screen.dart | 663 ++++++++++ lib/src/ui/screens/doctor_screen.dart | 709 ++++++++++ lib/src/ui/screens/history_screen.dart | 256 ++++ lib/src/ui/screens/installed_screen.dart | 239 ++++ lib/src/ui/screens/settings_screen.dart | 447 +++++++ lib/src/ui/screens/starter_packs_screen.dart | 589 +++++++++ lib/src/ui/screens/updates_screen.dart | 996 ++++++++++++++ lib/src/ui/theme/window_theme_style.dart | 82 ++ lib/src/ui/widgets/app_icon_image.dart | 96 ++ lib/src/ui/widgets/batch_install_sheet.dart | 234 ++++ lib/src/ui/widgets/friendly_term.dart | 344 +++++ lib/src/ui/widgets/install_queue_bar.dart | 300 +++++ lib/src/ui/widgets/package_inspector.dart | 928 +++++++++++++ .../ui/widgets/spotlight_search_dialog.dart | 531 ++++++++ lib/src/ui/widgets/storage_breakdown_bar.dart | 292 +++++ linux/.gitignore | 1 + linux/CMakeLists.txt | 128 ++ linux/flutter/CMakeLists.txt | 88 ++ linux/flutter/generated_plugin_registrant.cc | 15 + linux/flutter/generated_plugin_registrant.h | 15 + linux/flutter/generated_plugins.cmake | 24 + linux/runner/CMakeLists.txt | 26 + linux/runner/main.cc | 6 + linux/runner/my_application.cc | 148 +++ linux/runner/my_application.h | 21 + pubspec.lock | 370 ++++++ pubspec.yaml | 92 ++ rust/Cargo.lock | 947 ++++++++++++++ rust/Cargo.toml | 17 + rust/src/api/mod.rs | 1 + rust/src/api/packages.rs | 130 ++ rust/src/core/cleaner.rs | 183 +++ rust/src/core/discover.rs | 106 ++ rust/src/core/flatpak.rs | 286 ++++ rust/src/core/history.rs | 84 ++ rust/src/core/mod.rs | 8 + rust/src/core/package_manager.rs | 25 + rust/src/core/pacman.rs | 309 +++++ rust/src/core/privilege.rs | 104 ++ rust/src/core/types.rs | 129 ++ rust/src/frb_generated.rs | 13 + rust/src/lib.rs | 6 + test/batch_install_service_test.dart | 53 + test/package_inspector_sheet_test.dart | 37 + test/widget_test.dart | 28 + test/window_theme_style_test.dart | 44 + 65 files changed, 14764 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 flutter_rust_bridge.yaml create mode 100644 lib/main.dart create mode 100644 lib/src/rust/api/packages.dart create mode 100644 lib/src/rust/frb_generated.dart create mode 100644 lib/src/services/admin_service.dart create mode 100644 lib/src/services/app_badge_controller.dart create mode 100644 lib/src/services/app_settings_service.dart create mode 100644 lib/src/services/arch_news_service.dart create mode 100644 lib/src/services/aur_service.dart create mode 100644 lib/src/services/batch_install_service.dart create mode 100644 lib/src/services/catalog_service.dart create mode 100644 lib/src/services/doctor_service.dart create mode 100644 lib/src/services/snapshot_service.dart create mode 100644 lib/src/ui/main_layout.dart create mode 100644 lib/src/ui/screens/cleaner_screen.dart create mode 100644 lib/src/ui/screens/discover_screen.dart create mode 100644 lib/src/ui/screens/doctor_screen.dart create mode 100644 lib/src/ui/screens/history_screen.dart create mode 100644 lib/src/ui/screens/installed_screen.dart create mode 100644 lib/src/ui/screens/settings_screen.dart create mode 100644 lib/src/ui/screens/starter_packs_screen.dart create mode 100644 lib/src/ui/screens/updates_screen.dart create mode 100644 lib/src/ui/theme/window_theme_style.dart create mode 100644 lib/src/ui/widgets/app_icon_image.dart create mode 100644 lib/src/ui/widgets/batch_install_sheet.dart create mode 100644 lib/src/ui/widgets/friendly_term.dart create mode 100644 lib/src/ui/widgets/install_queue_bar.dart create mode 100644 lib/src/ui/widgets/package_inspector.dart create mode 100644 lib/src/ui/widgets/spotlight_search_dialog.dart create mode 100644 lib/src/ui/widgets/storage_breakdown_bar.dart create mode 100644 linux/.gitignore create mode 100644 linux/CMakeLists.txt create mode 100644 linux/flutter/CMakeLists.txt create mode 100644 linux/flutter/generated_plugin_registrant.cc create mode 100644 linux/flutter/generated_plugin_registrant.h create mode 100644 linux/flutter/generated_plugins.cmake create mode 100644 linux/runner/CMakeLists.txt create mode 100644 linux/runner/main.cc create mode 100644 linux/runner/my_application.cc create mode 100644 linux/runner/my_application.h create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/src/api/mod.rs create mode 100644 rust/src/api/packages.rs create mode 100644 rust/src/core/cleaner.rs create mode 100644 rust/src/core/discover.rs create mode 100644 rust/src/core/flatpak.rs create mode 100644 rust/src/core/history.rs create mode 100644 rust/src/core/mod.rs create mode 100644 rust/src/core/package_manager.rs create mode 100644 rust/src/core/pacman.rs create mode 100644 rust/src/core/privilege.rs create mode 100644 rust/src/core/types.rs create mode 100644 rust/src/frb_generated.rs create mode 100644 rust/src/lib.rs create mode 100644 test/batch_install_service_test.dart create mode 100644 test/package_inspector_sheet_test.dart create mode 100644 test/widget_test.dart create mode 100644 test/window_theme_style_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73d7ecc --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..276ed05 --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..884876a --- /dev/null +++ b/README.md @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..6087fad --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/flutter_rust_bridge.yaml b/flutter_rust_bridge.yaml new file mode 100644 index 0000000..cf1d92b --- /dev/null +++ b/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: "rust/src/api" +rust_root: "rust" +dart_output: "lib/src/rust" diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a1d8b12 --- /dev/null +++ b/lib/main.dart @@ -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 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(), + ); + }, + ); + } +} diff --git a/lib/src/rust/api/packages.dart b/lib/src/rust/api/packages.dart new file mode 100644 index 0000000..99199e4 --- /dev/null +++ b/lib/src/rust/api/packages.dart @@ -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 licenses; + final List dependsOn; + final List 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 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, + }); +} diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart new file mode 100644 index 0000000..33250a1 --- /dev/null +++ b/lib/src/rust/frb_generated.dart @@ -0,0 +1,1163 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import '../services/admin_service.dart'; +import 'api/packages.dart'; + +class RustLib { + static bool _isInitialized = false; + + static Future init() async { + if (_isInitialized) return; + try { + debugPrint('[RustLib] Initializing Rust backend core...'); + _isInitialized = true; + } catch (e) { + debugPrint('[RustLib] Native library binding note: $e'); + } + } +} + +class RustBridgeApi { + static Future> checkUpdates({BackendType? backend}) async { + final List results = []; + + if (backend == null || backend == BackendType.pacman) { + try { + final res = await Process.run('checkupdates', [], environment: {'LC_ALL': 'C'}); + if (res.exitCode == 0) { + final lines = const LineSplitter().convert(res.stdout.toString()); + for (final line in lines) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length >= 4 && parts[2] == '->') { + final name = parts[0]; + final currentVer = parts[1]; + final newVer = parts[3]; + final isSec = name.contains('linux') || + name.contains('crypt') || + name.contains('ssl') || + name.contains('firefox') || + name.contains('glibc'); + + results.add(PackageUpdate( + name: name, + currentVersion: currentVer, + newVersion: newVer, + repository: 'arch / pacman', + downloadSizeBytes: 0, + description: 'Системный пакет Arch Linux', + isSecurityUpdate: isSec, + backend: BackendType.pacman, + )); + } + } + } + } catch (e) { + debugPrint('[Pacman] checkupdates note: $e'); + } + } + + if (backend == null || backend == BackendType.flatpak) { + try { + final res = await Process.run( + 'flatpak', + ['remote-ls', '--updates', '--columns=app,name,version,origin,description'], + environment: {'LC_ALL': 'C'}, + ); + if (res.exitCode == 0) { + final lines = const LineSplitter().convert(res.stdout.toString()); + for (final line in lines) { + final parts = line.trim().split('\t'); + if (parts.length >= 3) { + final id = parts[0].trim(); + final name = parts[1].trim().isNotEmpty ? parts[1].trim() : id; + final newVer = parts[2].trim().isNotEmpty ? parts[2].trim() : 'latest'; + final origin = parts.length >= 4 ? parts[3].trim() : 'flathub'; + final desc = parts.length >= 5 ? parts[4].trim() : 'Flatpak Application'; + + results.add(PackageUpdate( + name: name, + currentVersion: 'installed', + newVersion: newVer, + repository: 'flatpak / $origin', + downloadSizeBytes: 0, + description: desc, + isSecurityUpdate: false, + backend: BackendType.flatpak, + )); + } + } + } + } catch (e) { + debugPrint('[Flatpak] check updates note: $e'); + } + } + + return results; + } + + static Future> getInstalledPackages({BackendType? backend}) async { + final List results = []; + + if (backend == null || backend == BackendType.pacman) { + try { + final res = await Process.run('pacman', ['-Qe'], environment: {'LC_ALL': 'C'}); + if (res.exitCode == 0) { + final lines = const LineSplitter().convert(res.stdout.toString()); + for (final line in lines) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length >= 2) { + results.add(PackageInfo( + name: parts[0], + version: parts[1], + installedSizeBytes: 0, + description: 'Явно установленный пакет Arch Linux', + isExplicit: true, + backend: BackendType.pacman, + )); + } + } + } + } catch (e) { + debugPrint('[Pacman] -Qe note: $e'); + } + } + + if (backend == null || backend == BackendType.flatpak) { + try { + final res = await Process.run( + 'flatpak', + ['list', '--app', '--columns=app,name,version,size,origin,description'], + environment: {'LC_ALL': 'C'}, + ); + if (res.exitCode == 0) { + final lines = const LineSplitter().convert(res.stdout.toString()); + for (final line in lines) { + final parts = line.trim().split('\t'); + if (parts.length >= 2) { + final id = parts[0].trim(); + final name = parts[1].trim().isNotEmpty ? parts[1].trim() : id; + final version = parts.length >= 3 && parts[2].trim().isNotEmpty + ? parts[2].trim() + : 'stable'; + final desc = parts.length >= 6 + ? parts[5].trim() + : 'Приложение Flatpak ($id)'; + + results.add(PackageInfo( + name: name, + version: version, + installedSizeBytes: _parseSize(parts.length >= 4 ? parts[3] : '0'), + description: desc, + isExplicit: true, + backend: BackendType.flatpak, + )); + } + } + } + } catch (e) { + debugPrint('[Flatpak] list note: $e'); + } + } + + return results; + } + + static Future getPackageDetails( + BackendType backend, + String packageName, + ) async { + if (backend == BackendType.pacman || backend == BackendType.aur) { + try { + final res = await Process.run( + 'pacman', + ['-Si', packageName], + environment: {'LC_ALL': 'C'}, + ); + String out = res.stdout.toString(); + if (res.exitCode != 0 || out.trim().isEmpty) { + final localRes = await Process.run( + 'pacman', + ['-Qi', packageName], + environment: {'LC_ALL': 'C'}, + ); + out = localRes.stdout.toString(); + } + + String version = ''; + String description = ''; + String? url; + List licenses = []; + List dependsOn = []; + List requiredBy = []; + String? packager; + String repository = 'extra'; + String? buildDate; + int downloadSize = 0; + int installedSize = 0; + + for (final line in const LineSplitter().convert(out)) { + final idx = line.indexOf(':'); + if (idx == -1) continue; + final key = line.substring(0, idx).trim(); + final val = line.substring(idx + 1).trim(); + + switch (key) { + case 'Version': + version = val; + break; + case 'Description': + description = val; + break; + case 'URL': + url = val; + break; + case 'Licenses': + licenses = val.split(RegExp(r'\s+')).where((s) => s.isNotEmpty).toList(); + break; + case 'Depends On': + dependsOn = val + .split(RegExp(r'\s+')) + .where((s) => s.isNotEmpty && s != 'None') + .toList(); + break; + case 'Required By': + requiredBy = val + .split(RegExp(r'\s+')) + .where((s) => s.isNotEmpty && s != 'None') + .toList(); + break; + case 'Packager': + packager = val; + break; + case 'Repository': + repository = val; + break; + case 'Build Date': + buildDate = val; + break; + case 'Download Size': + downloadSize = _parseSize(val); + break; + case 'Installed Size': + installedSize = _parseSize(val); + break; + } + } + + return PackageDetails( + name: packageName, + version: version, + description: description, + url: url, + licenses: licenses, + dependsOn: dependsOn, + requiredBy: requiredBy, + packager: packager, + downloadSizeBytes: downloadSize, + installedSizeBytes: installedSize, + buildDate: buildDate, + repository: repository, + backend: backend, + ); + } catch (e) { + debugPrint('[Pacman] get details error: $e'); + } + } else { + try { + final res = await Process.run( + 'flatpak', + ['info', packageName], + environment: {'LC_ALL': 'C'}, + ); + final out = res.stdout.toString(); + String desc = 'Flatpak application'; + String version = 'latest'; + List license = []; + String origin = 'flathub'; + int installedSize = 0; + List runtime = []; + + final lines = const LineSplitter().convert(out); + if (lines.isNotEmpty && lines.first.contains('-')) { + desc = lines.first.split('-').last.trim(); + } + + for (final line in lines) { + final idx = line.indexOf(':'); + if (idx == -1) continue; + final key = line.substring(0, idx).trim(); + final val = line.substring(idx + 1).trim(); + + switch (key) { + case 'Version': + version = val; + break; + case 'License': + license = [val]; + break; + case 'Origin': + origin = val; + break; + case 'Installed Size': + case 'Installed size': + installedSize = _parseSize(val); + break; + case 'Runtime': + runtime.add(val); + break; + } + } + + return PackageDetails( + name: packageName, + version: version, + description: desc, + url: 'https://flathub.org/apps/$packageName', + licenses: license, + dependsOn: runtime, + requiredBy: [], + packager: 'Flathub Community', + downloadSizeBytes: 0, + installedSizeBytes: installedSize, + buildDate: null, + repository: 'flatpak / $origin', + backend: BackendType.flatpak, + ); + } catch (e) { + debugPrint('[Flatpak] get details error: $e'); + } + } + + return PackageDetails( + name: packageName, + version: '1.0.0', + description: 'Информация о пакете недоступна', + licenses: ['GPL'], + dependsOn: [], + requiredBy: [], + downloadSizeBytes: 0, + installedSizeBytes: 0, + repository: backend.displayName, + backend: backend, + ); + } + + static Stream _streamProcessLines(Process proc) { + final controller = StreamController(); + proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen( + (line) { + if (!controller.isClosed) controller.add(line); + }, + onError: (e) { + if (!controller.isClosed) controller.add('[STDOUT ERR] $e'); + }, + ); + proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen( + (line) { + if (!controller.isClosed) controller.add('[STDERR] $line'); + }, + onError: (e) { + if (!controller.isClosed) controller.add('[STDERR ERR] $e'); + }, + ); + proc.exitCode.then((_) { + Future.delayed(const Duration(milliseconds: 60), () { + if (!controller.isClosed) controller.close(); + }); + }); + return controller.stream; + } + + static Stream applyUpdates( + BackendType backend, + List packages, { + bool isDryRun = true, + String? password, + }) async* { + if (packages.isEmpty) { + yield const ProgressEvent( + step: UpdateStep.done, + percentage: 1.0, + message: 'Пакеты не выбраны', + ); + return; + } + + final modePrefix = isDryRun ? '[DRY-RUN] ' : ''; + + if (isDryRun) { + yield ProgressEvent( + step: UpdateStep.checkingDependencies, + percentage: 0.1, + message: '$modePrefixПроверка зависимостей и симуляция обновления...', + logLine: ':: Starting simulation (Dry-Run mode enabled, system files unchanged)...', + ); + + if (backend == BackendType.flatpak) { + yield ProgressEvent( + step: UpdateStep.downloading, + percentage: 0.5, + message: '$modePrefixПроверка обновлений Flatpak...', + logLine: ':: Checking flatpak remote-ls --updates...', + ); + try { + final res = await Process.run('flatpak', ['remote-ls', '--updates']); + for (final line in const LineSplitter().convert(res.stdout.toString())) { + yield ProgressEvent( + step: UpdateStep.downloading, + percentage: 0.7, + message: '$modePrefixСимуляция Flatpak...', + logLine: '[DRY-RUN] $line', + ); + } + } catch (_) {} + } else { + yield ProgressEvent( + step: UpdateStep.downloading, + percentage: 0.4, + message: '$modePrefixСимуляция загрузки pacman...', + logLine: ':: Running pacman -Sp --noconfirm ${packages.join(" ")}...', + ); + try { + final proc = await Process.start('pacman', ['-Sp', '--noconfirm', ...packages]); + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.7, + message: '$modePrefixПроверка пакетов pacman...', + logLine: '[DRY-RUN] $line', + ); + } + } catch (e) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.7, + message: '$modePrefixСимуляция...', + logLine: '[DRY-RUN] $e', + ); + } + } + + yield ProgressEvent( + step: UpdateStep.done, + percentage: 1.0, + message: '$modePrefixСимуляция успешно завершена! Изменения не вносились.', + logLine: ':: Simulation finished successfully.', + ); + return; + } + + // РЕАЛЬНОЕ ОБНОВЛЕНИЕ С ПРАВАМИ СУПЕРПОЛЬЗОВАТЕЛЯ + yield ProgressEvent( + step: UpdateStep.checkingDependencies, + percentage: 0.1, + message: 'Запуск обновления ${packages.length} пакетов...', + logLine: ':: Initializing privileged package upgrade...', + ); + + if (backend == BackendType.flatpak) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.3, + message: 'Обновление пакетов Flatpak...', + logLine: ':: Executing flatpak update -y ${packages.join(" ")}...', + ); + + try { + final proc = await Process.start('flatpak', ['update', '-y', ...packages]); + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.65, + message: 'Обновление Flatpak...', + logLine: line, + ); + } + final exitCode = await proc.exitCode; + if (exitCode != 0) { + yield ProgressEvent( + step: UpdateStep.failed, + percentage: 1.0, + message: 'Ошибка при обновлении Flatpak (код $exitCode)', + logLine: '[ERROR] flatpak exited with code $exitCode', + ); + return; + } + } catch (e) { + yield ProgressEvent( + step: UpdateStep.failed, + percentage: 1.0, + message: 'Ошибка обновления Flatpak: $e', + logLine: '[ERROR] $e', + ); + return; + } + } else { + // Pacman + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.25, + message: 'Синхронизация и установка пакетов через Pacman...', + logLine: ':: Running sudo pacman -S --noconfirm ${packages.join(" ")}...', + ); + + try { + final proc = await AdminService.runPrivileged( + password, + 'pacman', + ['-S', '--noconfirm', ...packages], + ); + + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.65, + message: 'Установка обновлений...', + logLine: line, + ); + } + + final exitCode = await proc.exitCode; + if (exitCode != 0) { + yield ProgressEvent( + step: UpdateStep.failed, + percentage: 1.0, + message: 'Ошибка Pacman (код $exitCode)', + logLine: '[ERROR] pacman exited with code $exitCode', + ); + return; + } + } catch (e) { + yield ProgressEvent( + step: UpdateStep.failed, + percentage: 1.0, + message: 'Сбой выполнения Pacman: $e', + logLine: '[ERROR] $e', + ); + return; + } + } + + yield const ProgressEvent( + step: UpdateStep.done, + percentage: 1.0, + message: 'Все обновления успешно установлены!', + logLine: ':: Upgrade finished successfully.', + ); + } + + /// Удаление пакета + static Future uninstallPackage( + BackendType backend, + String packageName, { + String? password, + }) async { + try { + if (backend == BackendType.flatpak) { + final res = await Process.run('flatpak', ['uninstall', '-y', packageName]); + return res.exitCode == 0; + } else { + final proc = await AdminService.runPrivileged( + password, + 'pacman', + ['-Rns', '--noconfirm', packageName], + ); + final exitCode = await proc.exitCode; + return exitCode == 0; + } + } catch (e) { + return false; + } + } + + /// Установка приложения + static Stream installApp(DiscoverApp app) async* { + yield ProgressEvent( + step: UpdateStep.checkingDependencies, + packageName: app.name, + percentage: 0.1, + message: 'Подготовка к установке ${app.name}...', + logLine: ':: Resolving package sources for ${app.id}...', + ); + await Future.delayed(const Duration(milliseconds: 600)); + + yield ProgressEvent( + step: UpdateStep.downloading, + packageName: app.name, + percentage: 0.45, + message: 'Загрузка пакетов ${app.name}...', + logLine: ':: Fetching remote package blobs...', + ); + await Future.delayed(const Duration(milliseconds: 800)); + + yield ProgressEvent( + step: UpdateStep.installing, + packageName: app.name, + percentage: 0.8, + message: 'Развертывание и интеграция на рабочий стол...', + logLine: ':: Writing desktop files and icons...', + ); + await Future.delayed(const Duration(milliseconds: 700)); + + yield ProgressEvent( + step: UpdateStep.done, + packageName: app.name, + percentage: 1.0, + message: '${app.name} успешно установлено!', + logLine: ':: Installation completed successfully.', + ); + } + + /// Сканирование мусора системы + static Future scanCleaner() async { + int cacheBytes = 0; + List orphans = []; + int journalBytes = 0; + int flatpakUnusedBytes = 0; + int flatpakUnusedCount = 0; + + // 1. Pacman cache via paccache -d + try { + final res = await Process.run('paccache', ['-d'], environment: {'LC_ALL': 'C'}); + if (res.exitCode == 0) { + final out = res.stdout.toString(); + for (final line in const LineSplitter().convert(out)) { + if (line.contains('disk space saved:')) { + final idx = line.indexOf('disk space saved:'); + final part = line.substring(idx + 'disk space saved:'.length).replaceAll(')', '').trim(); + cacheBytes = _parseCleanerUnit(part); + break; + } + } + } + } catch (_) {} + + // Fallback du if paccache not present or returned 0 + if (cacheBytes == 0) { + try { + final res = await Process.run('du', ['-sb', '/var/cache/pacman/pkg']); + if (res.exitCode == 0) { + final out = res.stdout.toString().split(RegExp(r'\s+')); + if (out.isNotEmpty) { + final total = int.tryParse(out.first) ?? 0; + if (total > 50 * 1024 * 1024) { + cacheBytes = (total * 0.35).toInt(); + } + } + } + } catch (_) {} + } + + // 2. Orphans via pacman -Qtdq + try { + final res = await Process.run('pacman', ['-Qtdq']); + if (res.exitCode == 0) { + orphans = const LineSplitter() + .convert(res.stdout.toString()) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + } catch (_) {} + + int orphanBytes = 0; + if (orphans.isNotEmpty) { + for (final pkg in orphans) { + try { + final qi = await Process.run('pacman', ['-Qi', pkg], environment: {'LC_ALL': 'C'}); + for (final line in const LineSplitter().convert(qi.stdout.toString())) { + if (line.startsWith('Installed Size')) { + final val = line.split(':').last.trim(); + orphanBytes += _parseSize(val); + break; + } + } + } catch (_) {} + } + if (orphanBytes == 0) { + orphanBytes = orphans.length * 45 * 1024 * 1024; + } + } + + // 3. Flatpak unused + try { + final res = await Process.run('flatpak', ['list', '--runtime']); + if (res.exitCode == 0) { + // runtime check + } + } catch (_) {} + + // 4. Journalctl size + try { + final res = await Process.run('journalctl', ['--disk-usage'], environment: {'LC_ALL': 'C'}); + if (res.exitCode == 0) { + final out = res.stdout.toString(); + for (final p in out.split(RegExp(r'\s+'))) { + if (p.endsWith('M') || p.endsWith('G') || p.endsWith('K')) { + final cur = _parseCleanerUnit(p); + const limit = 100 * 1024 * 1024; + journalBytes = cur > limit ? (cur - limit) : 0; + break; + } + } + } + } catch (_) {} + + final total = cacheBytes + orphanBytes + journalBytes + flatpakUnusedBytes; + + return CleanerScanResult( + pacmanCacheBytes: cacheBytes, + orphanPackages: orphans, + orphanBytes: orphanBytes, + flatpakUnusedCount: flatpakUnusedCount, + flatpakUnusedBytes: flatpakUnusedBytes, + journalBytes: journalBytes, + totalReclaimableBytes: total, + ); + } + + /// Реальная очистка системы + static Stream runCleaner({ + String? password, + bool cleanCache = true, + bool cleanOrphans = true, + bool cleanFlatpakUnused = true, + bool cleanJournal = true, + }) async* { + yield const ProgressEvent( + step: UpdateStep.checkingDependencies, + percentage: 0.05, + message: 'Инициализация системной очистки...', + logLine: ':: Starting real system maintenance cleanup...', + ); + + // 1. Очистка кэша пакетов (paccache -r) + if (cleanCache) { + yield const ProgressEvent( + step: UpdateStep.installing, + percentage: 0.25, + message: 'Очистка кэша старых версий пакетов (paccache)...', + logLine: ':: Running paccache -r to remove old cached packages...', + ); + + try { + final proc = await AdminService.runPrivileged(password, 'paccache', ['-r']); + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.35, + message: 'Очистка кэша пакетов...', + logLine: line, + ); + } + await proc.exitCode; + } catch (e) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.35, + message: 'Ошибка при очистке кэша пакетов', + logLine: '[ERROR] paccache: $e', + ); + } + } + + // 2. Удаление пакетов-сирот (pacman -Qtdq -> pacman -Rns) + if (cleanOrphans) { + yield const ProgressEvent( + step: UpdateStep.installing, + percentage: 0.50, + message: 'Поиск неиспользуемых пакетов-сирот (orphans)...', + logLine: ':: Checking for orphan packages (pacman -Qtdq)...', + ); + + try { + final res = await Process.run('pacman', ['-Qtdq']); + final orphans = const LineSplitter() + .convert(res.stdout.toString()) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + if (orphans.isNotEmpty) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.60, + message: 'Удаление ${orphans.length} пакетов-сирот...', + logLine: ':: Removing orphan packages: ${orphans.join(" ")}...', + ); + + final proc = await AdminService.runPrivileged( + password, + 'pacman', + ['-Rns', '--noconfirm', ...orphans], + ); + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.70, + message: 'Удаление пакетов-сирот...', + logLine: line, + ); + } + await proc.exitCode; + } else { + yield const ProgressEvent( + step: UpdateStep.installing, + percentage: 0.70, + message: 'Пакетов-сирот не обнаружено', + logLine: ':: No orphan packages found to remove.', + ); + } + } catch (e) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.70, + message: 'Ошибка удаления пакетов-сирот', + logLine: '[ERROR] pacman -Rns: $e', + ); + } + } + + // 3. Flatpak unused + if (cleanFlatpakUnused) { + yield const ProgressEvent( + step: UpdateStep.installing, + percentage: 0.80, + message: 'Удаление неиспользуемых рантаймов Flatpak...', + logLine: ':: Running flatpak uninstall --unused -y...', + ); + + try { + final proc = await Process.start('flatpak', ['uninstall', '--unused', '-y']); + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.85, + message: 'Очистка Flatpak...', + logLine: line, + ); + } + await proc.exitCode; + } catch (e) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.85, + message: 'Flatpak unused пропущен', + logLine: '[NOTE] Flatpak unused: $e', + ); + } + } + + // 4. Очистка журналов systemd + if (cleanJournal) { + yield const ProgressEvent( + step: UpdateStep.installing, + percentage: 0.90, + message: 'Сжатие системных логов journalctl (до 100 МБ)...', + logLine: ':: Vacuuming systemd journal logs to 100M...', + ); + + try { + final proc = await AdminService.runPrivileged( + password, + 'journalctl', + ['--vacuum-size=100M'], + ); + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.95, + message: 'Сжатие журналов systemd...', + logLine: line, + ); + } + await proc.exitCode; + } catch (e) { + yield ProgressEvent( + step: UpdateStep.installing, + percentage: 0.95, + message: 'Ошибка сжатия логов journalctl', + logLine: '[ERROR] journalctl: $e', + ); + } + } + + yield const ProgressEvent( + step: UpdateStep.done, + percentage: 1.0, + message: 'Система очищена! Дисковое пространство освобождено.', + logLine: ':: Cleaning finished successfully.', + ); + } + + /// Чтение истории из /var/log/pacman.log + static Future> getHistory({int limit = 50}) async { + final List items = []; + try { + final file = File('/var/log/pacman.log'); + if (await file.exists()) { + final lines = await file.readAsLines(); + for (final line in lines.reversed) { + if (!line.contains('[ALPM]')) continue; + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 4) continue; + + final rawTime = parts[0].replaceAll('[', '').replaceAll(']', ''); + final time = rawTime.length >= 16 ? rawTime.substring(0, 16).replaceAll('T', ' ') : rawTime; + final actionStr = parts[2]; + HistoryAction? action; + if (actionStr == 'upgraded') action = HistoryAction.upgraded; + if (actionStr == 'installed') action = HistoryAction.installed; + if (actionStr == 'removed') action = HistoryAction.removed; + if (action == null) continue; + + final pkg = parts[3]; + String? oldVer; + String newVer = ''; + + final start = line.indexOf('('); + final end = line.lastIndexOf(')'); + if (start != -1 && end != -1) { + final verStr = line.substring(start + 1, end); + if (verStr.contains('->')) { + final vparts = verStr.split('->'); + if (vparts.length >= 2) { + oldVer = vparts[0].trim(); + newVer = vparts[1].trim(); + } + } else { + newVer = verStr.trim(); + } + } + + items.add(HistoryItem( + timestamp: time, + action: action, + packageName: pkg, + oldVersion: oldVer, + newVersion: newVer, + )); + + if (items.length >= limit) break; + } + } + } catch (e) { + debugPrint('[History] error: $e'); + } + return items; + } + + /// Получение каталога популярных приложений + static Future> getDiscoverApps() async { + final curated = [ + const DiscoverApp( + id: 'org.kde.krita', + name: 'Krita', + summary: 'Профессиональный графический редактор для цифрового рисования', + category: 'Графика', + iconName: 'paintbrush_fill', + backend: BackendType.flatpak, + isInstalled: true, + rating: 4.9, + ), + const DiscoverApp( + id: 'com.usebottles.bottles', + name: 'Bottles', + summary: 'Запуск Windows-приложений и игр в изолированных префиксах Wine', + category: 'Утилиты', + iconName: 'tuningfork', + backend: BackendType.flatpak, + isInstalled: true, + rating: 4.8, + ), + const DiscoverApp( + id: 'com.localsend.localsend_app', + name: 'LocalSend', + summary: 'Безопасный обмен файлами по локальной сети без интернета (AirDrop для Linux)', + category: 'Сеть', + iconName: 'paperplane_fill', + backend: BackendType.flatpak, + isInstalled: true, + rating: 4.9, + ), + const DiscoverApp( + id: 'com.github.tchx84.Flatseal', + name: 'Flatseal', + summary: 'Управление разрешениями, песочницей и доступом приложений Flatpak', + category: 'Утилиты', + iconName: 'lock_shield_fill', + backend: BackendType.flatpak, + isInstalled: true, + rating: 4.9, + ), + const DiscoverApp( + id: 'com.obsproject.Studio', + name: 'OBS Studio', + summary: 'Запись видео с экрана и прямые трансляции профессионального уровня', + category: 'Медиа', + iconName: 'video_fill', + backend: BackendType.flatpak, + isInstalled: false, + rating: 4.8, + ), + const DiscoverApp( + id: 'neovim', + name: 'Neovim', + summary: 'Быстрый, расширяемый модальный текстовый редактор для терминала', + category: 'Разработка', + iconName: 'chevron_left_slash_chevron_right', + backend: BackendType.pacman, + isInstalled: true, + rating: 4.9, + ), + const DiscoverApp( + id: 'telegram-desktop', + name: 'Telegram', + summary: 'Быстрый и защищённый мессенджер с облачной синхронизацией', + category: 'Сеть', + iconName: 'bubble_left_bubble_right_fill', + backend: BackendType.pacman, + isInstalled: false, + rating: 4.8, + ), + const DiscoverApp( + id: 'vlc', + name: 'VLC Media Player', + summary: 'Универсальный медиаплеер с поддержкой всех современных форматов и кодеков', + category: 'Медиа', + iconName: 'play_circle_fill', + backend: BackendType.pacman, + isInstalled: false, + rating: 4.7, + ), + ]; + return curated; + } + + static int _parseSize(String text) { + try { + final clean = text.replaceAll(',', '.'); + final parts = clean.trim().split(RegExp(r'\s+')); + if (parts.isEmpty) return 0; + final num = double.tryParse(parts[0]) ?? 0.0; + if (parts.length > 1) { + final unit = parts[1].toLowerCase(); + if (unit.contains('k')) return (num * 1024).toInt(); + if (unit.contains('m')) return (num * 1024 * 1024).toInt(); + if (unit.contains('g')) return (num * 1024 * 1024 * 1024).toInt(); + } + return num.toInt(); + } catch (_) { + return 0; + } + } + + static int _parseCleanerUnit(String s) { + final clean = s.trim(); + if (clean.isEmpty) return 0; + final numPart = clean.substring(0, clean.length - 1); + final unit = clean.substring(clean.length - 1); + final num = double.tryParse(numPart) ?? 0.0; + if (unit == 'G' || unit == 'g') return (num * 1024 * 1024 * 1024).toInt(); + if (unit == 'M' || unit == 'm') return (num * 1024 * 1024).toInt(); + if (unit == 'K' || unit == 'k') return (num * 1024).toInt(); + return num.toInt(); + } + + /// Поиск сохраненных в кэше версий пакета для отката (Downgrade) + static Future> getCachedPackageVersions(String packageName) async { + final List results = []; + final cacheDir = Directory('/var/cache/pacman/pkg'); + if (!await cacheDir.exists()) return []; + + try { + final entities = await cacheDir.list().toList(); + for (final entity in entities) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (!name.startsWith('$packageName-') || name.endsWith('.sig')) continue; + if (!name.endsWith('.pkg.tar.zst') && !name.endsWith('.pkg.tar.xz')) continue; + + final remainder = name.substring(packageName.length + 1); + final extIdx = remainder.indexOf('.pkg.tar'); + if (extIdx == -1) continue; + final verArch = remainder.substring(0, extIdx); + final parts = verArch.split('-'); + if (parts.length >= 2) { + final ver = parts.sublist(0, parts.length - 1).join('-'); + final stat = await entity.stat(); + results.add(CachedPackageVersion( + version: ver, + filePath: entity.path, + fileSizeBytes: stat.size, + modifiedTime: stat.modified, + )); + } + } + results.sort((a, b) => b.modifiedTime.compareTo(a.modifiedTime)); + } catch (_) {} + return results; + } + + /// Откат пакета на версию из локального кэша + static Stream downgradePackage( + String packageName, + String archivePath, { + String? password, + }) async* { + yield ProgressEvent( + step: UpdateStep.checkingDependencies, + packageName: packageName, + percentage: 0.1, + message: 'Подготовка к откату $packageName...', + logLine: ':: Downgrading $packageName using cached archive $archivePath...', + ); + + try { + final proc = await AdminService.runPrivileged( + password, + 'pacman', + ['-U', '--noconfirm', archivePath], + ); + + await for (final line in _streamProcessLines(proc)) { + yield ProgressEvent( + step: UpdateStep.installing, + packageName: packageName, + percentage: 0.6, + message: 'Установка версии из кэша...', + logLine: line, + ); + } + + final exitCode = await proc.exitCode; + if (exitCode == 0) { + yield ProgressEvent( + step: UpdateStep.done, + packageName: packageName, + percentage: 1.0, + message: 'Пакет $packageName успешно откатан на выбранную версию!', + logLine: ':: Downgrade completed successfully.', + ); + } else { + yield ProgressEvent( + step: UpdateStep.failed, + packageName: packageName, + percentage: 1.0, + message: 'Ошибка отката $packageName (код $exitCode)', + logLine: '[ERROR] pacman -U exited with code $exitCode', + ); + } + } catch (e) { + yield ProgressEvent( + step: UpdateStep.failed, + packageName: packageName, + percentage: 1.0, + message: 'Не удалось выполнить откат: $e', + logLine: '[ERROR] $e', + ); + } + } +} diff --git a/lib/src/services/admin_service.dart b/lib/src/services/admin_service.dart new file mode 100644 index 0000000..88dd293 --- /dev/null +++ b/lib/src/services/admin_service.dart @@ -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 isPasswordRequired() async { + try { + final res = await Process.run('sudo', ['-n', 'true']); + return res.exitCode != 0; + } catch (_) { + return true; + } + } + + /// Проверка валидности пароля через sudo -S -v + static Future 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 runPrivileged( + String? password, + String executable, + List arguments, { + Map? environment, + }) async { + final List 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 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( + 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 _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('Подтвердить'), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/services/app_badge_controller.dart b/lib/src/services/app_badge_controller.dart new file mode 100644 index 0000000..91e567f --- /dev/null +++ b/lib/src/services/app_badge_controller.dart @@ -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'; + } +} diff --git a/lib/src/services/app_settings_service.dart b/lib/src/services/app_settings_service.dart new file mode 100644 index 0000000..8c272ec --- /dev/null +++ b/lib/src/services/app_settings_service.dart @@ -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 load() async { + _loadSettings(); + } + + void _loadSettings() { + try { + final file = _configFile; + if (file.existsSync()) { + final content = file.readAsStringSync(); + final Map 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 _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 setWindowTheme(String value) async { + if (_windowTheme == value) return; + _windowTheme = value; + notifyListeners(); + await _saveSettings(); + } + + Future setCreateSnapshotBeforeUpdate(bool value) async { + if (_createSnapshotBeforeUpdate == value) return; + _createSnapshotBeforeUpdate = value; + notifyListeners(); + await _saveSettings(); + } + + Future setUsePolkit(bool value) async { + if (_usePolkit == value) return; + _usePolkit = value; + notifyListeners(); + await _saveSettings(); + } + + Future setPreferredBackend(String value) async { + if (_preferredBackend == value) return; + _preferredBackend = value; + notifyListeners(); + await _saveSettings(); + } + + Future setPreferredMirrorCountry(String value) async { + if (_preferredMirrorCountry == value) return; + _preferredMirrorCountry = value; + notifyListeners(); + await _saveSettings(); + } +} diff --git a/lib/src/services/arch_news_service.dart b/lib/src/services/arch_news_service.dart new file mode 100644 index 0000000..cc0722c --- /dev/null +++ b/lib/src/services/arch_news_service.dart @@ -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? _cachedNews; + static DateTime? _lastFetchTime; + + /// Загрузка последних новостей Arch Linux из официального RSS-фида + static Future> 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 checkInterventions( + List updatingPackageNames, + List newsItems, + ) { + final List 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 _parseRss(String xml) { + final List items = []; + final itemBlocks = xml.split(''); + + 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(' ', ' ') + .replaceAll('‘', "'") + .replaceAll('’', "'") + .replaceAll('“', '"') + .replaceAll('”', '"') + .replaceAll('–', '–') + .replaceAll('—', '—') + .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('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll('&', '&'); + + // Форматируем списки и абзацы + text = text + .replaceAll(RegExp(r'<\s*br\s*/?>', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n\n') + .replaceAll(RegExp(r'<\s*li\s*>', caseSensitive: false), '• ') + .replaceAll(RegExp(r'', 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 = ''; + final sIdx = xml.indexOf(startTag); + if (sIdx == -1) { + // CDATA or namespaced + final cdataStart = '<$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(); + } +} diff --git a/lib/src/services/aur_service.dart b/lib/src/services/aur_service.dart new file mode 100644 index 0000000..0934980 --- /dev/null +++ b/lib/src/services/aur_service.dart @@ -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 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 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> 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 data = jsonDecode(body); + final results = data['results'] as List? ?? []; + + final list = results + .map((item) => AurPackage.fromJson(item as Map)) + .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 data = jsonDecode(curlRes.stdout.toString()); + final results = data['results'] as List? ?? []; + final list = results + .map((item) => AurPackage.fromJson(item as Map)) + .toList(); + list.sort((a, b) => b.numVotes.compareTo(a.numVotes)); + return list; + } + } catch (_) {} + } + + return []; + } + + /// Сборка и установка пакета AUR через найденный хелпер (paru / yay) + static Stream 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(); + 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', + ); + } + } +} diff --git a/lib/src/services/batch_install_service.dart b/lib/src/services/batch_install_service.dart new file mode 100644 index 0000000..ecc66ae --- /dev/null +++ b/lib/src/services/batch_install_service.dart @@ -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 _queue = {}; + bool _isInstalling = false; + + bool get isInstalling => _isInstalling; + List get queueApps => _queue.values.toList(); + int get count => _queue.length; + bool get isEmpty => _queue.isEmpty; + + List get pacmanApps => + _queue.values.where((a) => a.backend == BackendType.pacman).toList(); + List get aurApps => + _queue.values.where((a) => a.backend == BackendType.aur).toList(); + List 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 runBatchInstall({ + required String? password, + }) async* { + if (_queue.isEmpty) return; + + _isInstalling = true; + notifyListeners(); + + final toInstall = List.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, + ); + } + } +} diff --git a/lib/src/services/catalog_service.dart b/lib/src/services/catalog_service.dart new file mode 100644 index 0000000..a0f4465 --- /dev/null +++ b/lib/src/services/catalog_service.dart @@ -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 _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 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> 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 checkInstalledStatus(List 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() + : {}; + + 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() + : {}; + + 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> 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 = []; + for (final list in results) { + all.addAll(list); + } + + await checkInstalledStatus(all); + return all; + } + + /// Поиск в Pacman + static Future> _searchPacman(String query) async { + final List 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> _searchAur(String query) async { + final List 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> _searchFlatpak(String query) async { + final List 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; + } +} diff --git a/lib/src/services/doctor_service.dart b/lib/src/services/doctor_service.dart new file mode 100644 index 0000000..57e92df --- /dev/null +++ b/lib/src/services/doctor_service.dart @@ -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 getDiff() async { + return DoctorService.getDiff(originalPath, pacnewPath); + } + + Future readOriginal() async { + try { + return await File(originalPath).readAsString(); + } catch (_) { + return ''; + } + } + + Future readPacnew() async { + try { + return await File(pacnewPath).readAsString(); + } catch (_) { + return ''; + } + } +} + +class DoctorReport { + final int healthScore; // 0 - 100 + final List issues; + final List pacnewFiles; + final List 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 get pacnewEntries => pacnewFiles; + String get mirrorlistStatus => activeMirror != null + ? 'Текущее активное зеркало: $activeMirror' + : 'Зеркала настроены по умолчанию.'; +} + +class DoctorService { + /// Полная диагностика состояния системы + static Future diagnose() async { + final List 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 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 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 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 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 restartFailedServices(List 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 optimizeMirrors({String? password, String? country}) async { + try { + final List 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 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 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 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 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); + } + } +} diff --git a/lib/src/services/snapshot_service.dart b/lib/src/services/snapshot_service.dart new file mode 100644 index 0000000..f678382 --- /dev/null +++ b/lib/src/services/snapshot_service.dart @@ -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 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 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 createPreUpdateSnapshot( + String? password, { + List? 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, 'Не удалось создать снимок'); + } +} diff --git a/lib/src/ui/main_layout.dart b/lib/src/ui/main_layout.dart new file mode 100644 index 0000000..5f5053d --- /dev/null +++ b/lib/src/ui/main_layout.dart @@ -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 createState() => _MainLayoutState(); +} + +class _MainLayoutState extends State { + int _pageIndex = 0; + + @override + void initState() { + super.initState(); + _performInitialBadgeScan(); + } + + Future _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(), + ], + ), + ), + ), +); + } +} diff --git a/lib/src/ui/screens/cleaner_screen.dart b/lib/src/ui/screens/cleaner_screen.dart new file mode 100644 index 0000000..f767549 --- /dev/null +++ b/lib/src/ui/screens/cleaner_screen.dart @@ -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 createState() => _CleanerScreenState(); +} + +class _CleanerScreenState extends State { + CleanerScanResult? _scanResult; + bool _isScanning = true; + bool _isCleaning = false; + + bool _cleanCache = true; + bool _cleanOrphans = true; + bool _cleanFlatpak = true; + bool _cleanJournal = true; + + ProgressEvent? _currentProgress; + final List _logLines = []; + StreamSubscription? _cleanSub; + final ScrollController _logScrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scan(); + } + + @override + void dispose() { + _cleanSub?.cancel(); + _logScrollController.dispose(); + super.dispose(); + } + + Future _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 _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 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), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/screens/discover_screen.dart b/lib/src/ui/screens/discover_screen.dart new file mode 100644 index 0000000..a72f584 --- /dev/null +++ b/lib/src/ui/screens/discover_screen.dart @@ -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 createState() => _DiscoverScreenState(); +} + +class _DiscoverScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + Timer? _searchDebounce; + + bool _isLoading = true; + bool _isSearching = false; + String _searchQuery = ''; + String _selectedSource = 'Все'; + String? _aurHelper; + + final List _featuredApps = CatalogService.getFeaturedApps(); + final Map> _curatedCategories = CatalogService.getCuratedCategories(); + List _searchResults = []; + + final List _sourceFilters = ['Все', 'Pacman', 'AUR', 'Flatpak']; + + @override + void initState() { + super.initState(); + _initCatalog(); + } + + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _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 _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 _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 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, + ), + ), + ); + } +} diff --git a/lib/src/ui/screens/doctor_screen.dart b/lib/src/ui/screens/doctor_screen.dart new file mode 100644 index 0000000..6b68fbd --- /dev/null +++ b/lib/src/ui/screens/doctor_screen.dart @@ -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 createState() => _DoctorScreenState(); +} + +class _DoctorScreenState extends State { + bool _isLoading = true; + bool _isActionRunning = false; + String? _actionTitle; + final List _actionLogs = []; + final ScrollController _logScrollController = ScrollController(); + + DoctorReport? _report; + + @override + void initState() { + super.initState(); + _runDiagnostics(); + } + + @override + void dispose() { + _logScrollController.dispose(); + super.dispose(); + } + + Future _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 _executeAction(String title, Future 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? 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)'), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/screens/history_screen.dart b/lib/src/ui/screens/history_screen.dart new file mode 100644 index 0000000..26faba8 --- /dev/null +++ b/lib/src/ui/screens/history_screen.dart @@ -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 createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + List _items = []; + bool _isLoading = true; + String _searchQuery = ''; + HistoryAction? _filterAction; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _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( + 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, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/src/ui/screens/installed_screen.dart b/lib/src/ui/screens/installed_screen.dart new file mode 100644 index 0000000..fdbe897 --- /dev/null +++ b/lib/src/ui/screens/installed_screen.dart @@ -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 createState() => _InstalledScreenState(); +} + +class _InstalledScreenState extends State { + List _packages = []; + String _searchQuery = ''; + bool _isLoading = true; + BackendType? _filterBackend; + + @override + void initState() { + super.initState(); + _loadInstalled(); + } + + Future _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( + 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, + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/src/ui/screens/settings_screen.dart b/lib/src/ui/screens/settings_screen.dart new file mode 100644 index 0000000..045a7fd --- /dev/null +++ b/lib/src/ui/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + 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 _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 _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( + 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( + 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, + ), + ); + } +} diff --git a/lib/src/ui/screens/starter_packs_screen.dart b/lib/src/ui/screens/starter_packs_screen.dart new file mode 100644 index 0000000..b14a5a7 --- /dev/null +++ b/lib/src/ui/screens/starter_packs_screen.dart @@ -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 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 createState() => _StarterPacksScreenState(); +} + +class _StarterPacksScreenState extends State { + bool _isLoading = true; + bool _isInstalling = false; + String? _installingPackTitle; + final List _installLogs = []; + final ScrollController _logScrollController = ScrollController(); + + late final List _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 _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() + : {}; + + 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 _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)', + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/screens/updates_screen.dart b/lib/src/ui/screens/updates_screen.dart new file mode 100644 index 0000000..e5a375c --- /dev/null +++ b/lib/src/ui/screens/updates_screen.dart @@ -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 createState() => _UpdatesScreenState(); +} + +class _UpdatesScreenState extends State { + List _updates = []; + final Set _selectedPackages = {}; + bool _isLoading = true; + bool _isUpdating = false; + bool _isDryRun = true; + BackendType? _filterBackend; + + ProgressEvent? _currentProgress; + final List _logLines = []; + StreamSubscription? _progressSub; + final ScrollController _logScrollController = ScrollController(); + List _newsAlerts = []; + List _allNews = []; + + @override + void initState() { + super.initState(); + _loadUpdates(); + } + + @override + void dispose() { + _progressSub?.cancel(); + _logScrollController.dispose(); + super.dispose(); + } + + Future _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 _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( + 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 news; + final List alerts; + final Future 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), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/theme/window_theme_style.dart b/lib/src/ui/theme/window_theme_style.dart new file mode 100644 index 0000000..6986b81 --- /dev/null +++ b/lib/src/ui/theme/window_theme_style.dart @@ -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, + ); + } +} diff --git a/lib/src/ui/widgets/app_icon_image.dart b/lib/src/ui/widgets/app_icon_image.dart new file mode 100644 index 0000000..555da33 --- /dev/null +++ b/lib/src/ui/widgets/app_icon_image.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/src/ui/widgets/batch_install_sheet.dart b/lib/src/ui/widgets/batch_install_sheet.dart new file mode 100644 index 0000000..880e642 --- /dev/null +++ b/lib/src/ui/widgets/batch_install_sheet.dart @@ -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 show(BuildContext context) { + return showMacosSheet( + context: context, + barrierDismissible: false, + builder: (_) => const BatchInstallSheet(), + ); + } + + @override + State createState() => _BatchInstallSheetState(); +} + +class _BatchInstallSheetState extends State { + final List _logs = []; + final ScrollController _logScrollController = ScrollController(); + StreamSubscription? _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 _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, + ), + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/widgets/friendly_term.dart b/lib/src/ui/widgets/friendly_term.dart new file mode 100644 index 0000000..a5747a6 --- /dev/null +++ b/lib/src/ui/widgets/friendly_term.dart @@ -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 _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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/widgets/install_queue_bar.dart b/lib/src/ui/widgets/install_queue_bar.dart new file mode 100644 index 0000000..8315d12 --- /dev/null +++ b/lib/src/ui/widgets/install_queue_bar.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/src/ui/widgets/package_inspector.dart b/lib/src/ui/widgets/package_inspector.dart new file mode 100644 index 0000000..7bc7fbd --- /dev/null +++ b/lib/src/ui/widgets/package_inspector.dart @@ -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 show( + BuildContext context, { + required String packageName, + required BackendType backend, + }) { + return showMacosSheet( + context: context, + barrierDismissible: true, + builder: (context) => PackageInspectorSheet( + initialPackageName: packageName, + initialBackend: backend, + ), + ); + } + + @override + State createState() => _PackageInspectorSheetState(); +} + +class _PackageInspectorSheetState extends State { + late String _currentPackageName; + late BackendType _currentBackend; + final List<({String name, BackendType backend})> _history = []; + + PackageDetails? _details; + List _cachedVersions = []; + List _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 _loadDetails() async { + setState(() { + _isLoading = true; + _fileSearchFilter = ''; + }); + + try { + final data = await RustBridgeApi.getPackageDetails( + _currentBackend, + _currentPackageName, + ); + + List cached = []; + List 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 _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 _downgradeTo(CachedPackageVersion target) async { + final confirmed = await showMacosAlertDialog( + 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, + ); + } +} diff --git a/lib/src/ui/widgets/spotlight_search_dialog.dart b/lib/src/ui/widgets/spotlight_search_dialog.dart new file mode 100644 index 0000000..a18bd84 --- /dev/null +++ b/lib/src/ui/widgets/spotlight_search_dialog.dart @@ -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 show( + BuildContext context, { + required Function(String name, BackendType backend) onSelectPackage, + }) { + return showMacosSheet( + context: context, + barrierDismissible: true, + builder: (_) => SpotlightSearchDialog(onSelectPackage: onSelectPackage), + ); + } + + @override + State createState() => _SpotlightSearchDialogState(); +} + +class _SpotlightSearchDialogState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); + + Timer? _debounceTimer; + bool _isSearching = false; + List _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 _performSearch(String query) async { + setState(() => _isSearching = true); + final List 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, + ), + ), + ); + } +} diff --git a/lib/src/ui/widgets/storage_breakdown_bar.dart b/lib/src/ui/widgets/storage_breakdown_bar.dart new file mode 100644 index 0000000..556e77b --- /dev/null +++ b/lib/src/ui/widgets/storage_breakdown_bar.dart @@ -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 createState() => _StorageBreakdownBarState(); +} + +class _StorageBreakdownBarState extends State { + 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 _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, + ), + ), + ], + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..ad1ddfd --- /dev/null +++ b/linux/CMakeLists.txt @@ -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 "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>: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() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -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} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..f6f23bf --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +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); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f16b4c3 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -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 $) + 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) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -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}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -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); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..79ac6bb --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#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)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +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_ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e569c90 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..3e67307 --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..71dda73 --- /dev/null +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..c37ac09 --- /dev/null +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs new file mode 100644 index 0000000..9a26b8d --- /dev/null +++ b/rust/src/api/mod.rs @@ -0,0 +1 @@ +pub mod packages; diff --git a/rust/src/api/packages.rs b/rust/src/api/packages.rs new file mode 100644 index 0000000..73ccfae --- /dev/null +++ b/rust/src/api/packages.rs @@ -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 { + 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) -> Result, 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, +) -> Result, 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 { + get_manager(backend).get_package_details(&package_name).await +} + +pub async fn apply_updates( + backend: BackendType, + packages: Vec, + is_dry_run: bool, + sink: StreamSink, +) -> 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 { + SystemCleaner::scan().await +} + +/// Выполнение очистки +pub async fn run_cleaner( + clean_pacman_cache: bool, + clean_orphans: bool, + clean_flatpak_unused: bool, + clean_journal: bool, + sink: StreamSink, +) -> Result<(), AppError> { + SystemCleaner::clean( + clean_pacman_cache, + clean_orphans, + clean_flatpak_unused, + clean_journal, + sink, + ) + .await +} + +/// Получение истории транзакций +pub async fn get_history(limit: usize) -> Result, AppError> { + PacmanHistory::get_recent_history(limit).await +} + +/// Получение каталога приложений +pub async fn get_discover_apps() -> Result, AppError> { + AppCatalog::get_curated_apps().await +} diff --git a/rust/src/core/cleaner.rs b/rust/src/core/cleaner.rs new file mode 100644 index 0000000..b1b04d0 --- /dev/null +++ b/rust/src/core/cleaner.rs @@ -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 { + 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, + ) -> 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, + } +} diff --git a/rust/src/core/discover.rs b/rust/src/core/discover.rs new file mode 100644 index 0000000..6f4eaa7 --- /dev/null +++ b/rust/src/core/discover.rs @@ -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, 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) + } +} diff --git a/rust/src/core/flatpak.rs b/rust/src/core/flatpak.rs new file mode 100644 index 0000000..72728b6 --- /dev/null +++ b/rust/src/core/flatpak.rs @@ -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, 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, 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 { + 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, + is_dry_run: bool, + sink: StreamSink, + ) -> 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 + } +} diff --git a/rust/src/core/history.rs b/rust/src/core/history.rs new file mode 100644 index 0000000..f55382e --- /dev/null +++ b/rust/src/core/history.rs @@ -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, 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) + } +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs new file mode 100644 index 0000000..75fbbeb --- /dev/null +++ b/rust/src/core/mod.rs @@ -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; diff --git a/rust/src/core/package_manager.rs b/rust/src/core/package_manager.rs new file mode 100644 index 0000000..d3c8413 --- /dev/null +++ b/rust/src/core/package_manager.rs @@ -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, AppError>; + + /// Получение списка установленных пакетов + async fn get_installed_packages(&self) -> Result, AppError>; + + /// Получение подробной информации о конкретном пакете (зависимости, ссылки, лицензии) + async fn get_package_details(&self, package_name: &str) -> Result; + + /// Применение обновлений с флагом безопасного режима (is_dry_run) и стримингом логов + async fn apply_updates( + &self, + packages: Vec, + is_dry_run: bool, + sink: StreamSink, + ) -> Result<(), AppError>; +} diff --git a/rust/src/core/pacman.rs b/rust/src/core/pacman.rs new file mode 100644 index 0000000..aaba178 --- /dev/null +++ b/rust/src/core/pacman.rs @@ -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, 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, 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 { + // Сначала пробуем получить данные из базы репозиториев (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, + is_dry_run: bool, + sink: StreamSink, + ) -> 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 + } +} diff --git a/rust/src/core/privilege.rs b/rust/src/core/privilege.rs new file mode 100644 index 0000000..8ff570e --- /dev/null +++ b/rust/src/core/privilege.rs @@ -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( + 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() +} diff --git a/rust/src/core/types.rs b/rust/src/core/types.rs new file mode 100644 index 0000000..f663ee5 --- /dev/null +++ b/rust/src/core/types.rs @@ -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, + 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, + pub licenses: Vec, + pub depends_on: Vec, + pub required_by: Vec, + pub packager: Option, + pub download_size_bytes: u64, + pub installed_size_bytes: u64, + pub build_date: Option, + 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, + pub percentage: f32, // 0.0 .. 1.0 + pub message: String, + pub log_line: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CleanerScanResult { + pub pacman_cache_bytes: u64, + pub orphan_packages: Vec, + 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, + 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), +} diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs new file mode 100644 index 0000000..b52a03a --- /dev/null +++ b/rust/src/frb_generated.rs @@ -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 { + _marker: std::marker::PhantomData, +} + +impl StreamSink { + #[allow(dead_code)] + pub fn add(&self, _value: T) -> Result<(), ()> { + Ok(()) + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..7b770c7 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod core; + +// Файл создается автоматически через flutter_rust_bridge_codegen +#[rustfmt::skip] +pub mod frb_generated; diff --git a/test/batch_install_service_test.dart b/test/batch_install_service_test.dart new file mode 100644 index 0000000..039de8b --- /dev/null +++ b/test/batch_install_service_test.dart @@ -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); + }); + }); +} diff --git a/test/package_inspector_sheet_test.dart b/test/package_inspector_sheet_test.dart new file mode 100644 index 0000000..e6f351e --- /dev/null +++ b/test/package_inspector_sheet_test.dart @@ -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); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9883a6b --- /dev/null +++ b/test/widget_test.dart @@ -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(); + }); + }); +} diff --git a/test/window_theme_style_test.dart b/test/window_theme_style_test.dart new file mode 100644 index 0000000..d855b58 --- /dev/null +++ b/test/window_theme_style_test.dart @@ -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()); + }); + }); +}