feat: Initial commit of Forge package manager (Flutter + Rust)
This commit is contained in:
+53
@@ -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/
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
rust_input: "rust/src/api"
|
||||
rust_root: "rust"
|
||||
dart_output: "lib/src/rust"
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'src/services/app_settings_service.dart';
|
||||
import 'src/ui/main_layout.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Инициализация Rust FFI бэкенда через flutter_rust_bridge v2
|
||||
await RustLib.init();
|
||||
|
||||
runApp(const ForgeApp());
|
||||
}
|
||||
|
||||
class ForgeApp extends StatelessWidget {
|
||||
const ForgeApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: AppSettingsService.instance,
|
||||
builder: (context, _) {
|
||||
final activeStyle = AppSettingsService.instance.effectiveWindowThemeStyle;
|
||||
final themeData = activeStyle.buildThemeData();
|
||||
return MacosApp(
|
||||
title: 'Forge',
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: ThemeMode.dark,
|
||||
theme: themeData,
|
||||
darkTheme: themeData,
|
||||
home: const MainLayout(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
enum BackendType {
|
||||
pacman,
|
||||
flatpak,
|
||||
aur;
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case BackendType.pacman:
|
||||
return 'Pacman';
|
||||
case BackendType.flatpak:
|
||||
return 'Flatpak';
|
||||
case BackendType.aur:
|
||||
return 'AUR';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateStep {
|
||||
checkingDependencies,
|
||||
downloading,
|
||||
verifying,
|
||||
installing,
|
||||
runningHooks,
|
||||
done,
|
||||
failed,
|
||||
}
|
||||
|
||||
class PackageUpdate {
|
||||
final String name;
|
||||
final String currentVersion;
|
||||
final String newVersion;
|
||||
final String repository;
|
||||
final int downloadSizeBytes;
|
||||
final String description;
|
||||
final bool isSecurityUpdate;
|
||||
final BackendType backend;
|
||||
|
||||
const PackageUpdate({
|
||||
required this.name,
|
||||
required this.currentVersion,
|
||||
required this.newVersion,
|
||||
required this.repository,
|
||||
required this.downloadSizeBytes,
|
||||
required this.description,
|
||||
required this.isSecurityUpdate,
|
||||
required this.backend,
|
||||
});
|
||||
|
||||
String get formattedSize {
|
||||
if (downloadSizeBytes <= 0) return '—';
|
||||
if (downloadSizeBytes >= 1024 * 1024 * 1024) {
|
||||
return '${(downloadSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (downloadSizeBytes >= 1024 * 1024) {
|
||||
return '${(downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (downloadSizeBytes >= 1024) {
|
||||
return '${(downloadSizeBytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$downloadSizeBytes B';
|
||||
}
|
||||
}
|
||||
|
||||
class PackageInfo {
|
||||
final String name;
|
||||
final String version;
|
||||
final int installedSizeBytes;
|
||||
final String description;
|
||||
final String? installDate;
|
||||
final bool isExplicit;
|
||||
final BackendType backend;
|
||||
|
||||
const PackageInfo({
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.installedSizeBytes,
|
||||
required this.description,
|
||||
this.installDate,
|
||||
required this.isExplicit,
|
||||
required this.backend,
|
||||
});
|
||||
|
||||
String get formattedSize {
|
||||
if (installedSizeBytes <= 0) return '—';
|
||||
if (installedSizeBytes >= 1024 * 1024 * 1024) {
|
||||
return '${(installedSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (installedSizeBytes >= 1024 * 1024) {
|
||||
return '${(installedSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (installedSizeBytes >= 1024) {
|
||||
return '${(installedSizeBytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$installedSizeBytes B';
|
||||
}
|
||||
}
|
||||
|
||||
class PackageDetails {
|
||||
final String name;
|
||||
final String version;
|
||||
final String description;
|
||||
final String? url;
|
||||
final List<String> licenses;
|
||||
final List<String> dependsOn;
|
||||
final List<String> requiredBy;
|
||||
final String? packager;
|
||||
final int downloadSizeBytes;
|
||||
final int installedSizeBytes;
|
||||
final String? buildDate;
|
||||
final String repository;
|
||||
final BackendType backend;
|
||||
|
||||
const PackageDetails({
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.description,
|
||||
this.url,
|
||||
required this.licenses,
|
||||
required this.dependsOn,
|
||||
required this.requiredBy,
|
||||
this.packager,
|
||||
required this.downloadSizeBytes,
|
||||
required this.installedSizeBytes,
|
||||
this.buildDate,
|
||||
required this.repository,
|
||||
required this.backend,
|
||||
});
|
||||
|
||||
String get formattedInstalledSize {
|
||||
if (installedSizeBytes <= 0) return 'Неизвестно';
|
||||
if (installedSizeBytes >= 1024 * 1024 * 1024) {
|
||||
return '${(installedSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (installedSizeBytes >= 1024 * 1024) {
|
||||
return '${(installedSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (installedSizeBytes >= 1024) {
|
||||
return '${(installedSizeBytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$installedSizeBytes B';
|
||||
}
|
||||
|
||||
String get formattedDownloadSize {
|
||||
if (downloadSizeBytes <= 0) return 'Неизвестно';
|
||||
if (downloadSizeBytes >= 1024 * 1024 * 1024) {
|
||||
return '${(downloadSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (downloadSizeBytes >= 1024 * 1024) {
|
||||
return '${(downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (downloadSizeBytes >= 1024) {
|
||||
return '${(downloadSizeBytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$downloadSizeBytes B';
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressEvent {
|
||||
final UpdateStep step;
|
||||
final String? packageName;
|
||||
final double percentage; // 0.0 .. 1.0
|
||||
final String message;
|
||||
final String? logLine;
|
||||
|
||||
const ProgressEvent({
|
||||
required this.step,
|
||||
this.packageName,
|
||||
required this.percentage,
|
||||
required this.message,
|
||||
this.logLine,
|
||||
});
|
||||
}
|
||||
|
||||
class CleanerScanResult {
|
||||
final int pacmanCacheBytes;
|
||||
final List<String> orphanPackages;
|
||||
final int orphanBytes;
|
||||
final int flatpakUnusedCount;
|
||||
final int flatpakUnusedBytes;
|
||||
final int journalBytes;
|
||||
final int totalReclaimableBytes;
|
||||
|
||||
const CleanerScanResult({
|
||||
required this.pacmanCacheBytes,
|
||||
required this.orphanPackages,
|
||||
required this.orphanBytes,
|
||||
required this.flatpakUnusedCount,
|
||||
required this.flatpakUnusedBytes,
|
||||
required this.journalBytes,
|
||||
required this.totalReclaimableBytes,
|
||||
});
|
||||
|
||||
static String formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$bytes B';
|
||||
}
|
||||
}
|
||||
|
||||
enum HistoryAction {
|
||||
upgraded,
|
||||
installed,
|
||||
removed;
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case HistoryAction.upgraded:
|
||||
return 'Обновлено';
|
||||
case HistoryAction.installed:
|
||||
return 'Установлено';
|
||||
case HistoryAction.removed:
|
||||
return 'Удалено';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryItem {
|
||||
final String timestamp;
|
||||
final HistoryAction action;
|
||||
final String packageName;
|
||||
final String? oldVersion;
|
||||
final String newVersion;
|
||||
|
||||
const HistoryItem({
|
||||
required this.timestamp,
|
||||
required this.action,
|
||||
required this.packageName,
|
||||
this.oldVersion,
|
||||
required this.newVersion,
|
||||
});
|
||||
}
|
||||
|
||||
class DiscoverApp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String summary;
|
||||
final String category;
|
||||
final String iconName;
|
||||
final BackendType backend;
|
||||
final bool isInstalled;
|
||||
final double rating;
|
||||
|
||||
const DiscoverApp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.summary,
|
||||
required this.category,
|
||||
required this.iconName,
|
||||
required this.backend,
|
||||
required this.isInstalled,
|
||||
required this.rating,
|
||||
});
|
||||
}
|
||||
|
||||
class CachedPackageVersion {
|
||||
final String version;
|
||||
final String filePath;
|
||||
final int fileSizeBytes;
|
||||
final DateTime modifiedTime;
|
||||
|
||||
const CachedPackageVersion({
|
||||
required this.version,
|
||||
required this.filePath,
|
||||
required this.fileSizeBytes,
|
||||
required this.modifiedTime,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
|
||||
class AdminService {
|
||||
/// Проверка, требуются ли права sudo без ввода пароля (NOPASSWD или уже кэшировано)
|
||||
static Future<bool> isPasswordRequired() async {
|
||||
try {
|
||||
final res = await Process.run('sudo', ['-n', 'true']);
|
||||
return res.exitCode != 0;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверка валидности пароля через sudo -S -v
|
||||
static Future<bool> verifyPassword(String password) async {
|
||||
try {
|
||||
final proc = await Process.start('sudo', ['-S', '-v', '-p', '']);
|
||||
proc.stdin.writeln(password);
|
||||
await proc.stdin.flush();
|
||||
await proc.stdin.close();
|
||||
final exitCode = await proc.exitCode;
|
||||
return exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Запуск команды с правами суперпользователя через sudo -S
|
||||
static Future<Process> runPrivileged(
|
||||
String? password,
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
Map<String, String>? environment,
|
||||
}) async {
|
||||
final List<String> sudoArgs = ['-S', '-p', '', executable, ...arguments];
|
||||
final proc = await Process.start(
|
||||
'sudo',
|
||||
sudoArgs,
|
||||
environment: {
|
||||
'LC_ALL': 'C',
|
||||
...?environment,
|
||||
},
|
||||
);
|
||||
|
||||
if (password != null && password.isNotEmpty) {
|
||||
proc.stdin.writeln(password);
|
||||
await proc.stdin.flush();
|
||||
}
|
||||
await proc.stdin.close();
|
||||
return proc;
|
||||
}
|
||||
|
||||
/// Запрос пароля администратора через аутентичный модальный диалог macOS
|
||||
static Future<String?> promptPassword(
|
||||
BuildContext context, {
|
||||
String title = 'Forge запрашивает права администратора',
|
||||
required String reason,
|
||||
}) async {
|
||||
// Если пароль не требуется (NOPASSWD), сразу возвращаем пустую строку
|
||||
final required = await isPasswordRequired();
|
||||
if (!required) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!context.mounted) return null;
|
||||
|
||||
return showMacosSheet<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _AdminPasswordSheet(
|
||||
title: title,
|
||||
reason: reason,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminPasswordSheet extends StatefulWidget {
|
||||
final String title;
|
||||
final String reason;
|
||||
|
||||
const _AdminPasswordSheet({
|
||||
required this.title,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AdminPasswordSheet> createState() => _AdminPasswordSheetState();
|
||||
}
|
||||
|
||||
class _AdminPasswordSheetState extends State<_AdminPasswordSheet> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _isVerifying = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final pass = _controller.text;
|
||||
if (pass.isEmpty) {
|
||||
setState(() => _errorMessage = 'Введите пароль администратора');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isVerifying = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final isValid = await AdminService.verifyPassword(pass);
|
||||
if (!mounted) return;
|
||||
|
||||
if (isValid) {
|
||||
Navigator.of(context).pop(pass);
|
||||
} else {
|
||||
setState(() {
|
||||
_isVerifying = false;
|
||||
_errorMessage = 'Неверный пароль. Пожалуйста, попробуйте снова.';
|
||||
});
|
||||
_controller.clear();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = MacosTheme.of(context);
|
||||
final user = Platform.environment['USER'] ?? 'admin';
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 440,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: MacosIcon(
|
||||
CupertinoIcons.lock_shield_fill,
|
||||
color: Color(0xFF0A84FF),
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.reason,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Пользователь
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.person_crop_circle,
|
||||
size: 18,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Пользователь: $user',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Поле пароля
|
||||
MacosTextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
obscureText: true,
|
||||
enabled: !_isVerifying,
|
||||
placeholder: 'Пароль учетной записи',
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.exclamationmark_triangle_fill,
|
||||
size: 13,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 22),
|
||||
|
||||
// Кнопки действий
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
secondary: true,
|
||||
onPressed: _isVerifying ? null : () => Navigator.of(context).pop(null),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
onPressed: _isVerifying ? null : _submit,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isVerifying) ...[
|
||||
const ProgressCircle(radius: 6),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Text('Подтвердить'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppBadgeController extends ChangeNotifier {
|
||||
static final AppBadgeController instance = AppBadgeController._();
|
||||
AppBadgeController._();
|
||||
|
||||
int _updatesCount = 0;
|
||||
int _cleanerBytes = 0;
|
||||
|
||||
int get updatesCount => _updatesCount;
|
||||
int get cleanerBytes => _cleanerBytes;
|
||||
|
||||
void setUpdatesCount(int count) {
|
||||
if (_updatesCount != count) {
|
||||
_updatesCount = count;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void setCleanerBytes(int bytes) {
|
||||
if (_cleanerBytes != bytes) {
|
||||
_cleanerBytes = bytes;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
static String formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$bytes B';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../ui/theme/window_theme_style.dart';
|
||||
|
||||
class AppSettingsService extends ChangeNotifier {
|
||||
static final AppSettingsService instance = AppSettingsService._();
|
||||
|
||||
AppSettingsService._() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
bool _createSnapshotBeforeUpdate = false;
|
||||
bool _usePolkit = true;
|
||||
String _preferredBackend = 'pacman';
|
||||
String _preferredMirrorCountry = '';
|
||||
String _windowTheme = 'auto';
|
||||
|
||||
bool get createSnapshotBeforeUpdate => _createSnapshotBeforeUpdate;
|
||||
bool get usePolkit => _usePolkit;
|
||||
String get preferredBackend => _preferredBackend;
|
||||
String get preferredMirrorCountry => _preferredMirrorCountry;
|
||||
String get windowTheme => _windowTheme;
|
||||
|
||||
static String detectDesktopEnvironmentName() {
|
||||
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
|
||||
final session = (Platform.environment['DESKTOP_SESSION'] ?? '').toUpperCase();
|
||||
if (xdg.contains('KDE') || session.contains('KDE') || session.contains('PLASMA')) {
|
||||
return 'KDE Plasma';
|
||||
} else if (xdg.contains('GNOME') || session.contains('GNOME')) {
|
||||
return 'GNOME';
|
||||
}
|
||||
return 'KDE Plasma';
|
||||
}
|
||||
|
||||
static WindowThemeStyle detectDesktopEnvironmentStyle() {
|
||||
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
|
||||
final session = (Platform.environment['DESKTOP_SESSION'] ?? '').toUpperCase();
|
||||
if (xdg.contains('KDE') || session.contains('KDE') || session.contains('PLASMA')) {
|
||||
return WindowThemeStyle.kde;
|
||||
} else if (xdg.contains('GNOME') || session.contains('GNOME')) {
|
||||
return WindowThemeStyle.gnome;
|
||||
}
|
||||
return WindowThemeStyle.kde;
|
||||
}
|
||||
|
||||
WindowThemeStyle get effectiveWindowThemeStyle {
|
||||
switch (_windowTheme) {
|
||||
case 'macos':
|
||||
return WindowThemeStyle.macos;
|
||||
case 'gnome':
|
||||
return WindowThemeStyle.gnome;
|
||||
case 'kde':
|
||||
return WindowThemeStyle.kde;
|
||||
case 'auto':
|
||||
default:
|
||||
return detectDesktopEnvironmentStyle();
|
||||
}
|
||||
}
|
||||
|
||||
File get _configFile {
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final dir = Directory('$home/.config/forge');
|
||||
if (!dir.existsSync()) {
|
||||
dir.createSync(recursive: true);
|
||||
}
|
||||
return File('${dir.path}/settings.json');
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
void _loadSettings() {
|
||||
try {
|
||||
final file = _configFile;
|
||||
if (file.existsSync()) {
|
||||
final content = file.readAsStringSync();
|
||||
final Map<String, dynamic> data = jsonDecode(content);
|
||||
_createSnapshotBeforeUpdate = data['createSnapshotBeforeUpdate'] as bool? ?? false;
|
||||
_usePolkit = data['usePolkit'] as bool? ?? true;
|
||||
_preferredBackend = data['preferredBackend'] as String? ?? 'pacman';
|
||||
_preferredMirrorCountry = data['preferredMirrorCountry'] as String? ?? '';
|
||||
_windowTheme = data['windowTheme'] as String? ?? 'auto';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
try {
|
||||
final file = _configFile;
|
||||
final data = {
|
||||
'createSnapshotBeforeUpdate': _createSnapshotBeforeUpdate,
|
||||
'usePolkit': _usePolkit,
|
||||
'preferredBackend': _preferredBackend,
|
||||
'preferredMirrorCountry': _preferredMirrorCountry,
|
||||
'windowTheme': _windowTheme,
|
||||
};
|
||||
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(data));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> setWindowTheme(String value) async {
|
||||
if (_windowTheme == value) return;
|
||||
_windowTheme = value;
|
||||
notifyListeners();
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setCreateSnapshotBeforeUpdate(bool value) async {
|
||||
if (_createSnapshotBeforeUpdate == value) return;
|
||||
_createSnapshotBeforeUpdate = value;
|
||||
notifyListeners();
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setUsePolkit(bool value) async {
|
||||
if (_usePolkit == value) return;
|
||||
_usePolkit = value;
|
||||
notifyListeners();
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setPreferredBackend(String value) async {
|
||||
if (_preferredBackend == value) return;
|
||||
_preferredBackend = value;
|
||||
notifyListeners();
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setPreferredMirrorCountry(String value) async {
|
||||
if (_preferredMirrorCountry == value) return;
|
||||
_preferredMirrorCountry = value;
|
||||
notifyListeners();
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
class ArchNewsItem {
|
||||
final String title;
|
||||
final String link;
|
||||
final String pubDate;
|
||||
final String description;
|
||||
final bool isManualIntervention;
|
||||
|
||||
const ArchNewsItem({
|
||||
required this.title,
|
||||
required this.link,
|
||||
required this.pubDate,
|
||||
required this.description,
|
||||
required this.isManualIntervention,
|
||||
});
|
||||
}
|
||||
|
||||
class ArchNewsService {
|
||||
static List<ArchNewsItem>? _cachedNews;
|
||||
static DateTime? _lastFetchTime;
|
||||
|
||||
/// Загрузка последних новостей Arch Linux из официального RSS-фида
|
||||
static Future<List<ArchNewsItem>> fetchNews({bool forceRefresh = false}) async {
|
||||
if (!forceRefresh && _cachedNews != null && _lastFetchTime != null) {
|
||||
if (DateTime.now().difference(_lastFetchTime!).inMinutes < 30) {
|
||||
return _cachedNews!;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 6);
|
||||
final request = await client.getUrl(Uri.parse('https://archlinux.org/feeds/news/'));
|
||||
final response = await request.close();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final xmlString = await response.transform(utf8.decoder).join();
|
||||
final items = _parseRss(xmlString);
|
||||
_cachedNews = items;
|
||||
_lastFetchTime = DateTime.now();
|
||||
return items;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback через curl, если HttpClient ограничен сетевыми политиками
|
||||
try {
|
||||
final res = await Process.run('curl', ['-s', '--max-time', '6', 'https://archlinux.org/feeds/news/']);
|
||||
if (res.exitCode == 0 && res.stdout.toString().isNotEmpty) {
|
||||
final items = _parseRss(res.stdout.toString());
|
||||
_cachedNews = items;
|
||||
_lastFetchTime = DateTime.now();
|
||||
return items;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return _cachedNews ?? [];
|
||||
}
|
||||
|
||||
/// Проверка, требуют ли какие-либо из доступных обновлений ручного вмешательства
|
||||
static List<ArchNewsItem> checkInterventions(
|
||||
List<String> updatingPackageNames,
|
||||
List<ArchNewsItem> newsItems,
|
||||
) {
|
||||
final List<ArchNewsItem> matched = [];
|
||||
final lowerPkgNames = updatingPackageNames.map((p) => p.toLowerCase()).toSet();
|
||||
|
||||
for (final news in newsItems) {
|
||||
if (!news.isManualIntervention) continue;
|
||||
final lowerTitle = news.title.toLowerCase();
|
||||
|
||||
for (final pkg in lowerPkgNames) {
|
||||
if (lowerTitle.contains(pkg)) {
|
||||
matched.add(news);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
static List<ArchNewsItem> _parseRss(String xml) {
|
||||
final List<ArchNewsItem> items = [];
|
||||
final itemBlocks = xml.split('<item>');
|
||||
|
||||
for (int i = 1; i < itemBlocks.length; i++) {
|
||||
final block = itemBlocks[i];
|
||||
final rawTitle = _extractTag(block, 'title');
|
||||
final link = _extractTag(block, 'link');
|
||||
final rawPubDate = _extractTag(block, 'pubDate');
|
||||
final rawDesc = _extractTag(block, 'description');
|
||||
|
||||
final title = _decodeHtmlEntities(rawTitle).trim();
|
||||
final cleanDesc = _cleanHtml(rawDesc);
|
||||
final pubDate = _formatPubDate(rawPubDate);
|
||||
|
||||
final isIntervention = title.toLowerCase().contains('manual intervention') ||
|
||||
title.toLowerCase().contains('breaking changes');
|
||||
|
||||
if (title.isNotEmpty) {
|
||||
items.add(ArchNewsItem(
|
||||
title: title,
|
||||
link: link,
|
||||
pubDate: pubDate,
|
||||
description: cleanDesc,
|
||||
isManualIntervention: isIntervention,
|
||||
));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
static String _decodeHtmlEntities(String text) {
|
||||
if (text.isEmpty) return '';
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.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'</\s*p\s*>', caseSensitive: false), '\n\n')
|
||||
.replaceAll(RegExp(r'<\s*li\s*>', caseSensitive: false), '• ')
|
||||
.replaceAll(RegExp(r'</\s*li\s*>', caseSensitive: false), '\n');
|
||||
|
||||
// Удаляем все оставшиеся HTML теги
|
||||
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
|
||||
// Декодируем оставшиеся сущности
|
||||
text = _decodeHtmlEntities(text);
|
||||
|
||||
// Нормализуем пробелы и пустые строки
|
||||
text = text.replaceAll(RegExp(r'[ \t]+'), ' ');
|
||||
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
static String _formatPubDate(String rawDate) {
|
||||
if (rawDate.isEmpty) return '';
|
||||
try {
|
||||
// Пример формата: "Tue, 21 Jul 2026 13:01:46 +0000"
|
||||
final parts = rawDate.trim().split(RegExp(r'\s+'));
|
||||
if (parts.length >= 4) {
|
||||
final day = parts[1];
|
||||
final monthStr = parts[2].toLowerCase();
|
||||
final year = parts[3];
|
||||
|
||||
const months = {
|
||||
'jan': 'янв',
|
||||
'feb': 'фев',
|
||||
'mar': 'мар',
|
||||
'apr': 'апр',
|
||||
'may': 'мая',
|
||||
'jun': 'июн',
|
||||
'jul': 'июл',
|
||||
'aug': 'авг',
|
||||
'sep': 'сен',
|
||||
'oct': 'окт',
|
||||
'nov': 'ноя',
|
||||
'dec': 'дек',
|
||||
};
|
||||
|
||||
final monthRu = months[monthStr] ?? monthStr;
|
||||
return '$day $monthRu $year';
|
||||
}
|
||||
} catch (_) {}
|
||||
return rawDate;
|
||||
}
|
||||
|
||||
static String _extractTag(String xml, String tag) {
|
||||
final startTag = '<$tag>';
|
||||
final endTag = '</$tag>';
|
||||
final sIdx = xml.indexOf(startTag);
|
||||
if (sIdx == -1) {
|
||||
// CDATA or namespaced
|
||||
final cdataStart = '<$tag><![CDATA[';
|
||||
final cdataEnd = ']]></$tag>';
|
||||
final cdIdx = xml.indexOf(cdataStart);
|
||||
if (cdIdx != -1) {
|
||||
final cdEnd = xml.indexOf(cdataEnd, cdIdx);
|
||||
if (cdEnd != -1) {
|
||||
return xml.substring(cdIdx + cdataStart.length, cdEnd).trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
final eIdx = xml.indexOf(endTag, sIdx);
|
||||
if (eIdx == -1) return '';
|
||||
return xml.substring(sIdx + startTag.length, eIdx).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../rust/api/packages.dart';
|
||||
|
||||
class AurPackage {
|
||||
final String name;
|
||||
final String version;
|
||||
final String description;
|
||||
final int numVotes;
|
||||
final double popularity;
|
||||
final String? maintainer;
|
||||
final int? lastModified;
|
||||
final String? url;
|
||||
final bool isOutOfDate;
|
||||
|
||||
const AurPackage({
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.description,
|
||||
required this.numVotes,
|
||||
required this.popularity,
|
||||
this.maintainer,
|
||||
this.lastModified,
|
||||
this.url,
|
||||
required this.isOutOfDate,
|
||||
});
|
||||
|
||||
factory AurPackage.fromJson(Map<String, dynamic> json) {
|
||||
return AurPackage(
|
||||
name: json['Name'] as String? ?? '',
|
||||
version: json['Version'] as String? ?? '1.0.0',
|
||||
description: json['Description'] as String? ?? '',
|
||||
numVotes: (json['NumVotes'] as num?)?.toInt() ?? 0,
|
||||
popularity: (json['Popularity'] as num?)?.toDouble() ?? 0.0,
|
||||
maintainer: json['Maintainer'] as String?,
|
||||
lastModified: (json['LastModified'] as num?)?.toInt(),
|
||||
url: json['URL'] as String?,
|
||||
isOutOfDate: json['OutOfDate'] != null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AurService {
|
||||
static String? _cachedHelper;
|
||||
|
||||
/// Определение доступного AUR-хелпера (предпочтение paru, затем yay)
|
||||
static Future<String?> getHelper() async {
|
||||
if (_cachedHelper != null) return _cachedHelper;
|
||||
|
||||
try {
|
||||
final paruRes = await Process.run('which', ['paru']);
|
||||
if (paruRes.exitCode == 0 && paruRes.stdout.toString().trim().isNotEmpty) {
|
||||
_cachedHelper = 'paru';
|
||||
return _cachedHelper;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final yayRes = await Process.run('which', ['yay']);
|
||||
if (yayRes.exitCode == 0 && yayRes.stdout.toString().trim().isNotEmpty) {
|
||||
_cachedHelper = 'yay';
|
||||
return _cachedHelper;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Поиск пакетов в AUR через официальный RPC API v5
|
||||
static Future<List<AurPackage>> search(String query) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
||||
try {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 5);
|
||||
final uri = Uri.parse('https://aur.archlinux.org/rpc/v5/search/${Uri.encodeComponent(query.trim())}');
|
||||
final req = await client.getUrl(uri);
|
||||
final res = await req.close();
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final body = await res.transform(utf8.decoder).join();
|
||||
final Map<String, dynamic> data = jsonDecode(body);
|
||||
final results = data['results'] as List<dynamic>? ?? [];
|
||||
|
||||
final list = results
|
||||
.map((item) => AurPackage.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Сортируем по количеству голосов
|
||||
list.sort((a, b) => b.numVotes.compareTo(a.numVotes));
|
||||
return list;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback через curl
|
||||
try {
|
||||
final curlRes = await Process.run('curl', [
|
||||
'-s',
|
||||
'--max-time',
|
||||
'5',
|
||||
'https://aur.archlinux.org/rpc/v5/search/${Uri.encodeComponent(query.trim())}',
|
||||
]);
|
||||
if (curlRes.exitCode == 0) {
|
||||
final Map<String, dynamic> data = jsonDecode(curlRes.stdout.toString());
|
||||
final results = data['results'] as List<dynamic>? ?? [];
|
||||
final list = results
|
||||
.map((item) => AurPackage.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
list.sort((a, b) => b.numVotes.compareTo(a.numVotes));
|
||||
return list;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Сборка и установка пакета AUR через найденный хелпер (paru / yay)
|
||||
static Stream<ProgressEvent> installPackage(String packageName) async* {
|
||||
final helper = await getHelper();
|
||||
if (helper == null) {
|
||||
yield const ProgressEvent(
|
||||
step: UpdateStep.failed,
|
||||
percentage: 1.0,
|
||||
message: 'Не найден AUR-хелпер (установите yay или paru)',
|
||||
logLine: '[ERROR] Neither yay nor paru is installed on this system.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
yield ProgressEvent(
|
||||
step: UpdateStep.checkingDependencies,
|
||||
packageName: packageName,
|
||||
percentage: 0.1,
|
||||
message: 'Подготовка к сборке $packageName через $helper...',
|
||||
logLine: ':: Resolving AUR dependencies using $helper for $packageName...',
|
||||
);
|
||||
|
||||
try {
|
||||
final proc = await Process.start(
|
||||
helper,
|
||||
['-S', '--noconfirm', packageName],
|
||||
environment: {'LC_ALL': 'C'},
|
||||
);
|
||||
|
||||
final controller = StreamController<String>();
|
||||
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
|
||||
(line) => controller.add(line),
|
||||
onError: (e) => controller.add('[STDOUT ERR] $e'),
|
||||
);
|
||||
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
|
||||
(line) => controller.add('[STDERR] $line'),
|
||||
onError: (e) => controller.add('[STDERR ERR] $e'),
|
||||
);
|
||||
proc.exitCode.then((_) {
|
||||
Future.delayed(const Duration(milliseconds: 60), () {
|
||||
if (!controller.isClosed) controller.close();
|
||||
});
|
||||
});
|
||||
|
||||
await for (final line in controller.stream) {
|
||||
yield ProgressEvent(
|
||||
step: UpdateStep.installing,
|
||||
packageName: packageName,
|
||||
percentage: 0.6,
|
||||
message: 'Сборка и установка $packageName...',
|
||||
logLine: line,
|
||||
);
|
||||
}
|
||||
|
||||
final exitCode = await proc.exitCode;
|
||||
if (exitCode == 0) {
|
||||
yield ProgressEvent(
|
||||
step: UpdateStep.done,
|
||||
packageName: packageName,
|
||||
percentage: 1.0,
|
||||
message: 'Пакет $packageName успешно собран и установлен!',
|
||||
logLine: ':: AUR installation finished successfully.',
|
||||
);
|
||||
} else {
|
||||
yield ProgressEvent(
|
||||
step: UpdateStep.failed,
|
||||
packageName: packageName,
|
||||
percentage: 1.0,
|
||||
message: 'Ошибка при установке $packageName (код $exitCode)',
|
||||
logLine: '[ERROR] $helper exited with code $exitCode',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
yield ProgressEvent(
|
||||
step: UpdateStep.failed,
|
||||
packageName: packageName,
|
||||
percentage: 1.0,
|
||||
message: 'Не удалось запустить $helper: $e',
|
||||
logLine: '[ERROR] Process launch error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import '../rust/api/packages.dart';
|
||||
import 'admin_service.dart';
|
||||
import 'app_settings_service.dart';
|
||||
import 'aur_service.dart';
|
||||
import 'catalog_service.dart';
|
||||
import 'snapshot_service.dart';
|
||||
|
||||
class BatchInstallProgress {
|
||||
final String currentStep;
|
||||
final double percentage; // 0.0 .. 1.0
|
||||
final String? logLine;
|
||||
final bool isCompleted;
|
||||
final bool isFailed;
|
||||
|
||||
const BatchInstallProgress({
|
||||
required this.currentStep,
|
||||
required this.percentage,
|
||||
this.logLine,
|
||||
this.isCompleted = false,
|
||||
this.isFailed = false,
|
||||
});
|
||||
}
|
||||
|
||||
class BatchInstallService extends ChangeNotifier {
|
||||
static final BatchInstallService instance = BatchInstallService._();
|
||||
|
||||
BatchInstallService._();
|
||||
|
||||
final Map<String, CatalogApp> _queue = {};
|
||||
bool _isInstalling = false;
|
||||
|
||||
bool get isInstalling => _isInstalling;
|
||||
List<CatalogApp> get queueApps => _queue.values.toList();
|
||||
int get count => _queue.length;
|
||||
bool get isEmpty => _queue.isEmpty;
|
||||
|
||||
List<CatalogApp> get pacmanApps =>
|
||||
_queue.values.where((a) => a.backend == BackendType.pacman).toList();
|
||||
List<CatalogApp> get aurApps =>
|
||||
_queue.values.where((a) => a.backend == BackendType.aur).toList();
|
||||
List<CatalogApp> get flatpakApps =>
|
||||
_queue.values.where((a) => a.backend == BackendType.flatpak).toList();
|
||||
|
||||
bool contains(CatalogApp app) => _queue.containsKey(app.uniqueKey);
|
||||
|
||||
void toggle(CatalogApp app) {
|
||||
if (contains(app)) {
|
||||
_queue.remove(app.uniqueKey);
|
||||
} else {
|
||||
_queue[app.uniqueKey] = app;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void add(CatalogApp app) {
|
||||
_queue[app.uniqueKey] = app;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(CatalogApp app) {
|
||||
_queue.remove(app.uniqueKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_queue.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Запуск пакетной установки выбранных приложений
|
||||
Stream<BatchInstallProgress> runBatchInstall({
|
||||
required String? password,
|
||||
}) async* {
|
||||
if (_queue.isEmpty) return;
|
||||
|
||||
_isInstalling = true;
|
||||
notifyListeners();
|
||||
|
||||
final toInstall = List<CatalogApp>.from(_queue.values);
|
||||
final pacmanList = toInstall.where((a) => a.backend == BackendType.pacman).toList();
|
||||
final aurList = toInstall.where((a) => a.backend == BackendType.aur).toList();
|
||||
final flatpakList = toInstall.where((a) => a.backend == BackendType.flatpak).toList();
|
||||
|
||||
final totalStages = (pacmanList.isNotEmpty ? 1 : 0) +
|
||||
(aurList.isNotEmpty ? 1 : 0) +
|
||||
(flatpakList.isNotEmpty ? 1 : 0);
|
||||
|
||||
int currentStage = 0;
|
||||
bool hasErrors = false;
|
||||
|
||||
// Шаг 0: Снимок системы, если включено в настройках
|
||||
if (AppSettingsService.instance.createSnapshotBeforeUpdate && password != null && (pacmanList.isNotEmpty || aurList.isNotEmpty)) {
|
||||
yield const BatchInstallProgress(
|
||||
currentStep: 'Создание точки восстановления системы (Snapshot)...',
|
||||
percentage: 0.05,
|
||||
logLine: '📸 Создание моментального снимка системы перед пакетной установкой...',
|
||||
);
|
||||
try {
|
||||
final snap = await SnapshotService.createPreUpdateSnapshot(
|
||||
password,
|
||||
description: 'Forge batch install (${toInstall.length} apps)',
|
||||
);
|
||||
yield BatchInstallProgress(
|
||||
currentStep: 'Снимок системы создан',
|
||||
percentage: 0.08,
|
||||
logLine: snap.success ? '✅ ${snap.message}' : '⚠️ ${snap.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: 'Снимок пропущен',
|
||||
percentage: 0.08,
|
||||
logLine: '⚠️ Не удалось создать снимок: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 1: Pacman пакеты
|
||||
if (pacmanList.isNotEmpty) {
|
||||
currentStage++;
|
||||
final pkgIds = pacmanList.map((a) => a.id).toList();
|
||||
final pkgLabels = pacmanList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Установка пакетов Pacman (${pkgLabels.join(", ")})...',
|
||||
percentage: (currentStage - 0.5) / (totalStages + 0.2),
|
||||
logLine: '🚀 [Pacman] Установка: ${pkgIds.join(" ")}',
|
||||
);
|
||||
|
||||
try {
|
||||
final proc = await AdminService.runPrivileged(
|
||||
password,
|
||||
'pacman',
|
||||
['-S', '--noconfirm', '--needed', ...pkgIds],
|
||||
);
|
||||
|
||||
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
|
||||
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
await for (final line in stdoutSub) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Pacman: установка...',
|
||||
percentage: (currentStage - 0.3) / (totalStages + 0.2),
|
||||
logLine: line,
|
||||
);
|
||||
}
|
||||
|
||||
await for (final errLine in stderrSub) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Pacman: обработка...',
|
||||
percentage: (currentStage - 0.2) / (totalStages + 0.2),
|
||||
logLine: errLine,
|
||||
);
|
||||
}
|
||||
|
||||
final code = await proc.exitCode;
|
||||
if (code == 0) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Pacman успешно завершен',
|
||||
percentage: currentStage / (totalStages + 0.2),
|
||||
logLine: '✅ [Pacman] Пакеты успешно установлены!',
|
||||
);
|
||||
for (final a in pacmanList) {
|
||||
a.isInstalled = true;
|
||||
_queue.remove(a.uniqueKey);
|
||||
}
|
||||
} else {
|
||||
hasErrors = true;
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Pacman завершился с ошибкой',
|
||||
percentage: currentStage / (totalStages + 0.2),
|
||||
logLine: '❌ [Pacman] Ошибка установки (код $code)',
|
||||
isFailed: true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
hasErrors = true;
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Исключение Pacman',
|
||||
percentage: currentStage / (totalStages + 0.2),
|
||||
logLine: '❌ [Pacman] Ошибка: $e',
|
||||
isFailed: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 2: AUR пакеты (через yay или paru)
|
||||
if (aurList.isNotEmpty) {
|
||||
currentStage++;
|
||||
final helper = await AurService.getHelper() ?? 'yay';
|
||||
final pkgIds = aurList.map((a) => a.id).toList();
|
||||
final pkgLabels = aurList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
|
||||
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Сборка и установка AUR ($helper: ${pkgLabels.join(", ")})...',
|
||||
percentage: (currentStage - 0.5) / (totalStages + 0.2),
|
||||
logLine: '🚀 [AUR] Сборка через $helper: ${pkgIds.join(" ")}',
|
||||
);
|
||||
|
||||
try {
|
||||
if (password != null) {
|
||||
await AdminService.verifyPassword(password);
|
||||
}
|
||||
|
||||
final proc = await Process.start(
|
||||
helper,
|
||||
['-S', '--noconfirm', '--needed', ...pkgIds],
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
|
||||
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
await for (final line in stdoutSub) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] AUR ($helper): компиляция и сборка...',
|
||||
percentage: (currentStage - 0.3) / (totalStages + 0.2),
|
||||
logLine: line,
|
||||
);
|
||||
}
|
||||
|
||||
await for (final errLine in stderrSub) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] AUR ($helper): сообщения...',
|
||||
percentage: (currentStage - 0.2) / (totalStages + 0.2),
|
||||
logLine: errLine,
|
||||
);
|
||||
}
|
||||
|
||||
final code = await proc.exitCode;
|
||||
if (code == 0) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] AUR успешно завершен',
|
||||
percentage: currentStage / (totalStages + 0.2),
|
||||
logLine: '✅ [AUR] Все пакеты успешно собраны и установлены!',
|
||||
);
|
||||
for (final a in aurList) {
|
||||
a.isInstalled = true;
|
||||
_queue.remove(a.uniqueKey);
|
||||
}
|
||||
} else {
|
||||
hasErrors = true;
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] AUR завершился с ошибкой',
|
||||
percentage: currentStage / (totalStages + 0.2),
|
||||
logLine: '❌ [AUR] Ошибка сборки пакетов (код $code)',
|
||||
isFailed: true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
hasErrors = true;
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Исключение AUR',
|
||||
percentage: currentStage / (totalStages + 0.2),
|
||||
logLine: '❌ [AUR] Ошибка: $e',
|
||||
isFailed: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 3: Flatpak пакеты
|
||||
if (flatpakList.isNotEmpty) {
|
||||
currentStage++;
|
||||
final pkgIds = flatpakList.map((a) => a.id).toList();
|
||||
final pkgLabels = flatpakList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
|
||||
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Установка Flatpak (${pkgLabels.join(", ")})...',
|
||||
percentage: (currentStage - 0.5) / (totalStages + 0.2),
|
||||
logLine: '🚀 [Flatpak] Установка из Flathub: ${pkgIds.join(" ")}',
|
||||
);
|
||||
|
||||
try {
|
||||
final proc = await Process.start(
|
||||
'flatpak',
|
||||
['install', '-y', 'flathub', ...pkgIds],
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
|
||||
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
await for (final line in stdoutSub) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Flatpak: скачивание и распаковка...',
|
||||
percentage: (currentStage - 0.3) / (totalStages + 0.2),
|
||||
logLine: line,
|
||||
);
|
||||
}
|
||||
|
||||
await for (final errLine in stderrSub) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Flatpak: логи...',
|
||||
percentage: (currentStage - 0.2) / (totalStages + 0.2),
|
||||
logLine: errLine,
|
||||
);
|
||||
}
|
||||
|
||||
final code = await proc.exitCode;
|
||||
if (code == 0) {
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Flatpak успешно завершен',
|
||||
percentage: 1.0,
|
||||
logLine: '✅ [Flatpak] Приложения успешно установлены!',
|
||||
);
|
||||
for (final a in flatpakList) {
|
||||
a.isInstalled = true;
|
||||
_queue.remove(a.uniqueKey);
|
||||
}
|
||||
} else {
|
||||
hasErrors = true;
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Flatpak завершился с кодом $code',
|
||||
percentage: 1.0,
|
||||
logLine: '❌ [Flatpak] Ошибка установки (код $code)',
|
||||
isFailed: true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
hasErrors = true;
|
||||
yield BatchInstallProgress(
|
||||
currentStep: '[$currentStage/$totalStages] Исключение Flatpak',
|
||||
percentage: 1.0,
|
||||
logLine: '❌ [Flatpak] Ошибка: $e',
|
||||
isFailed: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_isInstalling = false;
|
||||
notifyListeners();
|
||||
|
||||
if (hasErrors) {
|
||||
yield const BatchInstallProgress(
|
||||
currentStep: 'Установка завершена с ошибками',
|
||||
percentage: 1.0,
|
||||
logLine: '⚠️ Некоторые пакеты не удалось установить. Проверьте журнал ошибок выше.',
|
||||
isCompleted: true,
|
||||
isFailed: true,
|
||||
);
|
||||
} else {
|
||||
yield const BatchInstallProgress(
|
||||
currentStep: 'Пакетная установка успешно завершена',
|
||||
percentage: 1.0,
|
||||
logLine: '🎉 Все операции успешно завершены!',
|
||||
isCompleted: true,
|
||||
isFailed: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import '../rust/api/packages.dart';
|
||||
import 'aur_service.dart';
|
||||
|
||||
class CatalogApp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String summary;
|
||||
final String category;
|
||||
final BackendType backend;
|
||||
final String? iconUrl;
|
||||
final String? localIconPath;
|
||||
final IconData fallbackIcon;
|
||||
final Color accentColor;
|
||||
bool isInstalled;
|
||||
final double rating;
|
||||
final String? version;
|
||||
final int? votes;
|
||||
|
||||
CatalogApp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.summary,
|
||||
required this.category,
|
||||
required this.backend,
|
||||
this.iconUrl,
|
||||
this.localIconPath,
|
||||
required this.fallbackIcon,
|
||||
this.accentColor = const Color(0xFF0A84FF),
|
||||
this.isInstalled = false,
|
||||
this.rating = 4.8,
|
||||
this.version,
|
||||
this.votes,
|
||||
});
|
||||
|
||||
String get uniqueKey => '${backend.name}:$id';
|
||||
}
|
||||
|
||||
class CatalogService {
|
||||
static final Map<String, String> _knownIconUrls = {
|
||||
'telegram-desktop': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.telegram.desktop.png',
|
||||
'org.telegram.desktop': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.telegram.desktop.png',
|
||||
'visual-studio-code-bin': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
|
||||
'code': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
|
||||
'com.visualstudio.code': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
|
||||
'firefox': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.mozilla.firefox.png',
|
||||
'org.mozilla.firefox': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.mozilla.firefox.png',
|
||||
'google-chrome': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.google.Chrome.png',
|
||||
'com.google.Chrome': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.google.Chrome.png',
|
||||
'spotify': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.spotify.Client.png',
|
||||
'com.spotify.Client': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.spotify.Client.png',
|
||||
'discord': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.discordapp.Discord.png',
|
||||
'com.discordapp.Discord': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.discordapp.Discord.png',
|
||||
'steam': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.valvesoftware.Steam.png',
|
||||
'com.valvesoftware.Steam': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.valvesoftware.Steam.png',
|
||||
'obs-studio': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.obsproject.Studio.png',
|
||||
'com.obsproject.Studio': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.obsproject.Studio.png',
|
||||
'gimp': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.gimp.GIMP.png',
|
||||
'org.gimp.GIMP': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.gimp.GIMP.png',
|
||||
'blender': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.blender.Blender.png',
|
||||
'org.blender.Blender': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.blender.Blender.png',
|
||||
'krita': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.kde.krita.png',
|
||||
'org.kde.krita': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.kde.krita.png',
|
||||
'vlc': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.videolan.VLC.png',
|
||||
'org.videolan.VLC': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.videolan.VLC.png',
|
||||
'inkscape': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.inkscape.Inkscape.png',
|
||||
'org.inkscape.Inkscape': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.inkscape.Inkscape.png',
|
||||
'obsidian': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/md.obsidian.Obsidian.png',
|
||||
'md.obsidian.Obsidian': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/md.obsidian.Obsidian.png',
|
||||
'postman': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.getpostman.Postman.png',
|
||||
'com.getpostman.Postman': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.getpostman.Postman.png',
|
||||
'com.usebottles.bottles': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.usebottles.bottles.png',
|
||||
'com.localsend.localsend_app': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.localsend.localsend_app.png',
|
||||
'com.github.tchx84.Flatseal': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.github.tchx84.Flatseal.png',
|
||||
'neovim': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/io.neovim.nvim.png',
|
||||
'io.neovim.nvim': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/io.neovim.nvim.png',
|
||||
};
|
||||
|
||||
/// Получение кураторских подборок для витрины App Store
|
||||
static List<CatalogApp> getFeaturedApps() {
|
||||
return [
|
||||
CatalogApp(
|
||||
id: 'org.telegram.desktop',
|
||||
name: 'Telegram Desktop',
|
||||
summary: 'Официальный защищённый мессенджер с облачной синхронизацией, каналами и голосовыми чатами.',
|
||||
category: 'Сеть и общение',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['org.telegram.desktop'],
|
||||
fallbackIcon: CupertinoIcons.bubble_left_bubble_right_fill,
|
||||
accentColor: const Color(0xFF2AABEE),
|
||||
rating: 4.9,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'visual-studio-code-bin',
|
||||
name: 'Visual Studio Code',
|
||||
summary: 'Полнофункциональный и расширяемый редактор кода от Microsoft с поддержкой Git и тысяч плагинов.',
|
||||
category: 'Разработка',
|
||||
backend: BackendType.aur,
|
||||
iconUrl: _knownIconUrls['visual-studio-code-bin'],
|
||||
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
|
||||
accentColor: const Color(0xFF007ACC),
|
||||
rating: 4.9,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'org.blender.Blender',
|
||||
name: 'Blender 3D',
|
||||
summary: 'Профессиональный пакет для 3D-моделирования, анимации, симуляции и видеорендеринга.',
|
||||
category: 'Графика и 3D',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['org.blender.Blender'],
|
||||
fallbackIcon: CupertinoIcons.cube_box_fill,
|
||||
accentColor: const Color(0xFFE87D0D),
|
||||
rating: 4.95,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'com.valvesoftware.Steam',
|
||||
name: 'Steam',
|
||||
summary: 'Крупнейшая цифровая платформа для загрузки, покупки и запуска компьютерных игр.',
|
||||
category: 'Игры',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['com.valvesoftware.Steam'],
|
||||
fallbackIcon: CupertinoIcons.gamecontroller_fill,
|
||||
accentColor: const Color(0xFF171A21),
|
||||
rating: 4.8,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'org.kde.krita',
|
||||
name: 'Krita',
|
||||
summary: 'Свободный графический редактор для иллюстраторов, концепт-артистов и аниматоров.',
|
||||
category: 'Графика',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['org.kde.krita'],
|
||||
fallbackIcon: CupertinoIcons.paintbrush_fill,
|
||||
accentColor: const Color(0xFFEE3F80),
|
||||
rating: 4.9,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'com.obsproject.Studio',
|
||||
name: 'OBS Studio',
|
||||
summary: 'Бесплатная программа с открытым исходным кодом для записи видео и прямых трансляций.',
|
||||
category: 'Медиа',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['com.obsproject.Studio'],
|
||||
fallbackIcon: CupertinoIcons.video_camera_solid,
|
||||
accentColor: const Color(0xFF302E2E),
|
||||
rating: 4.85,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Категории витрины
|
||||
static Map<String, List<CatalogApp>> getCuratedCategories() {
|
||||
return {
|
||||
'Разработка и IT': [
|
||||
CatalogApp(
|
||||
id: 'neovim',
|
||||
name: 'Neovim',
|
||||
summary: 'Модальный текстовый редактор нового поколения для консоли.',
|
||||
category: 'Разработка',
|
||||
backend: BackendType.pacman,
|
||||
iconUrl: _knownIconUrls['neovim'],
|
||||
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
|
||||
accentColor: const Color(0xFF57A143),
|
||||
rating: 4.9,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'postman',
|
||||
name: 'Postman',
|
||||
summary: 'Платформа для тестирования, разработки и документирования REST API.',
|
||||
category: 'Разработка',
|
||||
backend: BackendType.aur,
|
||||
iconUrl: _knownIconUrls['postman'],
|
||||
fallbackIcon: CupertinoIcons.paperplane_fill,
|
||||
accentColor: const Color(0xFFFF6C37),
|
||||
rating: 4.7,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'git',
|
||||
name: 'Git',
|
||||
summary: 'Распределённая система управления версиями исходного кода.',
|
||||
category: 'Разработка',
|
||||
backend: BackendType.pacman,
|
||||
fallbackIcon: CupertinoIcons.arrow_branch,
|
||||
accentColor: const Color(0xFFF05032),
|
||||
rating: 5.0,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'docker',
|
||||
name: 'Docker',
|
||||
summary: 'Платформа автоматизации развёртывания приложений в контейнерах.',
|
||||
category: 'Разработка',
|
||||
backend: BackendType.pacman,
|
||||
fallbackIcon: CupertinoIcons.layers_alt_fill,
|
||||
accentColor: const Color(0xFF2496ED),
|
||||
rating: 4.8,
|
||||
),
|
||||
],
|
||||
'Творчество и Дизайн': [
|
||||
CatalogApp(
|
||||
id: 'gimp',
|
||||
name: 'GIMP',
|
||||
summary: 'Свободный графический редактор для растровых изображений и фотографий.',
|
||||
category: 'Графика',
|
||||
backend: BackendType.pacman,
|
||||
iconUrl: _knownIconUrls['gimp'],
|
||||
fallbackIcon: CupertinoIcons.paintbrush_fill,
|
||||
accentColor: const Color(0xFF5C5543),
|
||||
rating: 4.6,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'inkscape',
|
||||
name: 'Inkscape',
|
||||
summary: 'Векторный редактор профессионального уровня с открытым кодом.',
|
||||
category: 'Графика',
|
||||
backend: BackendType.pacman,
|
||||
iconUrl: _knownIconUrls['inkscape'],
|
||||
fallbackIcon: CupertinoIcons.pencil_outline,
|
||||
accentColor: const Color(0xFF000000),
|
||||
rating: 4.7,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'vlc',
|
||||
name: 'VLC Media Player',
|
||||
summary: 'Универсальный медиаплеер, воспроизводящий практически любые файлы.',
|
||||
category: 'Медиа',
|
||||
backend: BackendType.pacman,
|
||||
iconUrl: _knownIconUrls['vlc'],
|
||||
fallbackIcon: CupertinoIcons.play_circle_fill,
|
||||
accentColor: const Color(0xFFFF8800),
|
||||
rating: 4.8,
|
||||
),
|
||||
],
|
||||
'Интернет и Общение': [
|
||||
CatalogApp(
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
summary: 'Голосовые и текстовые чаты для геймеров, сообществ и друзей.',
|
||||
category: 'Сеть',
|
||||
backend: BackendType.pacman,
|
||||
iconUrl: _knownIconUrls['discord'],
|
||||
fallbackIcon: CupertinoIcons.chat_bubble_2_fill,
|
||||
accentColor: const Color(0xFF5865F2),
|
||||
rating: 4.7,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'firefox',
|
||||
name: 'Firefox',
|
||||
summary: 'Быстрый и защищённый веб-браузер с упором на конфиденциальность.',
|
||||
category: 'Сеть',
|
||||
backend: BackendType.pacman,
|
||||
iconUrl: _knownIconUrls['firefox'],
|
||||
fallbackIcon: CupertinoIcons.globe,
|
||||
accentColor: const Color(0xFFFF7139),
|
||||
rating: 4.85,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'com.localsend.localsend_app',
|
||||
name: 'LocalSend',
|
||||
summary: 'Безопасный обмен файлами по локальной сети без интернета (AirDrop для Linux).',
|
||||
category: 'Сеть',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['com.localsend.localsend_app'],
|
||||
fallbackIcon: CupertinoIcons.share,
|
||||
accentColor: const Color(0xFF00B4D8),
|
||||
rating: 4.95,
|
||||
),
|
||||
],
|
||||
'Утилиты и Система': [
|
||||
CatalogApp(
|
||||
id: 'com.usebottles.bottles',
|
||||
name: 'Bottles',
|
||||
summary: 'Запуск Windows-приложений и игр в изолированных префиксах Wine.',
|
||||
category: 'Утилиты',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['com.usebottles.bottles'],
|
||||
fallbackIcon: CupertinoIcons.tuningfork,
|
||||
accentColor: const Color(0xFF5C5F66),
|
||||
rating: 4.8,
|
||||
),
|
||||
CatalogApp(
|
||||
id: 'com.github.tchx84.Flatseal',
|
||||
name: 'Flatseal',
|
||||
summary: 'Управление разрешениями, песочницей и доступом приложений Flatpak.',
|
||||
category: 'Утилиты',
|
||||
backend: BackendType.flatpak,
|
||||
iconUrl: _knownIconUrls['com.github.tchx84.Flatseal'],
|
||||
fallbackIcon: CupertinoIcons.lock_shield_fill,
|
||||
accentColor: const Color(0xFF333333),
|
||||
rating: 4.9,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// Проверка установленных пакетов в системе
|
||||
static Future<void> checkInstalledStatus(List<CatalogApp> apps) async {
|
||||
try {
|
||||
final pacmanRes = await Process.run('pacman', ['-Qq']);
|
||||
final installedPacman = pacmanRes.exitCode == 0
|
||||
? LineSplitter.split(pacmanRes.stdout as String).map((e) => e.trim()).toSet()
|
||||
: <String>{};
|
||||
|
||||
final flatpakRes = await Process.run('flatpak', ['list', '--columns=application']);
|
||||
final installedFlatpak = flatpakRes.exitCode == 0
|
||||
? LineSplitter.split(flatpakRes.stdout as String).map((e) => e.trim()).toSet()
|
||||
: <String>{};
|
||||
|
||||
for (final app in apps) {
|
||||
if (app.backend == BackendType.flatpak) {
|
||||
app.isInstalled = installedFlatpak.contains(app.id);
|
||||
} else {
|
||||
app.isInstalled = installedPacman.contains(app.name) || installedPacman.contains(app.id);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Поиск по всем источникам (Pacman, AUR, Flatpak)
|
||||
static Future<List<CatalogApp>> searchAll(String query) async {
|
||||
final cleanQuery = query.trim();
|
||||
if (cleanQuery.isEmpty) return [];
|
||||
|
||||
final results = await Future.wait([
|
||||
_searchPacman(cleanQuery),
|
||||
_searchAur(cleanQuery),
|
||||
_searchFlatpak(cleanQuery),
|
||||
]);
|
||||
|
||||
final all = <CatalogApp>[];
|
||||
for (final list in results) {
|
||||
all.addAll(list);
|
||||
}
|
||||
|
||||
await checkInstalledStatus(all);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// Поиск в Pacman
|
||||
static Future<List<CatalogApp>> _searchPacman(String query) async {
|
||||
final List<CatalogApp> list = [];
|
||||
try {
|
||||
final res = await Process.run('pacman', ['-Ss', query], environment: {'LC_ALL': 'C'});
|
||||
if (res.exitCode == 0) {
|
||||
final lines = res.stdout.toString().split('\n');
|
||||
for (int i = 0; i < lines.length - 1; i += 2) {
|
||||
final header = lines[i].trim();
|
||||
if (header.isEmpty) continue;
|
||||
final parts = header.split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty) continue;
|
||||
|
||||
final repoName = parts[0];
|
||||
final name = repoName.contains('/') ? repoName.split('/').last : repoName;
|
||||
final version = parts.length > 1 ? parts[1] : '';
|
||||
final desc = (i + 1 < lines.length) ? lines[i + 1].trim() : '';
|
||||
|
||||
final iconUrl = _resolveIcon(name);
|
||||
|
||||
list.add(CatalogApp(
|
||||
id: name,
|
||||
name: name,
|
||||
summary: desc,
|
||||
category: repoName.split('/').first,
|
||||
backend: BackendType.pacman,
|
||||
version: version,
|
||||
iconUrl: iconUrl,
|
||||
fallbackIcon: CupertinoIcons.cube_box_fill,
|
||||
accentColor: const Color(0xFF1793D1),
|
||||
));
|
||||
|
||||
if (list.length >= 25) break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Поиск в AUR
|
||||
static Future<List<CatalogApp>> _searchAur(String query) async {
|
||||
final List<CatalogApp> list = [];
|
||||
try {
|
||||
final aurList = await AurService.search(query);
|
||||
for (final aur in aurList.take(25)) {
|
||||
final iconUrl = _resolveIcon(aur.name);
|
||||
|
||||
list.add(CatalogApp(
|
||||
id: aur.name,
|
||||
name: aur.name,
|
||||
summary: aur.description.isNotEmpty ? aur.description : 'Пакет AUR (${aur.numVotes} голосов)',
|
||||
category: 'AUR',
|
||||
backend: BackendType.aur,
|
||||
version: aur.version,
|
||||
votes: aur.numVotes,
|
||||
iconUrl: iconUrl,
|
||||
fallbackIcon: CupertinoIcons.sparkles,
|
||||
accentColor: const Color(0xFFFF9500),
|
||||
rating: (aur.popularity > 0
|
||||
? (3.5 + (aur.popularity.clamp(0, 15) / 10)).clamp(3.5, 5.0)
|
||||
: 4.2),
|
||||
));
|
||||
}
|
||||
} catch (_) {}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Поиск во Flatpak / Flathub
|
||||
static Future<List<CatalogApp>> _searchFlatpak(String query) async {
|
||||
final List<CatalogApp> list = [];
|
||||
try {
|
||||
final fpRes = await Process.run('flatpak', ['search', query, '--columns=app,name,version,description']);
|
||||
if (fpRes.exitCode == 0) {
|
||||
final lines = fpRes.stdout.toString().trim().split('\n');
|
||||
for (final line in lines.take(20)) {
|
||||
final parts = line.split('\t');
|
||||
if (parts.length >= 2) {
|
||||
final id = parts[0].trim();
|
||||
final name = parts.length > 1 ? parts[1].trim() : id;
|
||||
final version = parts.length > 2 ? parts[2].trim() : '';
|
||||
final desc = parts.length > 3 ? parts[3].trim() : '';
|
||||
|
||||
// Flathub AppStream CDN Icon URL
|
||||
final iconUrl = 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/$id.png';
|
||||
|
||||
list.add(CatalogApp(
|
||||
id: id,
|
||||
name: name,
|
||||
summary: desc.isNotEmpty ? desc : 'Приложение Flatpak ($id)',
|
||||
category: 'Flatpak',
|
||||
backend: BackendType.flatpak,
|
||||
version: version,
|
||||
iconUrl: iconUrl,
|
||||
fallbackIcon: CupertinoIcons.app_badge_fill,
|
||||
accentColor: const Color(0xFF4A90E2),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return list;
|
||||
}
|
||||
|
||||
static String? _resolveIcon(String name) {
|
||||
if (_knownIconUrls.containsKey(name)) {
|
||||
return _knownIconUrls[name];
|
||||
}
|
||||
final lower = name.toLowerCase();
|
||||
for (final entry in _knownIconUrls.entries) {
|
||||
if (entry.key.contains(lower) || lower.contains(entry.key)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import 'dart:io';
|
||||
import 'admin_service.dart';
|
||||
|
||||
enum IssueSeverity { info, warning, critical }
|
||||
|
||||
class DoctorIssue {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final IssueSeverity severity;
|
||||
final bool canAutoFix;
|
||||
final String fixButtonLabel;
|
||||
|
||||
const DoctorIssue({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.severity,
|
||||
required this.canAutoFix,
|
||||
this.fixButtonLabel = 'Исправить',
|
||||
});
|
||||
}
|
||||
|
||||
class PacnewFile {
|
||||
final String pacnewPath;
|
||||
final String originalPath;
|
||||
final String fileName;
|
||||
|
||||
const PacnewFile({
|
||||
required this.pacnewPath,
|
||||
required this.originalPath,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
Future<String> getDiff() async {
|
||||
return DoctorService.getDiff(originalPath, pacnewPath);
|
||||
}
|
||||
|
||||
Future<String> readOriginal() async {
|
||||
try {
|
||||
return await File(originalPath).readAsString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> readPacnew() async {
|
||||
try {
|
||||
return await File(pacnewPath).readAsString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DoctorReport {
|
||||
final int healthScore; // 0 - 100
|
||||
final List<DoctorIssue> issues;
|
||||
final List<PacnewFile> pacnewFiles;
|
||||
final List<String> failedServices;
|
||||
final bool isKeyringOk;
|
||||
final bool isDbLocked;
|
||||
final bool isStaleLock;
|
||||
final String? activeMirror;
|
||||
final int? mirrorPingMs;
|
||||
|
||||
const DoctorReport({
|
||||
required this.healthScore,
|
||||
required this.issues,
|
||||
required this.pacnewFiles,
|
||||
required this.failedServices,
|
||||
required this.isKeyringOk,
|
||||
required this.isDbLocked,
|
||||
required this.isStaleLock,
|
||||
this.activeMirror,
|
||||
this.mirrorPingMs,
|
||||
});
|
||||
|
||||
bool get hasNoIssues => issues.isEmpty;
|
||||
bool get dbLockExists => isDbLocked;
|
||||
bool get keyringHealthy => isKeyringOk;
|
||||
List<PacnewFile> get pacnewEntries => pacnewFiles;
|
||||
String get mirrorlistStatus => activeMirror != null
|
||||
? 'Текущее активное зеркало: $activeMirror'
|
||||
: 'Зеркала настроены по умолчанию.';
|
||||
}
|
||||
|
||||
class DoctorService {
|
||||
/// Полная диагностика состояния системы
|
||||
static Future<DoctorReport> diagnose() async {
|
||||
final List<DoctorIssue> issues = [];
|
||||
int penalties = 0;
|
||||
|
||||
// 1. Проверка замка базы pacman
|
||||
final lockFile = File('/var/lib/pacman/db.lck');
|
||||
final isDbLocked = lockFile.existsSync();
|
||||
bool isStaleLock = false;
|
||||
|
||||
if (isDbLocked) {
|
||||
// Проверяем, запущен ли pacman
|
||||
final pgrep = await Process.run('pgrep', ['-x', 'pacman']);
|
||||
if (pgrep.exitCode != 0) {
|
||||
isStaleLock = true;
|
||||
penalties += 35;
|
||||
issues.add(const DoctorIssue(
|
||||
id: 'db_lock_stale',
|
||||
title: 'Зависший замок базы данных pacman (db.lck)',
|
||||
description:
|
||||
'Файл /var/lib/pacman/db.lck существует, хотя pacman не запущен. Это блокирует любые операции установки и обновления.',
|
||||
severity: IssueSeverity.critical,
|
||||
canAutoFix: true,
|
||||
fixButtonLabel: 'Снять блокировку',
|
||||
));
|
||||
} else {
|
||||
issues.add(const DoctorIssue(
|
||||
id: 'db_lock_active',
|
||||
title: 'База pacman занята другим процессом',
|
||||
description: 'В данный момент запущен системный процесс pacman.',
|
||||
severity: IssueSeverity.info,
|
||||
canAutoFix: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Проверка ключей PGP (Keyring)
|
||||
bool isKeyringOk = true;
|
||||
try {
|
||||
final keyRes = await Process.run('pacman-key', ['-l']);
|
||||
if (keyRes.exitCode != 0 || keyRes.stdout.toString().trim().isEmpty) {
|
||||
isKeyringOk = false;
|
||||
penalties += 25;
|
||||
issues.add(const DoctorIssue(
|
||||
id: 'keyring_corrupt',
|
||||
title: 'Проблема со связкой ключей Arch Linux (Keyring)',
|
||||
description:
|
||||
'Связка ключей не инициализирована или повреждена. Это может вызывать ошибки "invalid or corrupted package (PGP signature)".',
|
||||
severity: IssueSeverity.critical,
|
||||
canAutoFix: true,
|
||||
fixButtonLabel: 'Обновить ключи',
|
||||
));
|
||||
}
|
||||
} catch (_) {
|
||||
isKeyringOk = false;
|
||||
}
|
||||
|
||||
// 3. Поиск файлов .pacnew
|
||||
final List<PacnewFile> pacnewFiles = [];
|
||||
try {
|
||||
final pacnewRes = await Process.run('find', ['/etc', '-name', '*.pacnew']);
|
||||
if (pacnewRes.exitCode == 0 && pacnewRes.stdout.toString().trim().isNotEmpty) {
|
||||
final lines = pacnewRes.stdout.toString().trim().split('\n');
|
||||
for (final line in lines) {
|
||||
final p = line.trim();
|
||||
if (p.endsWith('.pacnew')) {
|
||||
final orig = p.substring(0, p.length - 7);
|
||||
final name = p.split('/').last;
|
||||
pacnewFiles.add(PacnewFile(
|
||||
pacnewPath: p,
|
||||
originalPath: orig,
|
||||
fileName: name,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (pacnewFiles.isNotEmpty) {
|
||||
penalties += (pacnewFiles.length * 8).clamp(8, 24);
|
||||
issues.add(DoctorIssue(
|
||||
id: 'pacnew_files',
|
||||
title: 'Обнаружены неслитые конфигурации (.pacnew: ${pacnewFiles.length} шт.)',
|
||||
description:
|
||||
'При обновлениях pacman сохранил новые версии конфигурационных файлов рядом с вашими. Рекомендуется сравнить и объединить их.',
|
||||
severity: IssueSeverity.warning,
|
||||
canAutoFix: false,
|
||||
fixButtonLabel: 'Обзор файлов',
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Проверка упавших служб systemd
|
||||
final List<String> failedServices = [];
|
||||
try {
|
||||
final sysRes = await Process.run('systemctl', ['--failed', '--no-legend', '--plain']);
|
||||
if (sysRes.exitCode == 0 && sysRes.stdout.toString().trim().isNotEmpty) {
|
||||
final lines = sysRes.stdout.toString().trim().split('\n');
|
||||
for (final line in lines) {
|
||||
final parts = line.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isNotEmpty && parts.first.isNotEmpty) {
|
||||
failedServices.add(parts.first);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (failedServices.isNotEmpty) {
|
||||
penalties += (failedServices.length * 10).clamp(10, 30);
|
||||
issues.add(DoctorIssue(
|
||||
id: 'failed_services',
|
||||
title: 'Сбойные службы systemd (${failedServices.length} шт.)',
|
||||
description:
|
||||
'В системе есть службы с ошибкой запуска: ${failedServices.take(3).join(", ")}${failedServices.length > 3 ? "..." : ""}.',
|
||||
severity: IssueSeverity.warning,
|
||||
canAutoFix: false,
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Проверка текущего зеркала
|
||||
String? activeMirror;
|
||||
int? pingMs;
|
||||
try {
|
||||
final mirrorFile = File('/etc/pacman.d/mirrorlist');
|
||||
if (mirrorFile.existsSync()) {
|
||||
final lines = mirrorFile.readAsLinesSync();
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.startsWith('Server = ')) {
|
||||
activeMirror = trimmed.substring(9).split('\$').first;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final healthScore = (100 - penalties).clamp(15, 100);
|
||||
|
||||
return DoctorReport(
|
||||
healthScore: healthScore,
|
||||
issues: issues,
|
||||
pacnewFiles: pacnewFiles,
|
||||
failedServices: failedServices,
|
||||
isKeyringOk: isKeyringOk,
|
||||
isDbLocked: isDbLocked,
|
||||
isStaleLock: isStaleLock,
|
||||
activeMirror: activeMirror,
|
||||
mirrorPingMs: pingMs,
|
||||
);
|
||||
}
|
||||
|
||||
/// Снятие зависшей блокировки db.lck
|
||||
static Future<bool> removeDbLock({String? password}) async {
|
||||
try {
|
||||
final proc = await AdminService.runPrivileged(password, 'rm', ['-f', '/var/lib/pacman/db.lck']);
|
||||
return (await proc.exitCode) == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Обновление связки ключей pacman-key
|
||||
static Future<bool> fixKeyring({String? password}) async {
|
||||
try {
|
||||
final p1 = await AdminService.runPrivileged(password, 'pacman-key', ['--init']);
|
||||
await p1.exitCode;
|
||||
final p2 = await AdminService.runPrivileged(password, 'pacman-key', ['--populate', 'archlinux']);
|
||||
await p2.exitCode;
|
||||
final p3 = await AdminService.runPrivileged(password, 'pacman', ['-Sy', 'archlinux-keyring', '--noconfirm']);
|
||||
return (await p3.exitCode) == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Перезапуск упавших служб systemd
|
||||
static Future<bool> restartFailedServices(List<String> services, {String? password}) async {
|
||||
if (services.isEmpty) return true;
|
||||
try {
|
||||
final proc = await AdminService.runPrivileged(password, 'systemctl', ['reset-failed', ...services]);
|
||||
await proc.exitCode;
|
||||
final p2 = await AdminService.runPrivileged(password, 'systemctl', ['restart', ...services]);
|
||||
return (await p2.exitCode) == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Оптимизация списка зеркал через Reflector
|
||||
static Future<bool> optimizeMirrors({String? password, String? country}) async {
|
||||
try {
|
||||
final List<String> args = [
|
||||
'--latest', '10',
|
||||
'--protocol', 'https',
|
||||
'--sort', 'rate',
|
||||
'--save', '/etc/pacman.d/mirrorlist',
|
||||
];
|
||||
if (country != null && country.isNotEmpty) {
|
||||
args.addAll(['--country', country]);
|
||||
}
|
||||
final proc = await AdminService.runPrivileged(password, 'reflector', args);
|
||||
return (await proc.exitCode) == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Получение текстового diff между оригиналом и .pacnew
|
||||
static Future<String> getDiff(String originalPath, String pacnewPath) async {
|
||||
try {
|
||||
final res = await Process.run('diff', ['-u', originalPath, pacnewPath]);
|
||||
final output = res.stdout.toString().trim();
|
||||
return output.isNotEmpty ? output : 'Файлы идентичны по содержимому.';
|
||||
} catch (e) {
|
||||
return 'Не удалось получить различия: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// Оставить старый файл конфигурации, удалив .pacnew
|
||||
static Future<bool> resolvePacnewKeepOld(String pacnewPath, {String? password}) async {
|
||||
try {
|
||||
final rmProc = await AdminService.runPrivileged(password, 'rm', ['-f', pacnewPath]);
|
||||
return (await rmProc.exitCode) == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Заменить старый файл новым .pacnew и удалить .pacnew
|
||||
static Future<bool> resolvePacnewReplaceWithNew(String originalPath, String pacnewPath, {String? password}) async {
|
||||
try {
|
||||
final cpProc = await AdminService.runPrivileged(password, 'cp', ['-f', pacnewPath, originalPath]);
|
||||
if (await cpProc.exitCode != 0) return false;
|
||||
|
||||
final rmProc = await AdminService.runPrivileged(password, 'rm', ['-f', pacnewPath]);
|
||||
return (await rmProc.exitCode) == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка файла .pacnew: удаление или замена
|
||||
static Future<bool> resolvePacnew(
|
||||
PacnewFile file, {
|
||||
required bool replaceWithNew,
|
||||
required String? password,
|
||||
}) async {
|
||||
if (replaceWithNew) {
|
||||
return resolvePacnewReplaceWithNew(file.originalPath, file.pacnewPath, password: password);
|
||||
} else {
|
||||
return resolvePacnewKeepOld(file.pacnewPath, password: password);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:io';
|
||||
import 'admin_service.dart';
|
||||
|
||||
enum SnapshotEngine {
|
||||
snapper,
|
||||
timeshift,
|
||||
none;
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SnapshotEngine.snapper:
|
||||
return 'Snapper (Btrfs)';
|
||||
case SnapshotEngine.timeshift:
|
||||
return 'Timeshift';
|
||||
case SnapshotEngine.none:
|
||||
return 'Не обнаружен';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SnapshotResult {
|
||||
final bool success;
|
||||
final String message;
|
||||
|
||||
const SnapshotResult(this.success, this.message);
|
||||
}
|
||||
|
||||
class SnapshotService {
|
||||
static SnapshotEngine? _cachedEngine;
|
||||
|
||||
static Future<SnapshotEngine> detectEngine() async {
|
||||
if (_cachedEngine != null) return _cachedEngine!;
|
||||
|
||||
try {
|
||||
final snapperRes = await Process.run('which', ['snapper']);
|
||||
if (snapperRes.exitCode == 0 && snapperRes.stdout.toString().trim().isNotEmpty) {
|
||||
_cachedEngine = SnapshotEngine.snapper;
|
||||
return _cachedEngine!;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final timeshiftRes = await Process.run('which', ['timeshift']);
|
||||
if (timeshiftRes.exitCode == 0 && timeshiftRes.stdout.toString().trim().isNotEmpty) {
|
||||
_cachedEngine = SnapshotEngine.timeshift;
|
||||
return _cachedEngine!;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_cachedEngine = SnapshotEngine.none;
|
||||
return _cachedEngine!;
|
||||
}
|
||||
|
||||
static Future<String> getEngineDescription() async {
|
||||
final engine = await detectEngine();
|
||||
switch (engine) {
|
||||
case SnapshotEngine.snapper:
|
||||
return 'Snapper (обнаружена Btrfs-конфигурация корня /)';
|
||||
case SnapshotEngine.timeshift:
|
||||
return 'Timeshift (утилита создания точек восстановления)';
|
||||
case SnapshotEngine.none:
|
||||
return 'Утилиты снимков не найдены (установите snapper или timeshift)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Создание снимка системы перед установкой обновлений
|
||||
static Future<SnapshotResult> createPreUpdateSnapshot(
|
||||
String? password, {
|
||||
List<String>? packages,
|
||||
String? description,
|
||||
}) async {
|
||||
final engine = await detectEngine();
|
||||
if (engine == SnapshotEngine.none) {
|
||||
return const SnapshotResult(false, 'Утилиты snapper или timeshift не обнаружены в системе');
|
||||
}
|
||||
|
||||
final comment = description ?? (packages != null && packages.isNotEmpty
|
||||
? 'Forge: обновление ${packages.length} пакетов (${packages.take(3).join(", ")}${packages.length > 3 ? "..." : ""})'
|
||||
: 'Forge: обновление системы');
|
||||
|
||||
try {
|
||||
if (engine == SnapshotEngine.snapper) {
|
||||
final proc = await AdminService.runPrivileged(
|
||||
password,
|
||||
'snapper',
|
||||
['create', '-d', comment],
|
||||
);
|
||||
final code = await proc.exitCode;
|
||||
if (code == 0) {
|
||||
return SnapshotResult(true, 'Снимок Snapper успешно создан: "$comment"');
|
||||
} else {
|
||||
return SnapshotResult(false, 'Ошибка Snapper (код $code)');
|
||||
}
|
||||
} else if (engine == SnapshotEngine.timeshift) {
|
||||
final proc = await AdminService.runPrivileged(
|
||||
password,
|
||||
'timeshift',
|
||||
['--create', '--comments', comment, '--tags', 'O'],
|
||||
);
|
||||
final code = await proc.exitCode;
|
||||
if (code == 0) {
|
||||
return SnapshotResult(true, 'Снимок Timeshift успешно создан: "$comment"');
|
||||
} else {
|
||||
return SnapshotResult(false, 'Ошибка Timeshift (код $code)');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return SnapshotResult(false, 'Исключение при создании снимка: $e');
|
||||
}
|
||||
return const SnapshotResult(false, 'Не удалось создать снимок');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../rust/api/packages.dart';
|
||||
import '../rust/frb_generated.dart';
|
||||
import '../services/app_badge_controller.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import 'theme/window_theme_style.dart';
|
||||
import 'screens/cleaner_screen.dart';
|
||||
import 'screens/discover_screen.dart';
|
||||
import 'screens/doctor_screen.dart';
|
||||
import 'screens/history_screen.dart';
|
||||
import 'screens/installed_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'screens/starter_packs_screen.dart';
|
||||
import 'screens/updates_screen.dart';
|
||||
import 'widgets/package_inspector.dart';
|
||||
import 'widgets/spotlight_search_dialog.dart';
|
||||
|
||||
class MainLayout extends StatefulWidget {
|
||||
const MainLayout({super.key});
|
||||
|
||||
@override
|
||||
State<MainLayout> createState() => _MainLayoutState();
|
||||
}
|
||||
|
||||
class _MainLayoutState extends State<MainLayout> {
|
||||
int _pageIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_performInitialBadgeScan();
|
||||
}
|
||||
|
||||
Future<void> _performInitialBadgeScan() async {
|
||||
try {
|
||||
final updates = await RustBridgeApi.checkUpdates();
|
||||
AppBadgeController.instance.setUpdatesCount(updates.length);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final scan = await RustBridgeApi.scanCleaner();
|
||||
AppBadgeController.instance.setCleanerBytes(scan.totalReclaimableBytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onInspectPackage(String name, BackendType backend) {
|
||||
PackageInspectorSheet.show(
|
||||
context,
|
||||
packageName: name,
|
||||
backend: backend,
|
||||
);
|
||||
}
|
||||
|
||||
void _openSpotlight() {
|
||||
SpotlightSearchDialog.show(
|
||||
context,
|
||||
onSelectPackage: (name, backend) {
|
||||
_onInspectPackage(name, backend);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true): _openSpotlight,
|
||||
const SingleActivator(LogicalKeyboardKey.keyK, meta: true): _openSpotlight,
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: MacosWindow(
|
||||
sidebar: Sidebar(
|
||||
topOffset: 0.0, // Убираем пустой отступ macOS traffic lights на Linux
|
||||
minWidth: 200,
|
||||
startWidth: 220,
|
||||
maxWidth: 260,
|
||||
top: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde ? 4 : 8,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: macosTheme.primaryColor.withValues(alpha: 0.35),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(
|
||||
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde
|
||||
? CupertinoIcons.gear_alt_fill
|
||||
: AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.gnome
|
||||
? CupertinoIcons.app_badge_fill
|
||||
: CupertinoIcons.cube_box_fill,
|
||||
color: CupertinoColors.white,
|
||||
size: 17,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Forge',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde
|
||||
? 'KDE Breeze Edition'
|
||||
: AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.gnome
|
||||
? 'GNOME Adwaita'
|
||||
: 'macOS Cupertino',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Spotlight Search Trigger Button
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: _openSpotlight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6.5),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.07),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
MacosIcon(
|
||||
CupertinoIcons.search,
|
||||
size: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Поиск пакетов...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'⌘K',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
builder: (context, scrollController) {
|
||||
return ListenableBuilder(
|
||||
listenable: AppBadgeController.instance,
|
||||
builder: (context, _) {
|
||||
final badge = AppBadgeController.instance;
|
||||
|
||||
return SidebarItems(
|
||||
currentIndex: _pageIndex,
|
||||
onChanged: (index) {
|
||||
setState(() {
|
||||
_pageIndex = index;
|
||||
});
|
||||
},
|
||||
scrollController: scrollController,
|
||||
itemSize: SidebarItemSize.large,
|
||||
items: [
|
||||
const SidebarItem(
|
||||
label: Text('УПРАВЛЕНИЕ'),
|
||||
section: true,
|
||||
),
|
||||
SidebarItem(
|
||||
leading: const MacosIcon(CupertinoIcons.arrow_down_circle_fill),
|
||||
label: const Text('Обновления'),
|
||||
trailing: badge.updatesCount > 0
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${badge.updatesCount}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SidebarItem(
|
||||
leading: MacosIcon(CupertinoIcons.square_stack_3d_up_fill),
|
||||
label: Text('Установленные'),
|
||||
),
|
||||
const SidebarItem(
|
||||
leading: MacosIcon(CupertinoIcons.compass_fill),
|
||||
label: Text('Каталог'),
|
||||
),
|
||||
const SidebarItem(
|
||||
leading: MacosIcon(CupertinoIcons.square_grid_2x2_fill),
|
||||
label: Text('Наборы ПО'),
|
||||
),
|
||||
const SidebarItem(
|
||||
label: Text('СИСТЕМА И ОПТИМИЗАЦИЯ'),
|
||||
section: true,
|
||||
),
|
||||
SidebarItem(
|
||||
leading: const MacosIcon(CupertinoIcons.sparkles),
|
||||
label: const Text('Очистка'),
|
||||
trailing: badge.cleanerBytes > 0
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemOrange.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
AppBadgeController.formatBytes(badge.cleanerBytes),
|
||||
style: const TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.systemOrange,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SidebarItem(
|
||||
leading: MacosIcon(CupertinoIcons.bandage_fill),
|
||||
label: Text('Диагностика'),
|
||||
),
|
||||
const SidebarItem(
|
||||
leading: MacosIcon(CupertinoIcons.clock_fill),
|
||||
label: Text('История'),
|
||||
),
|
||||
const SidebarItem(
|
||||
leading: MacosIcon(CupertinoIcons.gear_alt_fill),
|
||||
label: Text('Настройки'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
bottom: MacosListTile(
|
||||
leading: const MacosIcon(
|
||||
CupertinoIcons.command,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
title: const Text(
|
||||
'Arch / Flatpak / AUR',
|
||||
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: IndexedStack(
|
||||
index: _pageIndex.clamp(0, 7),
|
||||
children: [
|
||||
UpdatesScreen(onInspectPackage: _onInspectPackage),
|
||||
InstalledScreen(onInspectPackage: _onInspectPackage),
|
||||
DiscoverScreen(onInspectPackage: _onInspectPackage),
|
||||
const StarterPacksScreen(),
|
||||
const CleanerScreen(),
|
||||
const DoctorScreen(),
|
||||
const HistoryScreen(),
|
||||
const SettingsScreen(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../rust/frb_generated.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/app_badge_controller.dart';
|
||||
import '../widgets/friendly_term.dart';
|
||||
import '../widgets/storage_breakdown_bar.dart';
|
||||
|
||||
class CleanerScreen extends StatefulWidget {
|
||||
const CleanerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CleanerScreen> createState() => _CleanerScreenState();
|
||||
}
|
||||
|
||||
class _CleanerScreenState extends State<CleanerScreen> {
|
||||
CleanerScanResult? _scanResult;
|
||||
bool _isScanning = true;
|
||||
bool _isCleaning = false;
|
||||
|
||||
bool _cleanCache = true;
|
||||
bool _cleanOrphans = true;
|
||||
bool _cleanFlatpak = true;
|
||||
bool _cleanJournal = true;
|
||||
|
||||
ProgressEvent? _currentProgress;
|
||||
final List<String> _logLines = [];
|
||||
StreamSubscription<ProgressEvent>? _cleanSub;
|
||||
final ScrollController _logScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scan();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cleanSub?.cancel();
|
||||
_logScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _scan() async {
|
||||
setState(() => _isScanning = true);
|
||||
try {
|
||||
final res = await RustBridgeApi.scanCleaner();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scanResult = res;
|
||||
_isScanning = false;
|
||||
});
|
||||
AppBadgeController.instance.setCleanerBytes(res.totalReclaimableBytes);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isScanning = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startClean() async {
|
||||
final totalBytes = _scanResult?.totalReclaimableBytes ?? 0;
|
||||
if (totalBytes <= 0) return;
|
||||
|
||||
// Запрашиваем пароль суперпользователя
|
||||
final password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Forge запрашивает права администратора',
|
||||
reason: 'Для очистки кэша пакетов pacman, удаления неиспользуемых пакетов-сирот и сжатия системных логов требуются права суперпользователя.',
|
||||
);
|
||||
|
||||
if (password == null) {
|
||||
// Отменено пользователем
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCleaning = true;
|
||||
_logLines.clear();
|
||||
_currentProgress = null;
|
||||
});
|
||||
|
||||
final stream = RustBridgeApi.runCleaner(
|
||||
password: password,
|
||||
cleanCache: _cleanCache,
|
||||
cleanOrphans: _cleanOrphans,
|
||||
cleanFlatpakUnused: _cleanFlatpak,
|
||||
cleanJournal: _cleanJournal,
|
||||
);
|
||||
|
||||
_cleanSub?.cancel();
|
||||
_cleanSub = stream.listen(
|
||||
(event) {
|
||||
setState(() {
|
||||
_currentProgress = event;
|
||||
if (event.logLine != null) {
|
||||
_logLines.add(event.logLine!);
|
||||
} else if (event.message.isNotEmpty) {
|
||||
_logLines.add(event.message);
|
||||
}
|
||||
});
|
||||
|
||||
if (_logScrollController.hasClients) {
|
||||
_logScrollController.animateTo(
|
||||
_logScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.step == UpdateStep.done || event.step == UpdateStep.failed) {
|
||||
setState(() => _isCleaning = false);
|
||||
_scan();
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
setState(() {
|
||||
_isCleaning = false;
|
||||
_logLines.add('[ERROR] $e');
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text('Очистка системы'),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ToolBarIconButton(
|
||||
label: 'Повторное сканирование',
|
||||
icon: const MacosIcon(CupertinoIcons.arrow_clockwise),
|
||||
showLabel: false,
|
||||
tooltipMessage: 'Сканировать диск',
|
||||
onPressed: _isCleaning ? null : _scan,
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isScanning) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ProgressCircle(radius: 18),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Анализ дискового пространства и кэшей...',
|
||||
style: TextStyle(color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final totalBytes = _scanResult?.totalReclaimableBytes ?? 0;
|
||||
final isZero = totalBytes == 0;
|
||||
final totalStr = CleanerScanResult.formatBytes(totalBytes);
|
||||
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
children: [
|
||||
// CleanMyMac Hero Banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isZero
|
||||
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
|
||||
: const Color(0xFF0A84FF).withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: isZero
|
||||
? CupertinoColors.systemGreen.withValues(alpha: 0.4)
|
||||
: const Color(0xFF0A84FF).withValues(alpha: 0.4),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(
|
||||
isZero ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.sparkles,
|
||||
color: isZero ? CupertinoColors.systemGreen : const Color(0xFF0A84FF),
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isZero
|
||||
? 'Система полностью очищена'
|
||||
: 'Доступно для очистки: $totalStr',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isZero
|
||||
? 'Дисковое пространство свободно от пакетов-сирот, старого кэша и избыточных логов.'
|
||||
: 'Удаление старых архивов пакетов, пакетов-сирот и сжатие журналов системы',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
onPressed: (_isCleaning || isZero) ? null : _startClean,
|
||||
child: Row(
|
||||
children: [
|
||||
if (_isCleaning) ...[
|
||||
const ProgressCircle(radius: 6),
|
||||
const SizedBox(width: 8),
|
||||
] else ...[
|
||||
MacosIcon(
|
||||
isZero ? CupertinoIcons.checkmark_alt : CupertinoIcons.trash_fill,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(_isCleaning
|
||||
? 'Очистка...'
|
||||
: isZero
|
||||
? 'Очищено'
|
||||
: 'Очистить ($totalStr)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Live Progress
|
||||
if (_isCleaning || _logLines.isNotEmpty) ...[
|
||||
_buildLiveProgressPanel(macosTheme),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// macOS Storage Breakdown Bar
|
||||
StorageBreakdownBar(
|
||||
pacmanCacheBytes: _scanResult?.pacmanCacheBytes ?? 0,
|
||||
journalBytes: _scanResult?.journalBytes ?? 0,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text(
|
||||
'КАТЕГОРИИ ОЧИСТКИ',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Card 1: Pacman Cache
|
||||
_buildCleanOptionCard(
|
||||
title: 'Кэш пакетов Pacman',
|
||||
friendlyTermKey: 'cache',
|
||||
subtitle:
|
||||
'Хранилище скачанных .pkg.tar.zst файлов. Утилита paccache очистит всё, кроме двух последних версий.',
|
||||
sizeText: CleanerScanResult.formatBytes(
|
||||
_scanResult?.pacmanCacheBytes ?? 0),
|
||||
icon: CupertinoIcons.archivebox_fill,
|
||||
iconColor: const Color(0xFF1793D1),
|
||||
value: _cleanCache,
|
||||
onChanged: (v) => setState(() => _cleanCache = v),
|
||||
),
|
||||
|
||||
// Card 2: Orphans
|
||||
_buildCleanOptionCard(
|
||||
title: 'Пакеты-сироты (Orphans)',
|
||||
friendlyTermKey: 'orphans',
|
||||
subtitle:
|
||||
'Пакеты (${_scanResult?.orphanPackages.length ?? 0} шт.), установленные как зависимости, но больше никем не используемые.',
|
||||
sizeText: CleanerScanResult.formatBytes(
|
||||
_scanResult?.orphanBytes ?? 0),
|
||||
icon: CupertinoIcons.person_crop_circle_badge_xmark,
|
||||
iconColor: CupertinoColors.systemOrange,
|
||||
value: _cleanOrphans,
|
||||
onChanged: (v) => setState(() => _cleanOrphans = v),
|
||||
),
|
||||
|
||||
// Card 3: Flatpak Unused
|
||||
_buildCleanOptionCard(
|
||||
title: 'Неиспользуемые рантаймы Flatpak',
|
||||
subtitle:
|
||||
'Старые версии базовых библиотек и SDK Flathub, оставшиеся после обновления приложений.',
|
||||
sizeText: 'По расписанию',
|
||||
icon: CupertinoIcons.app_badge_fill,
|
||||
iconColor: const Color(0xFF4A90E2),
|
||||
value: _cleanFlatpak,
|
||||
onChanged: (v) => setState(() => _cleanFlatpak = v),
|
||||
),
|
||||
|
||||
// Card 4: Journalctl
|
||||
_buildCleanOptionCard(
|
||||
title: 'Журналы systemd (Логи)',
|
||||
subtitle:
|
||||
'Архивные системные логи служб и ядра. Размер будет безопасно ограничен до 100 МБ.',
|
||||
sizeText: CleanerScanResult.formatBytes(
|
||||
_scanResult?.journalBytes ?? 0),
|
||||
icon: CupertinoIcons.doc_text_fill,
|
||||
iconColor: CupertinoColors.systemPurple,
|
||||
value: _cleanJournal,
|
||||
onChanged: (v) => setState(() => _cleanJournal = v),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCleanOptionCard({
|
||||
required String title,
|
||||
String? friendlyTermKey,
|
||||
required String subtitle,
|
||||
required String sizeText,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF222224),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(icon, color: iconColor, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (friendlyTermKey != null)
|
||||
FriendlyTerm(
|
||||
text: title,
|
||||
termKey: friendlyTermKey,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
sizeText,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: Color(0xFF0A84FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
MacosSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLiveProgressPanel(MacosThemeData theme) {
|
||||
final pct = _currentProgress?.percentage ?? 0.0;
|
||||
final message = _currentProgress?.message ?? 'Инициализация...';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: theme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (_isCleaning) ...[
|
||||
const ProgressCircle(radius: 7),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${(pct * 100).toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ProgressBar(
|
||||
value: (pct * 100).clamp(0.0, 100.0),
|
||||
height: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
height: 110,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF14171A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _logScrollController,
|
||||
itemCount: _logLines.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final line = _logLines[idx];
|
||||
return Text(
|
||||
line,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
height: 1.4,
|
||||
color: Color(0xFF34D399),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../services/aur_service.dart';
|
||||
import '../../services/batch_install_service.dart';
|
||||
import '../../services/catalog_service.dart';
|
||||
import '../widgets/app_icon_image.dart';
|
||||
import '../widgets/install_queue_bar.dart';
|
||||
|
||||
class DiscoverScreen extends StatefulWidget {
|
||||
final Function(String name, BackendType backend)? onInspectPackage;
|
||||
|
||||
const DiscoverScreen({super.key, this.onInspectPackage});
|
||||
|
||||
@override
|
||||
State<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends State<DiscoverScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _searchDebounce;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isSearching = false;
|
||||
String _searchQuery = '';
|
||||
String _selectedSource = 'Все';
|
||||
String? _aurHelper;
|
||||
|
||||
final List<CatalogApp> _featuredApps = CatalogService.getFeaturedApps();
|
||||
final Map<String, List<CatalogApp>> _curatedCategories = CatalogService.getCuratedCategories();
|
||||
List<CatalogApp> _searchResults = [];
|
||||
|
||||
final List<String> _sourceFilters = ['Все', 'Pacman', 'AUR', 'Flatpak'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initCatalog();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initCatalog() async {
|
||||
setState(() => _isLoading = true);
|
||||
final allCurated = [
|
||||
..._featuredApps,
|
||||
..._curatedCategories.values.expand((element) => element),
|
||||
];
|
||||
await CatalogService.checkInstalledStatus(allCurated);
|
||||
final helper = await AurService.getHelper();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aurHelper = helper;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_searchDebounce?.cancel();
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_isSearching = false;
|
||||
_searchResults.clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchQuery = trimmed;
|
||||
_isSearching = true;
|
||||
});
|
||||
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 320), () {
|
||||
_performSearch(trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final results = await CatalogService.searchAll(query);
|
||||
if (mounted && _searchQuery == query) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
titleWidth: 260,
|
||||
title: const Text(
|
||||
'Каталог приложений',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: 280,
|
||||
child: MacosTextField(
|
||||
controller: _searchController,
|
||||
placeholder: 'Поиск (Pacman, AUR, Flatpak)...',
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: MacosIcon(
|
||||
CupertinoIcons.search,
|
||||
size: 14,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
suffix: _searchQuery.isNotEmpty
|
||||
? CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const MacosIcon(
|
||||
CupertinoIcons.clear_circled_solid,
|
||||
size: 14,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: ProgressCircle(radius: 20));
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(28, 20, 28, 90),
|
||||
children: [
|
||||
// Mode 1: Active Search Results
|
||||
if (_searchQuery.isNotEmpty) ...[
|
||||
_buildSearchHeader(macosTheme),
|
||||
const SizedBox(height: 16),
|
||||
if (_isSearching)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: ProgressCircle(),
|
||||
),
|
||||
)
|
||||
else if (_searchResults.isEmpty)
|
||||
_buildEmptyResults()
|
||||
else
|
||||
_buildSearchResultsGrid(macosTheme),
|
||||
] else ...[
|
||||
// Mode 2: App Store Storefront
|
||||
_buildHeroBanner(macosTheme),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Section 1: Featured Apps
|
||||
_buildSectionHeader('ИЗБРАННОЕ И РЕКОМЕНДАЦИИ', 'Популярные приложения с быстрой установкой'),
|
||||
const SizedBox(height: 14),
|
||||
_buildAppsGrid(_featuredApps, macosTheme),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section 2: Curated Categories
|
||||
..._curatedCategories.entries.map((cat) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(cat.key.toUpperCase(), 'Подборка проверенного ПО'),
|
||||
const SizedBox(height: 14),
|
||||
_buildAppsGrid(cat.value, macosTheme),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Floating Queue Cart Bar at Bottom
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: InstallQueueBar(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchHeader(MacosThemeData macosTheme) {
|
||||
final filtered = _getFilteredSearchResults();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Результаты поиска по запросу «$_searchQuery»',
|
||||
style: macosTheme.typography.title1.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Найдено: ${filtered.length}',
|
||||
style: const TextStyle(fontSize: 12.5, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Filter pills: Все, Pacman, AUR, Flatpak
|
||||
Row(
|
||||
children: _sourceFilters.map((source) {
|
||||
final isSelected = _selectedSource == source;
|
||||
int count = 0;
|
||||
if (source == 'Все') {
|
||||
count = _searchResults.length;
|
||||
} else if (source == 'Pacman') {
|
||||
count = _searchResults.where((a) => a.backend == BackendType.pacman).length;
|
||||
} else if (source == 'AUR') {
|
||||
count = _searchResults.where((a) => a.backend == BackendType.aur).length;
|
||||
} else if (source == 'Flatpak') {
|
||||
count = _searchResults.where((a) => a.backend == BackendType.flatpak).length;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => setState(() => _selectedSource = source),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF0A84FF)
|
||||
: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF0A84FF)
|
||||
: macosTheme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
source == 'AUR' && _aurHelper != null
|
||||
? 'AUR ($_aurHelper) ($count)'
|
||||
: '$source ($count)',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? CupertinoColors.white : CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CatalogApp> _getFilteredSearchResults() {
|
||||
if (_selectedSource == 'Все') return _searchResults;
|
||||
if (_selectedSource == 'Pacman') {
|
||||
return _searchResults.where((a) => a.backend == BackendType.pacman).toList();
|
||||
}
|
||||
if (_selectedSource == 'AUR') {
|
||||
return _searchResults.where((a) => a.backend == BackendType.aur).toList();
|
||||
}
|
||||
if (_selectedSource == 'Flatpak') {
|
||||
return _searchResults.where((a) => a.backend == BackendType.flatpak).toList();
|
||||
}
|
||||
return _searchResults;
|
||||
}
|
||||
|
||||
Widget _buildEmptyResults() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 60),
|
||||
child: Column(
|
||||
children: [
|
||||
const MacosIcon(CupertinoIcons.search, size: 44, color: CupertinoColors.systemGrey),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'По запросу «$_searchQuery» ничего не найдено',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Попробуйте изменить поисковый запрос или переключить фильтр источников.',
|
||||
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultsGrid(MacosThemeData macosTheme) {
|
||||
final list = _getFilteredSearchResults();
|
||||
return _buildAppsGrid(list, macosTheme);
|
||||
}
|
||||
|
||||
Widget _buildHeroBanner(MacosThemeData macosTheme) {
|
||||
final heroApp = _featuredApps.first;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF0F2027),
|
||||
Color(0xFF203A43),
|
||||
Color(0xFF2C5364),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIconImage(
|
||||
iconUrl: heroApp.iconUrl,
|
||||
fallbackIcon: heroApp.fallbackIcon,
|
||||
accentColor: heroApp.accentColor,
|
||||
size: 76,
|
||||
),
|
||||
const SizedBox(width: 22),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.6)),
|
||||
),
|
||||
child: const Text(
|
||||
'ВЫБОР РЕДАКЦИИ',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
color: Color(0xFF64D2FF),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildBackendPill(heroApp.backend),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
heroApp.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
heroApp.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFFD1D1D6),
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ListenableBuilder(
|
||||
listenable: BatchInstallService.instance,
|
||||
builder: (context, _) {
|
||||
final inQueue = BatchInstallService.instance.contains(heroApp);
|
||||
return PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
secondary: inQueue,
|
||||
onPressed: heroApp.isInstalled
|
||||
? null
|
||||
: () => BatchInstallService.instance.toggle(heroApp),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MacosIcon(
|
||||
heroApp.isInstalled
|
||||
? CupertinoIcons.check_mark_circled_solid
|
||||
: (inQueue ? CupertinoIcons.check_mark : CupertinoIcons.cart_badge_plus),
|
||||
size: 15,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
heroApp.isInstalled
|
||||
? 'Установлено'
|
||||
: (inQueue ? 'В очереди' : '+ В очередь'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, String subtitle) {
|
||||
return Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppsGrid(List<CatalogApp> apps, MacosThemeData macosTheme) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final crossAxisCount = constraints.maxWidth > 950 ? 3 : (constraints.maxWidth > 650 ? 2 : 1);
|
||||
final itemWidth = (constraints.maxWidth - (crossAxisCount - 1) * 14) / crossAxisCount;
|
||||
|
||||
return Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 14,
|
||||
children: apps.map((app) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: _buildAppCard(app, macosTheme),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppCard(CatalogApp app, MacosThemeData macosTheme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor.withValues(alpha: 0.7),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.all(14),
|
||||
onPressed: () => widget.onInspectPackage?.call(app.id, app.backend),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppIconImage(
|
||||
iconUrl: app.iconUrl,
|
||||
fallbackIcon: app.fallbackIcon,
|
||||
accentColor: app.accentColor,
|
||||
size: 50,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
app.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildBackendPill(app.backend),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
app.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
height: 1.25,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
// Rating stars
|
||||
const Icon(CupertinoIcons.star_fill, size: 11, color: Color(0xFFFFCC00)),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
app.rating.toStringAsFixed(1),
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
if (app.votes != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'(${app.votes} 👍)',
|
||||
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
// Add to Cart / Queue Button
|
||||
ListenableBuilder(
|
||||
listenable: BatchInstallService.instance,
|
||||
builder: (context, _) {
|
||||
final inQueue = BatchInstallService.instance.contains(app);
|
||||
return PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: inQueue,
|
||||
onPressed: app.isInstalled
|
||||
? null
|
||||
: () => BatchInstallService.instance.toggle(app),
|
||||
child: Text(
|
||||
app.isInstalled
|
||||
? 'Установлено'
|
||||
: (inQueue ? '✓ В очереди' : '+ В очередь'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: app.isInstalled ? CupertinoColors.systemGreen : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackendPill(BackendType backend) {
|
||||
Color bg;
|
||||
Color fg;
|
||||
String text;
|
||||
|
||||
switch (backend) {
|
||||
case BackendType.pacman:
|
||||
bg = const Color(0xFF1793D1).withValues(alpha: 0.15);
|
||||
fg = const Color(0xFF1793D1);
|
||||
text = 'PACMAN';
|
||||
break;
|
||||
case BackendType.aur:
|
||||
bg = const Color(0xFFFF9500).withValues(alpha: 0.15);
|
||||
fg = const Color(0xFFFF9500);
|
||||
text = _aurHelper != null ? 'AUR ($_aurHelper)' : 'AUR';
|
||||
break;
|
||||
case BackendType.flatpak:
|
||||
bg = const Color(0xFF4A90E2).withValues(alpha: 0.15);
|
||||
fg = const Color(0xFF4A90E2);
|
||||
text = 'FLATPAK';
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart' show Divider, SelectableText;
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/doctor_service.dart';
|
||||
import '../widgets/friendly_term.dart';
|
||||
|
||||
class DoctorScreen extends StatefulWidget {
|
||||
const DoctorScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DoctorScreen> createState() => _DoctorScreenState();
|
||||
}
|
||||
|
||||
class _DoctorScreenState extends State<DoctorScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _isActionRunning = false;
|
||||
String? _actionTitle;
|
||||
final List<String> _actionLogs = [];
|
||||
final ScrollController _logScrollController = ScrollController();
|
||||
|
||||
DoctorReport? _report;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_runDiagnostics();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _runDiagnostics() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final report = await DoctorService.diagnose();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_report = report;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollLogToBottom() {
|
||||
if (_logScrollController.hasClients) {
|
||||
_logScrollController.animateTo(
|
||||
_logScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _executeAction(String title, Future<bool> Function(String password) action) async {
|
||||
final password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: title,
|
||||
reason: 'Для выполнения операций диагностики и восстановления требуются права администратора.',
|
||||
);
|
||||
|
||||
if (password == null) return;
|
||||
|
||||
setState(() {
|
||||
_isActionRunning = true;
|
||||
_actionTitle = title;
|
||||
_actionLogs.clear();
|
||||
_actionLogs.add('⚙️ Запуск: $title...');
|
||||
});
|
||||
_scrollLogToBottom();
|
||||
|
||||
try {
|
||||
final success = await action(password);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (success) {
|
||||
_actionLogs.add('✅ Операция успешно выполнена!');
|
||||
} else {
|
||||
_actionLogs.add('⚠️ Операция завершилась с ошибками или предупреждениями.');
|
||||
}
|
||||
});
|
||||
_scrollLogToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _actionLogs.add('❌ Ошибка: $e'));
|
||||
_scrollLogToBottom();
|
||||
}
|
||||
} finally {
|
||||
await _runDiagnostics();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isActionRunning = false;
|
||||
_actionTitle = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showDiffDialog(PacnewFile entry) async {
|
||||
final diff = await entry.getDiff();
|
||||
if (!mounted) return;
|
||||
|
||||
showMacosSheet(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (sheetContext) {
|
||||
return MacosSheet(
|
||||
child: Container(
|
||||
width: 760,
|
||||
height: 520,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const MacosIcon(CupertinoIcons.doc_plaintext, size: 22, color: Color(0xFF0A84FF)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Различия в конфигурации: ${entry.fileName}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
entry.originalPath,
|
||||
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: true,
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: const Text('Закрыть'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemGrey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Text('Легенда: ', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11)),
|
||||
Text('- Удаленные/старые строки', style: TextStyle(color: CupertinoColors.systemRed, fontSize: 11)),
|
||||
SizedBox(width: 14),
|
||||
Text('+ Новые строки из .pacnew', style: TextStyle(color: CupertinoColors.systemGreen, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161618),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
diff.isEmpty ? 'Файлы идентичны или не содержат текстовых различий.' : diff,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
color: Color(0xFFE5E5EA),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
secondary: true,
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_executeAction(
|
||||
'Удаление .pacnew (${entry.fileName})',
|
||||
(pwd) => DoctorService.resolvePacnewKeepOld(entry.pacnewPath, password: pwd),
|
||||
);
|
||||
},
|
||||
child: const Text('Оставить старый (.pacnew удалить)'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_executeAction(
|
||||
'Применение .pacnew (${entry.fileName})',
|
||||
(pwd) => DoctorService.resolvePacnewReplaceWithNew(entry.originalPath, entry.pacnewPath, password: pwd),
|
||||
);
|
||||
},
|
||||
child: const Text('Заменить на новый .pacnew'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text(
|
||||
'Диагностика',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ToolBarIconButton(
|
||||
label: 'Проверить снова',
|
||||
icon: const MacosIcon(CupertinoIcons.refresh),
|
||||
showLabel: false,
|
||||
tooltipMessage: 'Запустить повторное сканирование системы',
|
||||
onPressed: _isLoading || _isActionRunning ? null : _runDiagnostics,
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: ProgressCircle());
|
||||
}
|
||||
|
||||
final report = _report;
|
||||
if (report == null) {
|
||||
return const Center(child: Text('Не удалось получить отчёт диагностики'));
|
||||
}
|
||||
|
||||
final isHealthy = report.hasNoIssues;
|
||||
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
children: [
|
||||
// Header & Health Score
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: isHealthy
|
||||
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
|
||||
: CupertinoColors.systemOrange.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(
|
||||
isHealthy ? CupertinoIcons.check_mark_circled_solid : CupertinoIcons.exclamationmark_triangle_fill,
|
||||
color: isHealthy ? CupertinoColors.systemGreen : CupertinoColors.systemOrange,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isHealthy ? 'Система в отличном состоянии' : 'Обнаружены вопросы, требующие внимания',
|
||||
style: macosTheme.typography.largeTitle.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Arch Doctor сканирует целостность базы pacman, блокировки, состояние GPG-ключей, '
|
||||
'упавшие службы systemd и неслитые конфигурации .pacnew.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
secondary: true,
|
||||
onPressed: _isActionRunning ? null : _runDiagnostics,
|
||||
child: const Row(
|
||||
children: [
|
||||
MacosIcon(CupertinoIcons.arrow_clockwise, size: 14),
|
||||
SizedBox(width: 6),
|
||||
Text('Сканировать'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (_isActionRunning) ...[
|
||||
_buildRunningActionBanner(macosTheme),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Check 1: Pacman DB Lock
|
||||
_buildCheckCard(
|
||||
macosTheme: macosTheme,
|
||||
icon: CupertinoIcons.lock_fill,
|
||||
iconColor: report.dbLockExists ? CupertinoColors.systemRed : CupertinoColors.systemGreen,
|
||||
title: 'Файл блокировки базы Pacman (db.lck)',
|
||||
subtitle: report.dbLockExists
|
||||
? 'Обнаружен /var/lib/pacman/db.lck! Pacman может быть зависшим или прерванным.'
|
||||
: 'База данных pacman свободна, блокировки отсутствуют.',
|
||||
isAlert: report.dbLockExists,
|
||||
actions: report.dbLockExists
|
||||
? [
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
onPressed: _isActionRunning
|
||||
? null
|
||||
: () => _executeAction('Снятие блокировки pacman (db.lck)', (pwd) => DoctorService.removeDbLock(password: pwd)),
|
||||
child: const Text('Снять блокировку (удалить db.lck)'),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Check 2: Keyring
|
||||
_buildCheckCard(
|
||||
macosTheme: macosTheme,
|
||||
icon: CupertinoIcons.shield_fill,
|
||||
iconColor: report.keyringHealthy ? CupertinoColors.systemGreen : CupertinoColors.systemOrange,
|
||||
title: 'Связка GPG-ключей (Keyring)',
|
||||
friendlyTermKey: 'keyring',
|
||||
subtitle: report.keyringHealthy
|
||||
? 'Ключи доверия Arch Linux инициализированы и готовы к проверке сигнатур пакетов.'
|
||||
: 'Связка ключей может быть устаревшей, что приводит к ошибкам проверки подписей "invalid or corrupted package".',
|
||||
isAlert: !report.keyringHealthy,
|
||||
actions: [
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: report.keyringHealthy,
|
||||
onPressed: _isActionRunning
|
||||
? null
|
||||
: () => _executeAction('Инициализация и обновление GPG-ключей', (pwd) => DoctorService.fixKeyring(password: pwd)),
|
||||
child: const Text('Пересобрать связку ключей (pacman-key)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Check 3: Failed Services
|
||||
_buildCheckCard(
|
||||
macosTheme: macosTheme,
|
||||
icon: CupertinoIcons.gear_alt_fill,
|
||||
iconColor: report.failedServices.isEmpty ? CupertinoColors.systemGreen : CupertinoColors.systemRed,
|
||||
title: 'Службы Systemd',
|
||||
subtitle: report.failedServices.isEmpty
|
||||
? 'Все активные службы работают штатно, упавших сервисов (failed) нет.'
|
||||
: 'Обнаружено ${report.failedServices.length} упавших служб: ${report.failedServices.join(', ')}',
|
||||
isAlert: report.failedServices.isNotEmpty,
|
||||
actions: report.failedServices.isNotEmpty
|
||||
? [
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
onPressed: _isActionRunning
|
||||
? null
|
||||
: () => _executeAction('Перезапуск упавших служб', (pwd) => DoctorService.restartFailedServices(report.failedServices, password: pwd)),
|
||||
child: const Text('Попытаться перезапустить службы'),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Check 4: .pacnew files
|
||||
_buildPacnewCard(macosTheme, report),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Check 5: Mirrors & Reflector
|
||||
_buildMirrorsCard(macosTheme, report),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunningActionBanner(MacosThemeData macosTheme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const ProgressCircle(),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_actionTitle ?? 'Выполнение операции...',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
height: 120,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161618),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _logScrollController,
|
||||
itemCount: _actionLogs.length,
|
||||
itemBuilder: (context, i) => Text(
|
||||
_actionLogs[i],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: Color(0xFFD1D1D6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckCard({
|
||||
required MacosThemeData macosTheme,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String title,
|
||||
String? friendlyTermKey,
|
||||
required String subtitle,
|
||||
required bool isAlert,
|
||||
List<Widget>? actions,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isAlert ? iconColor.withValues(alpha: 0.4) : macosTheme.dividerColor.withValues(alpha: 0.7),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (friendlyTermKey != null)
|
||||
FriendlyTerm(
|
||||
text: title,
|
||||
termKey: friendlyTermKey,
|
||||
style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
|
||||
),
|
||||
if (actions != null && actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(children: actions),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPacnewCard(MacosThemeData macosTheme, DoctorReport report) {
|
||||
final hasPacnew = report.pacnewEntries.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: hasPacnew ? CupertinoColors.systemOrange.withValues(alpha: 0.4) : macosTheme.dividerColor.withValues(alpha: 0.7),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: hasPacnew
|
||||
? CupertinoColors.systemOrange.withValues(alpha: 0.15)
|
||||
: CupertinoColors.systemGreen.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
CupertinoIcons.doc_on_clipboard_fill,
|
||||
color: hasPacnew ? CupertinoColors.systemOrange : CupertinoColors.systemGreen,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const FriendlyTerm(
|
||||
text: 'Конфигурации .pacnew',
|
||||
termKey: 'pacnew',
|
||||
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
hasPacnew
|
||||
? 'Найдено ${report.pacnewEntries.length} неслитых конфигураций. При обновлениях пакетов pacman сохраняет новые шаблоны настроек с расширением .pacnew.'
|
||||
: 'Неслитых .pacnew файлов не обнаружено. Все конфигурации в /etc актуальны.',
|
||||
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasPacnew) ...[
|
||||
const SizedBox(height: 14),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 10),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: report.pacnewEntries.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 12),
|
||||
itemBuilder: (context, idx) {
|
||||
final entry = report.pacnewEntries[idx];
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.fileName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
entry.originalPath,
|
||||
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: true,
|
||||
onPressed: () => _showDiffDialog(entry),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MacosIcon(CupertinoIcons.arrow_right_arrow_left, size: 12),
|
||||
SizedBox(width: 4),
|
||||
Text('Сравнить Diff'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMirrorsCard(MacosThemeData macosTheme, DoctorReport report) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor.withValues(alpha: 0.7),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(CupertinoIcons.globe, color: Color(0xFF0A84FF), size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Оптимизация зеркал репозиториев (Reflector)',
|
||||
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
report.mirrorlistStatus,
|
||||
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: true,
|
||||
onPressed: _isActionRunning
|
||||
? null
|
||||
: () => _executeAction('Оптимизация зеркал через Reflector', (pwd) => DoctorService.optimizeMirrors(password: pwd)),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MacosIcon(CupertinoIcons.bolt_fill, size: 12, color: Color(0xFFFF9500)),
|
||||
SizedBox(width: 5),
|
||||
Text('Выбрать 10 быстрейших зеркал (Reflector)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../rust/frb_generated.dart';
|
||||
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
List<HistoryItem> _items = [];
|
||||
bool _isLoading = true;
|
||||
String _searchQuery = '';
|
||||
HistoryAction? _filterAction;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final list = await RustBridgeApi.getHistory();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_items = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _items.where((i) {
|
||||
final matchesSearch =
|
||||
i.packageName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
final matchesAction =
|
||||
_filterAction == null || i.action == _filterAction;
|
||||
return matchesSearch && matchesAction;
|
||||
}).toList();
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text('Журнал транзакций'),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: MacosPopupButton<HistoryAction?>(
|
||||
value: _filterAction,
|
||||
items: const [
|
||||
MacosPopupMenuItem(value: null, child: Text('Все действия')),
|
||||
MacosPopupMenuItem(
|
||||
value: HistoryAction.upgraded, child: Text('Обновления')),
|
||||
MacosPopupMenuItem(
|
||||
value: HistoryAction.installed, child: Text('Установки')),
|
||||
MacosPopupMenuItem(
|
||||
value: HistoryAction.removed, child: Text('Удаления')),
|
||||
],
|
||||
onChanged: (a) => setState(() => _filterAction = a),
|
||||
),
|
||||
),
|
||||
),
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: 180,
|
||||
child: MacosTextField(
|
||||
placeholder: 'Поиск по истории...',
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: MacosIcon(
|
||||
CupertinoIcons.search,
|
||||
size: 14,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: ProgressCircle(radius: 18));
|
||||
}
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'История транзакций пуста или не найдена',
|
||||
style: TextStyle(color: CupertinoColors.systemGrey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, idx) {
|
||||
final item = filtered[idx];
|
||||
|
||||
IconData icon;
|
||||
Color color;
|
||||
String actionText;
|
||||
|
||||
switch (item.action) {
|
||||
case HistoryAction.upgraded:
|
||||
icon = CupertinoIcons.arrow_up_circle_fill;
|
||||
color = const Color(0xFF0A84FF);
|
||||
actionText = 'Обновлен';
|
||||
break;
|
||||
case HistoryAction.installed:
|
||||
icon = CupertinoIcons.plus_circle_fill;
|
||||
color = CupertinoColors.systemGreen;
|
||||
actionText = 'Установлен';
|
||||
break;
|
||||
case HistoryAction.removed:
|
||||
icon = CupertinoIcons.minus_circle_fill;
|
||||
color = CupertinoColors.systemRed;
|
||||
actionText = 'Удален';
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF222224),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
MacosIcon(icon, color: color, size: 22),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.packageName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
actionText,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
item.timestamp,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
if (item.oldVersion != null)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.oldVersion!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 5),
|
||||
child: MacosIcon(
|
||||
CupertinoIcons.arrow_right,
|
||||
size: 10,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.newVersion,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
item.newVersion,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../rust/frb_generated.dart';
|
||||
|
||||
class InstalledScreen extends StatefulWidget {
|
||||
final Function(String name, BackendType backend)? onInspectPackage;
|
||||
|
||||
const InstalledScreen({
|
||||
super.key,
|
||||
this.onInspectPackage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InstalledScreen> createState() => _InstalledScreenState();
|
||||
}
|
||||
|
||||
class _InstalledScreenState extends State<InstalledScreen> {
|
||||
List<PackageInfo> _packages = [];
|
||||
String _searchQuery = '';
|
||||
bool _isLoading = true;
|
||||
BackendType? _filterBackend;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInstalled();
|
||||
}
|
||||
|
||||
Future<void> _loadInstalled() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final list =
|
||||
await RustBridgeApi.getInstalledPackages(backend: _filterBackend);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _packages.where((p) {
|
||||
return p.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text('Установленные пакеты'),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Filter Popup
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: MacosPopupButton<BackendType?>(
|
||||
value: _filterBackend,
|
||||
items: const [
|
||||
MacosPopupMenuItem(value: null, child: Text('Все бэкенды')),
|
||||
MacosPopupMenuItem(
|
||||
value: BackendType.pacman, child: Text('Pacman')),
|
||||
MacosPopupMenuItem(
|
||||
value: BackendType.flatpak, child: Text('Flatpak')),
|
||||
],
|
||||
onChanged: (b) {
|
||||
setState(() => _filterBackend = b);
|
||||
_loadInstalled();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: 220,
|
||||
child: MacosTextField(
|
||||
placeholder: 'Поиск пакета...',
|
||||
placeholderStyle: const TextStyle(
|
||||
color: CupertinoColors.systemGrey2,
|
||||
fontSize: 13,
|
||||
),
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: MacosIcon(
|
||||
CupertinoIcons.search,
|
||||
size: 14,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: ProgressCircle(radius: 16));
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, idx) {
|
||||
final pkg = filtered[idx];
|
||||
final isFlatpak = pkg.backend == BackendType.flatpak;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF222224),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
alignment: Alignment.centerLeft,
|
||||
pressedOpacity: 0.7,
|
||||
onPressed: () {
|
||||
widget.onInspectPackage?.call(pkg.name, pkg.backend);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
MacosIcon(
|
||||
isFlatpak
|
||||
? CupertinoIcons.app_badge_fill
|
||||
: CupertinoIcons.cube_box_fill,
|
||||
size: 20,
|
||||
color: isFlatpak
|
||||
? const Color(0xFF4A90E2)
|
||||
: const Color(0xFF1793D1),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
pkg.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
pkg.version,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
color: CupertinoColors.systemGrey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isFlatpak
|
||||
? const Color(0xFF4A90E2)
|
||||
.withValues(alpha: 0.12)
|
||||
: const Color(0xFF1793D1)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
pkg.backend.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFlatpak
|
||||
? const Color(0xFF4A90E2)
|
||||
: const Color(0xFF1793D1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
pkg.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Text(
|
||||
pkg.formattedSize,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const MacosIcon(
|
||||
CupertinoIcons.chevron_right,
|
||||
size: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart' show Divider;
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/app_settings_service.dart';
|
||||
import '../../services/snapshot_service.dart';
|
||||
import '../widgets/friendly_term.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _usePolkit = true;
|
||||
String _selectedBackend = 'pacman';
|
||||
bool _createSnapshotBeforeUpdate = false;
|
||||
String _windowTheme = 'auto';
|
||||
SnapshotEngine _snapshotEngine = SnapshotEngine.none;
|
||||
bool _isCreatingSnapshot = false;
|
||||
String? _snapshotMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final settings = AppSettingsService.instance;
|
||||
await settings.load();
|
||||
final engine = await SnapshotService.detectEngine();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_usePolkit = settings.usePolkit;
|
||||
_selectedBackend = settings.preferredBackend;
|
||||
_createSnapshotBeforeUpdate = settings.createSnapshotBeforeUpdate;
|
||||
_windowTheme = settings.windowTheme;
|
||||
_snapshotEngine = engine;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createManualSnapshot() async {
|
||||
final password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Создание точки восстановления системы',
|
||||
reason: 'Для создания системного снимка btrfs требуются права суперпользователя.',
|
||||
);
|
||||
|
||||
if (password == null) return;
|
||||
|
||||
setState(() {
|
||||
_isCreatingSnapshot = true;
|
||||
_snapshotMessage = null;
|
||||
});
|
||||
|
||||
final res = await SnapshotService.createPreUpdateSnapshot(
|
||||
password,
|
||||
description: 'Forge manual snapshot',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCreatingSnapshot = false;
|
||||
_snapshotMessage = res.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text('Настройки'),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
children: [
|
||||
Text(
|
||||
'Настройки системы',
|
||||
style: macosTheme.typography.largeTitle.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Group 0: Desktop Environment & Window Theme Style
|
||||
_buildSectionHeader('СТИЛЬ ИНТЕРФЕЙСА И ОКНА (DESKTOP ENVIRONMENT)'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Оформление окна и темы',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
AppSettingsService.instance.effectiveWindowThemeStyle.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
MacosPopupButton<String>(
|
||||
value: _windowTheme,
|
||||
items: [
|
||||
MacosPopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Text('Автоматически (${AppSettingsService.detectDesktopEnvironmentName()})'),
|
||||
),
|
||||
const MacosPopupMenuItem(
|
||||
value: 'kde',
|
||||
child: Text('KDE Plasma (Breeze)'),
|
||||
),
|
||||
const MacosPopupMenuItem(
|
||||
value: 'gnome',
|
||||
child: Text('GNOME (Adwaita)'),
|
||||
),
|
||||
const MacosPopupMenuItem(
|
||||
value: 'macos',
|
||||
child: Text('macOS (Apple Cupertino)'),
|
||||
),
|
||||
],
|
||||
onChanged: (val) async {
|
||||
if (val != null) {
|
||||
setState(() => _windowTheme = val);
|
||||
await AppSettingsService.instance.setWindowTheme(val);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: AppSettingsService.instance.effectiveWindowThemeStyle.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Активный профиль: ${AppSettingsService.instance.effectiveWindowThemeStyle.displayName}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppSettingsService.instance.effectiveWindowThemeStyle.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Group 1: Privileges
|
||||
_buildSectionHeader('БЕЗОПАСНОСТЬ И АВТОРИЗАЦИЯ'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Использовать PolicyKit (pkexec)',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Нативный диалог ввода пароля администратора в рабочем окружении Linux',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MacosSwitch(
|
||||
value: _usePolkit,
|
||||
onChanged: (val) async {
|
||||
setState(() => _usePolkit = val);
|
||||
await AppSettingsService.instance.setUsePolkit(val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Group 2: Package Backend
|
||||
_buildSectionHeader('ЯДРО ПАКЕТНОГО МЕНЕДЖЕРА'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Активный бэкенд',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Интерфейс ядра взаимодействия с репозиториями ОС',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MacosPopupButton<String>(
|
||||
value: _selectedBackend,
|
||||
items: const [
|
||||
MacosPopupMenuItem(
|
||||
value: 'pacman',
|
||||
child: Text('Pacman / ALPM (Arch)'),
|
||||
),
|
||||
MacosPopupMenuItem(
|
||||
value: 'flatpak',
|
||||
child: Text('Flatpak (D-Bus)'),
|
||||
),
|
||||
MacosPopupMenuItem(
|
||||
value: 'apt',
|
||||
child: Text('APT (Debian/Ubuntu)'),
|
||||
),
|
||||
],
|
||||
onChanged: (val) async {
|
||||
if (val != null) {
|
||||
setState(() => _selectedBackend = val);
|
||||
await AppSettingsService.instance.setPreferredBackend(val);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Group 3: Recovery Snapshots
|
||||
_buildSectionHeader('ТОЧКИ ВОССТАНОВЛЕНИЯ СИСТЕМЫ (SNAPSHOTS)'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
FriendlyTerm(
|
||||
text: 'Создавать снимки перед обновлениями',
|
||||
termKey: 'snapshots',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_snapshotEngine == SnapshotEngine.snapper
|
||||
? 'Обнаружен движок: Snapper (Btrfs root). Автоматически сохранять слепок системы до апгрейда.'
|
||||
: _snapshotEngine == SnapshotEngine.timeshift
|
||||
? 'Обнаружен движок: Timeshift. Автоматически сохранять слепок системы до апгрейда.'
|
||||
: 'Утилиты Snapper или Timeshift не обнаружены в системе. Снимки недоступны.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MacosSwitch(
|
||||
value: _createSnapshotBeforeUpdate,
|
||||
onChanged: _snapshotEngine == SnapshotEngine.none
|
||||
? null
|
||||
: (val) async {
|
||||
setState(() => _createSnapshotBeforeUpdate = val);
|
||||
await AppSettingsService.instance.setCreateSnapshotBeforeUpdate(val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_snapshotEngine != SnapshotEngine.none) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Ручное создание контрольной точки:',
|
||||
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
),
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: true,
|
||||
onPressed: _isCreatingSnapshot ? null : _createManualSnapshot,
|
||||
child: _isCreatingSnapshot
|
||||
? const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ProgressCircle(),
|
||||
SizedBox(width: 6),
|
||||
Text('Создание...'),
|
||||
],
|
||||
)
|
||||
: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MacosIcon(CupertinoIcons.camera_fill, size: 13),
|
||||
SizedBox(width: 5),
|
||||
Text('Создать снимок сейчас'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_snapshotMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_snapshotMessage!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGreen,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart' show Divider;
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
|
||||
class StarterPackItem {
|
||||
final String name;
|
||||
final String description;
|
||||
bool isInstalled;
|
||||
bool isSelected;
|
||||
|
||||
StarterPackItem({
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.isInstalled = false,
|
||||
this.isSelected = true,
|
||||
});
|
||||
}
|
||||
|
||||
class StarterPack {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final Color accentColor;
|
||||
final List<StarterPackItem> items;
|
||||
|
||||
StarterPack({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.accentColor,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
int get installedCount => items.where((i) => i.isInstalled).length;
|
||||
bool get isFullyInstalled => installedCount == items.length;
|
||||
}
|
||||
|
||||
class StarterPacksScreen extends StatefulWidget {
|
||||
const StarterPacksScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StarterPacksScreen> createState() => _StarterPacksScreenState();
|
||||
}
|
||||
|
||||
class _StarterPacksScreenState extends State<StarterPacksScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _isInstalling = false;
|
||||
String? _installingPackTitle;
|
||||
final List<String> _installLogs = [];
|
||||
final ScrollController _logScrollController = ScrollController();
|
||||
|
||||
late final List<StarterPack> _packs = [
|
||||
StarterPack(
|
||||
id: 'codecs',
|
||||
title: 'Мультимедиа кодеки и шрифты',
|
||||
description: 'Базовый набор GStreamer кодеков для воспроизведения любых аудио/видео форматов и популярные шрифты.',
|
||||
icon: CupertinoIcons.music_albums_fill,
|
||||
accentColor: const Color(0xFFFF9500),
|
||||
items: [
|
||||
StarterPackItem(name: 'gst-plugins-good', description: 'Набор свободных кодеков высокого качества'),
|
||||
StarterPackItem(name: 'gst-plugins-bad', description: 'Дополнительные современные кодеки'),
|
||||
StarterPackItem(name: 'gst-plugins-ugly', description: 'Популярные проприетарные кодеки (MP3/H.264)'),
|
||||
StarterPackItem(name: 'gst-libav', description: 'Плагин FFmpeg для GStreamer'),
|
||||
StarterPackItem(name: 'noto-fonts', description: 'Универсальные шрифты Google Noto'),
|
||||
StarterPackItem(name: 'noto-fonts-emoji', description: 'Цветные эмодзи для всех приложений'),
|
||||
StarterPackItem(name: 'ttf-dejavu', description: 'Классические четкие шрифты TrueType'),
|
||||
],
|
||||
),
|
||||
StarterPack(
|
||||
id: 'gaming',
|
||||
title: 'Игровой набор (Gaming Suite)',
|
||||
description: 'Все необходимое для запуска игр в Linux: Steam, менеджер Lutris, оптимизатор GameMode и мониторинг FPS.',
|
||||
icon: CupertinoIcons.gamecontroller_fill,
|
||||
accentColor: const Color(0xFFFF2D55),
|
||||
items: [
|
||||
StarterPackItem(name: 'steam', description: 'Крупнейшая платформа цифровой дистрибуции игр'),
|
||||
StarterPackItem(name: 'gamemode', description: 'Демон оптимизации приоритетов CPU/GPU для игр'),
|
||||
StarterPackItem(name: 'mangohud', description: 'Экранный оверлей FPS, температур и нагрузки'),
|
||||
StarterPackItem(name: 'wine-staging', description: 'Слой совместимости для запуска Windows-игр и программ'),
|
||||
],
|
||||
),
|
||||
StarterPack(
|
||||
id: 'office',
|
||||
title: 'Офис и продуктивность',
|
||||
description: 'Полноценный офисный пакет LibreOffice, просмотрщик PDF, калькулятор и скриншотер с аннотациями.',
|
||||
icon: CupertinoIcons.doc_text_fill,
|
||||
accentColor: const Color(0xFF007AFF),
|
||||
items: [
|
||||
StarterPackItem(name: 'libreoffice-fresh', description: 'Текстовый процессор, таблицы и презентации'),
|
||||
StarterPackItem(name: 'evince', description: 'Легкий и быстрый просмотр документов PDF/DjVu'),
|
||||
StarterPackItem(name: 'flameshot', description: 'Мощный инструмент создания скриншотов со стрелками и текстом'),
|
||||
StarterPackItem(name: 'qalculate-gtk', description: 'Универсальный научный и валютный калькулятор'),
|
||||
],
|
||||
),
|
||||
StarterPack(
|
||||
id: 'developer',
|
||||
title: 'Разработчик и системные утилиты',
|
||||
description: 'Инструменты компиляции, контроль версий Git, современный терминальный редактор и контейнеры.',
|
||||
icon: CupertinoIcons.chevron_left_slash_chevron_right,
|
||||
accentColor: const Color(0xFF5856D6),
|
||||
items: [
|
||||
StarterPackItem(name: 'base-devel', description: 'Компиляторы GCC, make, patch и утилиты сборки'),
|
||||
StarterPackItem(name: 'git', description: 'Распределенная система контроля версий'),
|
||||
StarterPackItem(name: 'neovim', description: 'Быстрый и расширяемый терминальный текстовый редактор'),
|
||||
StarterPackItem(name: 'jq', description: 'Утилита для разбора и форматирования JSON в консоли'),
|
||||
StarterPackItem(name: 'htop', description: 'Интерактивный просмотрщик системных процессов'),
|
||||
StarterPackItem(name: 'curl', description: 'Инструмент командной строки для передачи данных по URL'),
|
||||
],
|
||||
),
|
||||
StarterPack(
|
||||
id: 'media',
|
||||
title: 'Графика и видеомонтаж',
|
||||
description: 'Программы для творческой работы: монтаж, растровое и векторное рисование, запись экрана.',
|
||||
icon: CupertinoIcons.film_fill,
|
||||
accentColor: const Color(0xFFAF52DE),
|
||||
items: [
|
||||
StarterPackItem(name: 'vlc', description: 'Медиаплеер, читающий любые форматы без настроек'),
|
||||
StarterPackItem(name: 'gimp', description: 'Свободный аналог Photoshop для редактирования фото'),
|
||||
StarterPackItem(name: 'inkscape', description: 'Профессиональный редактор векторной графики SVG'),
|
||||
StarterPackItem(name: 'obs-studio', description: 'Запись экрана и онлайн-трансляции'),
|
||||
],
|
||||
),
|
||||
StarterPack(
|
||||
id: 'internet',
|
||||
title: 'Интернет и коммуникация',
|
||||
description: 'Браузер, мессенджер, почтовый клиент и безопасный торрент-клиент.',
|
||||
icon: CupertinoIcons.bubble_left_bubble_right_fill,
|
||||
accentColor: const Color(0xFF34C759),
|
||||
items: [
|
||||
StarterPackItem(name: 'telegram-desktop', description: 'Официальный клиент мессенджера Telegram'),
|
||||
StarterPackItem(name: 'firefox', description: 'Быстрый и приватный веб-браузер с открытым кодом'),
|
||||
StarterPackItem(name: 'thunderbird', description: 'Почтовый клиент, календарь и адресная книга'),
|
||||
StarterPackItem(name: 'transmission-gtk', description: 'Минималистичный BitTorrent клиент без рекламы'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scanInstalled();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _scanInstalled() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final res = await Process.run('pacman', ['-Qq']);
|
||||
final installedSet = res.exitCode == 0
|
||||
? LineSplitter.split(res.stdout as String).map((e) => e.trim()).toSet()
|
||||
: <String>{};
|
||||
|
||||
for (final pack in _packs) {
|
||||
for (final item in pack.items) {
|
||||
item.isInstalled = installedSet.contains(item.name);
|
||||
item.isSelected = !item.isInstalled;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _installPack(StarterPack pack) async {
|
||||
final toInstall = pack.items.where((i) => !i.isInstalled && i.isSelected).map((i) => i.name).toList();
|
||||
if (toInstall.isEmpty) return;
|
||||
|
||||
final password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Установка набора «${pack.title}»',
|
||||
reason: 'Для установки ${toInstall.length} пакетов требуются права суперпользователя.',
|
||||
);
|
||||
|
||||
if (password == null) return;
|
||||
|
||||
setState(() {
|
||||
_isInstalling = true;
|
||||
_installingPackTitle = pack.title;
|
||||
_installLogs.clear();
|
||||
_installLogs.add('🚀 Начало установки набора: ${pack.title}...');
|
||||
_installLogs.add('📦 Выбранные пакеты: ${toInstall.join(', ')}');
|
||||
});
|
||||
|
||||
try {
|
||||
final process = await Process.start(
|
||||
'sudo',
|
||||
['-S', 'pacman', '-S', '--noconfirm', '--needed', ...toInstall],
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
process.stdin.writeln(password);
|
||||
await process.stdin.flush();
|
||||
|
||||
process.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
|
||||
if (mounted) {
|
||||
setState(() => _installLogs.add(line));
|
||||
_scrollLogToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
|
||||
if (mounted) {
|
||||
setState(() => _installLogs.add('[STDERR] $line'));
|
||||
_scrollLogToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
final code = await process.exitCode;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (code == 0) {
|
||||
_installLogs.add('✅ Пакеты успешно установлены!');
|
||||
} else {
|
||||
_installLogs.add('❌ Установка завершилась с кодом ошибки $code');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _installLogs.add('❌ Ошибка: $e'));
|
||||
}
|
||||
} finally {
|
||||
await _scanInstalled();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInstalling = false;
|
||||
_installingPackTitle = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollLogToBottom() {
|
||||
if (_logScrollController.hasClients) {
|
||||
_logScrollController.animateTo(
|
||||
_logScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text(
|
||||
'Наборы ПО',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ToolBarIconButton(
|
||||
label: 'Обновить',
|
||||
icon: const MacosIcon(CupertinoIcons.refresh),
|
||||
showLabel: false,
|
||||
tooltipMessage: 'Проверить установленные пакеты',
|
||||
onPressed: _isLoading || _isInstalling ? null : _scanInstalled,
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: ProgressCircle());
|
||||
}
|
||||
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Стартовые наборы (Starter Packs)',
|
||||
style: macosTheme.typography.largeTitle.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Готовые проверенные комплекты программ для комфортного старта в Arch Linux. '
|
||||
'Выберите необходимые пакеты и установите их в 1 клик.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (_isInstalling) ...[
|
||||
_buildInstallingBanner(macosTheme),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Grid or Column of starter packs
|
||||
..._packs.map((pack) => _buildPackCard(pack, macosTheme)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInstallingBanner(MacosThemeData macosTheme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const ProgressCircle(),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Установка: ${_installingPackTitle ?? ""}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
height: 140,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161618),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _logScrollController,
|
||||
itemCount: _installLogs.length,
|
||||
itemBuilder: (context, i) => Text(
|
||||
_installLogs[i],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: Color(0xFFD1D1D6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPackCard(StarterPack pack, MacosThemeData macosTheme) {
|
||||
final neededCount = pack.items.where((i) => !i.isInstalled && i.isSelected).length;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: macosTheme.dividerColor.withValues(alpha: 0.8),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: pack.accentColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
pack.icon,
|
||||
color: pack.accentColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
pack.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: pack.isFullyInstalled
|
||||
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
|
||||
: CupertinoColors.systemGrey.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
pack.isFullyInstalled
|
||||
? '✓ Установлен'
|
||||
: '${pack.installedCount}/${pack.items.length} уст.',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: pack.isFullyInstalled
|
||||
? CupertinoColors.systemGreen
|
||||
: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
pack.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// Items list
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: pack.items.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1, indent: 46),
|
||||
itemBuilder: (context, index) {
|
||||
final item = pack.items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
MacosCheckbox(
|
||||
value: item.isInstalled || item.isSelected,
|
||||
onChanged: item.isInstalled || _isInstalling
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
item.isSelected = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
decoration: item.isInstalled ? TextDecoration.none : null,
|
||||
),
|
||||
),
|
||||
if (item.isInstalled) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Установлен',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: CupertinoColors.systemGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
item.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// Action bottom bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!pack.isFullyInstalled)
|
||||
Text(
|
||||
'Выбрано для установки: $neededCount',
|
||||
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
const Spacer(),
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
secondary: pack.isFullyInstalled,
|
||||
onPressed: pack.isFullyInstalled || neededCount == 0 || _isInstalling
|
||||
? null
|
||||
: () => _installPack(pack),
|
||||
child: Text(
|
||||
pack.isFullyInstalled
|
||||
? 'Все пакеты установлены'
|
||||
: 'Установить выбранные ($neededCount)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../rust/frb_generated.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/app_badge_controller.dart';
|
||||
import '../../services/app_settings_service.dart';
|
||||
import '../../services/arch_news_service.dart';
|
||||
import '../../services/snapshot_service.dart';
|
||||
import '../widgets/friendly_term.dart';
|
||||
|
||||
class UpdatesScreen extends StatefulWidget {
|
||||
final Function(String name, BackendType backend)? onInspectPackage;
|
||||
|
||||
const UpdatesScreen({
|
||||
super.key,
|
||||
this.onInspectPackage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UpdatesScreen> createState() => _UpdatesScreenState();
|
||||
}
|
||||
|
||||
class _UpdatesScreenState extends State<UpdatesScreen> {
|
||||
List<PackageUpdate> _updates = [];
|
||||
final Set<String> _selectedPackages = {};
|
||||
bool _isLoading = true;
|
||||
bool _isUpdating = false;
|
||||
bool _isDryRun = true;
|
||||
BackendType? _filterBackend;
|
||||
|
||||
ProgressEvent? _currentProgress;
|
||||
final List<String> _logLines = [];
|
||||
StreamSubscription<ProgressEvent>? _progressSub;
|
||||
final ScrollController _logScrollController = ScrollController();
|
||||
List<ArchNewsItem> _newsAlerts = [];
|
||||
List<ArchNewsItem> _allNews = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUpdates();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progressSub?.cancel();
|
||||
_logScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadUpdates() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final updates = await RustBridgeApi.checkUpdates(backend: _filterBackend);
|
||||
final news = await ArchNewsService.fetchNews();
|
||||
final interventions = ArchNewsService.checkInterventions(
|
||||
updates.map((e) => e.name).toList(),
|
||||
news,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_updates = updates;
|
||||
_selectedPackages.clear();
|
||||
_selectedPackages.addAll(updates.map((e) => e.name));
|
||||
_allNews = news;
|
||||
_newsAlerts = interventions;
|
||||
_isLoading = false;
|
||||
});
|
||||
AppBadgeController.instance.setUpdatesCount(updates.length);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startUpgrade() async {
|
||||
if (_selectedPackages.isEmpty) return;
|
||||
|
||||
String? password;
|
||||
final targetBackend = _filterBackend ?? BackendType.pacman;
|
||||
|
||||
// Если симуляция выключена, запрашиваем пароль администратора
|
||||
if (!_isDryRun && (targetBackend == BackendType.pacman || targetBackend == BackendType.aur)) {
|
||||
password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Forge запрашивает права администратора',
|
||||
reason: 'Для установки системных обновлений (${_selectedPackages.length} пакетов) требуются привилегии суперпользователя.',
|
||||
);
|
||||
|
||||
if (password == null) {
|
||||
// Отменено пользователем
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUpdating = true;
|
||||
_logLines.clear();
|
||||
_currentProgress = null;
|
||||
});
|
||||
|
||||
// Создание снимка системы (Snapshot), если включено в настройках
|
||||
if (!_isDryRun && AppSettingsService.instance.createSnapshotBeforeUpdate && password != null) {
|
||||
setState(() {
|
||||
_logLines.add('📸 Создание точки восстановления системы перед обновлением...');
|
||||
});
|
||||
try {
|
||||
final snapRes = await SnapshotService.createPreUpdateSnapshot(
|
||||
password,
|
||||
description: 'Forge pre-update (${_selectedPackages.length} pkgs)',
|
||||
);
|
||||
setState(() {
|
||||
_logLines.add(snapRes.success ? '✅ ${snapRes.message}' : '⚠️ ${snapRes.message}');
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_logLines.add('⚠️ Ошибка создания снимка: $e (продолжаем обновление)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final stream = RustBridgeApi.applyUpdates(
|
||||
targetBackend,
|
||||
_selectedPackages.toList(),
|
||||
isDryRun: _isDryRun,
|
||||
password: password,
|
||||
);
|
||||
|
||||
_progressSub?.cancel();
|
||||
_progressSub = stream.listen(
|
||||
(event) {
|
||||
setState(() {
|
||||
_currentProgress = event;
|
||||
if (event.logLine != null) {
|
||||
_logLines.add(event.logLine!);
|
||||
} else if (event.message.isNotEmpty) {
|
||||
_logLines.add(event.message);
|
||||
}
|
||||
});
|
||||
|
||||
if (_logScrollController.hasClients) {
|
||||
_logScrollController.animateTo(
|
||||
_logScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.step == UpdateStep.done || event.step == UpdateStep.failed) {
|
||||
setState(() => _isUpdating = false);
|
||||
_loadUpdates();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
setState(() {
|
||||
_isUpdating = false;
|
||||
_logLines.add('[ERROR] $error');
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int get _totalSelectedSize => _updates
|
||||
.where((u) => _selectedPackages.contains(u.name))
|
||||
.fold(0, (sum, u) => sum + u.downloadSizeBytes);
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '—';
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$bytes B';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macosTheme = MacosTheme.of(context);
|
||||
final sizeText =
|
||||
_totalSelectedSize > 0 ? ' • ${_formatBytes(_totalSelectedSize)}' : '';
|
||||
|
||||
return MacosScaffold(
|
||||
toolBar: ToolBar(
|
||||
title: const Text('Обновления'),
|
||||
leading: MacosTooltip(
|
||||
message: 'Боковая панель',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.sidebar_left),
|
||||
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Filter Popup
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: MacosPopupButton<BackendType?>(
|
||||
value: _filterBackend,
|
||||
items: const [
|
||||
MacosPopupMenuItem(value: null, child: Text('Все бэкенды')),
|
||||
MacosPopupMenuItem(
|
||||
value: BackendType.pacman, child: Text('Pacman (Arch)')),
|
||||
MacosPopupMenuItem(
|
||||
value: BackendType.flatpak, child: Text('Flatpak')),
|
||||
],
|
||||
onChanged: (b) {
|
||||
setState(() => _filterBackend = b);
|
||||
_loadUpdates();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ToolBarIconButton(
|
||||
label: 'Новости Arch',
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const MacosIcon(CupertinoIcons.news),
|
||||
if (_newsAlerts.isNotEmpty)
|
||||
Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: CupertinoColors.systemRed,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
showLabel: false,
|
||||
tooltipMessage: _newsAlerts.isNotEmpty
|
||||
? 'Требуется ручное вмешательство Arch Linux!'
|
||||
: 'Новости Arch Linux',
|
||||
onPressed: () => _showArchNewsDialog(context),
|
||||
),
|
||||
ToolBarIconButton(
|
||||
label: 'Проверить обновления',
|
||||
icon: const MacosIcon(CupertinoIcons.arrow_clockwise),
|
||||
showLabel: false,
|
||||
tooltipMessage: 'Проверить обновления',
|
||||
onPressed: _isUpdating ? null : _loadUpdates,
|
||||
),
|
||||
CustomToolbarItem(
|
||||
inToolbarBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
onPressed: _isUpdating || _selectedPackages.isEmpty
|
||||
? null
|
||||
: _startUpgrade,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isUpdating) ...[
|
||||
const ProgressCircle(radius: 6),
|
||||
const SizedBox(width: 6),
|
||||
] else ...[
|
||||
const MacosIcon(CupertinoIcons.arrow_down_to_line, size: 13),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(_isUpdating
|
||||
? 'Обновление...'
|
||||
: 'Обновить (${_selectedPackages.length})'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ContentArea(
|
||||
builder: (context, scrollController) {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ProgressCircle(radius: 16),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Проверка репозиториев...',
|
||||
style: TextStyle(color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
children: [
|
||||
// Header with Controls
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Доступные обновления',
|
||||
style: macosTheme.typography.largeTitle.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Найдено: ${_updates.length} • Выбрано: ${_selectedPackages.length}$sizeText',
|
||||
style: macosTheme.typography.subheadline.copyWith(
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Dry Run Switch
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: macosTheme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.1),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FriendlyTerm(
|
||||
text: 'Dry-Run',
|
||||
termKey: 'dryrun',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'(Симуляция)',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
MacosSwitch(
|
||||
value: _isDryRun,
|
||||
size: ControlSize.small,
|
||||
onChanged: (v) => setState(() => _isDryRun = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Arch News Intervention Alert Banner
|
||||
if (_newsAlerts.isNotEmpty) ...[
|
||||
_buildInterventionBanner(macosTheme),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Live Progress Panel
|
||||
if (_isUpdating || _logLines.isNotEmpty) ...[
|
||||
_buildLiveProgressPanel(macosTheme),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// List of Packages
|
||||
if (_updates.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.checkmark_seal_fill,
|
||||
size: 56,
|
||||
color: CupertinoColors.systemGreen,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Все компоненты системы обновлены',
|
||||
style: macosTheme.typography.title2.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._updates.map((pkg) => _buildPackageCard(pkg, macosTheme)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLiveProgressPanel(MacosThemeData theme) {
|
||||
final pct = _currentProgress?.percentage ?? 0.0;
|
||||
final message = _currentProgress?.message ?? 'Инициализация...';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: theme.dividerColor,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (_isUpdating) ...[
|
||||
const ProgressCircle(radius: 7),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${(pct * 100).toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ProgressBar(
|
||||
value: (pct * 100).clamp(0.0, 100.0),
|
||||
height: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// macOS Dark Terminal
|
||||
Container(
|
||||
height: 130,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF14171A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _logScrollController,
|
||||
itemCount: _logLines.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final line = _logLines[idx];
|
||||
final isErr =
|
||||
line.startsWith('[ERROR]') || line.startsWith('[STDERR]');
|
||||
return Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
height: 1.4,
|
||||
color: isErr
|
||||
? CupertinoColors.systemRed
|
||||
: const Color(0xFF34D399),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPackageCard(PackageUpdate pkg, MacosThemeData theme) {
|
||||
final isSelected = _selectedPackages.contains(pkg.name);
|
||||
final isFlatpak = pkg.backend == BackendType.flatpak;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF222224),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF0A84FF).withValues(alpha: 0.45)
|
||||
: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
pressedOpacity: 0.7,
|
||||
alignment: Alignment.centerLeft,
|
||||
onPressed: () {
|
||||
widget.onInspectPackage?.call(pkg.name, pkg.backend);
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, right: 12),
|
||||
child: MacosCheckbox(
|
||||
value: isSelected,
|
||||
onChanged: _isUpdating
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
if (val == true) {
|
||||
_selectedPackages.add(pkg.name);
|
||||
} else {
|
||||
_selectedPackages.remove(pkg.name);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
pkg.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Backend Tag
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: isFlatpak
|
||||
? const Color(0xFF4A90E2).withValues(alpha: 0.15)
|
||||
: const Color(0xFF1793D1).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
pkg.backend.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFlatpak
|
||||
? const Color(0xFF4A90E2)
|
||||
: const Color(0xFF1793D1),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (pkg.isSecurityUpdate) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemRed
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.systemRed
|
||||
.withValues(alpha: 0.4),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Безопасность',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
pkg.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
pkg.currentVersion,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: MacosIcon(
|
||||
CupertinoIcons.arrow_right,
|
||||
size: 11,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
pkg.newVersion,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF0A84FF),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const MacosIcon(
|
||||
CupertinoIcons.chevron_right,
|
||||
size: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInterventionBanner(MacosThemeData theme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemRed.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.systemRed.withValues(alpha: 0.35),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.exclamationmark_triangle_fill,
|
||||
color: CupertinoColors.systemRed,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Требуется ручное вмешательство перед обновлением!',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13.5,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
),
|
||||
),
|
||||
PushButton(
|
||||
controlSize: ControlSize.small,
|
||||
secondary: true,
|
||||
onPressed: () => _showArchNewsDialog(context),
|
||||
child: const Text('Инструкции'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Arch Linux опубликовал предупреждение для одного или нескольких обновляемых пакетов. Неправильное обновление может нарушить работу системы.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.white,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._newsAlerts.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(color: CupertinoColors.systemRed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () async {
|
||||
final uri = Uri.tryParse(item.link);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Читать на сайте ↗',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Color(0xFF0A84FF),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showArchNewsDialog(BuildContext context) {
|
||||
showMacosSheet(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => _ArchNewsSheet(
|
||||
news: _allNews,
|
||||
alerts: _newsAlerts,
|
||||
onRefresh: () async {
|
||||
final news = await ArchNewsService.fetchNews(forceRefresh: true);
|
||||
final interventions = ArchNewsService.checkInterventions(
|
||||
_updates.map((e) => e.name).toList(),
|
||||
news,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allNews = news;
|
||||
_newsAlerts = interventions;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArchNewsSheet extends StatefulWidget {
|
||||
final List<ArchNewsItem> news;
|
||||
final List<ArchNewsItem> alerts;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
const _ArchNewsSheet({
|
||||
required this.news,
|
||||
required this.alerts,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ArchNewsSheet> createState() => _ArchNewsSheetState();
|
||||
}
|
||||
|
||||
class _ArchNewsSheetState extends State<_ArchNewsSheet> {
|
||||
bool _isRefreshing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 620,
|
||||
height: 520,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.12),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.news,
|
||||
size: 20,
|
||||
color: Color(0xFF1793D1),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Новости Arch Linux & Вмешательства',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
MacosTooltip(
|
||||
message: 'Обновить ленту',
|
||||
child: MacosIconButton(
|
||||
icon: _isRefreshing
|
||||
? const ProgressCircle(radius: 7)
|
||||
: const MacosIcon(CupertinoIcons.arrow_clockwise, size: 14),
|
||||
onPressed: _isRefreshing
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isRefreshing = true);
|
||||
await widget.onRefresh();
|
||||
if (mounted) setState(() => _isRefreshing = false);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
MacosTooltip(
|
||||
message: 'Закрыть',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.xmark, size: 14),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: widget.news.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет доступных новостей или отсутствует соединение с сетью',
|
||||
style: TextStyle(color: CupertinoColors.systemGrey),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: widget.news.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, idx) {
|
||||
final item = widget.news[idx];
|
||||
final isAlert = item.isManualIntervention;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isAlert
|
||||
? CupertinoColors.systemRed.withValues(alpha: 0.08)
|
||||
: const Color(0xFF262628),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isAlert
|
||||
? CupertinoColors.systemRed.withValues(alpha: 0.3)
|
||||
: CupertinoColors.white.withValues(alpha: 0.06),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (isAlert) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemRed
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Вмешательство',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
item.pubDate,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.description,
|
||||
maxLines: 6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey2,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () async {
|
||||
final uri = Uri.tryParse(item.link);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Открыть на archlinux.org ↗',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Color(0xFF0A84FF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart' show SelectableText;
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/batch_install_service.dart';
|
||||
|
||||
class BatchInstallSheet extends StatefulWidget {
|
||||
const BatchInstallSheet({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showMacosSheet<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const BatchInstallSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<BatchInstallSheet> createState() => _BatchInstallSheetState();
|
||||
}
|
||||
|
||||
class _BatchInstallSheetState extends State<BatchInstallSheet> {
|
||||
final List<String> _logs = [];
|
||||
final ScrollController _logScrollController = ScrollController();
|
||||
StreamSubscription<BatchInstallProgress>? _sub;
|
||||
|
||||
String _currentStep = 'Инициализация пакетной установки...';
|
||||
double _percentage = 0.0;
|
||||
bool _isCompleted = false;
|
||||
bool _isFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_startInstallation();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_logScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startInstallation() async {
|
||||
final batch = BatchInstallService.instance;
|
||||
final needRoot = batch.pacmanApps.isNotEmpty || batch.aurApps.isNotEmpty;
|
||||
|
||||
String? password;
|
||||
if (needRoot) {
|
||||
password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Установка выбранных приложений (${batch.count} шт.)',
|
||||
reason: 'Для установки пакетов через pacman и сборки через AUR требуются права суперпользователя. Пароль будет запрошен только один раз.',
|
||||
);
|
||||
|
||||
if (password == null) {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final stream = batch.runBatchInstall(password: password);
|
||||
_sub = stream.listen(
|
||||
(progress) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_currentStep = progress.currentStep;
|
||||
_percentage = progress.percentage;
|
||||
if (progress.logLine != null) {
|
||||
_logs.add(progress.logLine!);
|
||||
}
|
||||
_isCompleted = progress.isCompleted;
|
||||
if (progress.isFailed) {
|
||||
_isFailed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (_logScrollController.hasClients) {
|
||||
_logScrollController.animateTo(
|
||||
_logScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_logs.add('❌ Критическая ошибка: $err');
|
||||
_isFailed = true;
|
||||
_isCompleted = true;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = _isFailed
|
||||
? CupertinoColors.systemRed
|
||||
: _isCompleted
|
||||
? CupertinoColors.systemGreen
|
||||
: const Color(0xFF0A84FF);
|
||||
|
||||
final statusIcon = _isFailed
|
||||
? CupertinoIcons.exclamationmark_triangle_fill
|
||||
: _isCompleted
|
||||
? CupertinoIcons.check_mark_circled_solid
|
||||
: CupertinoIcons.arrow_down_circle_fill;
|
||||
|
||||
final statusTitle = _isFailed
|
||||
? (_isCompleted ? 'Установка завершена с ошибками' : 'Ошибка при установке')
|
||||
: _isCompleted
|
||||
? 'Установка успешно завершена!'
|
||||
: 'Комплексная пакетная установка';
|
||||
|
||||
return MacosSheet(
|
||||
child: Container(
|
||||
width: 780,
|
||||
height: 540,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
statusIcon,
|
||||
color: statusColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
statusTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_currentStep,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _isFailed ? CupertinoColors.systemRed : CupertinoColors.systemGrey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isCompleted || _isFailed)
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Закрыть'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Progress Bar
|
||||
ProgressBar(value: (_percentage * 100).clamp(0, 100)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Console output
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161618),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _logScrollController,
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final line = _logs[i];
|
||||
Color textColor = const Color(0xFFE5E5EA);
|
||||
if (line.contains('✅') || line.contains('🎉')) {
|
||||
textColor = CupertinoColors.systemGreen;
|
||||
} else if (line.contains('❌') || line.contains('[STDERR]')) {
|
||||
textColor = const Color(0xFFFF6961);
|
||||
} else if (line.contains('🚀') || line.contains('📸')) {
|
||||
textColor = const Color(0xFF0A84FF);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1.5),
|
||||
child: SelectableText(
|
||||
line,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
color: textColor,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
|
||||
class FriendlyTermExplanation {
|
||||
final String title;
|
||||
final String humanSummary;
|
||||
final String whyItMatters;
|
||||
final String safetyNote;
|
||||
final String? example;
|
||||
final IconData icon;
|
||||
|
||||
const FriendlyTermExplanation({
|
||||
required this.title,
|
||||
required this.humanSummary,
|
||||
required this.whyItMatters,
|
||||
required this.safetyNote,
|
||||
this.example,
|
||||
this.icon = CupertinoIcons.question_circle_fill,
|
||||
});
|
||||
}
|
||||
|
||||
class FriendlyTermsDictionary {
|
||||
static final Map<String, FriendlyTermExplanation> _terms = {
|
||||
'aur': const FriendlyTermExplanation(
|
||||
title: 'Что такое AUR (Arch User Repository)?',
|
||||
icon: CupertinoIcons.cube_box_fill,
|
||||
humanSummary:
|
||||
'Это огромная онлайн-библиотека программ, поддерживаемая сообществом Arch Linux. Здесь можно найти почти любую программу, которой нет в стандартных репозиториях (например, Google Chrome, Яндекс Музыку, драйверы или темы).',
|
||||
whyItMatters:
|
||||
'Forge автоматически скачивает инструкции сборки (PKGBUILD) и компилирует пакеты прямо на вашем компьютере с помощью хелпера (yay или paru).',
|
||||
safetyNote:
|
||||
'Пакеты создаются пользователями. Forge показывает количество голосов и популярность пакета, чтобы вы могли легко выбирать проверенный софт.',
|
||||
example: 'Пакеты вроде visual-studio-code-bin или google-chrome устанавливаются именно из AUR.',
|
||||
),
|
||||
'orphans': const FriendlyTermExplanation(
|
||||
title: 'Кто такие «Пакеты-сироты» (Orphans)?',
|
||||
icon: CupertinoIcons.person_crop_circle_badge_xmark,
|
||||
humanSummary:
|
||||
'Это вспомогательные библиотеки и файлы, которые когда-то автоматически установились ради другой программы, но после её удаления остались в системе и больше никем не используются.',
|
||||
whyItMatters:
|
||||
'Они не вредят системе, но бесцельно занимают драгоценное дисковое пространство.',
|
||||
safetyNote:
|
||||
'Удалять пакеты-сироты в Forge полностью безопасно: удаляются только те файлы, от которых не зависит ни одна установленная сейчас программа.',
|
||||
example: 'Вы удалили видеоредактор, а 15 библиотек для работы с видеокодеками остались. Это и есть сироты.',
|
||||
),
|
||||
'cache': const FriendlyTermExplanation(
|
||||
title: 'Что такое Кэш пакетов Pacman?',
|
||||
icon: CupertinoIcons.archivebox_fill,
|
||||
humanSummary:
|
||||
'Каждый раз, когда вы устанавливаете или обновляете программу, pacman скачивает её установочный архив (.pkg.tar.zst) в папку /var/cache/pacman/pkg и оставляет его там.',
|
||||
whyItMatters:
|
||||
'Это позволяет в любой момент откатить программу на старую версию без доступа к интернету. Но со временем кэш может разрастись до 20-50 гигабайт.',
|
||||
safetyNote:
|
||||
'Очистка в Forge оставляет две последние версии каждого пакета на случай отката, освобождая всё остальное место.',
|
||||
example: 'Если свежее обновление сломало плеер, Forge позволит вернуть прошлую версию из этого кэша за 1 клик.',
|
||||
),
|
||||
'dryrun': const FriendlyTermExplanation(
|
||||
title: 'Что такое Dry-Run (Симуляция)?',
|
||||
icon: CupertinoIcons.shield_lefthalf_fill,
|
||||
humanSummary:
|
||||
'Режим репетиции или примерки. Менеджер пакетов проверяет все зависимости, загружает списки файлов, но НЕ вносит никаких реальных изменений на ваш диск.',
|
||||
whyItMatters:
|
||||
'Позволяет заранее увидеть, какие пакеты установятся, сколько места потребуется и не возникнет ли конфликтов библиотек, без риска что-то сломать.',
|
||||
safetyNote:
|
||||
'Абсолютно безопасно. В Forge включен по умолчанию на экране обновлений, чтобы вы всегда контролировали процесс.',
|
||||
example: 'Вы можете нажать «Обновить» и посмотреть журнал, не рискуя стабильностью системы.',
|
||||
),
|
||||
'pacnew': const FriendlyTermExplanation(
|
||||
title: 'Что такое файлы .pacnew?',
|
||||
icon: CupertinoIcons.doc_text_fill,
|
||||
humanSummary:
|
||||
'Когда вы редактируете системный файл (например, конфиг сети или авторизации), а разработчики выпускают обновление этого конфига — Arch Linux специально НЕ перезаписывает ваш файл, чтобы не сбросить ваши настройки, а кладет рядом файл с расширением .pacnew.',
|
||||
whyItMatters:
|
||||
'Если их не объединять месяцами, система может продолжать использовать устаревший синтаксис конфигов.',
|
||||
safetyNote:
|
||||
'Forge позволяет наглядно сравнить различия старого и нового файлов и в 1 клик заменить или оставить текущий.',
|
||||
example: '/etc/pam.d/su и рядом /etc/pam.d/su.pacnew с новыми системными правилами безопасности.',
|
||||
),
|
||||
'snapshots': const FriendlyTermExplanation(
|
||||
title: 'Что такое Снимки системы (Snapshots)?',
|
||||
icon: CupertinoIcons.camera_fill,
|
||||
humanSummary:
|
||||
'Мгновенная «фотография» всего системного диска до байта (через Btrfs / Snapper или Timeshift), занимающая минимум места.',
|
||||
whyItMatters:
|
||||
'Если после крупного обновления система перестанет загружаться или сломается графика, вы сможете мгновенно вернуть всё в состояние «до обновления» прямо из меню запуска компьютера.',
|
||||
safetyNote:
|
||||
'Снимки можно включить в Настройках Forge. Они создаются автоматически в фоновом режиме за 1 секунду перед обновлением.',
|
||||
example: 'Обновили 100 пакетов, что-то пошло не так — откатились назад за 10 секунд.',
|
||||
),
|
||||
'keyring': const FriendlyTermExplanation(
|
||||
title: 'Что такое Ключи PGP (Keyring)?',
|
||||
icon: CupertinoIcons.lock_shield_fill,
|
||||
humanSummary:
|
||||
'Цифровая подпись разработчиков Arch Linux. Каждый официальный пакет подписан персональным криптографическим ключом мейнтейнера, чтобы никто не мог подсунуть вам вирус.',
|
||||
whyItMatters:
|
||||
'Если связка ключей устарела, pacman отказывается устанавливать программы и пишет: «invalid or corrupted package (PGP signature)».',
|
||||
safetyNote:
|
||||
'Раздел «Диагностика» в Forge умеет автоматически освежать эти ключи одной кнопкой.',
|
||||
example: 'Ключи обновляются через pacman-key --refresh-keys.',
|
||||
),
|
||||
};
|
||||
|
||||
static FriendlyTermExplanation? get(String key) => _terms[key.toLowerCase()];
|
||||
}
|
||||
|
||||
class FriendlyTerm extends StatelessWidget {
|
||||
final String text;
|
||||
final String termKey;
|
||||
final String? customTitle;
|
||||
final String? customExplanation;
|
||||
final TextStyle? style;
|
||||
final bool showQuestionIcon;
|
||||
|
||||
const FriendlyTerm({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.termKey,
|
||||
this.customTitle,
|
||||
this.customExplanation,
|
||||
this.style,
|
||||
this.showQuestionIcon = true,
|
||||
});
|
||||
|
||||
void _showExplanation(BuildContext context) {
|
||||
final exp = FriendlyTermsDictionary.get(termKey) ??
|
||||
FriendlyTermExplanation(
|
||||
title: customTitle ?? text,
|
||||
humanSummary: customExplanation ?? 'Пояснение для термина $text.',
|
||||
whyItMatters: 'Понимание этого параметра помогает уверенно управлять вашей системой Arch Linux.',
|
||||
safetyNote: 'Это стандартная концепция управления пакетами.',
|
||||
);
|
||||
|
||||
showMacosSheet(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) => _FriendlyTermSheet(explanation: exp),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final defaultStyle = MacosTheme.of(context).typography.body;
|
||||
final mergedStyle = (style ?? defaultStyle).copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
decorationStyle: TextDecorationStyle.dashed,
|
||||
decorationColor: const Color(0xFF0A84FF).withValues(alpha: 0.6),
|
||||
);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showExplanation(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(text, style: mergedStyle),
|
||||
if (showQuestionIcon) ...[
|
||||
const SizedBox(width: 4),
|
||||
const MacosIcon(
|
||||
CupertinoIcons.question_circle,
|
||||
size: 13,
|
||||
color: Color(0xFF0A84FF),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FriendlyTermSheet extends StatelessWidget {
|
||||
final FriendlyTermExplanation explanation;
|
||||
|
||||
const _FriendlyTermSheet({required this.explanation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 500,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.12),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(
|
||||
explanation.icon,
|
||||
color: const Color(0xFF0A84FF),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
explanation.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.xmark, size: 14),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content Body
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCard(
|
||||
title: 'ПРОСТЫМИ СЛОВАМИ',
|
||||
content: explanation.humanSummary,
|
||||
icon: CupertinoIcons.lightbulb_fill,
|
||||
iconColor: CupertinoColors.systemYellow,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
title: 'ЗАЧЕМ ЭТО НУЖНО',
|
||||
content: explanation.whyItMatters,
|
||||
icon: CupertinoIcons.checkmark_seal_fill,
|
||||
iconColor: const Color(0xFF0A84FF),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
title: 'БЕЗОПАСНОСТЬ',
|
||||
content: explanation.safetyNote,
|
||||
icon: CupertinoIcons.shield_fill,
|
||||
iconColor: CupertinoColors.systemGreen,
|
||||
),
|
||||
if (explanation.example != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildCard(
|
||||
title: 'ПРИМЕР',
|
||||
content: explanation.example!,
|
||||
icon: CupertinoIcons.play_circle_fill,
|
||||
iconColor: CupertinoColors.systemPurple,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Close
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Понятно'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard({
|
||||
required String title,
|
||||
required String content,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF262628),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.06),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
MacosIcon(icon, size: 12, color: iconColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.6,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
content,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
height: 1.35,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../rust/frb_generated.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
|
||||
/// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога»)
|
||||
class PackageInspectorSheet extends StatefulWidget {
|
||||
final String initialPackageName;
|
||||
final BackendType initialBackend;
|
||||
|
||||
const PackageInspectorSheet({
|
||||
super.key,
|
||||
required this.initialPackageName,
|
||||
required this.initialBackend,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String packageName,
|
||||
required BackendType backend,
|
||||
}) {
|
||||
return showMacosSheet(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => PackageInspectorSheet(
|
||||
initialPackageName: packageName,
|
||||
initialBackend: backend,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<PackageInspectorSheet> createState() => _PackageInspectorSheetState();
|
||||
}
|
||||
|
||||
class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
|
||||
late String _currentPackageName;
|
||||
late BackendType _currentBackend;
|
||||
final List<({String name, BackendType backend})> _history = [];
|
||||
|
||||
PackageDetails? _details;
|
||||
List<CachedPackageVersion> _cachedVersions = [];
|
||||
List<String> _packageFiles = [];
|
||||
bool _isLoading = true;
|
||||
bool _isDeleting = false;
|
||||
bool _isDowngrading = false;
|
||||
int _selectedTab = 0; // 0: Обзор, 1: Зависимости, 2: Файлы, 3: Откат
|
||||
String _fileSearchFilter = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentPackageName = widget.initialPackageName;
|
||||
_currentBackend = widget.initialBackend;
|
||||
_loadDetails();
|
||||
}
|
||||
|
||||
Future<void> _loadDetails() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_fileSearchFilter = '';
|
||||
});
|
||||
|
||||
try {
|
||||
final data = await RustBridgeApi.getPackageDetails(
|
||||
_currentBackend,
|
||||
_currentPackageName,
|
||||
);
|
||||
|
||||
List<CachedPackageVersion> cached = [];
|
||||
List<String> files = [];
|
||||
|
||||
if (_currentBackend == BackendType.pacman) {
|
||||
try {
|
||||
cached = await RustBridgeApi.getCachedPackageVersions(_currentPackageName);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final res = await Process.run('pacman', ['-Qlq', _currentPackageName]);
|
||||
if (res.exitCode == 0) {
|
||||
files = (res.stdout as String)
|
||||
.split('\n')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_details = data;
|
||||
_cachedVersions = cached;
|
||||
_packageFiles = files;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateTo(String name, BackendType backend) {
|
||||
setState(() {
|
||||
_history.add((name: _currentPackageName, backend: _currentBackend));
|
||||
_currentPackageName = name;
|
||||
_currentBackend = backend;
|
||||
});
|
||||
_loadDetails();
|
||||
}
|
||||
|
||||
void _goBack() {
|
||||
if (_history.isEmpty) return;
|
||||
final prev = _history.removeLast();
|
||||
setState(() {
|
||||
_currentPackageName = prev.name;
|
||||
_currentBackend = prev.backend;
|
||||
});
|
||||
_loadDetails();
|
||||
}
|
||||
|
||||
Future<void> _openProjectUrl(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmUninstall() {
|
||||
showMacosAlertDialog(
|
||||
context: context,
|
||||
builder: (ctx) => MacosAlertDialog(
|
||||
appIcon: const MacosIcon(
|
||||
CupertinoIcons.trash,
|
||||
color: CupertinoColors.systemRed,
|
||||
size: 48,
|
||||
),
|
||||
title: Text('Удалить $_currentPackageName?'),
|
||||
message: Text(
|
||||
'Вы уверены, что хотите удалить пакет $_currentPackageName? Связанные конфигурации и неиспользуемые зависимости могут быть затронуты.',
|
||||
style: TextStyle(
|
||||
color: MacosTheme.of(ctx).typography.body.color,
|
||||
),
|
||||
),
|
||||
primaryButton: PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
color: CupertinoColors.systemRed,
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
|
||||
String? password;
|
||||
if (_currentBackend != BackendType.flatpak) {
|
||||
password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Forge запрашивает права администратора',
|
||||
reason: 'Для удаления системного пакета $_currentPackageName требуются права суперпользователя.',
|
||||
);
|
||||
if (password == null) return;
|
||||
}
|
||||
|
||||
setState(() => _isDeleting = true);
|
||||
await RustBridgeApi.uninstallPackage(
|
||||
_currentBackend,
|
||||
_currentPackageName,
|
||||
password: password,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _isDeleting = false);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Удалить', style: TextStyle(color: CupertinoColors.white)),
|
||||
),
|
||||
secondaryButton: PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
secondary: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downgradeTo(CachedPackageVersion target) async {
|
||||
final confirmed = await showMacosAlertDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => MacosAlertDialog(
|
||||
appIcon: const MacosIcon(
|
||||
CupertinoIcons.arrow_counterclockwise_circle_fill,
|
||||
color: CupertinoColors.systemOrange,
|
||||
size: 48,
|
||||
),
|
||||
title: Text('Откатить $_currentPackageName?'),
|
||||
message: Text(
|
||||
'Вы действительно хотите откатить пакет до версии ${target.version} из локального кэша?\nФайл: ${target.filePath.split('/').last}',
|
||||
style: TextStyle(
|
||||
color: MacosTheme.of(ctx).typography.body.color,
|
||||
),
|
||||
),
|
||||
primaryButton: PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
color: CupertinoColors.systemOrange,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Откатить', style: TextStyle(color: CupertinoColors.white)),
|
||||
),
|
||||
secondaryButton: PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
secondary: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final password = await AdminService.promptPassword(
|
||||
context,
|
||||
title: 'Forge запрашивает права администратора',
|
||||
reason: 'Для отката пакета $_currentPackageName версии ${target.version} требуются права суперпользователя.',
|
||||
);
|
||||
|
||||
if (password == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isDowngrading = true);
|
||||
final stream = RustBridgeApi.downgradePackage(
|
||||
_currentPackageName,
|
||||
target.filePath,
|
||||
password: password,
|
||||
);
|
||||
|
||||
bool success = false;
|
||||
await for (final event in stream) {
|
||||
if (event.step == UpdateStep.done) {
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isDowngrading = false);
|
||||
if (success) {
|
||||
_loadDetails();
|
||||
} else {
|
||||
showMacosAlertDialog(
|
||||
context: context,
|
||||
builder: (ctx) => MacosAlertDialog(
|
||||
appIcon: const MacosIcon(
|
||||
CupertinoIcons.exclamationmark_triangle,
|
||||
size: 48,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
title: const Text('Ошибка отката'),
|
||||
message: const Text('Не удалось установить выбранную версию пакета. Проверьте права и целостность архива.'),
|
||||
primaryButton: PushButton(
|
||||
controlSize: ControlSize.large,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$bytes B';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = MacosTheme.of(context);
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final sheetWidth = (screenSize.width * 0.78).clamp(580.0, 840.0);
|
||||
final sheetHeight = (screenSize.height * 0.84).clamp(500.0, 720.0);
|
||||
|
||||
return MacosSheet(
|
||||
child: Container(
|
||||
width: sheetWidth,
|
||||
height: sheetHeight,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.canvasColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.45),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(theme),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ProgressCircle(radius: 16),
|
||||
SizedBox(height: 14),
|
||||
Text(
|
||||
'Загрузка информации о пакете...',
|
||||
style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _details == null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const MacosIcon(CupertinoIcons.exclamationmark_triangle, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text('Пакет $_currentPackageName не найден в ${_currentBackend.name}'),
|
||||
const SizedBox(height: 16),
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
secondary: true,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Закрыть'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildContent(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(MacosThemeData theme) {
|
||||
final d = _details;
|
||||
final isFlatpak = _currentBackend == BackendType.flatpak;
|
||||
final isAur = _currentBackend == BackendType.aur;
|
||||
final backendColor = isFlatpak
|
||||
? const Color(0xFF4A90E2)
|
||||
: isAur
|
||||
? const Color(0xFFFF9500)
|
||||
: const Color(0xFF1793D1);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 18, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.canvasColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor, width: 0.8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_history.isNotEmpty) ...[
|
||||
MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.chevron_left, size: 18),
|
||||
onPressed: _goBack,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: backendColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: backendColor.withValues(alpha: 0.35),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(
|
||||
isFlatpak
|
||||
? CupertinoIcons.app_badge_fill
|
||||
: isAur
|
||||
? CupertinoIcons.layers_alt_fill
|
||||
: CupertinoIcons.cube_box_fill,
|
||||
color: backendColor,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_currentPackageName,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backendColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
_currentBackend.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: backendColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (d != null && d.repository.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
d.repository,
|
||||
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
d != null ? 'Версия: ${d.version}' : 'Свойства и метаданные пакета',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (d?.url != null)
|
||||
MacosTooltip(
|
||||
message: 'Открыть домашнюю страницу пакета',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.globe, size: 16),
|
||||
onPressed: () => _openProjectUrl(d!.url!),
|
||||
),
|
||||
),
|
||||
MacosTooltip(
|
||||
message: 'Скопировать название пакета',
|
||||
child: MacosIconButton(
|
||||
icon: const MacosIcon(CupertinoIcons.doc_on_clipboard, size: 15),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: _currentPackageName));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
secondary: true,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Закрыть'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(MacosThemeData theme) {
|
||||
final d = _details!;
|
||||
final tabs = [
|
||||
'Обзор',
|
||||
'Зависимости (${d.dependsOn.length})',
|
||||
if (_currentBackend == BackendType.pacman && _packageFiles.isNotEmpty)
|
||||
'Файлы (${_packageFiles.length})',
|
||||
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
|
||||
'Откат кэша (${_cachedVersions.length})',
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Navigation bar & Action button
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.canvasColor,
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 0.8)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Segmented Tabs
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(tabs.length, (idx) {
|
||||
final isSelected = _selectedTab == idx;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
color: isSelected ? theme.primaryColor.withValues(alpha: 0.18) : CupertinoColors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
onPressed: () => setState(() => _selectedTab = idx),
|
||||
child: Text(
|
||||
tabs[idx],
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
|
||||
color: isSelected ? theme.primaryColor : CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Action buttons
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
color: CupertinoColors.systemRed.withValues(alpha: 0.18),
|
||||
onPressed: _isDeleting ? null : _confirmUninstall,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isDeleting)
|
||||
const ProgressCircle(radius: 5)
|
||||
else
|
||||
const MacosIcon(CupertinoIcons.trash, size: 13, color: CupertinoColors.systemRed),
|
||||
const SizedBox(width: 5),
|
||||
const Text('Удалить', style: TextStyle(color: CupertinoColors.systemRed)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tab Content
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _selectedTab.clamp(0, tabs.length - 1),
|
||||
children: [
|
||||
_buildOverviewTab(theme, d),
|
||||
_buildDependenciesTab(theme, d),
|
||||
if (_currentBackend == BackendType.pacman && _packageFiles.isNotEmpty)
|
||||
_buildFilesTab(theme, d),
|
||||
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
|
||||
_buildDowngradeTab(theme, d),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewTab(MacosThemeData theme, PackageDetails d) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(22),
|
||||
children: [
|
||||
// Description Card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.5), width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'ОПИСАНИЕ',
|
||||
style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold, letterSpacing: 0.7, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
d.description.isNotEmpty ? d.description : 'Описание не предоставлено.',
|
||||
style: const TextStyle(fontSize: 13.5, height: 1.45),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Metadata grid
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildInfoCard(theme, 'Размер установки', d.formattedInstalledSize, CupertinoIcons.cube_box),
|
||||
if (d.downloadSizeBytes > 0)
|
||||
_buildInfoCard(theme, 'Размер загрузки', '${(d.downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB', CupertinoIcons.arrow_down_to_line),
|
||||
_buildInfoCard(theme, 'Лицензия', d.licenses.isNotEmpty ? d.licenses.join(', ') : 'Не указана', CupertinoIcons.doc_plaintext),
|
||||
if (d.packager != null)
|
||||
_buildInfoCard(theme, 'Сборщик', d.packager!, CupertinoIcons.person_badge_plus),
|
||||
if (d.buildDate != null)
|
||||
_buildInfoCard(theme, 'Дата сборки', d.buildDate!, CupertinoIcons.calendar),
|
||||
_buildInfoCard(theme, 'Репозиторий', d.repository, CupertinoIcons.archivebox),
|
||||
_buildInfoCard(theme, 'Пакетный менеджер', d.backend.displayName, CupertinoIcons.layers),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(MacosThemeData theme, String title, String value, IconData icon) {
|
||||
const width = 230.0;
|
||||
return Container(
|
||||
width: width,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.4), width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
MacosIcon(icon, size: 12, color: CupertinoColors.systemGrey),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 10.5, color: CupertinoColors.systemGrey, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w500),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDependenciesTab(MacosThemeData theme, PackageDetails d) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(22),
|
||||
children: [
|
||||
// Depends on
|
||||
_buildSectionHeader('НЕОБХОДИМЫЕ ЗАВИСИМОСТИ (${d.dependsOn.length})'),
|
||||
const SizedBox(height: 10),
|
||||
if (d.dependsOn.isEmpty)
|
||||
const Text('Зависимости отсутствуют.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: d.dependsOn.map((dep) {
|
||||
final cleanName = dep.split(RegExp(r'[><=]')).first.trim();
|
||||
return CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => _navigateTo(cleanName, d.backend),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.primaryColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.3), width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
dep,
|
||||
style: TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: theme.primaryColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
MacosIcon(CupertinoIcons.arrow_right, size: 10, color: theme.primaryColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Required by
|
||||
_buildSectionHeader('ИСПОЛЬЗУЕТСЯ ПАКЕТАМИ (${d.requiredBy.length})'),
|
||||
const SizedBox(height: 10),
|
||||
if (d.requiredBy.isEmpty)
|
||||
const Text('Ни один установленный пакет не зависит от этого компонента.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: d.requiredBy.map((req) {
|
||||
return CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => _navigateTo(req, d.backend),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.6), width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
req,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: CupertinoColors.white),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const MacosIcon(CupertinoIcons.arrow_right, size: 10, color: CupertinoColors.systemGrey),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilesTab(MacosThemeData theme, PackageDetails d) {
|
||||
final filtered = _fileSearchFilter.isEmpty
|
||||
? _packageFiles
|
||||
: _packageFiles.where((f) => f.toLowerCase().contains(_fileSearchFilter.toLowerCase())).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
|
||||
child: MacosTextField(
|
||||
placeholder: 'Фильтровать файлы пакета (${_packageFiles.length} всего)...',
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: MacosIcon(CupertinoIcons.search, size: 13, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
onChanged: (val) => setState(() => _fileSearchFilter = val),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final path = filtered[idx];
|
||||
final isDir = path.endsWith('/');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.5),
|
||||
child: Row(
|
||||
children: [
|
||||
MacosIcon(
|
||||
isDir ? CupertinoIcons.folder_fill : CupertinoIcons.doc,
|
||||
size: 13,
|
||||
color: isDir ? const Color(0xFF0A84FF) : CupertinoColors.systemGrey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
path,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11.5,
|
||||
color: isDir ? CupertinoColors.white : CupertinoColors.systemGrey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDowngradeTab(MacosThemeData theme, PackageDetails d) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(22),
|
||||
children: [
|
||||
_buildSectionHeader('СОХРАНЕННЫЕ ВЕРСИИ В /var/cache/pacman/pkg (${_cachedVersions.length})'),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Вы можете откатить данный пакет до любой ранее установленной версии из локального архива в один клик:',
|
||||
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
..._cachedVersions.map((cv) {
|
||||
final isCurrent = cv.version == d.version;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? const Color(0xFF0A84FF).withValues(alpha: 0.1) : theme.dividerColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isCurrent ? const Color(0xFF0A84FF).withValues(alpha: 0.35) : theme.dividerColor.withValues(alpha: 0.5),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
cv.version,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w500,
|
||||
color: isCurrent ? const Color(0xFF0A84FF) : CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
if (isCurrent) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('Текущая', style: TextStyle(fontSize: 10, color: Color(0xFF0A84FF), fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${_formatBytes(cv.fileSizeBytes)} • ${cv.modifiedTime.toLocal().toString().split('.').first}',
|
||||
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isCurrent)
|
||||
PushButton(
|
||||
controlSize: ControlSize.regular,
|
||||
color: CupertinoColors.systemOrange.withValues(alpha: 0.25),
|
||||
onPressed: _isDowngrading ? null : () => _downgradeTo(cv),
|
||||
child: _isDowngrading
|
||||
? const ProgressCircle(radius: 5)
|
||||
: const Text('Откатить', style: TextStyle(color: CupertinoColors.systemOrange, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Совместимая обёртка для прежних мест вызова
|
||||
class PackageInspector extends StatelessWidget {
|
||||
final String packageName;
|
||||
final BackendType backend;
|
||||
final VoidCallback onClose;
|
||||
final Function(String name, BackendType backend)? onNavigateToPackage;
|
||||
|
||||
const PackageInspector({
|
||||
super.key,
|
||||
required this.packageName,
|
||||
required this.backend,
|
||||
required this.onClose,
|
||||
this.onNavigateToPackage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PackageInspectorSheet(
|
||||
initialPackageName: packageName,
|
||||
initialBackend: backend,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../rust/api/packages.dart';
|
||||
import '../../services/aur_service.dart';
|
||||
|
||||
class SpotlightItem {
|
||||
final String name;
|
||||
final String version;
|
||||
final String description;
|
||||
final BackendType backend;
|
||||
final bool isInstalled;
|
||||
final int? aurVotes;
|
||||
|
||||
const SpotlightItem({
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.description,
|
||||
required this.backend,
|
||||
this.isInstalled = false,
|
||||
this.aurVotes,
|
||||
});
|
||||
}
|
||||
|
||||
class SpotlightSearchDialog extends StatefulWidget {
|
||||
final Function(String name, BackendType backend) onSelectPackage;
|
||||
|
||||
const SpotlightSearchDialog({
|
||||
super.key,
|
||||
required this.onSelectPackage,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required Function(String name, BackendType backend) onSelectPackage,
|
||||
}) {
|
||||
return showMacosSheet<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => SpotlightSearchDialog(onSelectPackage: onSelectPackage),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<SpotlightSearchDialog> createState() => _SpotlightSearchDialogState();
|
||||
}
|
||||
|
||||
class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
Timer? _debounceTimer;
|
||||
bool _isSearching = false;
|
||||
List<SpotlightItem> _results = [];
|
||||
int _selectedIndex = 0;
|
||||
String _activeFilter = 'Все';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
_focusNode.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() {
|
||||
_results.clear();
|
||||
_isSearching = false;
|
||||
_selectedIndex = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 250), () {
|
||||
_performSearch(query.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
setState(() => _isSearching = true);
|
||||
final List<SpotlightItem> items = [];
|
||||
|
||||
try {
|
||||
// 1. Поиск в официальных репозиториях Pacman
|
||||
if (_activeFilter == 'Все' || _activeFilter == 'Pacman') {
|
||||
final res = await Process.run('pacman', ['-Ss', query], environment: {'LC_ALL': 'C'});
|
||||
if (res.exitCode == 0) {
|
||||
final lines = res.stdout.toString().split('\n');
|
||||
for (int i = 0; i < lines.length - 1; i += 2) {
|
||||
final header = lines[i].trim();
|
||||
if (header.isEmpty) continue;
|
||||
final parts = header.split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty) continue;
|
||||
|
||||
final repoName = parts[0];
|
||||
final name = repoName.contains('/') ? repoName.split('/').last : repoName;
|
||||
final version = parts.length > 1 ? parts[1] : '';
|
||||
final isInst = header.contains('[installed]');
|
||||
final desc = (i + 1 < lines.length) ? lines[i + 1].trim() : '';
|
||||
|
||||
items.add(SpotlightItem(
|
||||
name: name,
|
||||
version: version,
|
||||
description: desc,
|
||||
backend: BackendType.pacman,
|
||||
isInstalled: isInst,
|
||||
));
|
||||
if (items.length >= 15) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Поиск в AUR
|
||||
if (_activeFilter == 'Все' || _activeFilter == 'AUR') {
|
||||
final aurList = await AurService.search(query);
|
||||
for (final aur in aurList.take(15)) {
|
||||
items.add(SpotlightItem(
|
||||
name: aur.name,
|
||||
version: aur.version,
|
||||
description: aur.description,
|
||||
backend: BackendType.aur,
|
||||
aurVotes: aur.numVotes,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Поиск во Flatpak
|
||||
if (_activeFilter == 'Все' || _activeFilter == 'Flatpak') {
|
||||
final fpRes = await Process.run('flatpak', ['search', query, '--columns=app,name,version,description']);
|
||||
if (fpRes.exitCode == 0) {
|
||||
final lines = fpRes.stdout.toString().trim().split('\n');
|
||||
for (final line in lines.take(10)) {
|
||||
final parts = line.split('\t');
|
||||
if (parts.length >= 2) {
|
||||
final id = parts[0].trim();
|
||||
final name = parts[1].trim().isNotEmpty ? parts[1].trim() : id;
|
||||
final ver = parts.length > 2 ? parts[2].trim() : 'latest';
|
||||
final desc = parts.length > 3 ? parts[3].trim() : '';
|
||||
|
||||
items.add(SpotlightItem(
|
||||
name: name,
|
||||
version: ver,
|
||||
description: desc,
|
||||
backend: BackendType.flatpak,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_results = items;
|
||||
_isSearching = false;
|
||||
_selectedIndex = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _selectCurrent() {
|
||||
if (_results.isNotEmpty && _selectedIndex < _results.length) {
|
||||
final item = _results[_selectedIndex];
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelectPackage(item.name, item.backend);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleKeyEvent(KeyEvent event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
if (_results.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedIndex = (_selectedIndex + 1).clamp(0, _results.length - 1);
|
||||
});
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
if (_results.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedIndex = (_selectedIndex - 1).clamp(0, _results.length - 1);
|
||||
});
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_selectCurrent();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = MacosTheme.of(context);
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 580,
|
||||
constraints: const BoxConstraints(maxHeight: 520),
|
||||
margin: const EdgeInsets.only(top: 60),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E20).withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.16),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.65),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Search Input Row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.search,
|
||||
size: 20,
|
||||
color: Color(0xFF0A84FF),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CupertinoTextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (_) => _selectCurrent(),
|
||||
placeholder: 'Поиск пакетов в Arch Linux, AUR, Flathub...',
|
||||
placeholderStyle: const TextStyle(
|
||||
color: CupertinoColors.systemGrey,
|
||||
fontSize: 15,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: null,
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const ProgressCircle(radius: 8)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: const Text(
|
||||
'ESC',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Filter Chips
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: ['Все', 'Pacman', 'AUR', 'Flatpak'].map((filter) {
|
||||
final isSel = filter == _activeFilter;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6, bottom: 8),
|
||||
child: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () {
|
||||
setState(() => _activeFilter = filter);
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: isSel ? const Color(0xFF0A84FF) : const Color(0xFF2C2C2E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
filter,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSel ? CupertinoColors.white : CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
Container(
|
||||
height: 0.8,
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
|
||||
// Results List
|
||||
if (_results.isEmpty)
|
||||
Container(
|
||||
height: 160,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_searchController.text.isEmpty
|
||||
? 'Начните вводить название утилиты или приложения...'
|
||||
: _isSearching
|
||||
? 'Поиск...'
|
||||
: 'Ничего не найдено',
|
||||
style: const TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = _results[idx];
|
||||
final isSelected = idx == _selectedIndex;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelectPackage(item.name, item.backend);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF0A84FF).withValues(alpha: 0.25)
|
||||
: CupertinoColors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildBackendIcon(item.backend),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13.5,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
item.version,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (item.aurVotes != null) ...[
|
||||
const MacosIcon(
|
||||
CupertinoIcons.star_fill,
|
||||
size: 11,
|
||||
color: CupertinoColors.systemYellow,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${item.aurVotes}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: CupertinoColors.systemYellow,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
_buildBackendBadge(item.backend),
|
||||
],
|
||||
),
|
||||
if (item.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom hint
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.canvasColor.withValues(alpha: 0.5),
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Text(
|
||||
'↑↓ для навигации • Enter чтобы открыть • ESC для выхода',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackendIcon(BackendType backend) {
|
||||
IconData icon;
|
||||
Color color;
|
||||
switch (backend) {
|
||||
case BackendType.pacman:
|
||||
icon = CupertinoIcons.cube_box_fill;
|
||||
color = const Color(0xFF1793D1);
|
||||
break;
|
||||
case BackendType.aur:
|
||||
icon = CupertinoIcons.bolt_fill;
|
||||
color = const Color(0xFFFF9500);
|
||||
break;
|
||||
case BackendType.flatpak:
|
||||
icon = CupertinoIcons.app_badge_fill;
|
||||
color = const Color(0xFFAF52DE);
|
||||
break;
|
||||
}
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Center(
|
||||
child: MacosIcon(icon, color: color, size: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackendBadge(BackendType backend) {
|
||||
Color color;
|
||||
String label;
|
||||
switch (backend) {
|
||||
case BackendType.pacman:
|
||||
color = const Color(0xFF1793D1);
|
||||
label = 'Pacman';
|
||||
break;
|
||||
case BackendType.aur:
|
||||
color = const Color(0xFFFF9500);
|
||||
label = 'AUR';
|
||||
break;
|
||||
case BackendType.flatpak:
|
||||
color = const Color(0xFFAF52DE);
|
||||
label = 'Flatpak';
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:macos_ui/macos_ui.dart';
|
||||
import '../../services/app_badge_controller.dart';
|
||||
|
||||
class StorageInfo {
|
||||
final int totalBytes;
|
||||
final int freeBytes;
|
||||
final int pacmanCacheBytes;
|
||||
final int journalBytes;
|
||||
final int flatpakBytes;
|
||||
final int pacmanInstalledBytes;
|
||||
|
||||
const StorageInfo({
|
||||
required this.totalBytes,
|
||||
required this.freeBytes,
|
||||
required this.pacmanCacheBytes,
|
||||
required this.journalBytes,
|
||||
required this.flatpakBytes,
|
||||
required this.pacmanInstalledBytes,
|
||||
});
|
||||
|
||||
int get usedBytes => (totalBytes - freeBytes).clamp(0, totalBytes);
|
||||
int get otherBytes {
|
||||
final accounted = pacmanCacheBytes + journalBytes + flatpakBytes + pacmanInstalledBytes;
|
||||
return (usedBytes - accounted).clamp(0, usedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
class StorageBreakdownBar extends StatefulWidget {
|
||||
final int pacmanCacheBytes;
|
||||
final int journalBytes;
|
||||
|
||||
const StorageBreakdownBar({
|
||||
super.key,
|
||||
required this.pacmanCacheBytes,
|
||||
required this.journalBytes,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StorageBreakdownBar> createState() => _StorageBreakdownBarState();
|
||||
}
|
||||
|
||||
class _StorageBreakdownBarState extends State<StorageBreakdownBar> {
|
||||
StorageInfo? _storageInfo;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDiskStats();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StorageBreakdownBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.pacmanCacheBytes != widget.pacmanCacheBytes ||
|
||||
oldWidget.journalBytes != widget.journalBytes) {
|
||||
_loadDiskStats();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDiskStats() async {
|
||||
try {
|
||||
int total = 0;
|
||||
int free = 0;
|
||||
|
||||
// 1. Root partition size via df -B1 /
|
||||
final dfRes = await Process.run('df', ['-B1', '/']);
|
||||
if (dfRes.exitCode == 0) {
|
||||
final lines = dfRes.stdout.toString().trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
final parts = lines[1].trim().split(RegExp(r'\s+'));
|
||||
if (parts.length >= 4) {
|
||||
total = int.tryParse(parts[1]) ?? 0;
|
||||
free = int.tryParse(parts[3]) ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Flatpak size estimation (/var/lib/flatpak)
|
||||
int flatpak = 0;
|
||||
try {
|
||||
final fpRes = await Process.run('du', ['-sb', '/var/lib/flatpak']);
|
||||
if (fpRes.exitCode == 0) {
|
||||
final first = fpRes.stdout.toString().trim().split(RegExp(r'\s+')).first;
|
||||
flatpak = int.tryParse(first) ?? 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 3. Pacman installed size estimation
|
||||
int pacmanInstalled = 0;
|
||||
try {
|
||||
final usrRes = await Process.run('du', ['-sb', '/usr']);
|
||||
if (usrRes.exitCode == 0) {
|
||||
final first = usrRes.stdout.toString().trim().split(RegExp(r'\s+')).first;
|
||||
final usrBytes = int.tryParse(first) ?? 0;
|
||||
pacmanInstalled = (usrBytes * 0.85).toInt();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storageInfo = StorageInfo(
|
||||
totalBytes: total > 0 ? total : 500 * 1024 * 1024 * 1024,
|
||||
freeBytes: free > 0 ? free : 350 * 1024 * 1024 * 1024,
|
||||
pacmanCacheBytes: widget.pacmanCacheBytes,
|
||||
journalBytes: widget.journalBytes,
|
||||
flatpakBytes: flatpak,
|
||||
pacmanInstalledBytes: pacmanInstalled,
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading || _storageInfo == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final info = _storageInfo!;
|
||||
final total = info.totalBytes > 0 ? info.totalBytes : 1;
|
||||
|
||||
final pctPacman = (info.pacmanInstalledBytes / total).clamp(0.0, 1.0);
|
||||
final pctFlatpak = (info.flatpakBytes / total).clamp(0.0, 1.0);
|
||||
final pctCache = (info.pacmanCacheBytes / total).clamp(0.0, 1.0);
|
||||
final pctJournal = (info.journalBytes / total).clamp(0.0, 1.0);
|
||||
final pctOther = (info.otherBytes / total).clamp(0.0, 1.0);
|
||||
final pctFree = (info.freeBytes / total).clamp(0.0, 1.0);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1C1C1E),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const MacosIcon(
|
||||
CupertinoIcons.circle_grid_hex_fill,
|
||||
color: Color(0xFF0A84FF),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Распределение дискового пространства',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Свободно ${AppBadgeController.formatBytes(info.freeBytes)} из ${AppBadgeController.formatBytes(info.totalBytes)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// macOS Storage Segmented Bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: SizedBox(
|
||||
height: 14,
|
||||
child: Row(
|
||||
children: [
|
||||
if (pctPacman > 0)
|
||||
Flexible(
|
||||
flex: (pctPacman * 1000).toInt(),
|
||||
child: Container(color: const Color(0xFF0A84FF)),
|
||||
),
|
||||
if (pctFlatpak > 0)
|
||||
Flexible(
|
||||
flex: (pctFlatpak * 1000).toInt(),
|
||||
child: Container(color: const Color(0xFFAF52DE)),
|
||||
),
|
||||
if (pctCache > 0)
|
||||
Flexible(
|
||||
flex: (pctCache * 1000).toInt(),
|
||||
child: Container(color: const Color(0xFFFF9500)),
|
||||
),
|
||||
if (pctJournal > 0)
|
||||
Flexible(
|
||||
flex: (pctJournal * 1000).toInt(),
|
||||
child: Container(color: const Color(0xFFFFD60A)),
|
||||
),
|
||||
if (pctOther > 0)
|
||||
Flexible(
|
||||
flex: (pctOther * 1000).toInt(),
|
||||
child: Container(color: const Color(0xFF48484A)),
|
||||
),
|
||||
if (pctFree > 0)
|
||||
Flexible(
|
||||
flex: (pctFree * 1000).toInt(),
|
||||
child: Container(color: const Color(0xFF2C2C2E)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Legend
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFF0A84FF),
|
||||
label: 'Пакеты Pacman',
|
||||
size: AppBadgeController.formatBytes(info.pacmanInstalledBytes),
|
||||
),
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFFAF52DE),
|
||||
label: 'Flatpak',
|
||||
size: AppBadgeController.formatBytes(info.flatpakBytes),
|
||||
),
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFFFF9500),
|
||||
label: 'Кэш пакетов',
|
||||
size: AppBadgeController.formatBytes(info.pacmanCacheBytes),
|
||||
),
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFFFFD60A),
|
||||
label: 'Логи systemd',
|
||||
size: AppBadgeController.formatBytes(info.journalBytes),
|
||||
),
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFF2C2C2E),
|
||||
label: 'Свободно',
|
||||
size: AppBadgeController.formatBytes(info.freeBytes),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegendItem({
|
||||
required Color color,
|
||||
required String label,
|
||||
required String size,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
size,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
@@ -0,0 +1,128 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "forge")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.forge.forge")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
@@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
@@ -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}")
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
}
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
|
||||
// Use a header bar when running in GNOME as this is the common style used
|
||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||
// desktop).
|
||||
// If running on X and not using GNOME then just use a traditional title bar
|
||||
// in case the window manager does more exotic layout, e.g. tiling.
|
||||
// If running on Wayland assume the header bar will work (may need changing
|
||||
// if future cases occur).
|
||||
gboolean use_header_bar = TRUE;
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
GdkScreen* screen = gtk_window_get_screen(window);
|
||||
if (GDK_IS_X11_SCREEN(screen)) {
|
||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||
use_header_bar = FALSE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "forge");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "forge");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(
|
||||
project, self->dart_entrypoint_arguments);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
GdkRGBA background_color;
|
||||
// Background defaults to black, override it here if necessary, e.g. #00000000
|
||||
// for transparent.
|
||||
gdk_rgba_parse(&background_color, "#000000");
|
||||
fl_view_set_background_color(view, &background_color);
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
// Show the window when Flutter renders.
|
||||
// Requires the view to be realized so we can start rendering.
|
||||
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
|
||||
self);
|
||||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
// Implements GApplication::local_command_line.
|
||||
static gboolean my_application_local_command_line(GApplication* application,
|
||||
gchar*** arguments,
|
||||
int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
// Strip out the first argument as it is the binary name.
|
||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Implements GApplication::startup.
|
||||
static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
// Implements GApplication::shutdown.
|
||||
static void my_application_shutdown(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application shutdown.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||
}
|
||||
|
||||
// Implements GObject::dispose.
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void my_application_class_init(MyApplicationClass* klass) {
|
||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||
G_APPLICATION_CLASS(klass)->local_command_line =
|
||||
my_application_local_command_line;
|
||||
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
|
||||
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
// Set the program name to the application ID, which helps various systems
|
||||
// like GTK and desktop environments map this running application to its
|
||||
// corresponding .desktop file. This ensures better integration by allowing
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||
#define FLUTTER_MY_APPLICATION_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication,
|
||||
my_application,
|
||||
MY,
|
||||
APPLICATION,
|
||||
GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
*
|
||||
* Creates a new Flutter-based application.
|
||||
*
|
||||
* Returns: a new #MyApplication.
|
||||
*/
|
||||
MyApplication* my_application_new();
|
||||
|
||||
#endif // FLUTTER_MY_APPLICATION_H_
|
||||
+370
@@ -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"
|
||||
@@ -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
|
||||
Generated
+947
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
pub mod packages;
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::frb_generated::StreamSink;
|
||||
pub use crate::core::types::{
|
||||
AppError, BackendType, CleanerScanResult, DiscoverApp, HistoryAction, HistoryItem,
|
||||
PackageDetails, PackageInfo, PackageUpdate, ProgressEvent, UpdateStep,
|
||||
};
|
||||
use crate::core::cleaner::SystemCleaner;
|
||||
use crate::core::discover::AppCatalog;
|
||||
use crate::core::flatpak::FlatpakPackageManager;
|
||||
use crate::core::history::PacmanHistory;
|
||||
use crate::core::package_manager::PackageManager;
|
||||
use crate::core::pacman::PacmanPackageManager;
|
||||
use crate::core::privilege::PrivilegedCommandRunner;
|
||||
|
||||
fn get_manager(backend: BackendType) -> Arc<dyn PackageManager> {
|
||||
match backend {
|
||||
BackendType::Pacman | BackendType::Aur => Arc::new(PacmanPackageManager::new()),
|
||||
BackendType::Flatpak => Arc::new(FlatpakPackageManager::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init_app() -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_updates(backend: Option<BackendType>) -> Result<Vec<PackageUpdate>, AppError> {
|
||||
match backend {
|
||||
Some(b) => get_manager(b).check_updates().await,
|
||||
None => {
|
||||
let mut all = Vec::new();
|
||||
if let Ok(pacman_updates) = get_manager(BackendType::Pacman).check_updates().await {
|
||||
all.extend(pacman_updates);
|
||||
}
|
||||
if let Ok(flatpak_updates) = get_manager(BackendType::Flatpak).check_updates().await {
|
||||
all.extend(flatpak_updates);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_installed_packages(
|
||||
backend: Option<BackendType>,
|
||||
) -> Result<Vec<PackageInfo>, AppError> {
|
||||
match backend {
|
||||
Some(b) => get_manager(b).get_installed_packages().await,
|
||||
None => {
|
||||
let mut all = Vec::new();
|
||||
if let Ok(pacman_pkgs) = get_manager(BackendType::Pacman).get_installed_packages().await {
|
||||
all.extend(pacman_pkgs);
|
||||
}
|
||||
if let Ok(flatpak_pkgs) = get_manager(BackendType::Flatpak).get_installed_packages().await {
|
||||
all.extend(flatpak_pkgs);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_package_details(
|
||||
backend: BackendType,
|
||||
package_name: String,
|
||||
) -> Result<PackageDetails, AppError> {
|
||||
get_manager(backend).get_package_details(&package_name).await
|
||||
}
|
||||
|
||||
pub async fn apply_updates(
|
||||
backend: BackendType,
|
||||
packages: Vec<String>,
|
||||
is_dry_run: bool,
|
||||
sink: StreamSink<ProgressEvent>,
|
||||
) -> Result<(), AppError> {
|
||||
get_manager(backend).apply_updates(packages, is_dry_run, sink).await
|
||||
}
|
||||
|
||||
/// Удаление пакета
|
||||
pub async fn uninstall_package(backend: BackendType, package_name: String) -> Result<(), AppError> {
|
||||
match backend {
|
||||
BackendType::Pacman | BackendType::Aur => {
|
||||
PrivilegedCommandRunner::run_privileged(
|
||||
"pacman",
|
||||
&["-Rns", "--noconfirm", &package_name],
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
}
|
||||
BackendType::Flatpak => {
|
||||
tokio::process::Command::new("flatpak")
|
||||
.args(["uninstall", "-y", &package_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::ExecutionFailed(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Сканирование системы на мусор
|
||||
pub async fn scan_cleaner() -> Result<CleanerScanResult, AppError> {
|
||||
SystemCleaner::scan().await
|
||||
}
|
||||
|
||||
/// Выполнение очистки
|
||||
pub async fn run_cleaner(
|
||||
clean_pacman_cache: bool,
|
||||
clean_orphans: bool,
|
||||
clean_flatpak_unused: bool,
|
||||
clean_journal: bool,
|
||||
sink: StreamSink<ProgressEvent>,
|
||||
) -> Result<(), AppError> {
|
||||
SystemCleaner::clean(
|
||||
clean_pacman_cache,
|
||||
clean_orphans,
|
||||
clean_flatpak_unused,
|
||||
clean_journal,
|
||||
sink,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Получение истории транзакций
|
||||
pub async fn get_history(limit: usize) -> Result<Vec<HistoryItem>, AppError> {
|
||||
PacmanHistory::get_recent_history(limit).await
|
||||
}
|
||||
|
||||
/// Получение каталога приложений
|
||||
pub async fn get_discover_apps() -> Result<Vec<DiscoverApp>, AppError> {
|
||||
AppCatalog::get_curated_apps().await
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use tokio::process::Command;
|
||||
use crate::core::types::{AppError, CleanerScanResult, ProgressEvent, UpdateStep};
|
||||
use crate::core::privilege::PrivilegedCommandRunner;
|
||||
use crate::frb_generated::StreamSink;
|
||||
|
||||
pub struct SystemCleaner;
|
||||
|
||||
impl SystemCleaner {
|
||||
pub async fn scan() -> Result<CleanerScanResult, AppError> {
|
||||
let mut pacman_cache = 0u64;
|
||||
let mut orphan_pkgs = Vec::new();
|
||||
let mut orphan_bytes = 0u64;
|
||||
let mut journal_bytes = 0u64;
|
||||
let flatpak_unused_count = 0usize;
|
||||
let flatpak_unused_bytes = 0u64;
|
||||
|
||||
// 1. Pacman cache size
|
||||
if let Ok(out) = Command::new("du")
|
||||
.args(["-sb", "/var/cache/pacman/pkg"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(first) = s.split_whitespace().next() {
|
||||
pacman_cache = first.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Orphans (pacman -Qtdq)
|
||||
if let Ok(out) = Command::new("pacman").args(["-Qtdq"]).output().await {
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
for line in s.lines() {
|
||||
let name = line.trim();
|
||||
if !name.is_empty() {
|
||||
orphan_pkgs.push(name.to_string());
|
||||
}
|
||||
}
|
||||
// Примерная оценка ~50 МБ на пакет-сироту
|
||||
orphan_bytes = (orphan_pkgs.len() as u64) * 50 * 1024 * 1024;
|
||||
}
|
||||
|
||||
// 3. Journalctl size
|
||||
if let Ok(out) = Command::new("journalctl")
|
||||
.args(["--disk-usage"])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
// "Archived and active journals take up 126.2M in the file system."
|
||||
for part in s.split_whitespace() {
|
||||
if part.ends_with('M') || part.ends_with('G') || part.ends_with('K') {
|
||||
journal_bytes = parse_cleaner_unit(part);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = pacman_cache + orphan_bytes + journal_bytes + flatpak_unused_bytes;
|
||||
|
||||
Ok(CleanerScanResult {
|
||||
pacman_cache_bytes: pacman_cache,
|
||||
orphan_packages: orphan_pkgs,
|
||||
orphan_bytes,
|
||||
flatpak_unused_count,
|
||||
flatpak_unused_bytes,
|
||||
journal_bytes,
|
||||
total_reclaimable_bytes: total,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn clean(
|
||||
clean_pacman_cache: bool,
|
||||
clean_orphans: bool,
|
||||
clean_flatpak_unused: bool,
|
||||
clean_journal: bool,
|
||||
sink: StreamSink<ProgressEvent>,
|
||||
) -> Result<(), AppError> {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::CheckingDependencies,
|
||||
package_name: None,
|
||||
percentage: 0.05,
|
||||
message: "Запуск очистки системного мусора...".to_string(),
|
||||
log_line: Some(":: Initializing system maintenance cleanup...".to_string()),
|
||||
});
|
||||
|
||||
// 1. Очистка кэша пакетов
|
||||
if clean_pacman_cache {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: None,
|
||||
percentage: 0.25,
|
||||
message: "Очистка кэша старых версий пакетов (paccache)...".to_string(),
|
||||
log_line: Some(":: Running paccache -r to keep only latest 2 versions...".to_string()),
|
||||
});
|
||||
|
||||
let _ = PrivilegedCommandRunner::run_privileged(
|
||||
"paccache",
|
||||
&["-r"],
|
||||
|_| {},
|
||||
).await;
|
||||
}
|
||||
|
||||
// 2. Удаление пакетов-сирот
|
||||
if clean_orphans {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: None,
|
||||
percentage: 0.55,
|
||||
message: "Удаление неиспользуемых зависимостей (orphans)...".to_string(),
|
||||
log_line: Some(":: Finding and removing orphan packages...".to_string()),
|
||||
});
|
||||
|
||||
if let Ok(out) = Command::new("pacman").args(["-Qtdq"]).output().await {
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
let orphans: Vec<&str> = s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
|
||||
if !orphans.is_empty() {
|
||||
let mut args = vec!["-Rns", "--noconfirm"];
|
||||
args.extend(orphans);
|
||||
let _ = PrivilegedCommandRunner::run_privileged("pacman", &args, |_| {}).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Flatpak unused
|
||||
if clean_flatpak_unused {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: None,
|
||||
percentage: 0.75,
|
||||
message: "Удаление неиспользуемых рантаймов Flatpak...".to_string(),
|
||||
log_line: Some(":: Running flatpak uninstall --unused -y...".to_string()),
|
||||
});
|
||||
|
||||
let _ = Command::new("flatpak")
|
||||
.args(["uninstall", "--unused", "-y"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// 4. Очистка журналов systemd
|
||||
if clean_journal {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: None,
|
||||
percentage: 0.9,
|
||||
message: "Ограничение размера журналов systemd (до 100 МБ)...".to_string(),
|
||||
log_line: Some(":: Vacuuming systemd journal logs to 100M...".to_string()),
|
||||
});
|
||||
|
||||
let _ = PrivilegedCommandRunner::run_privileged(
|
||||
"journalctl",
|
||||
&["--vacuum-size=100M"],
|
||||
|_| {},
|
||||
).await;
|
||||
}
|
||||
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Done,
|
||||
package_name: None,
|
||||
percentage: 1.0,
|
||||
message: "Очистка успешно завершена!".to_string(),
|
||||
log_line: Some(":: System maintenance finished successfully.".to_string()),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cleaner_unit(s: &str) -> u64 {
|
||||
let clean = s.trim();
|
||||
if clean.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let (num_part, unit) = clean.split_at(clean.len().saturating_sub(1));
|
||||
let num: f64 = num_part.parse().unwrap_or(0.0);
|
||||
match unit {
|
||||
"G" | "g" => (num * 1024.0 * 1024.0 * 1024.0) as u64,
|
||||
"M" | "m" => (num * 1024.0 * 1024.0) as u64,
|
||||
"K" | "k" => (num * 1024.0) as u64,
|
||||
_ => num as u64,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::core::types::{AppError, BackendType, DiscoverApp};
|
||||
use tokio::process::Command;
|
||||
|
||||
pub struct AppCatalog;
|
||||
|
||||
impl AppCatalog {
|
||||
pub async fn get_curated_apps() -> Result<Vec<DiscoverApp>, AppError> {
|
||||
let mut apps = vec![
|
||||
DiscoverApp {
|
||||
id: "org.kde.krita".to_string(),
|
||||
name: "Krita".to_string(),
|
||||
summary: "Профессиональный графический редактор для цифрового рисования".to_string(),
|
||||
category: "Графика".to_string(),
|
||||
icon_name: "paintbrush_fill".to_string(),
|
||||
backend: BackendType::Flatpak,
|
||||
is_installed: false,
|
||||
rating: 4.9,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "com.usebottles.bottles".to_string(),
|
||||
name: "Bottles".to_string(),
|
||||
summary: "Запуск Windows-приложений и игр в изолированных префиксах Wine".to_string(),
|
||||
category: "Утилиты".to_string(),
|
||||
icon_name: "tuningfork".to_string(),
|
||||
backend: BackendType::Flatpak,
|
||||
is_installed: false,
|
||||
rating: 4.8,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "com.localsend.localsend_app".to_string(),
|
||||
name: "LocalSend".to_string(),
|
||||
summary: "Безопасный обмен файлами по локальной сети без интернета (AirDrop для Linux)".to_string(),
|
||||
category: "Сеть".to_string(),
|
||||
icon_name: "paperplane_fill".to_string(),
|
||||
backend: BackendType::Flatpak,
|
||||
is_installed: false,
|
||||
rating: 4.9,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "com.github.tchx84.Flatseal".to_string(),
|
||||
name: "Flatseal".to_string(),
|
||||
summary: "Управление разрешениями, песочницей и доступом приложений Flatpak".to_string(),
|
||||
category: "Утилиты".to_string(),
|
||||
icon_name: "lock_shield_fill".to_string(),
|
||||
backend: BackendType::Flatpak,
|
||||
is_installed: false,
|
||||
rating: 4.9,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "com.obsproject.Studio".to_string(),
|
||||
name: "OBS Studio".to_string(),
|
||||
summary: "Запись видео с экрана и прямые трансляции профессионального уровня".to_string(),
|
||||
category: "Медиа".to_string(),
|
||||
icon_name: "video_fill".to_string(),
|
||||
backend: BackendType::Flatpak,
|
||||
is_installed: false,
|
||||
rating: 4.8,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "neovim".to_string(),
|
||||
name: "Neovim".to_string(),
|
||||
summary: "Быстрый, расширяемый модальный текстовый редактор для терминала".to_string(),
|
||||
category: "Разработка".to_string(),
|
||||
icon_name: "chevron_left_slash_chevron_right".to_string(),
|
||||
backend: BackendType::Pacman,
|
||||
is_installed: false,
|
||||
rating: 4.9,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "telegram-desktop".to_string(),
|
||||
name: "Telegram".to_string(),
|
||||
summary: "Быстрый и защищённый мессенджер с облачной синхронизацией".to_string(),
|
||||
category: "Сеть".to_string(),
|
||||
icon_name: "bubble_left_bubble_right_fill".to_string(),
|
||||
backend: BackendType::Pacman,
|
||||
is_installed: false,
|
||||
rating: 4.8,
|
||||
},
|
||||
DiscoverApp {
|
||||
id: "vlc".to_string(),
|
||||
name: "VLC Media Player".to_string(),
|
||||
summary: "Универсальный медиаплеер с поддержкой всех современных форматов и кодеков".to_string(),
|
||||
category: "Медиа".to_string(),
|
||||
icon_name: "play_circle_fill".to_string(),
|
||||
backend: BackendType::Pacman,
|
||||
is_installed: false,
|
||||
rating: 4.7,
|
||||
},
|
||||
];
|
||||
|
||||
// Проверяем, установлены ли приложения
|
||||
for app in &mut apps {
|
||||
if app.backend == BackendType::Flatpak {
|
||||
if let Ok(out) = Command::new("flatpak").args(["info", &app.id]).output().await {
|
||||
app.is_installed = out.status.success();
|
||||
}
|
||||
} else if app.backend == BackendType::Pacman {
|
||||
if let Ok(out) = Command::new("pacman").args(["-Q", &app.id]).output().await {
|
||||
app.is_installed = out.status.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(apps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::process::Stdio;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::core::package_manager::PackageManager;
|
||||
use crate::core::types::{
|
||||
AppError, BackendType, PackageDetails, PackageInfo, PackageUpdate, ProgressEvent, UpdateStep,
|
||||
};
|
||||
use crate::frb_generated::StreamSink;
|
||||
|
||||
pub struct FlatpakPackageManager;
|
||||
|
||||
impl FlatpakPackageManager {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PackageManager for FlatpakPackageManager {
|
||||
async fn check_updates(&self) -> Result<Vec<PackageUpdate>, AppError> {
|
||||
let output = Command::new("flatpak")
|
||||
.args(["remote-ls", "--updates", "--columns=app,name,version,origin,description"])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let stdout = match output {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 4 {
|
||||
let id = parts[0].trim().to_string();
|
||||
let name = if parts[1].trim().is_empty() {
|
||||
id.clone()
|
||||
} else {
|
||||
parts[1].trim().to_string()
|
||||
};
|
||||
let new_version = parts[2].trim().to_string();
|
||||
let origin = parts[3].trim().to_string();
|
||||
let desc = if parts.len() >= 5 {
|
||||
parts[4].trim().to_string()
|
||||
} else {
|
||||
"Flatpak Application".to_string()
|
||||
};
|
||||
|
||||
updates.push(PackageUpdate {
|
||||
name,
|
||||
current_version: "installed".to_string(),
|
||||
new_version,
|
||||
repository: format!("flatpak / {}", origin),
|
||||
download_size_bytes: 0,
|
||||
description: desc,
|
||||
is_security_update: false,
|
||||
backend: BackendType::Flatpak,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
async fn get_installed_packages(&self) -> Result<Vec<PackageInfo>, AppError> {
|
||||
let output = Command::new("flatpak")
|
||||
.args(["list", "--app", "--columns=app,name,version,size,origin,description"])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::ExecutionFailed(format!("Failed to run flatpak: {}", e)))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut list = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 2 {
|
||||
let id = parts[0].trim().to_string();
|
||||
let name = if parts[1].trim().is_empty() {
|
||||
id.clone()
|
||||
} else {
|
||||
parts[1].trim().to_string()
|
||||
};
|
||||
let version = if parts.len() >= 3 && !parts[2].trim().is_empty() {
|
||||
parts[2].trim().to_string()
|
||||
} else {
|
||||
"stable".to_string()
|
||||
};
|
||||
let size_str = if parts.len() >= 4 { parts[3].trim() } else { "0" };
|
||||
let desc = if parts.len() >= 6 {
|
||||
parts[5].trim().to_string()
|
||||
} else {
|
||||
format!("Flatpak application ({})", id)
|
||||
};
|
||||
|
||||
list.push(PackageInfo {
|
||||
name,
|
||||
version,
|
||||
installed_size_bytes: parse_flatpak_size(size_str),
|
||||
description: desc,
|
||||
install_date: None,
|
||||
is_explicit: true,
|
||||
backend: BackendType::Flatpak,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
async fn get_package_details(&self, package_name: &str) -> Result<PackageDetails, AppError> {
|
||||
let output = Command::new("flatpak")
|
||||
.args(["info", package_name])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::NotFound(e.to_string()))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.trim().is_empty() {
|
||||
return Err(AppError::NotFound(format!("Flatpak {} not found", package_name)));
|
||||
}
|
||||
|
||||
let mut desc = String::new();
|
||||
let mut version = "latest".to_string();
|
||||
let mut license = Vec::new();
|
||||
let mut origin = "flathub".to_string();
|
||||
let mut installed_size = 0;
|
||||
let mut runtime = Vec::new();
|
||||
|
||||
// Первая строка часто содержит: "Name - Summary"
|
||||
if let Some(first_line) = stdout.lines().next() {
|
||||
if first_line.contains('-') {
|
||||
let parts: Vec<&str> = first_line.splitn(2, '-').collect();
|
||||
desc = parts[1].trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
let key = parts[0].trim();
|
||||
let val = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"Version" => version = val.to_string(),
|
||||
"License" => license = vec![val.to_string()],
|
||||
"Origin" => origin = val.to_string(),
|
||||
"Installed Size" | "Installed size" => installed_size = parse_flatpak_size(val),
|
||||
"Runtime" => runtime.push(val.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PackageDetails {
|
||||
name: package_name.to_string(),
|
||||
version,
|
||||
description: desc,
|
||||
url: Some(format!("https://flathub.org/apps/{}", package_name)),
|
||||
licenses: license,
|
||||
depends_on: runtime,
|
||||
required_by: Vec::new(),
|
||||
packager: Some("Flathub Community".to_string()),
|
||||
download_size_bytes: 0,
|
||||
installed_size_bytes: installed_size,
|
||||
build_date: None,
|
||||
repository: format!("flatpak / {}", origin),
|
||||
backend: BackendType::Flatpak,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_updates(
|
||||
&self,
|
||||
packages: Vec<String>,
|
||||
is_dry_run: bool,
|
||||
sink: StreamSink<ProgressEvent>,
|
||||
) -> Result<(), AppError> {
|
||||
if is_dry_run {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::CheckingDependencies,
|
||||
package_name: None,
|
||||
percentage: 0.2,
|
||||
message: "[DRY-RUN] Симуляция обновления Flatpak...".to_string(),
|
||||
log_line: Some(":: [DRY-RUN] flatpak update --dry-run".to_string()),
|
||||
});
|
||||
sleep(Duration::from_millis(600)).await;
|
||||
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Done,
|
||||
package_name: None,
|
||||
percentage: 1.0,
|
||||
message: "[DRY-RUN] Симуляция Flatpak завершена!".to_string(),
|
||||
log_line: Some(":: [DRY-RUN] All runtimes and apps simulated.".to_string()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Flatpak обновляется БЕЗ root прав!
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::CheckingDependencies,
|
||||
package_name: None,
|
||||
percentage: 0.1,
|
||||
message: "Подключение к Flathub репозиторию...".to_string(),
|
||||
log_line: Some(":: Looking for Flatpak updates...".to_string()),
|
||||
});
|
||||
|
||||
let mut cmd = Command::new("flatpak");
|
||||
cmd.args(["update", "-y"]);
|
||||
for pkg in &packages {
|
||||
cmd.arg(pkg);
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
AppError::ExecutionFailed(format!("Failed to spawn flatpak: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: None,
|
||||
percentage: 0.6,
|
||||
message: "Загрузка и установка слоев Flatpak...".to_string(),
|
||||
log_line: Some(line),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| AppError::ExecutionFailed(e.to_string()))?;
|
||||
if !status.success() {
|
||||
return Err(AppError::ExecutionFailed(format!("Flatpak exited with: {:?}", status.code())));
|
||||
}
|
||||
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Done,
|
||||
package_name: None,
|
||||
percentage: 1.0,
|
||||
message: "Приложения Flatpak успешно обновлены!".to_string(),
|
||||
log_line: Some(":: Flatpak update completed.".to_string()),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_flatpak_size(text: &str) -> u64 {
|
||||
// "1.4 MB", "708.6 kB", "3.7 GB"
|
||||
let clean = text.replace(',', ".");
|
||||
let parts: Vec<&str> = clean.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let number: f64 = parts[0].parse().unwrap_or(0.0);
|
||||
if parts.len() > 1 {
|
||||
let unit = parts[1].to_lowercase();
|
||||
if unit.contains('k') {
|
||||
(number * 1000.0) as u64
|
||||
} else if unit.contains('m') {
|
||||
(number * 1000.0 * 1000.0) as u64
|
||||
} else if unit.contains('g') {
|
||||
(number * 1000.0 * 1000.0 * 1000.0) as u64
|
||||
} else {
|
||||
number as u64
|
||||
}
|
||||
} else {
|
||||
number as u64
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use crate::core::types::{AppError, HistoryAction, HistoryItem};
|
||||
|
||||
pub struct PacmanHistory;
|
||||
|
||||
impl PacmanHistory {
|
||||
pub async fn get_recent_history(limit: usize) -> Result<Vec<HistoryItem>, AppError> {
|
||||
let path = "/var/log/pacman.log";
|
||||
let file = match File::open(path).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut reader = BufReader::new(file).lines();
|
||||
let mut all_matches = Vec::new();
|
||||
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if !line.contains("[ALPM]") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Примеры:
|
||||
// [2026-09-13T17:04:47+0300] [ALPM] upgraded libgcrypt (1.12.3-1 -> 1.12.4-1)
|
||||
// [2026-09-13T17:04:47+0300] [ALPM] installed neovim (0.10.1-1)
|
||||
// [2026-09-13T17:04:47+0300] [ALPM] removed pkg (1.0-1)
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_time = parts[0].trim_start_matches('[').trim_end_matches(']');
|
||||
let time_formatted = if raw_time.len() >= 16 {
|
||||
raw_time[..16].replace('T', " ")
|
||||
} else {
|
||||
raw_time.to_string()
|
||||
};
|
||||
|
||||
let action_str = parts[2];
|
||||
let action = match action_str {
|
||||
"upgraded" => HistoryAction::Upgraded,
|
||||
"installed" => HistoryAction::Installed,
|
||||
"removed" => HistoryAction::Removed,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let pkg_name = parts[3].to_string();
|
||||
let mut old_ver = None;
|
||||
let mut new_ver = String::new();
|
||||
|
||||
// Версии внутри скобок
|
||||
if let Some(start_idx) = line.find('(') {
|
||||
if let Some(end_idx) = line.rfind(')') {
|
||||
let ver_str = &line[start_idx + 1..end_idx];
|
||||
if ver_str.contains("->") {
|
||||
let ver_parts: Vec<&str> = ver_str.split("->").collect();
|
||||
if ver_parts.len() >= 2 {
|
||||
old_ver = Some(ver_parts[0].trim().to_string());
|
||||
new_ver = ver_parts[1].trim().to_string();
|
||||
}
|
||||
} else {
|
||||
new_ver = ver_str.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_matches.push(HistoryItem {
|
||||
timestamp: time_formatted,
|
||||
action,
|
||||
package_name: pkg_name,
|
||||
old_version: old_ver,
|
||||
new_version: new_ver,
|
||||
});
|
||||
}
|
||||
|
||||
// Берем последние записи с конца
|
||||
all_matches.reverse();
|
||||
if all_matches.len() > limit {
|
||||
all_matches.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(all_matches)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,25 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::frb_generated::StreamSink;
|
||||
|
||||
use crate::core::types::{AppError, PackageDetails, PackageInfo, PackageUpdate, ProgressEvent};
|
||||
|
||||
/// Абстракция пакетного менеджера (Pacman, Flatpak и т.д.)
|
||||
#[async_trait]
|
||||
pub trait PackageManager: Send + Sync {
|
||||
/// Проверка наличия обновлений
|
||||
async fn check_updates(&self) -> Result<Vec<PackageUpdate>, AppError>;
|
||||
|
||||
/// Получение списка установленных пакетов
|
||||
async fn get_installed_packages(&self) -> Result<Vec<PackageInfo>, AppError>;
|
||||
|
||||
/// Получение подробной информации о конкретном пакете (зависимости, ссылки, лицензии)
|
||||
async fn get_package_details(&self, package_name: &str) -> Result<PackageDetails, AppError>;
|
||||
|
||||
/// Применение обновлений с флагом безопасного режима (is_dry_run) и стримингом логов
|
||||
async fn apply_updates(
|
||||
&self,
|
||||
packages: Vec<String>,
|
||||
is_dry_run: bool,
|
||||
sink: StreamSink<ProgressEvent>,
|
||||
) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use async_trait::async_trait;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::core::package_manager::PackageManager;
|
||||
use crate::core::privilege::PrivilegedCommandRunner;
|
||||
use crate::core::types::{
|
||||
AppError, BackendType, PackageDetails, PackageInfo, PackageUpdate, ProgressEvent, UpdateStep,
|
||||
};
|
||||
use crate::frb_generated::StreamSink;
|
||||
|
||||
pub struct PacmanPackageManager;
|
||||
|
||||
impl PacmanPackageManager {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PackageManager for PacmanPackageManager {
|
||||
async fn check_updates(&self) -> Result<Vec<PackageUpdate>, AppError> {
|
||||
// Попытка запустить системную утилиту `checkupdates`
|
||||
let output = Command::new("checkupdates")
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let stdout = match output {
|
||||
Ok(o) => {
|
||||
// Код 2 у checkupdates означает "нет обновлений"
|
||||
if o.status.code() == Some(2) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
String::from_utf8_lossy(&o.stdout).to_string()
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback: pacman -Qu
|
||||
let fallback = Command::new("pacman")
|
||||
.args(["-Qu"])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::ExecutionFailed(e.to_string()))?;
|
||||
String::from_utf8_lossy(&fallback.stdout).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Формат checkupdates: "name 1.0-1 -> 2.0-1"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 && parts[2] == "->" {
|
||||
let name = parts[0].to_string();
|
||||
let current_ver = parts[1].to_string();
|
||||
let new_ver = parts[3].to_string();
|
||||
|
||||
let is_security = name.contains("linux")
|
||||
|| name.contains("crypt")
|
||||
|| name.contains("ssl")
|
||||
|| name.contains("firefox")
|
||||
|| name.contains("sudo")
|
||||
|| name.contains("glibc");
|
||||
|
||||
updates.push(PackageUpdate {
|
||||
name,
|
||||
current_version: current_ver,
|
||||
new_version: new_ver,
|
||||
repository: "arch / pacman".to_string(),
|
||||
download_size_bytes: 0, // Уточняется при инспекции
|
||||
description: "System package from Arch Linux repository".to_string(),
|
||||
is_security_update: is_security,
|
||||
backend: BackendType::Pacman,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
async fn get_installed_packages(&self) -> Result<Vec<PackageInfo>, AppError> {
|
||||
// Получаем явно установленные пакеты: pacman -Qe
|
||||
let output = Command::new("pacman")
|
||||
.args(["-Qe"])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::ExecutionFailed(format!("Failed to run pacman -Qe: {}", e)))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut packages = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
packages.push(PackageInfo {
|
||||
name: parts[0].to_string(),
|
||||
version: parts[1].to_string(),
|
||||
installed_size_bytes: 0,
|
||||
description: "Explicitly installed system package".to_string(),
|
||||
install_date: None,
|
||||
is_explicit: true,
|
||||
backend: BackendType::Pacman,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(packages)
|
||||
}
|
||||
|
||||
async fn get_package_details(&self, package_name: &str) -> Result<PackageDetails, AppError> {
|
||||
// Сначала пробуем получить данные из базы репозиториев (pacman -Si), затем из локальной (pacman -Qi)
|
||||
let output = Command::new("pacman")
|
||||
.args(["-Si", package_name])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let stdout = match output {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
_ => {
|
||||
let local_out = Command::new("pacman")
|
||||
.args(["-Qi", package_name])
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::NotFound(e.to_string()))?;
|
||||
String::from_utf8_lossy(&local_out.stdout).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if stdout.trim().is_empty() {
|
||||
return Err(AppError::NotFound(format!("Package {} not found", package_name)));
|
||||
}
|
||||
|
||||
let mut version = String::new();
|
||||
let mut description = String::new();
|
||||
let mut url = None;
|
||||
let mut licenses = Vec::new();
|
||||
let mut depends_on = Vec::new();
|
||||
let mut required_by = Vec::new();
|
||||
let mut packager = None;
|
||||
let mut repository = "extra".to_string();
|
||||
let mut build_date = None;
|
||||
let mut download_size: u64 = 0;
|
||||
let mut installed_size: u64 = 0;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
let key = parts[0].trim();
|
||||
let val = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"Version" => version = val.to_string(),
|
||||
"Description" => description = val.to_string(),
|
||||
"URL" => url = Some(val.to_string()),
|
||||
"Licenses" => licenses = val.split_whitespace().map(|s| s.to_string()).collect(),
|
||||
"Depends On" => {
|
||||
depends_on = val
|
||||
.split_whitespace()
|
||||
.filter(|&s| s != "None")
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
"Required By" => {
|
||||
required_by = val
|
||||
.split_whitespace()
|
||||
.filter(|&s| s != "None")
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
"Packager" => packager = Some(val.to_string()),
|
||||
"Repository" => repository = val.to_string(),
|
||||
"Build Date" => build_date = Some(val.to_string()),
|
||||
"Download Size" => download_size = parse_pacman_size(val),
|
||||
"Installed Size" => installed_size = parse_pacman_size(val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PackageDetails {
|
||||
name: package_name.to_string(),
|
||||
version,
|
||||
description,
|
||||
url,
|
||||
licenses,
|
||||
depends_on,
|
||||
required_by,
|
||||
packager,
|
||||
download_size_bytes: download_size,
|
||||
installed_size_bytes: installed_size,
|
||||
build_date,
|
||||
repository,
|
||||
backend: BackendType::Pacman,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_updates(
|
||||
&self,
|
||||
packages: Vec<String>,
|
||||
is_dry_run: bool,
|
||||
sink: StreamSink<ProgressEvent>,
|
||||
) -> Result<(), AppError> {
|
||||
let total = packages.len();
|
||||
if total == 0 {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Done,
|
||||
package_name: None,
|
||||
percentage: 1.0,
|
||||
message: "Список пакетов пуст".to_string(),
|
||||
log_line: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_dry_run {
|
||||
// Безопасный тестовый режим без изменения системы
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::CheckingDependencies,
|
||||
package_name: None,
|
||||
percentage: 0.1,
|
||||
message: "[DRY-RUN] Симуляция обновления системы...".to_string(),
|
||||
log_line: Some(":: [DRY-RUN] Synchronizing package databases...".to_string()),
|
||||
});
|
||||
sleep(Duration::from_millis(600)).await;
|
||||
|
||||
for (idx, pkg) in packages.iter().enumerate() {
|
||||
let pct = 0.2 + (idx as f32 / total as f32) * 0.7;
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: Some(pkg.clone()),
|
||||
percentage: pct,
|
||||
message: format!("[DRY-RUN] Обновление {} ({}/{})", pkg, idx + 1, total),
|
||||
log_line: Some(format!(":: [DRY-RUN] upgrading {} (simulation mode)", pkg)),
|
||||
});
|
||||
sleep(Duration::from_millis(400)).await;
|
||||
}
|
||||
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Done,
|
||||
package_name: None,
|
||||
percentage: 1.0,
|
||||
message: "[DRY-RUN] Симуляция успешно завершена! Изменения не применены.".to_string(),
|
||||
log_line: Some(":: [DRY-RUN] Transaction simulated successfully.".to_string()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Боевое обновление через PolicyKit (pkexec)
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::CheckingDependencies,
|
||||
package_name: None,
|
||||
percentage: 0.05,
|
||||
message: "Запрос привилегий администратора через Polkit...".to_string(),
|
||||
log_line: Some(":: Waiting for Polkit authorization...".to_string()),
|
||||
});
|
||||
|
||||
let mut args = vec!["-S", "--noconfirm", "--needed"];
|
||||
for pkg in &packages {
|
||||
args.push(pkg.as_str());
|
||||
}
|
||||
|
||||
PrivilegedCommandRunner::run_privileged("pacman", &args, move |line| {
|
||||
let _ = sink.add(ProgressEvent {
|
||||
step: UpdateStep::Installing,
|
||||
package_name: None,
|
||||
percentage: 0.5,
|
||||
message: "Установка системных пакетов...".to_string(),
|
||||
log_line: Some(line),
|
||||
});
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pacman_size(text: &str) -> u64 {
|
||||
// "1728.88 KiB" или "5.01 MiB" или "1024 B"
|
||||
let parts: Vec<&str> = text.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return 0;
|
||||
}
|
||||
let number: f64 = parts[0].parse().unwrap_or(0.0);
|
||||
let unit = parts[1].to_lowercase();
|
||||
|
||||
if unit.contains("k") {
|
||||
(number * 1024.0) as u64
|
||||
} else if unit.contains("m") {
|
||||
(number * 1024.0 * 1024.0) as u64
|
||||
} else if unit.contains("g") {
|
||||
(number * 1024.0 * 1024.0 * 1024.0) as u64
|
||||
} else {
|
||||
number as u64
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::core::types::AppError;
|
||||
|
||||
pub struct PrivilegedCommandRunner;
|
||||
|
||||
impl PrivilegedCommandRunner {
|
||||
/// Проверка, запущен ли процесс уже с root-правами
|
||||
pub fn is_root() -> bool {
|
||||
// Проверка через EUID (в Linux root = 0)
|
||||
unsafe { libc_geteuid() == 0 }
|
||||
}
|
||||
|
||||
/// Асинхронный запуск команды с повышением привилегий через pkexec (Polkit).
|
||||
/// Вывод процесса построчно передается в callback `on_output_line`.
|
||||
/// UI-поток Flutter не блокируется, так как выполнение происходит в Tokio runtime.
|
||||
pub async fn run_privileged<F>(
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
mut on_output_line: F,
|
||||
) -> Result<(), AppError>
|
||||
where
|
||||
F: FnMut(String) + Send + 'static,
|
||||
{
|
||||
let mut cmd = if Self::is_root() {
|
||||
let mut c = Command::new(program);
|
||||
c.args(args);
|
||||
c
|
||||
} else {
|
||||
// Запуск через PolKit (pkexec) вызывает нативный GUI-диалог авторизации Linux
|
||||
let mut c = Command::new("pkexec");
|
||||
c.arg(program).args(args);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
AppError::ExecutionFailed(format!("Failed to spawn privileged command {}: {}", program, e))
|
||||
})?;
|
||||
|
||||
// Читаем stdout асинхронно построчно
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let stdout_handle = tokio::spawn(async move {
|
||||
let mut lines_vec = Vec::new();
|
||||
if let Some(stdout) = stdout {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
lines_vec.push(line);
|
||||
}
|
||||
}
|
||||
lines_vec
|
||||
});
|
||||
|
||||
let stderr_handle = tokio::spawn(async move {
|
||||
let mut err_vec = Vec::new();
|
||||
if let Some(stderr) = stderr {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
err_vec.push(line);
|
||||
}
|
||||
}
|
||||
err_vec
|
||||
});
|
||||
|
||||
// Ожидаем завершения процесса
|
||||
let status = child.wait().await.map_err(|e| {
|
||||
AppError::ExecutionFailed(format!("Failed waiting for child process: {}", e))
|
||||
})?;
|
||||
|
||||
if let Ok(lines) = stdout_handle.await {
|
||||
for line in lines {
|
||||
on_output_line(line);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(err_lines) = stderr_handle.await {
|
||||
for line in err_lines {
|
||||
on_output_line(format!("[STDERR] {}", line));
|
||||
}
|
||||
}
|
||||
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(126) => Err(AppError::PrivilegeDenied("Polkit authorization was cancelled or dismissed by user".into())),
|
||||
Some(127) => Err(AppError::PrivilegeDenied("Polkit authorization failed or pkexec not found".into())),
|
||||
Some(code) => Err(AppError::ExecutionFailed(format!("Process exited with error code: {}", code))),
|
||||
None => Err(AppError::ExecutionFailed("Process terminated by signal".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback функция получения euid без лишней зависимости от nix
|
||||
unsafe fn libc_geteuid() -> u32 {
|
||||
extern "C" {
|
||||
fn geteuid() -> u32;
|
||||
}
|
||||
geteuid()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BackendType {
|
||||
Pacman,
|
||||
Flatpak,
|
||||
Aur,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackageUpdate {
|
||||
pub name: String,
|
||||
pub current_version: String,
|
||||
pub new_version: String,
|
||||
pub repository: String,
|
||||
pub download_size_bytes: u64,
|
||||
pub description: String,
|
||||
pub is_security_update: bool,
|
||||
pub backend: BackendType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub installed_size_bytes: u64,
|
||||
pub description: String,
|
||||
pub install_date: Option<String>,
|
||||
pub is_explicit: bool,
|
||||
pub backend: BackendType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackageDetails {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub url: Option<String>,
|
||||
pub licenses: Vec<String>,
|
||||
pub depends_on: Vec<String>,
|
||||
pub required_by: Vec<String>,
|
||||
pub packager: Option<String>,
|
||||
pub download_size_bytes: u64,
|
||||
pub installed_size_bytes: u64,
|
||||
pub build_date: Option<String>,
|
||||
pub repository: String,
|
||||
pub backend: BackendType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum UpdateStep {
|
||||
CheckingDependencies,
|
||||
Downloading,
|
||||
Verifying,
|
||||
Installing,
|
||||
RunningHooks,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProgressEvent {
|
||||
pub step: UpdateStep,
|
||||
pub package_name: Option<String>,
|
||||
pub percentage: f32, // 0.0 .. 1.0
|
||||
pub message: String,
|
||||
pub log_line: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CleanerScanResult {
|
||||
pub pacman_cache_bytes: u64,
|
||||
pub orphan_packages: Vec<String>,
|
||||
pub orphan_bytes: u64,
|
||||
pub flatpak_unused_count: usize,
|
||||
pub flatpak_unused_bytes: u64,
|
||||
pub journal_bytes: u64,
|
||||
pub total_reclaimable_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HistoryAction {
|
||||
Upgraded,
|
||||
Installed,
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryItem {
|
||||
pub timestamp: String,
|
||||
pub action: HistoryAction,
|
||||
pub package_name: String,
|
||||
pub old_version: Option<String>,
|
||||
pub new_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverApp {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
pub category: String,
|
||||
pub icon_name: String,
|
||||
pub backend: BackendType,
|
||||
pub is_installed: bool,
|
||||
pub rating: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("Command execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
|
||||
#[error("Authentication/Privilege denied: {0}")]
|
||||
PrivilegeDenied(String),
|
||||
|
||||
#[error("Parsing output failed: {0}")]
|
||||
ParsingError(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
#[error("Package not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Placeholder for frb_generated.rs until `flutter_rust_bridge_codegen generate` is executed.
|
||||
// When codegen runs, it replaces this file with the complete FFI bindings and StreamSink implementation.
|
||||
|
||||
pub struct StreamSink<T> {
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> StreamSink<T> {
|
||||
#[allow(dead_code)]
|
||||
pub fn add(&self, _value: T) -> Result<(), ()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod api;
|
||||
pub mod core;
|
||||
|
||||
// Файл создается автоматически через flutter_rust_bridge_codegen
|
||||
#[rustfmt::skip]
|
||||
pub mod frb_generated;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user